给Python模块添加版本属性

6
我正在构建一个Python模块,其结构如下所示:

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

为了构建它,我运行$ python -m build。我注意到版本号不可用(例如在安装后mypackage.__version__未定义),目前我只是手动设置它,如下所示:

setup.py

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

pyproject.toml

[project]
version = '0.0.1'

我刚开始接触Python包开发,虽然有一些相关帖子,但好像没有标准的方法。

这个包非常小,我希望只更新一个东西,比如在__init__.py中更新__version__ = '0.0.1',然后让setup.pypyproject.toml自动解析它。


这是针对Python 3.6+的,我只是在按照这里的指南进行操作(https://packaging.python.org/en/latest/tutorials/packaging-projects/)。我可以弄清楚如何解析`setup.py`中的版本,因为它是Python代码,但是`pyproject.toml`的语法有点令人困惑。 - Adam
为什么你在同时使用 pyproject.tomlsetup.py?你应该只使用其中一个。 - wim
3个回答

11

你真的需要/想要一个__version__属性吗?

将一个__version__属性保持在模块命名空间中是一种流行的约定,但这可能正在逐渐过时,因为stdlib importlib.metadata不再是临时的。版本字符串的一个明显位置是在软件包元数据中,在模块属性中重复这些信息可能被认为是不必要和多余的。

当版本字符串存在于两个不同的位置时,也会给用户带来一些困惑 - 我们应该首先在软件包元数据还是在模块顶级命名空间中寻找它?如果这些位置中找到的版本信息是不同的,我们应该相信哪一个?

所以,仅将其存储在一个地方确实有一些好处,而这个地方必须是包的元数据。这是因为在核心元数据规范中,Version字段是一个必需字段,但选择提供__version__属性的包只是遵循一种约定。

获取/设置包元数据中的版本

如果您正在使用现代构建系统,那么您将直接在pyproject.toml中指定版本字符串,如PEP 621 – 将项目元数据存储在 pyproject.toml 中所述。问题中已经展示的方式是正确的:

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

使用 mypkg 的用户可以这样获取版本号:
from importlib.metadata import version
version("mypkg")

请注意,与访问__version__属性不同,此版本仅从软件包元数据中检索,并且实际软件包甚至不需要被导入。这在某些情况下非常有用,例如具有导入副作用(如numpy)或能够检索软件包版本的能力,即使它们具有未满足的依赖关系/复杂的环境设置要求。
如果您想在软件包内部访问版本号怎么办?
有时这是有用的,例如为命令行界面添加--version选项。但这并不意味着您需要一个挂在那里的__version__属性,您可以以相同的方式从自己的软件包元数据中检索版本:
parser = argparse.ArgumentParser(...)
...
parser.add_argument(
    "--version",
    action="version",
    version=importlib.metadata.version("mypkg"),
)

我现在明白了,这样更有意义。所以说,更现代的方式是只在pyproject.toml中声明所有元数据,而不使用setup.py吗? - Adam
1
对于一个纯Python项目,使用.py文件的方式是可行的。但对于需要构建C扩展的项目,我认为在这个阶段保留setup.py仍然是最好的选择。 - wim
为了更进一步,可以添加versioneer来自动从github标签直接生成软件包版本号。这是我第一次尝试构建任何Python软件包,令人惊讶的是,在pyproject.toml、setup.py、versioneer、setup.cfg、pypi构建软件包、可导入的软件包名称、setuptools后端、importlib.metadata、wheel软件包名称、软件包相对导入等方面,让一切都变得清晰起来是相当困惑的。 - undefined

2

查看各种流行的库应该会给出一些想法。

一个简单的方法是在setup.py中解析你的__init__.py,就像来源于精彩的diff-match-patch库的这个例子一样:

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

据我所见,大多数流行的Python项目并没有将版本放在pyproject.toml中,即使它们有版本号也是如此。
或者,您可以使用巧妙的versioneer,该库从git中获取版本。例如,在我回答这个问题时,numpy存储库主分支历史记录中的最新标记为v1.22.3,并且它在numpy.__version__中干净地反映为1.22.3,几乎没有numpy开发人员的工作量。请注意保留HTML标签。

谢谢,这很有效!关于@wim上面的评论 - 我甚至需要pyproject.toml吗? - Adam
1
你不是_必须_要它。如果它有你需要的功能(例如,如果你需要指定pip的版本,而setup.py已经太晚了),你仍然可以选择使用它。例如,numpy(每天有四百万次下载,因此是一个“相当”经过测试的库:P)有pyproject.tomlsetup.cfgsetup.py,但在pyproject.toml中没有指定版本信息。其他几个流行的项目也是如此。 - Amadan
再次感谢。我认为浏览一些流行的库可能是一个好主意,以了解通常如何打包事物。我阅读的一些指南是来自10年前... - Adam
1
请注意,许多流行的项目都有悠久的历史,并且早于@wim演示的pyproject.toml的酷炫功能。并不是说你需要拥有pyproject.tomlsetup.*中的任何一个,而是版本字符串不应在多个位置定义:选择一种机制并坚持使用它。 - Amadan
我会谨慎地参考“各种流行库”来获取想法。新项目不应盲目复制旧项目的模式,许多流行库是在distutils/py 2.x甚至更早的版本中设置的。这些模式可能有效,但它们可能只是冗余代码,或者仅用于支持旧版Python。如果您正在建立一个新项目,请充分利用我们在Python 3.8+中有的改进工具。 - wim

1

我不知道为什么其他答案说你的方法不推荐,相反地,它是官方推荐的(截至2023年)。Python核心开发人员使用这种方法。你需要确保的是以__version__字符串的形式静态地而非动态地定义它(即不要在运行时从文本文件中获取,它必须硬编码在Python文件的一个Python字符串中,在构建时静态可用)。

自 setuptools v61 起, 你确实可以在你的包的__init__.py文件中设置一个__version__属性,就像你所做的那样,然后在pyproject.toml中动态获取它,如下所示:

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

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

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

请注意,如果您希望从mypackage内部访问mypackage.__version__,则不应该使用from . import __version__,因为如果您还在__init__.py中导入其他内容,这将导致无限循环导入!相反,您需要实现一个函数来读取(而不是导入!)__init__.py并提取版本字符串,幸运的是,官方文档现在提供了一个简单的示例
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")

如果您只想支持 Py3,那么一切都准备就绪,您可以停止阅读了。
但是,如果您需要使用旧版本的 setuptools,例如需要支持 Python 2.7(这是非常不建议的,但某些遗留项目可能需要),那么您可以使用支持动态版本的 setup.cfg,因为较早的 setuptools 版本开始支持它。
[metadata]
name = mypackage
version = attr: mypackage.__version__

请注意,您仍然需要一个几乎为空的setup.py,以使setup.cfg起作用,否则setuptools将无法正常工作(这是官方规定)。
from setuptools import setup

setup()

但是,如果您还希望您的模块既有一个 setup.cfg 和 setup.py 来支持 Py2,又有一个 pyproject.toml 来支持 Py3,那么您会注意到在 Py2 下构建可能会失败,因为需要一个版本太高的 setuptools(Py2 上最新版本为 v41.1.1)。实际上,即使在 Py2 下构建是通过 setup.cfg 和 setup.py 完成的,如果您更新了 pip,它仍然会访问 pyproject.toml 并访问 [build-system] 表,因为这是一个模块可以指定构建时需求的唯一位置,根据 PEP 517 的规定。
要解决这个问题,您需要编辑 pyproject.toml 中的构建要求,以指定 Python 版本(关于依赖规范,请参阅 PEP 508 的 链接)。
[build-system]
requires = ["setuptools>=44;python_version<'3'", "setuptools>=61;python_version>='3'"]
build-backend = "setuptools.build_meta"

然后你的软件包应该能够正常构建(使用build模块或pip install --pep-517),在Py3和Py2下都可以,前者只需使用pyproject.toml,后者则需要使用setup.cfg(同时还需要一点pyproject.toml来配置构建系统)。

我在谈论setup.py的方式,而使用pyproject.toml的方法实际上只是在你根本不使用setup.py的情况下描述了一种做基本相同事情的方式。出于同样的原因,它较差 - 版本信息现在在源代码和元数据的.dist-info目录中重复。 - wim
@wim 我不是setuptools的维护者,也对代码库不够熟悉,无法对你的说法发表任何意见。你可能是对的,这种做法可能是低效的,但你主要的观点——单一来源的软件包版本已经过时——似乎并不是setuptools维护者的官方立场。希望将来能有更好的解决方案,但目前我认为没有,除非你知道一个解决办法? - gaborous
也可以为您提供来自CPython核心开发人员和Steering Council成员的参考资料这里这里,他们表示__version__属性通常是无意义的,“徒劳无功”,并且“不幸地成了一种根深蒂固的习惯”。此外,试图规定模块的“版本应该在__version__属性中可用”的PEP396拒绝 - wim
拒绝通知中提到:该PEP在2021年4月14日正式被拒绝。自该PEP首次撰写以来,软件包生态系统发生了重大变化,而importlib.metadata.version()等API提供了更好的使用体验。 我希望这足以证明我的观点,即这种方法在2023年并非官方推荐! - wim
@wim 沃沙也讨论了 PEP396 关于 __version__ 的部分可能仍然相关。正如他所写的,这个 PEP 是在一个不同的时代为了解决一个不同的问题而编写的,它的拒绝并不意味着对于 __version__可选使用被拒绝,只是其强制使用被拒绝了。我们需要一个被接受的 PEP 来禁止使用 __version__,PEP 的拒绝并不意味着那样。无论如何,我对任何系统都没有情感依恋,但正如讨论中所述,__version__ 允许一些通过 importlib 不可能实现的用例。 - gaborous
显示剩余4条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接