pythonでモジュールにversion情報を含めたい場合の方法のメモ

個人的には、pythonでパッケージを公開するときに、モジュールにバージョン情報を含めるのが好きではなかった。setup時のimportエラーなどが起きうる可能性を考えて。

ただ、たまたま要望されたのでその方法を調べてみたところ、手軽に扱えるようになっていた。これなら使っても良いかもしれない。

setup.cfgでattr:を使う

いろいろ調べた結果、setup.cfgでattr:でバージョン情報を載せるのが一番良さそう。

例えば以下の様なファイルの構成で _version.py__version__ を載せるときには以下の様にすればOK。

foo/
├── __init__.py
├── _version.py
└── tests
    └── __init__.py

1 directory, 3 files

__version__.py

__version__ = "0.0.0"

__init__.py

from ._version import __version__  # noqa:F401

setup.cfg

# see: https://setuptools.readthedocs.io/en/latest/setuptools.html#specifying-values
[metadata]
name = foo
version = attr: foo._version.__version__

# その他の設定は省略

attr: foo._version.__version__ が大切。かつてはだめだったけれど、すでに解決されていたのでこれでOK。

詳細

ここからは詳細。元々どのような問題が起きるかなどをメモしておく。

setup時にimportされてModuleNotFoundError

再現するrepositoryは以下(新しいsetuptoolsでは再現しないが)。

https://github.com/kohr-h/minimal

詳しい話題は以下のissueで。

https://github.com/pypa/setuptools/issues/1724

少しだけ真面目に解説すると、以下の様なファイル構成になってるときに、 __init__.pymod.py をimportし、そこでnumpyなど何らかのモジュールをimportするような構成になっていたときに、setup.cfgで attr: minimal.__version__ と記述していたときにエラーになっていた。

.
├── minimal
│   ├── __init__.py
│   ├── _version.py
│   └── mod.py
├── setup.cfg
└── setup.py

1 directory, 5 files
$ pip install -e . # or python setup.py develop
Traceback (most recent call last):
  File "setup.py", line 3, in <module>
    setup()
  File "/home/hkohr/miniconda/envs/tmp/lib/python3.7/site-packages/setuptools/__init__.py", line 145, in setup
...
  File "/home/hkohr/git/minimal/minimal/__init__.py", line 2, in <module>
    from . import mod
  File "/home/hkohr/git/minimal/minimal/mod.py", line 1, in <module>
    import numpy
ModuleNotFoundError: No module named 'numpy'

このときのファイルは以下のようなもの。

__init__.py

from ._version import __version__
from . import mod

mod.py

import numpy

setup.cfg

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

かつての実装ではsetup.pyで以下の様なコードを書いたものと同じだったため。

setup.py

from setuptools import setup
from minimal._version import __version__

setup(version=__version__, ...)

pythonのモジュールの読み込み方は、下位のモジュールを読み込んだタイミングで、上位のモジュールも読み込まれるので、仮にminimal._versionをimportしようとしても、その前に minimalがimportされる。そしてminimal経由でminimal.modが読み込まれてしまう。

かつてのwork-around

work aroundとして以前は以下のように VERSIONというファイルを用意して、setup.cfgではこれを読み込む形にしていた。

version = file: VERSION

VERSIOIN

0.0.0

このとき、VERSIONと言うファイルを書き換えれば、pypiにアップロードされるバージョンを変更できるという形になっていたのだけれど、この方法ではモジュールにバージョン情報をもたせたいときに、何らかの方法でpythonのモジュール側のコードと同期させる手順が必要になった。これがめんどくさくてやっていなかった。

方法自体は以下の様な2種類の方法がある

  • pythonのパッケージにVERSIONファイルが含まれるようにして、importlib.resourceなどで取り出す
  • 何らかのビルド時のpre-processとして、VERSIONファイルの情報をコードに転写する処理を加える

どちらも手間がかかってめんどくさい。そもそもバージョン情報などが知りたかったら pip freeze でもすれば良いじゃんと言う立場だった。

変更後

この処理がsetuptoolsのv46.4.0から変更されている。ファイルを直接読み込んでASTベースで情報を取得するように変更された。

実際どのようなコードになっているかと言うと以下の様な感じ。このStaticModuleというクラスを介して値を取得するように変更された。例えば、このStaticModuleの値mに対して、m.versionなどとアクセスしたときには、構文上の代入部分(assign)を取り出してその値を返す。

setuptools/config.py

import ast

class StaticModule:
    """
    Attempt to load the module by the name
    """
    def __init__(self, name):
        spec = importlib.util.find_spec(name)
        with open(spec.origin) as strm:
            src = strm.read()
        module = ast.parse(src)
        vars(self).update(locals())
        del self.self

    def __getattr__(self, attr):
        try:
            return next(
                ast.literal_eval(statement.value)
                for statement in self.module.body
                if isinstance(statement, ast.Assign)
                for target in statement.targets
                if isinstance(target, ast.Name) and target.id == attr
            )
        except Exception as e:
            raise AttributeError(
                "{self.name} has no attribute {attr}".format(**locals())
            ) from e

そんなわけで、 version = attr: <module>._version.__version__ で良い。

参考

gist