Python包中嵌入版本的标准方法是什么?

395

是否有一种标准方法将版本字符串与Python包相关联,以便我可以执行以下操作?

import foo
print(foo.version)

我想必须有某种方式可以检索到那些数据,而不需要任何额外的硬编码,因为次要/主要字符串已经在setup.py中指定了。我发现的另一种替代方案是,在我的foo/__init__.py中使用import __version__,然后由setup.py生成__version__.py


22
请注意,这里有一个非常好的概述:https://packaging.python.org/en/latest/guides/single-sourcing-package-version/ - ionelmc
2
使用 setuptools 可以从元数据中检索已安装软件包的版本,因此在许多情况下,仅在 setup.py 中放置版本即可。请参见此问题 - saaj
2
FYI,基本上有5种常见模式来维护版本号的单一真相(在设置和运行时)。 - KF Lin
8
@ionelmc,你的链接已经失效了。这是更新后的链接: https://packaging.python.org/guides/single-sourcing-package-version/ - Adam
1
这应该更简单。看看所有这个简单问题的复杂答案。天啊。如果setup.py有一个版本(它必须有),为什么package_name.__version__不能正常工作并显示该版本呢? - eric
显示剩余6条评论
25个回答

201

这并不是直接回答您的问题,但您应该考虑将其命名为__version__而不是version

这几乎是一种准标准。许多标准库中的模块使用__version__很多第三方模块也使用它,因此它是准标准。

通常,__version__是一个字符串,但有时它也是浮点数或元组。

正如S.Lott所提到的(谢谢!),PEP 8明确指出:

模块级别的Dunder名称

模块级别的“dunders”(即具有两个前导和两个尾随下划线的名称),例如__all____author____version__等, 应该放在模块文档字符串之后,但在任何导入语句之前,除了来自__future__的导入。

您还需要确保版本号符合PEP 440PEP 386是该标准的先前版本)中描述的格式。


13
应该是一个字符串,并有一个元组版本的__version_info__。 - James Antill
詹姆斯:为什么要特别使用 __version_info__?(这是你自己“发明”的双下划线词汇。)[当詹姆斯发表评论时,下划线在注释中没有任何作用,现在它们表示强调,所以詹姆斯确实也写了 __version_info__。---编者注。] - Roger Pate
你可以在http://packages.python.org/distribute/setuptools.html#specifying-your-project-s-version上看到有关__version__应该包含什么内容的信息。该页面涉及distribute,但版本号的含义正在成为一种事实上的标准。 - sienkiew
尽管 PEP 8 表示,但是让 __version__ 的内容遵循 PEP 386 中的规则非常理想,这样可以可靠地比较版本号。 - Daira Hopwood
太棒了。然后再进一步,使用'optparse'创建一个命令行标志(-v, --version)以打印版本号:https://docs.python.org/3/library/optparse.html#module-optparse - BoomShadow
10
你会把那个 version 放在哪里?考虑到这是被接受的版本,我希望在这里看到额外的信息。 - Darkgaze

170

我使用单个_version.py文件作为存储版本信息的“唯一权威地点”:

  1. 它提供了一个__version__属性。

  2. 它提供了标准的元数据版本。因此,pkg_resources或其他解析包元数据(EGG-INFO和/或PKG-INFO,PEP 0345)的工具将检测到它。

  3. 在构建软件包时,它不会导入您的软件包(或任何其他内容),这可能会在某些情况下引起问题(请参见下面关于这可能引起什么问题的评论)。

  4. 只有一个位置记录版本号,因此,在版本号更改时只有一个位置需要更改,减少了不一致版本的机会。

它的工作方式如下:存储版本号的“唯一权威地点”是一个名为_version.py的.py文件,位于您的Python包中,例如在myniftyapp/_version.py中。该文件是一个Python模块,但是您的setup.py不会导入它!(这将破坏功能3。)相反,您的setup.py知道这个文件的内容非常简单,类似于:

__version__ = "3.6.5"

因此,您的setup.py会打开文件并解析它,代码类似于:

import re
VERSIONFILE="myniftyapp/_version.py"
verstrline = open(VERSIONFILE, "rt").read()
VSRE = r"^__version__ = ['\"]([^'\"]*)['\"]"
mo = re.search(VSRE, verstrline, re.M)
if mo:
    verstr = mo.group(1)
else:
    raise RuntimeError("Unable to find version string in %s." % (VERSIONFILE,))

然后,你的setup.py将该字符串作为"version"参数的值传递给setup(),从而满足特性2。

为了满足特性1,你可以在运行时(而不是设置时间),像这样从myniftyapp/__init__.py导入_version文件来引用你的包:

from _version import __version__

这里是我多年来一直在使用的技巧的示例

那个示例中的代码有点复杂,但我在这条评论中写入的简化示例应该是一个完整的实现。

这里是导入版本的示例代码

如果您对这种方法有任何疑问,请告诉我。


11
请描述激发第三个问题的原因。Glyph说这与“setuptools在运行setup.py时喜欢假装您的代码不在系统上”的情况有关,但详细信息将有助于说服我和其他人。答案:请描述引起#3问题的原因。Glyph表示这与“setuptools在运行setup.py时喜欢假装您的代码不存在于系统中”的某些事情有关,但详细信息将有助于说服我和其他人。 - Ivan Kozik
2
@Iva 现在,这个工具应该按什么顺序执行呢?它甚至无法(在今天的setuptools/pip/virtualenv系统中)知道依赖关系是什么,直到它评估了你的 setup.py。此外,如果它尝试在执行所有依赖项之前进行完整的深度优先搜索,那么如果存在循环依赖,则会被卡住。但是,如果它在安装依赖项之前尝试构建此软件包,那么如果您从 setup.py 导入软件包,则不一定能够导入其依赖项或其依赖项的正确版本。 - Zooko
3
你能否使用写入的方式创建一个名为 "version.py" 的文件,而不是解析 "setup.py" 文件呢?这样更简单些。 - Jonathan Hartley
4
我同意如果你的 "setup.py" 能够写入 "version.py" 文件而不是解析它,这会使得过程稍微简单些。但这也会打开一扇窗口让不一致性进来,当你编辑了 "setup.py" 以具有新版本时却还没有执行 "setup.py" 来更新 "version.py" 文件。将规范版本保存在一个小的独立文件中的另一个原因是可以方便其他工具(例如读取修订控制状态的工具)编写版本文件。 - Zooko
4
在setup.py中,与其尝试手动解析版本代码,更好的方法是使用execfile("myniftyapp/_version.py")。建议在https://dev59.com/V3I95IYBdhLWcg3w_zWs#2073599中提到,那里的讨论也可能会有所帮助。 - medmunds
显示剩余9条评论

141

2017年5月重写

经过13年的Python编程和管理各种包,我得出结论,自己动手可能不是最好的方法。

我开始使用pbr包来处理我的包中的版本控制。如果您使用git作为您的源代码管理工具,这将像魔法一样适合您的工作流程,可以节省您数周的工作时间(您会惊讶于这个问题可能有多么复杂)。

截至今日,pbr每月下载量达到了1200万次,并且达到这个水平并没有采用任何肮脏的技巧。只是一个简单的解决常见的打包问题的方法。

pbr可以承担更多的软件包维护负担,并且不仅限于版本控制,但它不会强制您采用所有的优点。

因此,为了让您对在一个提交中采用pbr的情况有一个想法,请看切换打包到pbr

您可能会注意到版本根本没有存储在仓库中。PBR会从Git分支和标签中检测它。

当您打包或安装应用程序时,不必担心没有git存储库时会发生什么,因为pbr会“编译”并缓存版本,因此在运行时没有对git的依赖性。

旧方案

这是我迄今为止看过的最好的解决方案,它也解释了原因:

yourpackage/version.py内部:

# Store the version here so:
# 1) we don't load dependencies by storing it in __init__.py
# 2) we can import it in setup.py for the same reason
# 3) we can import it into your module module
__version__ = '0.12'

在您的包中的__init__.py文件中:
from .version import __version__

setup.py 内容如下:

exec(open('yourpackage/version.py').read())
setup(
    ...
    version=__version__,
    ...

如果您知道其他更好的方法,请告诉我。


13
不,execfile()在Python 3中不存在,因此最好使用exec(open().read())。 - Christophe Vu-Brugier
7
为什么在 setup.py 中不加上 from .version import __version__ 呢? - Aprillion
9
因为在运行setup.py时包没有被加载 - 你可以试试,会得到一个错误(至少我是这样的 :-)) - darthbith
3
PBR的链接出现了502错误。 - MERose
9
毫无疑问,“pbr”是一个很好的工具,但你没有回答问题。如何通过“bpr”访问当前版本或已安装的包? - nad2000
显示剩余13条评论

33

根据被推迟的 [紧急新闻:被拒绝了] PEP 396 (模块版本号),提出了一种实现此目的的建议方式。它描述了一个(可选的)标准供模块遵循,同时附带了理由。这是一个片段:

  1. 当一个模块(或包)包含一个版本号时,该版本号应该在 __version__ 属性中提供。
  1. 对于位于命名空间包内的模块,应包含 __version__ 属性。命名空间包本身不应包含自己的 __version__ 属性。
  1. __version__ 属性的值应该是一个字符串。

18
该PEP并未被接受或标准化,而是被推迟(因为缺乏兴趣)。因此,将其指定为“有一种标准方式”有些误导。 - weaver
1
@weaver:哦,天啊!我学到了新知识。我不知道那是我需要检查的东西。 - Oddthinking
5
编辑备注说明它不是标准。现在我感到尴尬,因为我曾经在项目中提出功能请求,要求他们遵循这个“标准”。 - Oddthinking
1
也许你应该接手那个PEP的标准化工作,因为你似乎很感兴趣 :) - weaver
1
感谢更新,@Spartan。我注意到他们指向了新方法 - Oddthinking
显示剩余5条评论

31

还有一种比其他答案稍微简单的替代方法:

__version_info__ = ('1', '2', '3')
__version__ = '.'.join(__version_info__)
(而且,使用 str() 将自动增量部分转换为字符串将是相当简单的。)
当然,根据我所见,人们在使用 __version_info__ 时倾向于使用前面提到的版本,并将其存储为一组整数;然而,我不太明白这样做的意义所在,因为我怀疑除了好奇心或自动增量之外,你不会在任何目的上对版本号的某些部分执行数学运算(就算这种情况下,int()str() 也可以非常容易地使用)。 (另一方面,有可能其他人的代码期望一个数字元组而不是一个字符串元组,从而失败。)
当然,这只是我的看法,我很乐意听取关于使用数字元组的其他人的意见。
正如 shezi 提醒我的那样,(词汇)数字字符串的比较不一定与直接的数字比较具有相同的结果;为了实现这一点,需要加上前导零。因此,将 __version_info__(或它被称为什么)存储为整数值的元组将允许更高效的版本比较。

17
不错(+1),但您是否更喜欢使用数字而不是字符串?例如,__version_info__ = (1,2,3) - orip
4
当版本号超过9时,字符串比较可能变得危险,因为例如 '10' < '2'。为确保正确比较,需要将字符串转换为数字进行比较。 - Chiara Coetzee
14
我也做了这件事,但稍微调整了一下以解决整数的问题.. version_info = (0, 1, 0) version= '.'.join(map(str, version_info)) - rh0dium
2
__version__ = '.'.join(__version_info__) 的问题在于 __version_info__ = ('1', '2', 'beta') 会变成 1.2.beta,而不是 1.2beta 或者 1.2 beta - nagisa
4
我认为这种方法的问题在于如何放置声明__version__的代码行。如果它们在setup.py中,那么你的程序将无法从其包内部导入它们。也许这对你来说不是问题,那就没事了。如果它们在你的程序内部,那么你的setup.py可以导入它们,但不应该这样做,因为setup.py在安装时运行,此时你程序的依赖项尚未安装(setup.py用于确定依赖项是什么)。因此,Zooko的答案是:手动解析产品源文件中的值,而不导入产品包。 - Jonathan Hartley
显示剩余7条评论

16

这里的许多解决方案都忽略了git版本标签,这意味着您仍然必须在多个地方跟踪版本(不好)。我采用以下目标来解决这个问题:

  • git存储库中的标签中派生所有Python版本引用
  • 使用一个命令自动执行git tag/pushsetup.py upload步骤,而不需要输入任何内容。

它是如何工作的:

  1. 通过“make release”命令,找到Git存储库中最后一个标记的版本并递增。该标记被推回给“origin”。

  2. Makefile将版本存储在src/_version.py中,setup.py会读取它,并包含在发布中。请勿将_version.py检入源代码控制!

  3. setup.py命令从package.__version__读取新的版本字符串。

详情:

Makefile

# remove optional 'v' and trailing hash "v1.0-N-HASH" -> "v1.0-N"
git_describe_ver = $(shell git describe --tags | sed -E -e 's/^v//' -e 's/(.*)-.*/\1/')
git_tag_ver      = $(shell git describe --abbrev=0)
next_patch_ver = $(shell python versionbump.py --patch $(call git_tag_ver))
next_minor_ver = $(shell python versionbump.py --minor $(call git_tag_ver))
next_major_ver = $(shell python versionbump.py --major $(call git_tag_ver))

.PHONY: ${MODULE}/_version.py
${MODULE}/_version.py:
    echo '__version__ = "$(call git_describe_ver)"' > $@

.PHONY: release
release: test lint mypy
    git tag -a $(call next_patch_ver)
    $(MAKE) ${MODULE}/_version.py
    python setup.py check sdist upload # (legacy "upload" method)
    # twine upload dist/*  (preferred method)
    git push origin master --tags

release 目标总是增加第三个版本数字,但您可以使用 next_minor_vernext_major_ver 来增加其他数字。这些命令依赖于位于 repo 根目录中的 versionbump.py 脚本。

versionbump.py

"""An auto-increment tool for version strings."""

import sys
import unittest

import click
from click.testing import CliRunner  # type: ignore

__version__ = '0.1'

MIN_DIGITS = 2
MAX_DIGITS = 3


@click.command()
@click.argument('version')
@click.option('--major', 'bump_idx', flag_value=0, help='Increment major number.')
@click.option('--minor', 'bump_idx', flag_value=1, help='Increment minor number.')
@click.option('--patch', 'bump_idx', flag_value=2, default=True, help='Increment patch number.')
def cli(version: str, bump_idx: int) -> None:
    """Bumps a MAJOR.MINOR.PATCH version string at the specified index location or 'patch' digit. An
    optional 'v' prefix is allowed and will be included in the output if found."""
    prefix = version[0] if version[0].isalpha() else ''
    digits = version.lower().lstrip('v').split('.')

    if len(digits) > MAX_DIGITS:
        click.secho('ERROR: Too many digits', fg='red', err=True)
        sys.exit(1)

    digits = (digits + ['0'] * MAX_DIGITS)[:MAX_DIGITS]  # Extend total digits to max.
    digits[bump_idx] = str(int(digits[bump_idx]) + 1)  # Increment the desired digit.

    # Zero rightmost digits after bump position.
    for i in range(bump_idx + 1, MAX_DIGITS):
        digits[i] = '0'
    digits = digits[:max(MIN_DIGITS, bump_idx + 1)]  # Trim rightmost digits.
    click.echo(prefix + '.'.join(digits), nl=False)


if __name__ == '__main__':
    cli()  # pylint: disable=no-value-for-parameter

这个功能会从git中处理和递增版本号重活。

__init__.py

my_module/_version.py 文件被导入到 my_module/__init__.py 中。在这里放置任何你想要与你的模块一起分发的静态安装配置。

from ._version import __version__
__author__ = ''
__email__ = ''

setup.py

最后一步是从my_module模块中读取版本信息。
from setuptools import setup, find_packages

pkg_vars  = {}

with open("{MODULE}/_version.py") as fp:
    exec(fp.read(), pkg_vars)

setup(
    version=pkg_vars['__version__'],
    ...
    ...
)

当然,为了让所有这些工作正常运行,您必须在您的存储库中至少有一个版本标签。

git tag -a v0.0.1

1
确实 - 这整个线程都忘记了版本控制系统在这个讨论中非常重要。只是一个观察:版本增量应该保持手动处理,所以首选方式应该是
  1. 手动创建并推送标签
  2. 让VCS工具发现该标签并将其存储在需要的位置
(哇 - 这个SO编辑界面真的让我很难受 - 我不得不编辑十几次才能处理换行符,但它仍然不起作用!@#$%^&*)
- axd
1
在Python中有一个很棒的bumpversion包,所以我们不需要使用versionbump.py - Oran
@cmcginty 抱歉回复晚了,请查看我的答案:https://dev59.com/b3RB5IYBdhLWcg3w9Lvn#48664839 - Oran
2
为什么不应该使用版本控制跟踪_version.py文件? - Stevoisiak
1
@StevenVascellaro 这会使发布过程变得复杂。现在你必须确保你的标签和提交与 _version.py 中的值匹配。在我看来,使用单个版本标签更加简洁,这意味着你可以直接从 Github UI 创建一个发布。 - cmcginty
显示剩余3条评论

15

我在包目录中使用了一个JSON文件,这符合Zooko的要求。

pkg_dir/pkg_info.json文件内:

{"version": "0.1.0"}

setup.py 文件中:

from distutils.core import setup
import json

with open('pkg_dir/pkg_info.json') as fp:
    _info = json.load(fp)

setup(
    version=_info['version'],
    ...
    )

pkg_dir/__init__.py文件中:

import json
from os.path import dirname

with open(dirname(__file__) + '/pkg_info.json') as fp:
    _info = json.load(fp)

__version__ = _info['version']

我还在pkg_info.json中放置了其他信息,例如作者。我喜欢使用JSON,因为我可以自动化管理元数据。


1
你能详细解释一下如何使用JSON来自动化元数据管理吗?谢谢! - ryanjdillon

12
自从这个问题首次被提出以来,已经完成了许多关于统一版本控制和支持约定的工作。现在在Python Packaging User Guide中详细介绍了可行的选项。值得注意的是,根据PEP 440,Python中的版本号方案相对严格,因此如果您的软件包将发布到Cheese Shop,保持清晰明了非常重要。
以下是版本控制选项的简短概述:
  1. 读取setup.pysetuptools)文件并获取版本。
  2. 使用外部构建工具(更新__init__.py和源代码控制),例如bump2versionchangeszest.releaser
  3. 在特定模块中将该值设置为全局变量__version__
  4. 将该值放置在简单的VERSION文本文件中,以供setup.py和代码读取。
  5. 通过setup.py发布设置该值,并使用importlib.metadata在运行时进行拾取。(警告,有3.8之前和3.8之后的版本。)
  6. 将该值设置为sample/__init__.py中的__version__,并在setup.py中导入样本。
  7. 使用setuptools_scm从源代码控制中提取版本控制,使其成为规范引用,而不是代码。
请注意,(7)可能是最现代的方法(构建元数据独立于代码,由自动化发布)。还要注意,如果使用setup进行软件包发布,简单的python3 setup.py --version将直接报告版本号。

7
值得注意的是,除了__version__在Python中是半标准之外,__version_info__也是一个元组。在简单情况下,您可以执行以下操作:
__version__ = '1.2.3'
__version_info__ = tuple([ int(num) for num in __version__.split('.')])

你可以从文件或其他地方获取__version__字符串。


1
你有关于__version_info__使用起源的任何参考资料/链接吗? - Craig McQueen
4
这个映射跟sys.version到sys.version_info是相同的。因此:http://docs.python.org/library/sys.html#sys.version_info - James Antill
7
在另一个方向上进行映射更容易(__version_info__ = (1, 2, 3)__version__ = '.'.join(map(str, __version_info__)))。 - Eric O. Lebigot
2
@EOL - __version__ = '.'.join(str(i) for i in __version_info__) - 稍微长一些但更符合Python风格。 - ArtOfWarfare
2
我不确定你提出的方案是否更符合Pythonic,因为它包含一个不是必需的虚拟变量,其含义有些难以表达(i没有意义,version_num有点长且模糊)。我甚至认为Python中存在map()函数是应该在这里使用的强烈提示,因为我们需要做的就是map()的典型用例(与现有函数一起使用)-我没有看到其他合理的用途。 - Eric O. Lebigot

6

Arrow 以一种有趣的方式处理它。

现在(自从2e5031b版本起)

arrow/__init__.py 文件中:

__version__ = 'x.y.z'

setup.py文件中:
from arrow import __version__

setup(
    name='arrow',
    version=__version__,
    # [...]
)

Before

In arrow/__init__.py:

__version__ = 'x.y.z'
VERSION = __version__

setup.py 文件中:
def grep(attrname):
    pattern = r"{0}\W*=\W*'([^']+)'".format(attrname)
    strval, = re.findall(pattern, file_text)
    return strval

file_text = read(fpath('arrow/__init__.py'))

setup(
    name='arrow',
    version=grep('__version__'),
    # [...]
)

file_text是什么? - ely
3
更新后的解决方案实际上是有害的。当运行 setup.py 时,它不一定会看到本地文件路径中的软件包版本。它可能会回滚到以前安装的版本,例如在测试期间在开发分支上运行 pip install -e . 时。setup.py 绝对不应依赖于导入正在安装过程中的软件包以确定部署参数。哎呀。 - ely
是的,我不知道为什么Arrow决定回归到这个解决方案。此外,提交消息中说“使用现代Python标准更新了setup.py”... - Anto

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