If you have ever forgotten to pack a charging cable or something else for a trip, you probably have noticed that we humans are prone to errors in simple and repetitive routine tasks 1. Because machines have a much lower error rate at such tasks, I prefer to delegate and automate as many routine tasks as possible. Versioning a package is one of such tasks. If you want your package to live on the Python Package Index (PyPI), you must include a version. It’s furthermore probably expected by your users that you provide a __version__
attribute in your package’s namespace.
Talking about error-proneness, the obvious and probably most error-prone way to include your version is to hardcode it both in your package metadata (e.g., in a setup.py
) and in your package’s files, most likely the top level __init__.py
. Say, I want to update the version of my package binary4fun to 0.2.0 in a new release. Following the aforementioned method, I need to manually put it in the setup.py
:
1
2
3
4
5
6
7
from setuptools import setup
setup(
name="binary4fun",
version="0.2.0",
# ...
)
And I need to manually update it in the package’s files, for example in src/binary4fun/__init__.py
:
1
__version__="0.2.0"
It is the most error-prone method because you can not only forget to increase the version but also accidentally increase just one of them. In the first version of the binary4fun package, I put a harcoded string only into the __init__.py
to at least avoid the latter. Then, I read it in the setup.py
, which looked like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import pathlib
from setuptools import find_packages, setup
def get_version(rel_path: str):
file_path = pathlib.Path(__file__).parent.absolute() / rel_path
for line in file_path.read_text().splitlines():
if line.startswith("__version__"):
return line.split('"')[1]
raise RuntimeError("Unable to find version string.")
setup(
name="binary4fun",
version=get_version("src/binary4fun/__init__.py"),
# ...
)
And in scr/binar4fun/__init__.py
:
1
__version__ = "0.2.0"
Note that I did not import my package but just read the content of scr/binar4fun/__init__.py
. You could, for example, also put it into a dedicated _version.py
or version.txt
file, although you may have to manually add the file to a MANIFEST.in
, depending on its location, its file type and your setup()
arguments – another spot for some sweet errors.
Other options are listed for example here or in the Python Packaging User Guide by PyPA (the aforementioned method is their first listed entry). If you insist on keeping the version number in the code, there is also for example bump2version
, which updates all version strings in a configured file list.
setuptools_scm takes the stage
All of the aforementioned approaches have one thing in common: you hardcode your version in at least one place in the package. setuptools_scm, as the name says, retrieves the package version from scm metadata instead of from an explicit declaration. Again, coming back to human errors, if you work with a version control system such as Git and publish releases on GitHub, you’re working with scm metadata already anyway. The way recommended by the authors to implement setuptools_scm
is to add it to your build configuration in pyproject.toml
and a line to invoke version inference:
1
2
3
4
5
[build-system]
requires = ["setuptools>=40.8.0", "wheel", "setuptools_scm[toml]>=6.0"]
build-backend = "setuptools.build_meta"
[tool.setuptools_scm]
The entry tells build tools that setuptools_scm
is required to build the package and, therefore, it gets fetched the isolated environment together with the other listed packages. To infer the version, setuptools_scm
looks at three things in the standard configuration (quoted from their manual):
- latest tag (with a version number)
- the distance to this tag (e.g. number of revisions since latest tag)
- workdir state (e.g. uncommitted changes since latest tag)
You can check the to be inferred version with git describe --tags
or the inference result with:
1
2
❯ python -m setuptools_scm
Guessed Version 0.2.0
If you want, you can create a release, build, and publish on PyPi in GitHub Actions. Since this post is about setuptools_scm
, here is my approach to creating the release. I implemented a manual workflow, which needs the release version as input. Check out a complete workflow (test, create release, build, and publish) here.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
on:
workflow_dispatch:
inputs:
tag:
required: true
jobs:
release:
name: Create release
runs-on: ubuntu-latest
needs: testing
steps:
- name: Create GitHub release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: $
with:
tag_name: $
release_name: $
If you decide to also build in GitHub Actions, be careful to use fetch-depth: 0
to fetch all branches and tags:
1
2
3
4
- name: Checkout source
uses: actions/checkout@v2
with:
fetch-depth: 0
What about __version__?
The steps above ensure a version for the package’s metadata. One option to provide a __version__
attribute is to first let setuptools_scm
write the inferred version to a file:
1
2
[tool.setuptools_scm]
write_to = "src/binary4fun/_version.py"
Then, upon build, your _version.py
file (automatically included since it resides within the package path) will contain the tag version written into the designated file:
1
2
3
4
5
# coding: utf-8
# file generated by setuptools_scm
# don't change, don't track in version control
version = '0.2.0'
version_tuple = (0, 2, 0)
To provide the __version__
attribute, you need to read it from the designated location. Hence, put something like this in your __init__.py
:
1
2
3
4
5
6
try:
from ._version import version as __version__
from ._version import version_tuple
except ImportError:
__version__ = "unknown version"
version_tuple = (0, 0, "unknown version")
A little less conversation, a little more action, please
If you do not want to have a static version written in your package but also do not want to let go of your __version__
attribute, there is also an option to retrieve the version from the installed package’s metadata at runtime. It’s simple, you just read the installed package’s metadata, which as we’ve seen above must include a version if it comes from PyPI, via importlib.metadata. To make this work, put something like this in your package’s __init__.py
:
1
2
3
4
5
6
from importlib.metadata import version, PackageNotFoundError
try:
__version__ = version("binary4fun")
except PackageNotFoundError:
__version__ = "unknown version"
However, be aware that importlib.metadata
came in fresh in 3.8 and, thus, you’d have to rely on its backport for prior python versions, which means effectively that you have to deal with a(nother) dependency - another “charging cable” to forget.
Fitts, P. M. (1951). Human engineering for an effective air navigation and traffic control system. National Research Council, Washington, D. C. ↩