12

I am building a Python module with a structure like:

mypackage/
    mypackage/
        __init__.py
        etc.py
    setup.py
    setup.cfg
    pyproject.toml

To build it, I am running $ python -m build. I noticed that version numbers weren't available (e.g. mypackage.__version__ is undefined after installing), and currently I am just setting it manually like:

setup.py

setup(..., version='0.0.1' )

pyproject.toml

[project]
version = '0.0.1'

I am new to Python package development and there are a few posts on this, but there does not seem to be a standard way of doing it.

The package is quite small and ideally I'd like to just update one thing like __version__ = '0.0.1' inside __init__.py, and then have this parsed automatically in setup.py and pyproject.toml.

2
  • This is for Python 3.6+ and I am just following this guide here (packaging.python.org/en/latest/tutorials/packaging-projects). I can figure out how to parse the version in setup.py since its Python code, but the pyproject.toml syntax is a bit confusing. Commented May 9, 2022 at 6:41
  • Why are you using both pyproject.toml and setup.py? You should use one or the other. Commented May 9, 2022 at 7:04

4 Answers 4

19

Do you really need/want a __version__ attribute at all?

Keeping a __version__ attribute available in the module namespace is a popular convention, but it's possibly falling out of fashion these days because stdlib importlib.metadata is no longer provisional. The one obvious place for a version string is in the package metadata, duplicating that same information in a module attribute may be considered unnecessary and redundant.

It also presents some conundrums for users when the version string exists in two different places - where should we look for it first, in the package metadata or in a module top-level namespace? And which one should we trust if the version information found in each of these places is different?

So, there is some benefit to only storing it in one place, and that place must be the package's metadata. This is because the Version field is a required field in the Core metadata specifications, but packages which opt-in to providing a __version__ attribute are just following a convention.

Getting/setting the version in package metadata

If you're using a modern build system, then you would specify the version string directly in pyproject.toml as described in PEP 621 – Storing project metadata in pyproject.toml. The way already shown in the question is correct:

[project]
name = "mypkg"
version = "0.0.1"

Users of mypkg could retrieve the version like so:

from importlib.metadata import version
version("mypkg")

Note that unlike accessing a __version__ attribute, this version is retrieved from the package metadata only, and the actual package doesn't even need to be imported. That's useful in some cases, e.g. packages which have import side-effects such as numpy, or the ability to retrieve versions of packages even if they have unsatisfied dependencies / complicated environment setup requirements.

What if you want access to the version number within the package itself?

Sometimes this is useful, for example to add a --version option to your command-line interface. But this doesn't imply you need a __version__ attribute hanging around, you can just retrieve the version from your own package metadata the same way:

parser = argparse.ArgumentParser(...)
...
parser.add_argument(
    "--version",
    action="version",
    version=importlib.metadata.version("mypkg"),
)
Sign up to request clarification or add additional context in comments.

3 Comments

I see that makes a lot more sense now. So the more modern way is to just declare all the metadata in pyproject.toml, and not use setup.py altogether?
For a pure-python project, yes. For projects which require building C extensions, I think keeping a setup.py is still best at this stage.
To take it one step further, add versioneer to generate the package version number automatically and directly from a github tag. It was my first attempt doing any python package building, and it was surprisingly confusing to get it all straight between pyproject.toml, setup.py, versioneer, setup.cfg, pypi build package, importable package names, setuptools backend, importlib.metadata. wheel package names, package relative imports, the whole works.
3

Looking at various popular libraries should give some ideas.

One simple way is to parse your __init__.py inside setup.py, like this example from the wonderful diff-match-patch library:

with open("diff_match_patch/__init__.py") as f:
    for line in f:
        if line.startswith("__version__"):
            version = line.split('"')[1]

As far as I saw, most of the popular Python projects do not put the version in pyproject.toml, if they even have it.

Alternately, you could use the nifty versioneer library, which picks up the version from git. For example, at the time I write this answer, the latest tag in numpy repository's main branch history is v1.22.3, and it is cleanly reflected in numpy.__version__ being 1.22.3, without practically any work on the numpy developers' part.

5 Comments

Thanks, that works great! Related to @wim's comment above - do I even need pyproject.toml?
You don't need it. You can still choose to have it, if it does something you need (e.g. if you need to specify a version of pip, which setup.py is too late for). For example, numpy (with four million daily downloads, so "somewhat" well-tested library :P ) has pyproject.toml, setup.cfg and setup.py, but there is no version information specified in pyproject.toml. Several other popular projects do the same.
Thanks again. I think going through some of the popular libraries is probably a good idea to get a sense of how things are typically packaged. Some of the guides I was reading are from 10 years ago...
Note though that many of the popular projects have lengthy history, and predate the cool things you could do with pyproject.toml that @wim has demonstrated. It is not that you need to have either pyproject.toml or setup.*, but rather that a version string should not be defined in multiple places: pick one mechanism and stick with it.
I would be wary of looking at "various popular libraries" for ideas. New projects should not blindly copy the patterns of older projects, many of those popular libraries were set up back on distutils / py 2.x or even earlier. The patterns may work, but they may just be cruft, or only remaining to support older Python versions. If you're setting up a new project, take advantage of the improved tooling we have available today in Python 3.8+.
3

I have no idea why other answers say that your approach is not recommended, on the contrary, it is the officially recommended one (as of 2023). Core Python developers use this approach. What you need to make sure is to define the __version__ string statically, not dynamically (ie, do not fetch at runtime from a text file, it must be hardcoded in a Python string in a Python file, to be statically available at build time).

Since setuptools v61, you can indeed set a __version__ attribute in your package's __init__.py file as you did, and then dynamically fetch it into your pyproject.toml like so:

[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"

[project]
name = ["mypackage"]
dynamic = ["version"]

[tool.setuptools.dynamic]
version = {attr = "mypackage.__version__"}

Note however that if you also want to access mypackage.__version__ from inside mypackage, you should NOT from . import __version__ because if you also import things in your __init__.py, this will cause an infinite import loop! Instead, you need to implement a function to read (not import!) __init__.py and extract the version string, fortunately the official documentation nowadays provides an easy example:

import codecs
import os.path

def read(rel_path):
    here = os.path.abspath(os.path.dirname(__file__))
    with codecs.open(os.path.join(here, rel_path), 'r') as fp:
        return fp.read()

def get_version(rel_path):
    for line in read(rel_path).splitlines():
        if line.startswith('__version__'):
            delim = '"' if '"' in line else "'"
            return line.split(delim)[1]
    else:
        raise RuntimeError("Unable to find version string.")

version = get_version("mypackage/__init__.py")

If you only want to support Py3, then you are all set, you can stop reading here.

But if you need to use an older version of setuptools, eg if you need to support Python 2.7 (which is very disadvised but some legacy projects may need it), then you can instead use setup.cfg, which supports dynamic version since an earlier version of setuptools:

[metadata]
name = mypackage
version = attr: mypackage.__version__

Note that you still need to have an almost empty setup.py for the setup.cfg to work, otherwise setuptools will choke (it's an official rule):

from setuptools import setup

setup()

But then, if you also want your module to have both a setup.cfg and setup.py to support Py2 and also have a pyproject.toml to support Py3, you will notice that building may fail under Py2 because of requiring a setuptools version too high (the latest on Py2 being v41.1.1). Indeed, even if on Py2 building is done through setup.cfg and setup.py, if you updated pip, then it will STILL access pyproject.toml and access the [build-system] table, because this is the only place where a module can specify build-time requirements, according to PEP 517.

To fix this issue, you need to edit the build requirement in pyproject.toml to specify the python version (see PEP 508 about dependencies specifications):

[build-system]
requires = ["setuptools>=44;python_version<'3'", "setuptools>=61;python_version>='3'"]
build-backend = "setuptools.build_meta"

Then your package should build fine (with the build module or pip install --pep-517) under both Py3 and Py2, using only the pyproject.toml for the former, or setup.cfg for the latter (with a bit of pyproject.toml just for the build-system).

9 Comments

That recommendation in single-sourcing the package version is almost a decade old at this stage. It's a jumble of hacks and most of them should be removed nowadays, packaging has come a long way since then. I consider the advice offered here to be somewhat passé and outdated for a 2023 answer.
@wim The first recommendation I point to, which is to use the dynamic attribute in the pyproject.toml, requires setuptools 61.0.0.0, which was released March 24, 2022, hence 1 year and 3 months ago. This does not look outdated to me. The diff you provide does not include the part I specifically mention and link to. Ofc the page exists since a long time, but instructions got updated over time.
I was talking about the setup.py approach, and the method with pyproject.toml is really just describing a way to do essentially the same thing in the case that you don't use a setup.py at all. It is inferior for the same reason - the version info is now duplicated in the source code and in the metadata .dist-info directory.
Can also offer you references from CPython core developers and Steering Council Members here and here saying that __version__ attributes are generally pointless, "wasted effort", and "unfortunately a fairly ingrained habit". Additionally, the PEP396 which attempted to specify that a module's "version SHOULD be available in the __version__ attribute" was Status: REJECTED.
The rejection notice says: This PEP was formally rejected on 2021-04-14. The packaging ecosystem has changed significantly in the intervening years since this PEP was first written, and APIs such as importlib.metadata.version() provide for a much better experience. I hope this is enough evidence to back up my assertions that this approach is not officially recommended in 2023!
|
-1

Here is an example how to add version attribute to python module using hatchling

# dir tree
├── my_package
│   ├── foo.py
│   └── __init__.py
└── pyproject.toml
# pyproject.toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.sdist]
packages = ['my_package']

[tool.hatch.build.targets.wheel]
packages = ['my_package']

[project]
requires-python = ">= 3.10"
name='my_package'
# this is important thing
dynamic = ["version"]

[tool.hatch.version]
# from <https://hatch.pypa.io/1.13/version/>
path = "my_package/__init__.py"
# __init__.py
__version__ = '0.0.1'

Result after building and/or installing:

import my_package


# will return '0.0.1'
my_package.__version__

Comments

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.