BumpVer: Automatic Versioning https://github.com/mbarkhau/bumpver
Find a file
2020-10-15 16:53:19 +00:00
.github/workflows run actions for branches and PRs 2020-10-05 20:04:26 +00:00
requirements update requirements 2020-10-15 16:53:19 +00:00
scripts readme updates 2020-10-09 22:12:12 +00:00
src/pycalver2 fix importerror 2020-10-15 11:38:43 +00:00
stubs more mypy coverage 2019-01-07 18:47:04 +01:00
test update tests for new defaults 2020-10-14 22:17:18 +00:00
.gitignore bootstrapit updates 2020-07-19 13:58:33 +00:00
.gitlab-ci.yml fix grep regression 2020-10-04 21:37:03 +00:00
activate bootstrapit update 2018-12-05 09:48:24 +01:00
bootstrapit.sh update tests for new defaults 2020-10-14 22:17:18 +00:00
CHANGELOG.md update changelog 2020-10-07 21:16:24 +00:00
CONTRIBUTING.md project maintenance updates 2020-10-04 17:21:45 +00:00
docker_base.Dockerfile project maintenance updates 2020-10-04 17:21:45 +00:00
Dockerfile project maintenance updates 2020-10-04 17:21:45 +00:00
LICENSE support for glob patterns 2020-09-18 19:52:40 +00:00
license.header wip: add v2 module placeholders 2020-09-06 21:10:27 +00:00
Makefile readme updates 2020-10-09 22:12:12 +00:00
Makefile.bootstrapit.make update requirements 2020-10-15 16:53:19 +00:00
MANIFEST.in add MANIFEST 2018-12-08 23:15:28 +01:00
pycalver.png add icons 2018-10-19 20:46:02 +02:00
pycalver.svg add icons 2018-10-19 20:46:02 +02:00
pycalver1k.svg wip: add v2 module placeholders 2020-09-06 21:10:27 +00:00
pycalver1k2_128.png wip: add v2 module placeholders 2020-09-06 21:10:27 +00:00
pycalver1k_128.png logo updates 2020-08-30 10:29:19 +00:00
pycalver_128.png add icons 2018-10-19 20:46:02 +02:00
pylint-ignore.md improve test coverage 2020-10-04 12:10:38 +00:00
README.md readme updates 2020-10-09 22:12:12 +00:00
setup.cfg update tests for new defaults 2020-10-14 22:17:18 +00:00
setup.py update tests for new defaults 2020-10-14 22:17:18 +00:00

logo

PyCalVer: Automatic Calendar Versioning

PyCalVer is a CLI-tool to search and replace all version strings in your project files (calver, semver or otherwise). PyCalVer has support for

  • Configurable version patterns
  • Git, Mercurial or no VCS
  • Operates only on plaintext files, so it can be used for any project, not just python projects.

Project/Repo:

MIT License Supported Python Versions PyCalVer v202010.1041-beta PyPI Releases PyPI Downloads

Code Quality/CI:

GitHub Build Status GitLab Build Status Type Checked with mypy Code Coverage Code Style: sjfmt

Name role since until
Manuel Barkhau (mbarkhau@gmail.com) author/maintainer 2018-09 -

Overview

Search and Replace

With PyCalVer, you only configure a single version_pattern which is then used

  1. Search for version strings in your project files
  2. Replace these occurrences with an updated/bumped version number.

Your configuration might look something like this:

[pycalver]
current_version = "2020.9"
version_pattern = "YYYY.MM"

[pycalver:file_patterns]
src/mymodule/__init__.py
	__version__ = "{version}"
src/mymodule/__main__.py
	@click.version_option(version="{version}")
setup.py
	version="{version}",

Throughout the examples, we use the --date argument. Without this argument PyCalVer will just use the current date. We use it here so that you can easily reproduce the examples.

Using this configuration, the output of pycalver bump --dry might look something like this:

$ pycalver bump --date 2020-10-01 --dry
INFO    - fetching tags from remote (to turn off use: -n / --no-fetch)
INFO    - Old Version: 2020.9
INFO    - New Version: 2020.10
--- setup.py
+++ setup.py
@@ -63,7 +63,7 @@
 setuptools.setup(
     name="mymodule",
-    version="2020.9",
+    version="2020.10",
     description=description,
     long_description=long_description,

--- src/mymodule/__init__.py
+++ src/mymodule/__init__.py
@@ -3,3 +3,3 @@

-__version__ = "2020.9"
+__version__ = "2020.10"


--- src/mymodule/__main__.py
+++ src/mymodule/__main__.py
@@ -101,7 +101,7 @@

 @click.group()
-@click.version_option(version="2020.9")
+@click.version_option(version="2020.10")
 @click.help_option()
 @click.option('-v', '--verbose', count=True, help="Control log level. -vv for debug level.")

If PyCalVer does not serve your purposes, you may wish to look at the [bump2version][url_bump2version] project, by which PyCalVer was heavily inspired. You may also wish to take a look at their list of related projects: [bump2version/RELATED.md][url_bump2version_related]

[url_bump2version] https://github.com/c4urself/bump2version/

[url_bump2version_related] https://github.com/c4urself/bump2version/blob/master/RELATED.md

Example Usage

Testing a version pattern

You can validate a pattern and how it is incremented using pycalver test.

$ pycalver test --date 2018-09-22 '2018.37' 'YYYY.WW'
New Version: 2018.38
PEP440     : 2018.38

$ pycalver test --date 2018-09-22 '2018.37' 'YYYY.MM'     # expected to fail
ERROR   - Incomplete match '2018.3' for version string '2018.37' with pattern 'YYYY.MM'/'(?P<year_y>[1-9][0-9]{3})\.(?P<month>1[0-2]|[1-9])'
ERROR   - Version did not change: '2018.37'. Invalid version and/or pattern 'YYYY.MM'.

This illustrates that each pattern is internally translated to a regular expression which must match your version string. The --verbose flag shows a slightly more readable form.

$ pycalver test --date 2018-09-22 'v2018.37' 'YYYY.WW' --verbose
INFO    - Using pattern YYYY.WW
INFO    - regex = re.compile(r"""
    (?P<year_y>[1-9][0-9]{3})
    \.
    (?P<week_w>5[0-2]|[1-4][0-9]|[0-9])
""", flags=re.VERBOSE)
ERROR   - Invalid version string 'v2018.37' for pattern ...

In other words, you don't specify regular expressions manually, they are generated for by PyCalVer based on the parts defined in the Parts Overview.

SemVer: MAJOR/MINOR/PATCH

You can do tradition SemVer without any kind of calendar component if you like.

$ pycalver test '1.2.3' 'MAJOR.MINOR.PATCH' --patch
New Version: 1.2.4
PEP440     : 1.2.4

$ pycalver test '1.2.3' 'MAJOR.MINOR.PATCH' --minor
New Version: 1.3.0
PEP440     : 1.3.0

$ pycalver test '1.2.3' 'MAJOR.MINOR.PATCH' --major
New Version: 2.0.0
PEP440     : 2.0.0

These are the same CLI flags as are accepted by the pycalver bump command.

In the context of a CalVer version, a typical use would be to include a PATCH part in your version pattern, so that you can create multiple releases in the same month.

$ pycalver test --date 2018-09-22 '2018.9.0' 'YYYY.MM.PATCH'
ERROR   - Invalid arguments or pattern, version did not change.
ERROR   - Version did not change: '2018.9.0'. Invalid version and/or pattern 'YYYY.MM.PATCH'.
INFO    - Perhaps try: pycalver test --patch

$ pycalver test --date 2018-09-22 '2018.9.0' 'YYYY.MM.PATCH' --patch
New Version: 2018.9.1
PEP440     : 2018.9.1

The PATCH part will roll over back to zero when leading parts change (in this case the year and month).

$ pycalver test --date 2018-10-22 '2018.9.1' 'YYYY.MM.PATCH'
New Version: 2018.10.0
PEP440     : 2018.10.0

This will happen even if you use the --patch argument, so that your first release of the month has a PATCH of 0 instead of 1.

$ pycalver test --date 2018-10-22 '2018.9.1' 'YYYY.MM.PATCH' --patch
New Version: 2018.10.0
PEP440     : 2018.10.0

Auto Incrementing Parts: BUILD/INC0/INC1

The following parts are incremented automatically, and do not use/require a CLI flag: BUILD/INC0/INC1. This means you can just do pycalver bump without any further CLI flags and special cases, which can simplify your build scripts.

$ pycalver test --date 2018-09-22 '2018.9.1' 'YYYY.MM.INC0'
New Version: 2018.9.2
PEP440     : 2018.9.2

$ pycalver test --date 2018-10-22 '2018.9.2' 'YYYY.MM.INC0'
New Version: 2018.10.0
PEP440     : 2018.10.0

$ pycalver test --date 2018-10-22 '2018.9.2' 'YYYY.MM.INC1'
New Version: 2018.10.1
PEP440     : 2018.10.1

If it is rare for you to make multiple releases within a given period, you can make such a part optional using the [PART] syntax with square braces:

$ pycalver test --date 2018-09-22 '2018.9' 'YYYY.MM[.INC0]'
New Version: 2018.9.1
PEP440     : 2018.9.1

$ pycalver test --date 2018-10-22 '2018.9.1' 'YYYY.MM[.INC0]'
New Version: 2018.10
PEP440     : 2018.10

If the extra INC0 part is needed, it is added. If the date rolls over and it's no longer needed, it is omitted. Any literal text enclosed in the braces (such as a separator) will also be added or omitted as needed.

Persistent Parts: BUILD/RELEASE/PYTAG

The BUILD and RELEASE parts are not reset. Instead they are carried forward.

$ pycalver test --date 2018-09-22 '201809.1051-beta' 'YYYY0M.BUILD[-RELEASE]'
New Version: 201809.1052-beta
PEP440     : 201809.1052b0

$ pycalver test --date 2018-09-22 '201809.1051-beta' 'YYYY0M.BUILD[-RELEASE]' --release rc
New Version: 201809.1052-rc
PEP440     : 201809.1052rc0

To remove a release tag, mark it as final with --release final.

$ pycalver test --date 2018-09-22 '201809.1051-beta' 'YYYY0M.BUILD[-RELEASE]' --release final
New Version: 201809.1052
PEP440     : 201809.1052

Searching for Patterns with grep

Using pycalver grep, you can search for occurrences of a version pattern in your project files.

$ pycalver grep '__version__ = "YYYY.MM[-RELEASENUM]"' src/module/__init__.py
src/module/__init__.py
 3:
 4: __version__ = "2020.9-beta1"
 5:

Note that everything in the pattern is treated as literal text, except for a valid part (in all caps).

When you write your configuration, you can avoid repeating your version pattern in every search pattern, by using these placeholders

  • {version}
  • {pep440_version}

Applied to the above example, you can instead use this:

$ pycalver grep --version-pattern "YYYY.MM[-RELEASENUM]"  '__version__ = "{version}"' src/module/__init__.py
src/module/__init__.py
 3:
 4: __version__ = "2020.9-beta1"
 5:

The corresponding configuration would look like this.

[pycalver]
current_version = "2020.9-beta1"
version_pattern = "YYYY.MM[-RELEASENUM]"
...

[pycalver:file_patterns]
src/module/__init__.py
  __version__ = "{version}"
...

If your pattern produces non PEP440 version numbers, you may wish to use the placeholder {pep440_version} in your search pattern and specify your --version-pattern separately.

$ pycalver grep --version-pattern "YYYY.MM[-RELEASENUM]" 'version="{pep440_version}"' setup.py
setup.py
  65:     url="https://github.com/org/project",
  66:     version="2020.9b1",
  67:     description=description,

The placeholder {version} matches 2020.9-beta1, while the placeholder {pep440_version} matches 2020.9b1 (excluding the "v" prefix, the "-" separator and with a short form release tag "b1" instead of "beta1"). These two placeholders make it possible to mostly use your preferred format for version strings, but use a PEP440 compliant/normalized version string where appropriate.

As a further illustration of how the search and replace works, you might want use a file pattern entry to keep the year of your copyright header up to date.

$ python -m pycalver grep 'Copyright (c) 2018-YYYY' src/mymodule/*.py | head
src/mymodule/__init__.py
   3:
   4: # Copyright (c) 2018-2020 Vandelay Industries - All rights reserved.
   5:

src/mymodule/config.py
   3:
   4: # Copyright (c) 2018-2020 Vandelay Industries - All rights reserved.
   5:

The corresponding configuration for this pattern would look like this.

[pycalver:file_patterns]
...
src/mymodule/*.py
  Copyright (c) 2018-YYYY Vandelay Industries - All rights reserved.

Reference

Command Line

$ pycalver --help
Usage: pycalver [OPTIONS] COMMAND [ARGS]...

  Automatically update PyCalVer version strings in all project files.

Options:
  --version      Show the version and exit.
  --help         Show this message and exit.
  -v, --verbose  Control log level. -vv for debug level.

Commands:
  bump  Increment the current version string and update project files.
  grep  Search file(s) for a version pattern.
  init  Initialize [pycalver] configuration.
  show  Show current version of your project.
  test  Increment a version number for demo purposes.
$ pycalver bump --help
Usage: pycalver bump [OPTIONS]

  Increment the current version string and update project files.

Options:
  -v, --verbose                 Control log level. -vv for debug level.
  -f, --fetch / -n, --no-fetch  Sync tags from remote origin.
  -d, --dry                     Display diff of changes, don't rewrite files.
  --release <NAME>              Override release name of current_version.
                                Valid options are: alpha, beta, rc, post,
                                final.

  --allow-dirty                 Commit even when working directory is has
                                uncomitted changes. (WARNING: The commit will
                                still be aborted if there are uncomitted to
                                files with version strings.

  --major                       Increment major component.
  -m, --minor                   Increment minor component.
  -p, --patch                   Increment patch component.
  -r, --release-num             Increment release number (rc1, rc2, rc3..).
  --pin-date                    Leave date components unchanged.
  --date <ISODATE>              Set explicit date in format YYYY-0M-0D (e.g.
                                2020-10-09).

  --help                        Show this message and exit.

Part Overview

Where possible, these patterns match the conventions from calver.org.

part range / example(s) comment
YYYY 2019, 2020... Full year, based on strftime('%Y')
YY 18, 19..99, 0, 1 Short year, based on int(strftime('%y'))
MM 9, 10, 11, 12 Month, based on int(strftime('%m'))
DD 1, 2, 3..31 Day, based on int(strftime('%d'))
MAJOR 0..9, 10..99, 100.. pycalver bump --major
MINOR 0..9, 10..99, 100.. pycalver bump --minor
PATCH 0..9, 10..99, 100.. pycalver bump --patch
RELEASE alpha, beta, rc, post --release=<tag>
PYTAG a, b, rc, post --release=<tag>
NUM 0, 1, 2... -r/--release-num
BUILD 1001, 1002 .. 1999, 22000 build number (maintains lexical order)
INC0 0, 1, 2... 0-based auto incrementing number
INC1 1, 2... 1-based auto incrementing number

The following are also available, but you should review the Normalization Caveats before you decide to use them.

part range / example(s) comment
Q 1, 2, 3, 4 Quarter
0Y 18, 19..99, 00, 01 Short Year strftime('%y')(zero-padded)
0M 09, 10, 11, 12 Month strftime('%m') (zero-padded)
0D 01, 02, 03..31 Day strftime('%d') (zero-padded)
JJJ 1,2,3..366 Day of year int(strftime('%j'))
00J 001, 002..366 Day of year strftime('%j') (zero-padded)
WW 0, 1, 2..52 Week number¹ int(strftime('%W'))
0W 00, 01, 02..52 Week number¹ strftime('%W') (zero-padded)
UU 0, 1, 2..52 Week number² int(strftime('%U'))
0U 00, 01, 02..52 Week number² strftime('%U') (zero-padded)
VV 1, 2..53 Week number¹³ int(strftime('%V'))
0V 01, 02..53 Week number¹³ strftime('%V') (zero-padded)
GGGG 2019, 2020... strftime("%G") ISO 8601 week-based year
GG 19, 20...99, 0, 1 Short ISO 8601 week-based year
0G 19, 20...99, 00, 01 Zero-padded ISO 8601 week-based year
  • ¹ Monday is the first day of the week.
  • ² Sunday is the first day of the week.
  • ³ ISO 8601 week. Week 1 contains Jan 4th.

Normalization Caveats

Package managers and installation tools will parse your version numbers. When doing so, your version number may go through a normalization process and may not be displayed as you specified it. In the case of Python, the packaging tools (such as pip, twine, setuptools) follow PEP440 normalization rules.

According to these rules (among other things):

  • Any non-numerical prefix (such as v) is removed
  • Leading zeros in delimited parts are truncated XX.08 -> XX.8
  • Tags are converted to a short form (-alpha -> a0)

For example:

  • Pattern: vYY.0M.0D[-RELEASE]
  • Version: v20.08.02-beta
  • PEP440 : 20.8.2b0

It may not be obvious to everyone that v20.08.02-beta is the same 20.8.2b0 on pypi. To avoid this confusion, you should choose a pattern which is always in a normalized form or as close to it as possible.

A further consideration for the choice of your version format is that it may be processed by tools that do not interpret it as a version number, but treat it just like any other string. It may also be confusing to your users if they a list of version numbers, sorted lexicographically by some tool (e.g. from git tags) and versions are not listed in order of their release as here:

$ git tag
18.6b4
18.9b0
19.10b0
19.3b0
20.8b0
20.8b1

If you wish to avoid this, you should use a pattern which maintains lexicographical ordering.

Pattern Examples

pattern examples PEP440 lexico.
MAJOR.MINOR.PATCH[PYTAGNUM] 0.13.10 0.16.10rc1 yes no
MAJOR.MINOR[.PATCH[PYTAGNUM]] 1.11 0.3.0b5 yes no
YYYY.BUILD[PYTAGNUM] 2020.1031 2020.1148a0 yes yes
YYYY.BUILD[-RELEASE] 2021.1393-beta 2022.1279 no yes
YYYY.INC0[PYTAGNUM] 2020.10 2021.12b2 yes no
YYYY0M.PATCH[-RELEASE] 202005.12 202210.15-beta no no¹
YYYY0M.BUILD[-RELEASE] 202106.1071 202106.1075-beta no yes
YYYY.0M 2020.02 2022.09 no yes
YYYY.MM 2020.8 2020.10 yes no
YYYY.WW 2020.8 2021.14 yes no
YYYY.MM.PATCH[PYTAGNUM] 2020.3.12b0 2021.6.19b0 yes no
YYYY.0M.PATCH[PYTAGNUM] 2020.10.15b0 2022.07.7b0 no no¹
YYYY.MM.INC0 2021.6.2 2022.8.9 yes no
YYYY.MM.DD 2020.5.18 2021.8.2 yes no
YYYY.0M.0D 2020.08.24 2022.05.03 no yes
YY.0M.PATCH 21.04.2 21.11.12 no no²
  • ¹ If PATCH > 9
  • ² For 2100 YY produces 00...

Week Numbering

Week numbering is a bit special, as it depends on your definition of "week":

  • Does it start on a Monday or a Sunday?
  • Range from 0-52 or 1-53 ?
  • At the beginning/end of the year, do you have partial weeks or do you have a week that span multiple years?
  • If a week spans multiple years, what is the year number?

If you use VV/0V, be aware that you cannot also use YYYY. Instead use GGGG. This is to avoid an edge case where your version number would run backwards if it was created around New Year.

                   YYYY WW UU  GGGG VV
2020-12-26 (Sat):  2020 51 51  2020 52
2020-12-27 (Sun):  2020 51 52  2020 52
2020-12-28 (Mon):  2020 52 52  2020 53
2020-12-29 (Tue):  2020 52 52  2020 53
2020-12-30 (Wed):  2020 52 52  2020 53
2020-12-31 (Thu):  2020 52 52  2020 53
2021-01-01 (Fri):  2021 00 00  2020 53
2021-01-02 (Sat):  2021 00 00  2020 53
2021-01-03 (Sun):  2021 00 01  2020 53
2021-01-04 (Mon):  2021 01 01  2021 01

Configuration

The fastest way to setup the configuration for project is to use pycalver init.

$ pip install pycalver
...
Installing collected packages: click pathlib2 typing toml pycalver
Successfully installed pycalver-202010.1041b0

$ cd myproject
~/myproject/
$ pycalver init --dry
Exiting because of '-d/--dry'. Would have written to pycalver.toml:

    [pycalver]
    current_version = "v202010.1001-alpha"
    version_pattern = "vYYYY0M.BUILD[-RELEASE]"
    commit_message = "bump version to {new_version}"
    commit = true
    tag = true
    push = true

    [pycalver.file_patterns]
    "README.md" = [
        "{version}",
        "{pep440_version}",
    ]
    "pycalver.toml" = [
        'current_version = "{version}"',
    ]

If you already have configuration file in your project (such as a setup.cfg file), then pycalver init will update that file instead.

~/myproject
$ pycalver init
Updated setup.cfg

Your setup.cfg may now look something like this:

# setup.cfg
[pycalver]
current_version = "v201902.1001-alpha"
version_pattern = "vYYYY0M.BUILD[-RELEASE]"
commit_message = "bump version to {new_version}"
commit = True
tag = True
push = True

[pycalver:file_patterns]
setup.cfg =
    current_version = "{version}"
setup.py =
    "{version}",
    "{pep440_version}",
README.md =
    {version}
    {pep440_version}

For the entries in [pycalver:file_patterns] you can expect two failure modes:

  • A pattern won't match a version number in the associated file.
  • A pattern will match something it shouldn't (less likely).

To debug such issues, you can use pycalver grep .

$ pycalver grep 'Copyright (c) 2018-YYYY' src/module/*.py
src/module/__init__.py
   3: #
   4: # Copyright (c) 2018-2020 Vandelay Industries - All rights reserved.
   5: # SPDX-License-Identifier: MIT

src/module/config.py
   3: #
   4: # Copyright (c) 2018-2020 Vandelay Industries - All rights reserved.
   5: # SPDX-License-Identifier: MIT

Of course, you may not get the pattern correct right away. If your pattern is not found, pycalver grep will show an error message with the regular expression it uses, to help you debug the issue.

$ pycalver grep 'Copyright 2018-YYYY' src/pycalver/*.py
ERROR   - Pattern not found: 'Copyright 2018-YYYY'
# https://regex101.com/?flavor=python&flags=gmx&regex=Copyright%5B%20%5D2018%5C-%0A%28%3FP%3Cyear_y%3E%5B1-9%5D%5B0-9%5D%7B3%7D%29
re.compile(r"""
    Copyright[ ]2018\-
    (?P<year_y>[1-9][0-9]{3})
""", flags=re.VERBOSE)

Let's say you want to keep a badge your README.md up to date.

$ pycalver grep --version-pattern='vYYYY0M.BUILD[-RELEASE]' 'img.shields.io/static/v1.svg?label=PyCalVer&message={version}&color=blue' README.md

  61:
  62: [img_version]: https://img.shields.io/static/v1.svg?label=PyCalVer&message=v202010.1040-beta&color=blue
  63: [url_version]: https://pypi.org/org/package/

Found 1 match for pattern 'img.shields.io/static/v1.svg?label=PyCalVer&message=vYYYY0M.BUILD[-RELEASE]&color=blue' in README.md

This probably won't cover all version numbers present in your project, so you will have to manually add entries to pycalver:file_patterns. To determine what to add, you can use pycalver grep :

$ pycalver grep 'Copyright (c) 2018-YYYY' src/project/*.py

Something like the following may illustrate additional changes you might need to make.

[pycalver:file_patterns]
setup.cfg =
    current_version = {version}
setup.py =
    version="{pep440_version}"
src/mymodule_v*/__init__.py =
    __version__ = "{version}"
README.md =
    [CalVer {version}]
    img.shields.io/static/v1.svg?label=CalVer&message={version}&color=blue

To see if a pattern is found, you can use pycalver bump --dry, which will leave your project files untouched and only show you a diff of the changes it would have made.

$ pycalver bump --dry --no-fetch
INFO    - Old Version: v201901.1001-beta
INFO    - New Version: v201902.1002-beta
--- README.md
+++ README.md
@@ -11,7 +11,7 @@

 [![Supported Python Versions][pyversions_img]][pyversions_ref]
-[![Version v201901.1001-beta][version_img]][version_ref]
+[![Version v201902.1002-beta][version_img]][version_ref]
 [![PyPI Releases][pypi_img]][pypi_ref]

--- src/mymodule_v1/__init__.py
+++ src/mymodule_v1/__init__.py
@@ -1,1 +1,1 @@
-__version__ = "v201901.1001-beta"
+__version__ = "v201902.1002-beta"

--- src/mymodule_v2/__init__.py
+++ src/mymodule_v2/__init__.py
@@ -1,1 +1,1 @@
-__version__ = "v201901.1001-beta"
+__version__ = "v201902.1002-beta"

--- setup.py
+++ setup.py
@@ -44,7 +44,7 @@
     name="myproject",
-    version="201901.1001b0",
+    version="201902.1002b0",
     license="MIT",

If there is no match for a pattern, bump will report an error.

# TODO (mb 2020-08-29):  update regex pattern
$ pycalver bump --dry --no-fetch
INFO    - Old Version: v201901.1001-beta
INFO    - New Version: v201902.1002-beta
ERROR   - No match for pattern 'img.shields.io/static/v1.svg?label=CalVer&message={version}&color=blue'
ERROR   - Pattern compiles to regex 'img\.shields\.io/static/v1\.svg\?label=CalVer&message=(?P<year_y>\d{4})(?P<month>(?:0[0-9]|1[0-2]))\.(?P<bid>\d{4,})(?:-(?P
<tag>(?:alpha|beta|dev|rc|post|final)))?)&color=blue'

The internally used regular expression is also shown, which you can use to debug the issue, for example on regex101.com.

TODO Update above link

Legacy Patterns

These patterns use curly braces {} and were the initial implementation. They are still supported and still follow their original semantics.

The pycalver:file_patterns section of the configuration uses a different set of placeholders and does not use curly braces to mark placeholders. It is still supported, but we don't recomend you use it.

Available placeholders are:

placeholder range / example(s) comment
{year} 2019... %Y
{yy} 18, 19..99, 01, 02 %y
{quarter} 1, 2, 3, 4
{month} 09, 10, 11, 12 %m
{iso_week} 00..53 %W
{us_week} 00..53 %U
{dom} 01..31 %d
{doy} 001..366 %j
{build} .1023 lexical id
{build_no} 1023, 20345 ...
{release} -alpha, -beta, -rc --release=
{release_tag} alpha, beta, rc ...
placeholder range / example(s) comment
{pycalver} v201902.1001-beta
{pep440_pycalver} 201902.1b0
{semver} 1.2.3

Pattern Usage

There are some limitations to keep in mind:

  1. A version string cannot span multiple lines.
  2. Characters generated by a placeholder cannot be escaped.
  3. The timezone is always UTC.

The lack of escaping may for example be an issue with badge URLs. You may want to put the following text in your README.md (note that shields.io parses the two "-" dashes before beta as one literal "-"):

https://img.shields.io/badge/myproject-v202010.1116--beta-blue.svg

While you could use the following pattern, which will work fine for a while:

README.md =
    /badge/myproject-{vYYYY0M.BUILD[--RELEASE]}-blue.svg

Eventually this will break, when you do a final release, at which point the following will be put in your README.md:

https://img.shields.io/badge/myproject-v202010.1117--final-blue.svg

When what you probably wanted was this (with the --final tag omitted):

https://img.shields.io/badge/myproject-v202010.1117-blue.svg

Version State

The "current version" is considered global state that needs to be stored somewhere. Typically this might be stored in a VERSION file, or some other file which is part of the repository. This creates the risk that parallel branches can have different states. If the "current version" were defined only by files in the local checkout, the same version might be generated for different commits.

To avoid this issue, pycalver treats VCS tags as the canonical / SSOT for the most recent version and attempts to change this state in the most atomic way possible. This is why some actions of the pycalver command can take a while, as it is synchronizing with the remote repository to get the most recent versions and to push any new version tags as soon as possible.

The Current Version

The current version that will be bumped is defined either as

  • Typically: The lexically largest git/mercurial tag which matches the version_pattern from your config.
  • Initially: Before any tags have been created (or you're not using a supported VCS), the value of pycalver.current_version in setup.cfg / pyproject.toml / pycalver.toml.

As part of doing pycalver bump and pycalver show, your local VCS index is updated using git fetch --tags/hg pull.

$ time pycalver show --verbose
INFO    - fetching tags from remote (to turn off use: -n / --no-fetch)
INFO    - Working dir version        : v202010.1018
INFO    - Latest version from git tag: v202010.1019-beta
Current Version: v202010.1019-beta
PEP440         : 202010.1019b0

real    0m4,254s

$ time pycalver show --verbose --no-fetch
...
real    0m0,840s

Here we see that:

  • The VCS had a newer version than we had locally.
  • It took 4 seconds to fetch the tags from the remote repository.

This approach reduces the risk that new tags are unknown locally and makes it less likely that the same version string is generated for different commits, which would result in an ambiguous version tag. This can happen if multiple maintainers produce a release at the same time or if a build system is triggered multiple times and multiple builds run concurrently to each other.

For a small project (with only one maintainer and no build system) this is a non-issue and you can always use -n/--no-fetch to skip updating the tags.

Bump It Up

To increment the current version and publish a new version, you can use the pycalver bump sub-command. bump is configured in the pycalver config section:

[pycalver]
current_version = "v202010.1006-beta"
version_pattern = "vYYYY0M.BUILD[-RELEASE]"
commit_message = "bump version to {new_version}"
commit = True
tag = True
push = True

This configuration is appropriate to create a commit which

  1. contains the changes to the version strings,
  2. contains no other changes (unrelated to bumping the version),
  3. is tagged with the new version,
  4. has a version tag that is unique in the repository.

In order to make sure only changes to version strings are in the commit, you need to make sure you have a clean VCS checkout when you invoke pycalver bump.

The steps performed by bump are:

  1. Check that your repo doesn't have any local changes.
  2. Fetch the most recent global VCS tags from origin (-n/--no-fetch to disable).
  3. Generate a new version, incremented from the current version.
  4. Update version strings in all files configured in file_patterns.
  5. Commit the updated version strings.
  6. Tag the new commit.
  7. Push the new commit and tag.

Again, you can use -d/--dry to inspect the changes first.

$ pycalver bump --dry
--- setup.cfg
+++ setup.cfg
@@ -65,7 +65,7 @@

 [pycalver]
-current_version = v202010.1005-beta
+current_version = v202010.1006-beta
 version_pattern = "vYYYY0M.BUILD[-RELEASE]"
 commit_message = "bump version to {new_version}"
 commit = True
...

If everything looks OK, you can do pycalver bump.

$ pycalver bump --verbose
INFO    - fetching tags from remote (to turn off use: -n / --no-fetch)
INFO    - Old Version: v202010.1005-beta
INFO    - New Version: v202010.1006-beta
INFO    - git commit --message 'bump version to v202010.1006-beta'
INFO    - git tag --annotate v202010.1006-beta --message v202010.1006-beta
INFO    - git push origin v202010.1006-beta

Config Parameters

Config Parameter Type Description
current_version string
version_pattern string
commit_message string Template for commit message¹
commit boolean
tag boolean²
push boolean²
  • ¹ Available placeholders: {new_version}, {old_version}, {new_version_pep440}, {old_version_pep440}
  • ² Requires commit = True

The PyCalVer Format

The PyCalVer format for version strings has three parts:


    o Year and Month of Release
    |       o Sequential Build Number
    |       |      o Release Tag (optional)
    |       |      |
 ---+---  --+--  --+--
 v202010  .1001  -beta


Some examples:

v201711.0001-alpha
v201712.0027-beta
v201801.0031
v201801.0032-post
...
v202207.18133
v202207.18134

This format was chosen in part to be distinctive from others, so that users of your package can see at a glance that your project will strive to maintain the one semantic that really matters: newer is better.

To convince you of the merits of not breaking things, here are some resources which PyCalVer was inspired by:

Parsing

These version strings can be parsed with the following regular expression:

import re

# https://regex101.com/r/fnj60p/10
PYCALVER_PATTERN = r"""
\b
(?P<pycalver>
    (?P<vYYYY0M>
       v                    # "v" version prefix
       (?P<year>\d{4})
       (?P<month>\d{2})
    )
    (?P<build>
        \.                  # "." build nr prefix
        (?P<build_no>\d{4,})
    )
    (?P<release>
        \-                  # "-" release prefix
        (?P<release_tag>alpha|beta|dev|rc|post)
    )?
)(?:\s|$)
"""
PYCALVER_REGEX = re.compile(PYCALVER_PATTERN, flags=re.VERBOSE)

version_str = "v201712.0001-alpha"
version_match = PYCALVER_REGEX.match(version_str)

assert version_match.groupdict() == {
    "pycalver"   : "v201712.0001-alpha",
    "vYYYY0M"    : "v201712",
    "year"       : "2017",
    "month"      : "12",
    "build"      : ".0001",
    "build_no"   : "0001",
    "release"    : "-alpha",
    "release_tag": "alpha",
}

version_str = "v201712.0033"
version_match = PYCALVER_REGEX.match(version_str)

assert version_match.groupdict() == {
    "pycalver"   : "v201712.0033",
    "vYYYY0M"    : "v201712",
    "year"       : "2017",
    "month"      : "12",
    "build"      : ".0033",
    "build_no"   : "0033",
    "release"    : None,
    "release_tag": None,
}

Incrementing Behaviour

To see how version strings are incremented, we can use pycalver test:

$ pycalver test v201801.1033-beta
New Version: v201902.1034-beta
PEP440     : 201902.1034b0

This is the simple case:

  • The calendar component is updated to the current year and month.
  • The build number is incremented by 1.
  • The optional release tag is preserved as is.

You can explicitly update the release tag by using the --release=<tag> argument:

$ pycalver test v201801.1033-alpha --release=beta
New Version: v201902.1034-beta
PEP440     : 201902.1034b0

$ pycalver test v201902.1034-beta --release=final
New Version: v201902.1035
PEP440     : 201902.1035

To maintain lexical ordering of version numbers, the version number is padded with extra zeros using Lexical Ids. This means that the expression older_id < newer_id will always be true, whether you are dealing with integers or strings.

Semantics of PyCalVer

Disclaimer: This section is of course only aspirational. Nothing will stop a package maintainer from publishing updates that violate the semantics presented here.

Pitch

  • dates are good information
    • how old is the software
    • is the software maintained
    • is my dependency outdated
    • can I trust an update?

blah

PyCalVer places a greater burden on package maintainers than SemVer. Backward incompatibility is not encoded in the version string, because maintainers should not intentionally introduce breaking changes. This is great for users of a package, who can worry a bit less about an update causing their project to break. A paranoid user can of course still pin to a known good version, and freezing dependencies for deployments is still a good practice, but for development, users ideally shouldn't need any version specifiers in their requirements.txt. This way they always get the newest bug fixes and features.

Part of the reason for the distinctive PyCalVer version string, is for users to be able to recognize, just from looking at the version string, that a package comes with the promise (or at least aspiration) that it won't break, that it is safe for users to update. Compare this to a SemVer version string, where maintainers explicitly state that an update might break their program and that they may have to do extra work after updating and even if it hasn't in the past, the package maintainers anticipate that they might make such breaking changes in the future.

In other words, the onus is on the user of a package to update their software, if they want to update to the latest version of a package. With PyCalVer the onus is on package maintainer to maintain backward compatibility.

Ideally users can trust the promise of a maintainer that the following semantics will always be true:

  • Newer is compatible.
  • Newer has fewer bugs.
  • Newer has more features.
  • Newer has equal or better performance.

Alas, the world is not ideal. So how do users and maintainers deal with changes that violate these promises?

Intentional Breaking Changes

Namespaces are a honking great idea

  • let's do more of those!

  • The Zen of Python

If you must make a breaking change to a package, instead of incrementing a number, the recommended approach with PyCalVer is to create a whole new namespace. Put differently, the major version becomes part of the name of the module or even of the package. Typically you might add a numerical suffix, eg. mypkg -> mypkg2.

In the case of python distributions, you can include multiple module packages like this.

# setup.py
setuptools.setup(
    name="my-package",
    license="MIT",
    packages=["mypkg", "mypkg2"],
    package_dir={"": "src"},
    ...
)

In other words, you can ship older versions side by side with newer ones, and users can import whichever one they need. Alternatively you can publish a new package distribution, with new namespace, but please consider also renaming the module.

# setup.py
setuptools.setup(
    name="my-package-v2",
    license="MIT",
    packages=["mypkg2"],
    package_dir={"": "src"},
    ...
)

Users will have an easier time working with your package if import mypkg2 is enough to determine which version of your project they are using. A further benefit of creating multiple modules is that users can import both old and new modules in the same environment and can use some packages which depend on the old version as well as some that depend on the new version. The downside for users, is that they may have to do minimal changes to their code, even if the breaking change did not affect them.

- import mypkg
+ import mypkg2

  def usage_code():
-     mypkg.myfun()
+     mypkg2.myfun()

Costs and Benefits

If this seems like overkill because it's a lot of work for you as a maintainer, consider first investing some time in your tools, so you minimize future work required to create new packages. I've done this for my personal projects, but you may find other approaches to be more appropriate for your use.

If this seems like overkill because you're not convinced that imposing a very small burden on users is such a big deal, consider that your own projects may indirectly depend on dozens of libraries which you've never even heard of. If every maintainer introduced breaking changes only once per year, users who depend on only a dozen libraries would be dealing with packaging issues every month! In other words: Breaking things is a big deal. A bit of extra effort for a few maintainers seems like a fair trade to lower the effort imposed on many users, who would be perfectly happy to continue using the old code until they decide when to upgrade.

Unintentional Breaking Changes

The other kind of breaking change is the non-intentional kind, otherwise known as a "bug" or "regression". Realize first of all, that it is impossible for any versioning system to encode that this has happened: Since the maintainer isn't knowingly introducing a bug they naturally can't update their version numbers to reflect what they don't know about. Instead we have to deal with these issues after the fact.

The first thing a package maintainer can do is to minimize the chance of inflicting buggy software on users. After any non-trivial (potentially breaking) change, it is a good practice to first create an -alpha/-beta/-rc release. These so called --pre releases are intended to be downloaded only by the few and the brave: Those who are willing to participate in testing. After any issues are ironed out with the --pre releases, a final release can be made for the wider public.

Note that the default behaviour of pip install <package> (without any version specifier) is to download the latest final release. It will download a --pre release only if

  1. no final release is available
  2. the --pre flag is explicitly used, or
  3. if the requirement specifier explicitly includes the version number of a pre release, eg. pip install mypkg==v202009.1007-alpha.

Should a release include a bug (heaven forbid and despite all precautions), then the maintainer should publish a new release which either fixes the bug or reverts the change. If users previously downloaded a version of the package which included the bug, they only have to do pip install --upgrade <package> and the issue will be resolved.

Perhaps a timeline will illustrate more clearly:

v202008.1665       # last stable release
v202008.1666-beta  # pre release for testers
v201901.1667       # final release after testing

# bug is discovered which effects v202008.1666-beta and v201901.1667

v201901.1668-beta  # fix is issued for testers
v201901.1669       # fix is issued everybody

# Alternatively, revert before fixing

v201901.1668       # same as v202008.1665
v201901.1669-beta  # reintroduce change from v202008.1666-beta + fix
v201901.1670       # final release after testing

In the absolute worst case, a change is discovered to break backward compatibility, but the change is nonetheless considered to be desirable. At that point, a new release should be made to revert the change.

This allows 1. users who were exposed to the breaking change to update to the latest release and get the old (working) code again, and 2. users who were not exposed to the breaking change to never even know anything was broken.

Remember that the goal is to always make things easy for users who have your package as a dependency. If there is any issue whatsoever, all they should have to do is pip install --update. If this doesn't work, they may have to temporarily pin to a known good version, until a fixed release has been published.

After this immediate fire has been extinguished, if the breaking change is worth keeping, then create a new module or even a new package. This package will perhaps have 99% overlap to the previous one and the old one may eventually be abandoned.

mypkg  v202008.1665    # last stable release
mypkg  v202008.1666-rc # pre release for testers
mypkg  v201901.1667    # final release after testing period

# bug is discovered in v202008.1666-beta and v201901.1667

mypkg  v201901.1668    # same as v202008.1665

# new package is created with compatibility breaking code

mypkg2 v201901.1669    # same as v201901.1667
mypkg  v201901.1669    # updated readme, declaring support
                       # level for mypkg, pointing to mypgk2
                       # and documenting how to upgrade.

Pinning is not a Panacea

Freezing your dependencies by using pip freeze to create a file with packages pinned to specific version numbers is great to get a stable and repeatable deployment.

The main problem with pinning is that it is another burden imposed on users, and it is a burden which in practice only some can bear. The vast majority of users either 1) pin their dependencies and update them without determining what changed or if it is safe for them to update, or 2) pin their dependencies and forget about them. In case 1 the only benefit is that users might at least be aware of when an update happened, so they can perhaps correlate that a new bug in their software might be related to a recent update. Other than that, keeping tabs on dependencies and updating without diligence is hardly better than not having pinned at all. In case 2, an insurmountable debt will pile up and the dependencies of a project are essentially frozen in the past.

Yes, it is true that users will be better off if they have sufficient test coverage to determine for themselves that their code is not broken even after their dependencies are updated. It is also true however, that a package maintainer is usually in a better position to judge if a change might cause something to break.

Zeno's 1.0 and The Eternal Beta

There are two opposite approaches to backward compatibility which find a reflection in the version numbers they use. In the case of SemVer, if a project has a commitment to backward compatibility, it may end up never incriminating the major version, leading to the Zeno 1.0 paradox. On the other end are projects that avoid any commitment to backward compatibility and forever keep the "beta" label.

Of course an unpaid Open Source developer does not owe anybody a commitment to backward compatibility. Especially when a project is young and going through major changes, such a commitment may not make any sense. For these cases you can still use PyCalVer, just so long as there is a big fat warning at the top of your README, that your project is not ready for production yet.

Note that there is a difference between software that is considered to be in a "beta" state and individual releases which have a -beta tag. These do not mean the same thing. In the case of releases of python packages, the release tag (-alpha, -beta, -rc) says something about the stability of a particular release. This is similar (perhaps identical) to the meaning of release tags used by the CPython interpreter. A release tag is not a statement about the general stability of the software as a whole, it is metadata about a particular release artifact of a package, eg. a .whl file.