I’m following PyPA’s current guidance to use only setup.cfg
without setup.py
for configuring metadata when building a package for distribution. (“Static metadata (setup.cfg
) should be preferred. Dynamic metadata (setup.py
) should be used only as an escape hatch when absolutely necessary. setup.py
used to be required, but can be omitted with newer versions of setuptools
and pip
.”)
To be concrete, I’m working with the following directory/file structure:
my-project/
├── LICENSE
├── pyproject.toml
├── README.md
├── setup.cfg
├── src/
│ └── my_package/
│ ├── __init__.py
│ └── my_module.py
└── tests/
(If you’re unfamiliar with this structure that has a src/
directory intermediating between the project directory and the import-package directory, it’s the structure given in the Packaging Python Projects tutorial and argued for by Ionel Cristian Mărieș and Mark Smith, et al. See also § “Using a src/
layout” in Configuring setuptools using setup.cfg
files.)
I had been successfully specifying the version number in setup.cfg
simply with:
[metadata]
version = "0.0.1"
However, PyPA et al. have discussed the concept of “single-sourcing the package version,” which basically means AFAICT moving the definition of the version outside of setup.py
and setup.cfg
to (a) make it more broadly accessible by other tools and (b) put the definition of the version into a file that’s within the import-package directory (viz., my-project/src/my_package
) and therefore installed with the code. (Admittedly, my use case is so simple there may be little benefit to moving the definition outside setup.cfg
, but I’m drawn to exploring best practices—perhaps irrationally so. 😉)
In particular, much of the discussion includes defining a separate file, e.g., __version__.py
, version.py
, or even VERSION
, to hold the version information. Moreover, this version file would be located at (a) the root of the import package, e.g., of my_package
:
path-to/project-directory/src/my_package/__version__.py
rather than at (b) the root of the project-directory
where setup.cfg
resides. (I deliberately hyphenate project-directory
and use underscores in my_package
to emphasize that PyPI normalizes underscores, etc. to hyphens in project names, whereas those normalized names would be illegal as the name of a Python package.)
The overwhelming majority of the discussion about single-sourcing the version number takes place in the context of defining metadata dynamically using setup.py
rather than statically using only setup.cfg
. And that part of the discussion that does address using static metadata (i.e., setup.cfg
without setup.py
) (e.g., Configuring setuptools using setup.cfg files) raises as many questions as it answers. In particular, it’s silent on how to configure the separate version-specifying file and how the various files involved interrelate.
What are the options, and how precisely do you configure the appropriate files, to specify the version of a package/module when you specify the project metadata statically with setup.cfg
?
In the course of formulating the above question, I incrementally solved it to find three methods that work:
attr:
special directive in setup.cfg
. Of these:
__init__.py
file.__version__.py
) and then __init__.py
imports the version string from that separate file.file:
special directive in setup.cfg
.
VERSION
) directly and doesn’t involve the package’s __init__.py
file at all.In light of these three possibilities, I re-present the directory/file structure with the addition of the two version-specifying files __version__.py
and VERSION
:
my-project/
├── LICENSE
├── pyproject.toml
├── README.md
├── setup.cfg
├── src/
│ └── my_package/
│ ├── __init__.py
│ ├── __version__.py
│ ├── VERSION
│ └── my_module.py
└── tests/
Of course, you’d have at most one of those two files, depending on which of the three solutions you chose to implement.
attr:
special-directive solutionsIn both of the solutions using the setup.cfg
’s attr:
special-directive, setup.cfg
obtains the import package’s version from the import package’s __version__
attribute. (When you import some_package
that has a version, use dir(some_package)
and you’ll see that it has a __version__
attribute.) Now you see the connection here between the attr:
name of the special directive and our goal.
The key task: how to assign the __version__
attribute to my_package
?
We assign the __version__
attribute, either directly or indirectly, using the package’s __init__.py
file, which already exists (assuming you have a traditional package rather than a namespace package, which is outside the scope of this answer).
setup.cfg
that is common in both Method A and Method BIn both of these attr:
special-directive solutions, the configuration of the setup.cfg
file is the same, with the following snippet:
[metadata]
name = my-project
version = attr: my_package.__version__
To be clear, here the .__version__
references an attribute, not a file, subpackage, or anything else.
Now we branch depending on whether the version information goes directly into __init__.py
or instead into its own file.
__init__.py
fileThis method doesn’t use a separate file for specifying the version number, but rather inserts it into the package’s __init__.py
file:
# path-to/my-project/src/my_package/__init__.py
__version__ = '0.0.2'
Note two elements of the assignment:
__version__
) corresponds to the attr:
line in setup.cfg
(version = attr: my_package.__version__
)We’re done with Method A.
__version__.py
file and import it in __init__.py
__version__.py
and put the version string in itWe construct a new Python file and locate it at the same level as the import package’s __init__.py
file.
We insert the exact same __version__
directive that we inserted in __init__.py
in Method A:
# my-project/src/my_package/__version__.py
__version__ = '0.0.2'
__init__.py
, import
__version__
from __version__.py
In __init__.py
, we do a relative import to access the __version__
that was assigned in the separate file:
# path-to/my-project/src/my_package/__init__.py
from . __version__ import __version__
To unpack this a little…
from … import …
syntax. (Absolute imports may use either the import <>
or from <> import <>
syntax, but relative imports may only use the second form.).
indicates a relative import, starting with the current package.__version__
refers to the “module” __version__.py
.
__version__.py
. That’s just conventional. Whatever the filename is, however, it must match the name after from .
(except that the .py
is stripped off in the from . import
statement).__version__
refers to the assignment statement inside of __version__.py
.
__version__
, but it certainly at a minimum needs to match the assignment statement.We’re done with Method B.
file:
special directiveIn this method, we use a separate file for the version number, as in Method B. Unlike Method B, we read the contents of this file directly, rather than importing it.
To prevent confusion, I’ll call this file simply VERSION
. Like __init__.py
and Method B’s __version__.py
, VERSION
is at the root level of import package. (See the directory/file diagram.) (Of course, in this method, you won’t have __version__.py
.)
However, the contents of this VERSION
file are much different than the contents of Method B’s __version__.py
.
Here’s the contents of my-project/src/my_package/VERSION
:
0.0.2
Note that:
__version__ =
” preamble to the assignment string.VERSION does not comply with PEP 440: # comment line
.setup.cfg
is different than beforeThere are two points of note that distinguish setup.cfg
in Method C from the setup.cfg
that was common to both Methods A and B.
setup.cfg
uses file:
rather than attr:
In Method C, we use a different formulation in setup.cfg
, swapping out the attr:
special directive and replacing it with the file:
special directive. The new snippet is:
[metadata]
name = my-project
version = file: src/my_package/VERSION
VERSION
is relative to the project directoryNote the path to VERSION
in the assignment statement: src/my_package/VERSION
.
The relative file path to the VERSIONS
file is relative to the root of the project directory my-project
. This differs from Method B, where the relative import was relative to the import-package root, i.e., my_package
.
We’re done with Method C.
Method A might be seen to have a virtue of needing no additional file to set the version (because, in addition to setup.cfg
, which is needed in any case, Method A uses only __init__.py
, which likewise already exists). However, having a separate file for the version number has its own virtue of being obvious where the version number is set. In Method A, sending someone to change the version number who didn’t already know where it was stored might take a while; it wouldn’t be obvious to look in __init__.py
.
Method C might seem to have the advantage over Method B, because Method B requires modification to two files (__init__.py
and __version__.py
) rather than only one for Method C (VERSION
). The only perhaps countervailing advantage of Method B is that its __version__.py
is a Python file that allows embedded comments, which Method C’s VERSION
does not.