Python能做大项目(7) - Poetry: 项目管理的诗和远方之二

发布时间:2023年12月26日

依赖管理

实现依赖管理的意义

我们已经通过大量的例子说明了依赖管理的作用。总结起来,依赖管理不仅要检查项目中声明的直接依赖之间的冲突,还要检查它们各自的传递依赖之间的彼此兼容性。

Poetry 进行依赖管理的相关命令

在 Poetry 管理的工程中,当我们向工程中加入(或者更新)依赖时,总是使用poetry add命令,比如:poetry add pytest

这里可以指定,也可以不指定版本号。命令在执行时,会对pytest所依赖的库进行解析,直到找到合适的版本为止。如果您指定了版本号,该版本与工程里已有的其它库不兼容的话,命令将会失败。

我们在添加依赖时,一般要指定较为准确的版本号,界定上下界,从而避免意外升级带来的各种风险。在指定依赖库的版本范围时,有以下各种语法:

$ poetry add SQLAlchemy               # 使用最新的版本

使用通配符语法:

# 使用任意版本,无法锁定上界,不推荐
$ poetry add SQLAlchemy=*    

# 使用>=1.0.0, <2.0.0 的版本
$ poetry add SQLAlchemy=1.*  

使用插字符 (caret) 语法:

# 使用>=1.2.3, <2.0.0 的版本
$ poetry add SQLAlchemy^1.2.3

# 使用>=1.2.0, <2.0.0 的版本
$ poetry add SQLAlchemy^1.2

# 使用>=1.0.0, <2.0.0 的版本
$ poetry add SQLAlchemy^1             

使用波浪符 (Tilde) 语法:

# 使用>=1.2.0,<1.3 的版本
$ poetry add SQLAlchemy~1.2 

# 使用>=1.2.3,<1.3 的版本
$ poetry add SQLAlchemy~1.2.3         

使用不等式语法(及多个不等式):

# 使用>=1.2,<1.4 的版本
$ poetry add SQLAlchemy>=1.2,<1.4

最后,精确匹配语法:

# 使用 1.2.3 版本
$ poetry add SQLAlchemy==1.2.3        

如果有可能,我们推荐总是使用波浪符或者不等式语法。它们有助于在可升级性和可匹配性上取得较好的平衡。比如,如果在增加对 SQLAlchemy 的依赖时,如果使用了插字符语法,已经发行出去的安装包,则会在安装时自动采用直到 2.0.0 之前的 SQLAlchemy 的最新版本。因此,如果你的安装包是在 SQLAlchemy 1.4 之前被安装,此后用户不再升级,则它们将可以正常运行;而如果是在 SQLAlchemy 1.4 发布之后被安装,pip 将自动使用 1.4 及以后最新的 SQLAlchemy,于是 这个跟之前版本不兼容的1.4版本就被安装上了,导致你的程序崩溃;除非发行新的升级包,而你将不会有任何办法来解决这一问题。

这也看出来 SQLAlchemy 的发行并不符合 Semantic 的标准。一旦出现 API 不兼容的情况,是需要对主版本升级的。如果 SQLAlchemy 不是将版本升级到 1.4,则是升级到 2.0,则不会导致程序出现问题。

始终遵循社区规范进行开发,这是每一个开源程序开发者都应该重视的问题。

指定过于具体的版本也会有它的问题。在向工程中增加依赖时,如果我们直接指定了具体的版本,有可能因为依赖冲突的原因,无法指定成功。此时可以指定一个较宽泛一点的版本范围,待解析成功和测试通过后,再改为固定版本。另外,如果该依赖发布了一个紧急的安全更新,通常会使用递增修订号的方式来递增版本。使用指定的版本号会导致你的应用无法快速获得此安全更新。

在上一章里,我们已经提到了依赖分组。我们的应用程序会依赖许多第三方库,这些第三方库中,有的是运行时依赖,因此它们必须随我们的程序一同被分发到终端用户那里;有的则只是开发过程中需要,比如象 pytest,black,mkdocs 等等。因此,我们应该将依赖进行分组,并且只向终端用户分发必要的依赖。

这样做的益处是显而易见的。一方面,依赖解析并不容易,一个程序同时依赖的第三方库越多,依赖解析就越困难,耗时越长,也越容易失败;另一方面,我们向终端用户的环境里注入的依赖越多,他们的环境中就越容易遇到依赖冲突问题。

最新的 Python 规范允许你的程序使用发行依赖(在最新的 poetry 版本中,被归类为 main 依赖)和 extra requirements。在上一章向导创建的工程中,我们把 extra reuqirement 分为了三个组,即 dev, test, doc。

[tool.poetry.dependencies]
black  = { version = "20.8b1", optional = true}
isort  = { version = "5.6.4", optional = true}
flake8  = { version = "3.8.4", optional = true}
flake8-docstrings = { version = "^1.6.0", optional = true }
pytest  = { version = "6.1.2", optional = true}
pytest-cov  = { version = "2.10.1", optional = true}
tox  = { version = "^3.20.1", optional = true}
virtualenv  = { version = "^20.2.2", optional = true}
pip  = { version = "^20.3.1", optional = true}
mkdocs  = { version = "^1.1.2", optional = true}
mkdocs-include-markdown-plugin  = { version = "^1.0.0", optional = true}
mkdocs-material  = { version = "^6.1.7", optional = true}
mkdocstrings  = { version = "^0.13.6", optional = true}
mkdocs-material-extensions  = { version = "^1.0.1", optional = true}
twine  = { version = "^3.3.0", optional = true}
mkdocs-autorefs = {version = "0.1.1", optional = true}
pre-commit = {version = "^2.12.0", optional = true}
toml = {version = "^0.10.2", optional = true}

[tool.poetry.extras]
test = [
    "pytest",
    "black",
    "isort",
    "flake8",
    "flake8-docstrings",
    "pytest-cov",
    "twine"
    ]

dev = ["tox", "pre-commit", "virtualenv", "pip",  "toml"]

doc = [
    "mkdocs",
    "mkdocs-include-markdown-plugin",
    "mkdocs-material",
    "mkdocstrings",
    "mkdocs-material-extension",
    "mkdocs-autorefs"
    ]

这里 tox, pre-commit 等是我们开发过程中使用的工具;pytest 等是测试时需要的依赖;而 doc 则是构建文档时需要的工具。通过这样划分,可以使 CI 或者文档托管平台只安装必要的依赖;同时也容易让开发者分清每个依赖的具体作用。

当你使用 poetry add 命令,不加任何选项时,该依赖将被添加为发行依赖(在 1.3 以上的 poetry 中,被归为 main 组),即安装你的包的最终用户,他们也将安装该依赖。但有一些依赖只是开发者需要,比如象 mkdocs, pytest 等,它们不应该被分发到最终用户那里。

在 python proejct wizard 开发时,poetry 还只支持一个 dev 分组,这样的粒度当然是不够的,因此,python project wizard 借用了 extras 字段来向项目添加可选依赖分组,其它工具,比如 tox 也支持这样的语法。

现在最新的 poetry 已经完全支持分组模式,并且从文档可以看出,它建议至少使用 main, docs 和 test 三个分组。后续 python project wizard 生成的项目框架,也将完全使用最新的语法,但仍然保留四个分组,即 main, dev, docs 和 test。

通过 poetry 向项目增加分组及依赖,语法是:

$ poetry add pytest --group test

这样,生成的 pyproject.toml 片段如下:

[tool.poetry.group.test.dependencies]
pytest = "*"

一般地,我们应该将其指定为 optional。目前最新版本的 poetry 仍然不支持通过命令行直接将 group 指定为 optional,您可能需要手工编辑这个文件。

[tool.poetry.group.test]
optional = true

!!! Info
注意,通过上述命令生成的 toml 文件的内容可能与 python project wizard 当前版本生成的有所不同。但 python project wizard 的未来版本最终将使用同样的语法。

poetry 依赖解析的工作原理

在上一节,我们简单地介绍了如何使用 poetry 来向我们的项目中增加依赖。我们强调了依赖解析的困难,但并没有解释 poetry 是如何进行依赖解析的,它会遇到哪些困难,可能遭遇什么样的失败,以及应该如何排错。对于初学者来说,这往往是配置 poetry 项目时最困难和最耗时间的部分。

现在,我们往项目中增加一个新的依赖,通常我们使用poetry add xxx来往项目中增加依赖。为了一窥 poetry 依赖解析的究竟,这次我们加上详细信息输出:

$ poetry add gino -vvv

输出会很长很长。我们摘要读一下跟 gino 相关的一些解析过程:

首先,poetry 注意到 sample 0.1.0 依赖到 gino(>=1.0.1, < 2.0.0),以及其它一些依赖,生成了第一步的解析结果:

   1: fact: sample is 0.1.0
   1: derived: sample
   1: selecting sample (0.1.0)
   1: derived: gino (>=1.0.1,<2.0.0)
   1: derived: mike (>=1.1.2,<2.0.0)
    ...

接下来,下载 gino,解析出下面的依赖:

1 packages found for gino >=1.0.1,<2.0.0
   1: fact: gino (1.0.1) depends on SQLAlchemy (>=1.2.16,<1.4)
   1: fact: gino (1.0.1) depends on asyncpg (>=0.18,<1.0)
   1: selecting gino (1.0.1)
   1: derived: asyncpg (>=0.18,<1.0)
   1: derived: SQLAlchemy (>=1.2.16,<1.4)

再接下来,它找到 sqlalchemy 的 29 个版本:

Source (ali): 14 packages found for asyncpg >=0.18,<1.0
Source (ali): 29 packages found for sqlalchemy >=1.2.16,<1.4

接下来比较幸运,当 poetry 查找 asyncpg 和 sqlalchemy 的传递依赖时,没有找到它们有更多的传递依赖,解析结束,这样,poetry 就顺利地选择了 29 个版本中,最新的一个,即 SQLAlchemy-1.3.24。这个版本又有 linux, windows 和 mac 等好几个包,poetry 最终选择跟当前环境中操作系统版本一致、python 版本一致的那个进行安装。

现在让我们看一看 poetry 最终解析出来的依赖树:


$ poetry show -t
black 22.12.0 The uncompromising code formatter.
├── click >=8.0.0
│   ├── colorama * 
│   └── importlib-metadata * 
│       ├── typing-extensions >=3.6.4 
│       └── zipp >=0.5 
├── mypy-extensions >=0.4.3
├── pathspec >=0.9.0
├── platformdirs >=2
│   └── typing-extensions >=4.4 
├── tomli >=1.1.0
├── typed-ast >=1.4.2
└── typing-extensions >=3.10.0.0
gino 1.0.1 GINO Is Not ORM - a Python asyncio ORM on SQLAlchemy core.
├── asyncpg >=0.18,<1.0
└── sqlalchemy >=1.2.16,<1.4
mkdocs 1.2.4 Project documentation with Markdown.
├── click >=3.3
│   ├── colorama * 
│   └── importlib-metadata * 
│       ├── typing-extensions >=3.6.4 
│       └── zipp >=0.5 
├── ghp-import >=1.0
│   └── python-dateutil >=2.8.1 
│       └── six >=1.5 
├── importlib-metadata >=3.10
│   ├── typing-extensions >=3.6.4 
│   └── zipp >=0.5 
├── jinja2 >=2.10.1
│   └── markupsafe >=2.0 
├── markdown >=3.2.1
│   └── importlib-metadata * 
│       ├── typing-extensions >=3.6.4 
│       └── zipp >=0.5 
├── mergedeep >=1.3.4
├── packaging >=20.5
├── pyyaml >=3.10
├── pyyaml-env-tag >=0.1
│   └── pyyaml * 
└── watchdog >=2.0

这个依赖树很长,这里只截取了一小部分,但大致上可以帮助我们了解 poetry 的工作原理。我们可以看到blackmkdocs都依赖了click,但black要求更新到 8.0 以上,而mkdocs则认为只要是 3.3 以上都可以。两者版本要求差距如此之大,也不免让人担心,8.0 的click与 3.3 的click还会是同一个click吗?

最终,关于 gino 和 sqlalchemy,poetry 安装的分别是 1.0.1 和 1.3.24,但是,上述解析树表明,如果存在 sqlalchemy 的 1.3.25 版本,它是可以自动升级的。我们许的愿,poetry 帮助实现了。

生成这棵依赖树可能要比你想像的困难得多。首先,PyPI 目前还没有给出它上面的某一个 package 的依赖树,这意味着 poetry 要知道black依赖哪些库,它必须先把black下载下来,打开它并解析才能知道。然后它从black中发现更多的依赖,这往往就需要它把这些依赖也下载下来,依次递归下去。

!!! Info
类似的系统在其它语言中已经存在了。比如Java有maven来保存各个开源库的依赖树。在依赖解析时,它不需要下载整个包,而只需要下载索引就可以进行解析,因此速度会更快一些。

更为糟糕的是,在这个过程中,某个库的好几个版本可能都需要依次下载下来 – 因为它们的传递依赖不能兼容。我记得在某次解析中,poetry 把 numpy 的版本从 1.2.x 一直下载到了 0.1!最终还是失败了。

所以,如果你在添加某个依赖时,发现 poetry 耗时过长,不要慌张,很多人都有与你一样的经历。这种情况主要是 poetry 无法快速锁定某个 package 的正确版本,不得不向后一个个版本搜索下载所致。我们能做的,就是加快 poetry 下载的速度。

poetry 正常情况下,是从 pypi.org 上下载 package。如果遇到解析速度问题,我们可以临时添加一个源:

poetry source add ali https://mirrors.aliyun.com/pypi/simple --default

再次运行poetry add,这次你会发现解析速度快了很多。

!!! Info
早期 poetry 的依赖解析可以慢到 10 多个小时都做不完。这有两方面的原因,一是早期 poetry 的依赖解析还没有启用多线程下载优化;二是在特殊情况下,poetry 需要把某些 package 在 pypi 上所有的版本全部下载一次,才能得出无法(或者可以)加入该依赖的结论。随着 python 生态的变化,现在这种需要数小时的依赖解析的时代基本结束了。在添加国内源的情况下,慢的时候也往往是不到一刻钟就能完成解析。

现在我们来移除 gino:

$ poetry remove gino
Updating dependencies
Resolving dependencies... (1.2s)

Writing lock file

Package operations: 0 installs, 0 updates, 3 removals

  ? Removing asyncpg (0.27.0)
  ? Removing gino (1.0.1)
  ? Removing sqlalchemy (1.3.24)

可以看出,不仅是 gino 本身被卸载,它的传递依赖 – asyncpg 和 sqlalchemy 也被移除掉了。这是 pip 做不到的。

虚拟运行时

Poetry 自己管理着虚拟运行时环境。当你执行poetry install命令时,Poetry 就会安装一个基于 venv 的虚拟环境,然后把项目依赖都安装到这个虚拟的运行环境中去。此后,当你通过 poetry 来执行其它命令时,比如poetry pytest,也会在这个虚拟环境中执行。反之,如果你直接执行pytest,则会报告一些模块无法导入,因为你的工程依赖并没有安装在当前的环境下。

我们推荐在开发过程中,使用 conda 来创建集中式管理的运行时。在调试 Python 程序时,都要事先给 IDE 指定解析器,这里使用集中式管理的运行时,可能更方便一点。Poetry 也允许这种做法。当 Poetry 检测到当前是运行在虚拟运行时环境下时,它是不会创建新的虚拟环境的。

但是 Poetry 的创建虚拟环境的功能也是有用的,主要是在测试时,通过 virtualenv/venv 创建虚拟环境速度非常快。

构建发行包

Python 构建标准和工具的变化

在 poetry 1.0 发布之前,打包一个 python 项目,需要准备 MANIFEST.in, setup.cfg, setup.py,makefile 等文件。这是 PyPA(python packaging authority) 的要求,只有遵循这些要求打出来的包,才可以上传到 pypi.org,从而向全世界发布。

但是这一套系统也有不少问题,比如缺少构建时依赖声明,自动配置,版本管理。因此,PEP 517 被提出,然后基于 PEP 517, PEP 518 等一系列新的标准,Sébastien Eustace 开发了 poetry。

基于 Poetry 进行发行包的构建

我们通过运行poetry build来打包,打包的文件约定俗成地放在 dist 目录下。

poetry 支持向 pypi 进行发布,其命令是poetry publish。不过,在运行该命令之前,我们需要对 poetry 进行一些配置,主要是 repo 和 token。

# PUBLISH TO TEST PYPI
$ poetry config repositories.testpypi https://test.pypi.org/legacy/
$ poetry config testpypi-token.pypi my-token
$ poetry publish -r testpypi

# PUBLISH TO PYPI
$ poetry config pypi-token.pypi my-token
$ poetry publish

上面的命令分别对发布到 test pypi 和 pypi 进行了演示。默认地 Poetry 支持 PyPI 发布,所以有些参数就不需要提供了。当然,一般情况下,我们都不应该直接运行poetry publish命令来发布版本。版本的发布,都应该通过 CI 机制来进行。这样的好处时,可以保证每次发布,都经过了完整的测试,并且,构建环境是始终一致的,不会出现因构建环境不一致,导致打出来的包有问题的情况。

其它重要的 Poetry 命令

我们已经介绍了 poetry add, poetry remove, poetry show, poetry build, poetry publish, poetry version 等命令。还有一些命令也值得介绍。

poetry lock

该命令将进行依赖解析,锁定所有的依赖到最新的兼容版本,并将结果写入到 poetry.lock 文件中。通常,运行 poetry add 时也会生成新的锁定文件。

在对代码执行测试、CI 或者发布之前,务必要确保 poetry.lock 存在,并且这个文件也应该提交到代码仓库中,这样所有的测试,CI 服务器,你的同侪开发者构建的环境才会是完全一致的。

poetry export
$ poetry export -f requirements.txt --output requirements.txt
poetry config

我们可以通过 poetry config --list 来查看当前配置项:

cache-dir = "/path/to/cache/directory"
virtualenvs.create = true
virtualenvs.in-project = null
virtualenvs.options.always-copy = true
virtualenvs.options.no-pip = false
virtualenvs.options.no-setuptools = false
virtualenvs.options.system-site-packages = false
virtualenvs.path = "{cache-dir}/virtualenvs"  # /path/to/cache/directory/virtualenvs
virtualenvs.prefer-active-python = false
virtualenvs.prompt = "{project_name}-py{python_version}"

这里面比较重要的有配置 pypi-token,配置之后,就可以免登录进行项目发布。不过,我们建议对重要项目,不要在本地配置这个token, 我们应该只在CI/CD系统中配置这个token,以实现仅从CI/CD进行发布。


本文来源于《Python能做大项目》(暂定名),将由机械工业出版社出版。全书已经在大富翁量化官网上首发,欢迎提前阅读。


【本系列其它文章】

Python能做大项目(1) - 为什么要学Python之一
Python能做大项目(2) - 开发环境构建
Python能做大项目(3) - 依赖地狱与Conda虚拟环境
Python能做大项目(4) - 项目布局与生成向导
Python能做大项目(5) - 基于语义的版本管理
Python能做大项目(6) - Poetry: 项目管理的诗和远方之一

文章来源:https://blog.csdn.net/hbaaron/article/details/135210078
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。