6

I want my package's version number to live in a single place where everything that needs it can refer to it.

I found several suggestions in this Python guide to Single Sourcing the Package Version and decided to try #4, storing it in a simple text file in my project root named VERSION.

Here's a shortened version of my project's directory tree (you can see the the full project on GitHub):

.
├── MANIFEST.in
├── README.md
├── setup.py
├── VERSION
├── src/
│   └── fluidspaces/
│       ├── __init__.py
│       ├── __main__.py
│       ├── i3_commands.py
│       ├── rofi_commands.py
│       ├── workspace.py
│       └── workspaces.py
└── tests/
    ├── test_workspace.py
    └── test_workspaces.py

Since VERSION and setup.py are siblings, it's very easy to read the version file inside the setup script and do whatever I want with it.

But VERSION and src/fluidspaces/__main__.py aren't siblings and the main module doesn't know the project root's path, so I can't use this approach.

The guide had this reminder:

Warning: With this approach you must make sure that the VERSION file is included in all your source and binary distributions (e.g. add include VERSION to your MANIFEST.in).

That seemed reasonable - instead of package modules needing the project root path, the version file could be copied into the package at build time for easy access - but I added that line to the manifest and the version file still doesn't seem to be showing up in the build anywhere.

To build, I'm running pip install -U . from the project root and inside a virtualenv. Here are the folders that get created in <virtualenv>/lib/python3.6/site-packages as a result:

fluidspaces/
├── i3_commands.py
├── __init__.py
├── __main__.py
├── __pycache__/  # contents snipped
├── rofi_commands.py
├── workspace.py
└── workspaces.py
fluidspaces-0.1.0-py3.6.egg-info/
├── dependency_links.txt
├── entry_points.txt
├── installed-files.txt
├── PKG-INFO
├── SOURCES.txt
└── top_level.txt

More of my configuration files:

MANIFEST.in:

include README.md
include VERSION
graft src
prune tests

setup.py:

#!/usr/bin/env python3

from setuptools import setup, find_packages


def readme():
    '''Get long description from readme file'''
    with open('README.md') as f:
        return f.read()


def version():
    '''Get version from version file'''
    with open('VERSION') as f:
        return f.read().strip()


setup(
    name='fluidspaces',
    version=version(),
    description='Navigate i3wm named containers',
    long_description=readme(),
    author='Peter Henry',
    author_email='me@peterhenry.net',
    url='https://github.com/mosbasik/fluidspaces',
    license='MIT',
    classifiers=[
      'Development Status :: 3 - Alpha',
      'Programming Language :: Python :: 3.6',
    ],
    packages=find_packages('src'),
    include_package_data=True,
    package_dir={'': 'src'},
    package_data={'': ['VERSION']},
    setup_requires=[
        'pytest-runner',
    ],
    tests_require=[
        'pytest',
    ],
    entry_points={
        'console_scripts': [
            'fluidspaces = fluidspaces.__main__:main',
        ],
    },
    python_requires='~=3.6',
)

I found this SO question Any python function to get “data_files” root directory? that makes me think the pkg_resources library is the answer to my problems, but I've not been able to figure out how to use it in my situation.

I've been having trouble because most examples I've found have python packages directly in the project root instead of isolated in a src/ directory. I'm using a src/ directory because of recommendations like these:

Other knobs I've found and tried twisting a little are the package_data, include_package_data, and data_files kwargs for setup(). Don't know how relevent they are. Seems like there's some interplay between things declared with these and things declared in the manifest, but I'm not sure about the details.

Peter Henry
  • 443
  • 4
  • 13

2 Answers2

2

Chatted with some people in the #python IRC channel on Freenode about this issue. I learned:

  • pkg_resources was probably how I should to do what I was asking for, but it would require putting the version file in the package directory instead of the project root.
  • In setup.py I could read in such a version file from the package directory without importing the package itself (a no-no for a few reasons) but it would require hard-coding the path from the root to the package, which I wanted to avoid.

Eventually I decided to use the setuptools_scm package to get version information from my git tags instead of from a file in my repo (someone else was doing that with their package and their arguments were convincing).

As a result, I got my version number in setup.py very easily:

setup.py:

from setuptools import setup, find_packages

def readme():
    '''Get long description from readme file'''
    with open('README.md') as f:
        return f.read()

setup(
    name='fluidspaces',
    use_scm_version=True,  # use this instead of version
    description='Navigate i3wm named containers',
    long_description=readme(),
    author='Peter Henry',
    author_email='me@peterhenry.net',
    url='https://github.com/mosbasik/fluidspaces',
    license='MIT',
    classifiers=[
      'Development Status :: 3 - Alpha',
      'Programming Language :: Python :: 3.6',
    ],
    packages=find_packages('src'),
    package_dir={'': 'src'},
    setup_requires=[
        'pytest-runner',
        'setuptools_scm',  # require package for setup
    ],
    tests_require=[
        'pytest',
    ],
    entry_points={
        'console_scripts': [
            'fluidspaces = fluidspaces.__main__:main',
        ],
    },
    python_requires='~=3.6',
)

but I ended up having to have a hard-coded path indicating what the project root should be with respect to the package code, which is kind of what I had been avoiding before. I think this issue on the setuptools_scm GitHub repo might be why this is is necessary.

src/fluidspaces/__main__.py:

import argparse
from setuptools_scm import get_version  # import this function

def main(args=None):
    # set up command line argument parsing
    parser = argparse.ArgumentParser()
    parser.add_argument('-V', '--version',
                        action='version',
                        version=get_version(root='../..', relative_to=__file__))  # and call it here
Peter Henry
  • 443
  • 4
  • 13
1

For folks still looking for the answer to this, below is my attempt at following variety #4 of the guide to Single Sourcing the Package Version. It's worth noting WHY you might choose this solutions when there are other simpler ones. As the link notes, this approach is useful when you have external tools that might also want to easily check the version (e.g. CI/CD tools).

File tree

myproj
├── MANIFEST.in
├── myproj
│   ├── VERSION
│   └── __init__.py
└── setup.py

myproj/VERSION

1.4.2

MANIFEST.in

include myproj/VERSION

setup.py

with open('myproj/VERSION') as version_file:
    version = version_file.read().strip()

setup(
    ...
    version=version,
    ...
    include_package_data=True,  # needed for the VERSION file
    ...
)

myproj/__init__.py

import pkgutil

__name__ = 'myproj'
__version__ = pkgutil.get_data(__name__, 'VERSION').decode()

It's worth noting that setting configuration in setup.cfg is a nice, clean alternative to including everything in the setup.py setup function. Instead of reading version in setup.py, and then including in the function, you could do the following:

setup.cfg

[metadata]
name = my_package
version = attr: myproj.VERSION

In the full example I chose to leave everything in setup.py for the ease of one less file and uncertainty about whether or not potential whitespace around the version in the VERSION file would be stripped by the cfg solution.

ZaxR
  • 4,896
  • 4
  • 23
  • 42
  • Some notes: **1.** Why put the file `VERSION` inside the package if it is never used inside the package? Just place it next to `setup.py` then. **2.** Why put the version in a file, if that file is only ever read once from `setup.py`, place the version string directly in `setup.py` then. **3.** Since at least version _39.2.0_ of _setuptools_ this can be simplified by using a [`setup.cfg` file](https://setuptools.readthedocs.io/en/latest/setuptools.html#configuring-setup-using-setup-cfg-files): `version = VERSION.txt`. **4.** Probably risky to change the value of `__name__`. – sinoroc Feb 05 '20 at 08:18
  • Before downvoting, you might want to consider waiting for the answers to your questions. There are MANY cases where there’s value to having the version in a text file that can be read besides by the library. Re: your questions 1-3, I wanted the version to be easily readable by my CI/CD pipeline, which looks for a version text file. Re 4: ñame is hard coded. Whether it’s in init or setup.py, I don’t see a difference in the riskiness. There are 6 recorded ways in the pypi docs for single-sourcing version, and this is one of them. If you don’t like it, yes of course there are other ways to do it. – ZaxR Feb 05 '20 at 13:06
  • Also, I’m happy to update my answer for improvements that still meet the needs of OP (such as potentially your setup.cfg note). You seem quick to criticize, but I don’t see you posting an answer. – ZaxR Feb 05 '20 at 13:14
  • 1
    Vote was a misfire, but can't change until the answer is edited. This answer is good (mine would be close to yours), but could be improved, hence the notes. If the file `VERSION` is meant to be installed as package data, then make sure to set `include_package_data` or add it to `package_data`. `__name__` is some kind of special variable that is usually already set (by the interpreter?), so I would consider it bad practice to overwrite it. – sinoroc Feb 05 '20 at 13:44
  • I’ll look into the setup.cfg and include_package_data changes and update later today. Thanks. – ZaxR Feb 05 '20 at 13:47
  • Added include_package_data above. Re: cfg, I'm a bit torn. Having an extra cfg file is cleaner for the setup, but limits my ability to do stuff to the opened files. For example, in this case I strip whitespace from VERSION to prevent easy to make/hard to identify errors in updating the file. Re: your original point #2, I had originally intended for both __init__.py and setup.py to read the VERSION file, but had trouble with the pathing to the VERSION file in __init__.py. Any thoughts on what that path should be? I had thought just a relative path to 'VERSION'. – ZaxR Feb 05 '20 at 14:14
  • Maybe add the link to the `setup.cfg` doc as a final note, I would believe the white spaces would be automatically stripped. Read the version string from the VERSION file (as package data): `import pkgutil; __version__ = pkgutil.get_data('myproj', 'VERSION').decode()`. Read the version string from the metadata: `import importlib.metadata; __version__ = importlib.metadata.version('myproj')`. For info, this is how I really do it in my projects: https://sinoroc.gitlab.io/kb/python/project_version.html – sinoroc Feb 05 '20 at 14:30
  • Thanks for the collaboration on this. In an effort to stay true to single-source vesion #4, and broader python version support, I went with the pkgutil option. – ZaxR Feb 05 '20 at 14:47