Stop Manually Defining Versions in Code
I recently was going through projects at work and updating all the places where version
gets defined or set. Configuring commitizen is a whole different topic for a different day, but I (embarrassingly) only recently discovered a better technique for defining the version of a library or tool in code that is more DRY and just feels “more correct”. So in the name of suggesting what feels like “more correct”, I suggest you stop manually setting a variable in your code to declare the version.
So old normal would be something like this probably in a __init__.py
file of a library:
__version__ = "0.1.0"
It’s simple. It’s one line of code and it allows you then import your library and then fetch __version__
to know the version. Or you might also declare VERSION
this way or equal to __version__
. All good, I’m not worried about that particular standards argument.
The issue you have is that version
is also now typically declared in your pyproject.toml
file. And depending on what tools you use, it might be declared multiple times in that file. Again, another topic for another post for that issue.
For the moment, I’m concerned about not re-declaring a version number in code when it’s already being set and maintained (hopefully) in your pyproject.toml
file (somewhere).
Suggestion
For Libraries
Assuming you still feel it’s important to be able to return either VERSION
or __version__
from your library import (and that might could be up for debate), then I would suggest this slight modification to your library’s __init__.py
file:
from importlib.metadata import version
__version__ = version(__name__)
VERSION = __version__ # if you also want to expose `VERSION` etc...
As a note about where this is coming from. Starting with Python 3.10, importlib.metadata is no longer a provisional standard and is now an official way to fetch package metadata. The old mechanism, pkg_resources, is deprecated.
For CLI Tools
So for a CLI tool, you can further shortcut this issue, depending on what toolkit you’re using for providing the command-line parsing.
click
If you’re smart 😉, you’ll use click for your command line interface. If you then declare a version_option
on your command, click will automatically do the right thing and fetch your package version for you without having to do anything.
warning
If you’re not installing your package or project as part of the project, then this is not going to work. Most default setups with, pdm
, poetry
, and rye
all seem biased towards importing the current project as an editable package. Also, the fact that you’re exposing a version
of some kind implies either you have an installable package/library or installable command-line script. If you just create a simple project that imports click
for instance, without defining a package of any kind, then click
will throw an error if you actually do a --version
option on a click.command()
because it can’t determine the version.
argparse
If you’re committed to the standard library and using argparse
then you can define version
like this:
import argparse
from importlib.metadata import version
def main() -> int:
parser = argparse.ArgumentParser(description="just an example")
parser.add_argument('-V', '--version', action='version', version=version(__name__))
args = parser.parse_args()
print("Hello from apexample!")
print(__name__)
return 0
note
In this example, the script here is part of the __init__.py
file. If the script name is something else, you could replace __name__
with the name of your package.
So there ya go, version
info where the source of truth is only in the pyproject.toml
file (sort of).
Dynamic in the Source (updated 2024-10-05)
As pointed out by Brandon, in the comments, there is another way to specify a version number in just one place. There are pros and cons to this, but it’s definitely worse considering.
So the official documentation on this is found here: Single Sourcing the Version. However, the documentation only makes reference to setuptools
. In my quest to keep things as uv
aligned as possible, I found this article on achieving the same thing as the documentation but with the hatchling
build system instead.
So rather than creating a new about.py
file, I chose instead to stick with including the __version__
definition in my __init__.py
file. The resulting diff for a project already using the single version mentioned above would be just this.
Code Based (using dynamic) Version in Detail
In the pyproject.toml
:
[project]
name = "yourpackagename"
dynamic = ["version"]
...
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
...
[tool.hatch.version]
path = "src/yourpackagename/__init__.py"
note
This example assumes of course that you’re using a build system of hatchling
which is why I included those snippets in the example pyproject.toml
file.
Advantages
The pros to this approach seem to be:
- Your code can now directly access the
__version__
in a more old-school way. Of course, you don’t have to include it in the__init__.py
file. You can choose to put it wherever you feel makes most sense for your project. - Existing code that uses the
from importlib.metadata import version
will still work as will theclick
library. - You can use the
hatch version ...
command line to control the version number. If you were usinguvx
to execute hatch, then the command would be like this:uvx hatch version patch
. This would bump the patch value of the version.
Disadvantages
The cons to this approach seem to be:
- It’s two specific lines in
pyproject.toml
that must be done in order for this work (fully). - It varies based on the build backend you’re using.
- It might be difficult for people new to the project to identify how the version number works (though the configuration does rather specifically point you to where it’s at).
- Commitizen (if you use that) might have a bit more trouble (because it’s going to require extra configuration, I believe) controlling the version number.
Personal Thoughts
I’m really torn about how I feel about this “dynamic” version. I like that it’s python code again, but I also find having the version number right in the pyproject.toml
file very compelling and simple.
For projects where the package isn’t actually self-installing, the dynamic approach also offers an intermediate step since you’ll have a version number in code for that anyway and then if transitioning to a distributed/published package later, it will actually require less of a change.
Other Topics
In some future post, I need to discuss commitizen as well as accomplishing a basic version of this using rye
, poetry
or pdm
.