Python能做大项目(5)基于语义的版本管理

上一章里,我们通过 ppw 生成了一个规范的 python 项目,对初学者来说,许多闻所未闻、见所未见的概念和名词扑面而来,不免让人一时眼花缭乱,目不暇接。然而,如果我们不从头讲起,可能读者也无从理解,ppw 为何要应用这些技术,又倒底解决了哪些问题。

在 2021 年 3 月的某个孤独的夜晚,我决定创建一个创建一个 python 项目以打发时间,这个项目有以下文件:


├── foo
│   ├── foo
│   │   ├── bar
│   │   │   └── data.py
│   └── README.md

当然,作为一个有经验的开发者,我的机器上已经有了好多个其它的 python 项目,这些项目往往使用不同的 Python 版本,彼此相互冲突。所以,从一开始,我就决定通过虚拟开发环境来隔离这些不同的工程。这一次也不例外:我通过 conda 创建了一个名为 foo 的虚拟环境,并且始终在这个环境下工作。

我们的程序将会访问 postgres 数据库里的 users 表。一般来说,我们都会使用 sqlalchemy 来访问数据库,而避免直接使用特定的数据库驱动。这样做的好处是,万一将来我们需要更换数据库,那么这种迁移带来的工作量将轻松不少。

在 2021 年 3 月,python 的异步 io 已经大放异彩。而 sqlalchemy 依然不支持这一最新特性,这不免让人有些失望——这会导致在进行数据库查询时,python 进程会死等数据库返回结果,从而无法有效利用 CPU 时间。好在有一个名为 Gino 的项目弥补了这一缺陷:

$ pip install gino

Python能做大项目(5)基于语义的版本管理_第1张图片

做完这一切准备工作,开始编写代码,其中 data.py 的内容如下:

# 运行以下代码前,请确保本地已安装 POSTGRES 数据库,并且创建了名为 GINO 的数据库。

import asyncio
from gino import Gino

db = Gino()

class User(db.Model):
    __tablename__ = 'users'

    id = db.Column(db.Integer(), primary_key=True)
    nickname = db.Column(db.Unicode(), default='noname')

async def main():
    # 请根据实际情况,添加用户名和密码
    # 示例:POSTGRESQL://ZILLIONARE:123456@LOCALHOST/GINO
    # 并在本地 POSTGRES 数据库中,创建 GINO 数据库。
    await db.set_bind('postgresql://localhost/gino')
    await db.gino.create_all()

    # FURTHER CODE GOES HERE

    await db.pop_bind().close()

asyncio.run(main())

作为一个对代码有洁癖的人,我坚持始终使用black来格式化代码:

$ pip install black
$ black .

一切 ok,现在运行一下:

$ python foo/bar/data.py

检查数据库,发现 users 表已经创建。一切正常。

我希望这个程序在 macos, windows 和 linux 等操作系统上都能运行,并且可以运行在从 python 3.6 到 3.9 的所有版本上。

这里出现第一个问题。你需要准备 12 个环境:三个操作系统,每个操作系统上 4 个 python 版本,而且还要考虑如何进行"可复现的部署"的问题。在通过 ppw 创建的项目中,这些仅仅是通过修改 tox.ini 和.github\dev.yaml 中相关配置就可以做到了。但在没有使用 ppw 之前,我只能这么做:

在三台分别安装有 macos, windows 和 ubuntu 的机器上,分别创建 python 3.6 到 python 3.9 的虚拟环境,然后安装相同的依赖。首先,我通过pip freeze把开发机器上的依赖抓取出来:

$ pip freeze > requirements.txt

然后在另一台机器上的准备好的虚拟环境中,运行安装命令:

$ pip install -r requirements.txt

这里又出现了**第二个问题。black纯粹是只用于开发目的,为什么也需要在测试/部署环境上安装呢?**因此,在制作requirements.txt之前,我决定将black卸载掉:

$ pip uninstall -y black && pip freeze > requirements.txt

然而,仔细检查 requirements.txt 之后发现,black是被移除了,但仅仅是它自己。它的一些依赖,比如clicktomli等等,仍然出现在这个文件中。

!!! Info
这里的 click 就是我们前面提到的 Pallets 开发的那个 click。 black 作为格式化工具,它既可以作为 API 被其它工具调用,也可以作为独立应用,通过命令行来运行。black 就使用了 click 来进行命令行参数的解析。

于是,我不得不抛弃 pip freeze 这种作法,只在 requirements.txt 中加上直接依赖(在这里, black 是直接依赖,而 click 是间接依赖,由 black 引入),并且,将这个文件一分为二,将 black 放在 requirements_dev.txt 中。

# REQUIREMENTS.TXT
gino==1.0
# REQUIREMENTS_DEV.TXT
black==18.0

现在,在测试环境下,我们将只安装 requirements.txt 中的那些依赖。不出所料,项目运行得很流畅,目标达成,放心地去睡觉了。但是,gino 还依赖于 sqlalchemy 和 asyncpg。后二者被称为传递依赖。我们锁定了 gino 的版本,但是 gino 是否正确锁定了 sqlalchemy 和 asyncpg 的版本呢?这一切仍然不得而知。

第二天早晨醒来,sqlalchemy 1.4 版本发布了。突然地,当我再安装新的测试环境并进行测试时,程序报出了以下错误:

Traceback (most recent call last):
  File "/Users/aaronyang/workspace/best-practice-python/code/05/foo/foo/bar/data.py", line 3, in 
    from gino import Gino
  File "/Users/aaronyang/miniforge3/envs/bpp/lib/python3.9/site-packages/gino/__init__.py", line 2, in 
    from .engine import GinoEngine, GinoConnection  # NOQA
  File "/Users/aaronyang/miniforge3/envs/bpp/lib/python3.9/site-packages/gino/engine.py", line 181, in 
    class GinoConnection:
  File "/Users/aaronyang/miniforge3/envs/bpp/lib/python3.9/site-packages/gino/engine.py", line 211, in GinoConnection
    schema_for_object = schema._schema_getter(None)
AttributeError: module 'sqlalchemy.sql.schema' has no attribute '_schema_getter'

我差不多花了整整两天才弄明白发生了什么。我的程序依赖于 gino, 而 gino 又依赖于著名的 SQLAlchemy。gino 1.0 是这样锁定 SQLAlchemy 的版本的:

$pip install gino==1.0
Looking in indexes: https://pypi.jieyu.ai/simple, https://pypi.org/simple
Collecting gino==1.0
  Downloading gino-1.0.0-py3-none-any.whl (48 kB)
     |████████████████████████████████| 48 kB 129 kB/s 
Collecting SQLAlchemy<2.0,>=1.2
  Downloading SQLAlchemy-1.4.0.tar.gz (8.5 MB)
     |████████████████████████████████| 8.5 MB 2.3 MB/s 

Python能做大项目(5)基于语义的版本管理_第2张图片

从 pip 的安装日志可以看到,gino 声明能接受的 SQLAlchemy 的最小版本是 1.2,最大版本则是不到 2.0。因此,当我们安装 gino 1.0 时,只要 SQLAlchemy 存在超过 1.2,且小于 2.0 的最新版本,它就一定会选择安装这个最新版本,最终,SQLAlchemy 1.4.0 被安装到环境中。

SQLAlchemy 在 2020 年也意识到了 asyncio 的重要性,并计划在 1.4 版本时转向 asyncio。然而,这样一来,调用接口就必须发生改变 – 也就是,之前依赖于 SQLAlchemy 的那些程序,不进行修改是无法直接使用 SQLAlchemy 1.4 的。1.4.0 这个版本发布于 2021 年 3 月 16 日。

原因找到了,最终问题也解决了。最终,我把这个错误报告给了 gino,gino 的开发者承担了责任,发布了 1.0.1,将 SQLAlchemy 的版本锁定在">1.2,<1.4"这个范围内。

pip install gino==1.0.1
Looking in indexes: https://pypi.jieyu.ai/simple, https://pypi.org/simple
Collecting gino==1.0.1
  Using cached gino-1.0.1-py3-none-any.whl (49 kB)
Collecting SQLAlchemy<1.4,>=1.2.16
  Using cached SQLAlchemy-1.3.24-cp39-cp39-macosx_11_0_arm64.whl

在这个案例中,我并没有要求升级并使用 SQLAlchemy 的新功能,因此,新的安装本不应该去升级这样一个破坏性的版本;但是如果 SQLAlchemy 出了新的安全更新,或者 bug 修复,显然,我们也希望我们的程序在不进行更新发布的情况下,就能对依赖进行更新(否则,如果任何一个依赖发布安全更新,都将导致主程序不得不发布更新的话,这种耦合也是很难接受的)。因此,是否存在一种机制,使得我们的应用在指定直接依赖时,也可以恰当地锁定传递依赖的版本,并且允许传递依赖进行合理的更新?这是我们这个案例提出来的第三个问题。

现在,似乎是我们将产品发布的时候了。我们看到其它人开发的开源项目发布在 pypi 上,这很酷。我也希望我的程序能被千百万人使用。这就需要编写 MANINFEST.in, setup.cfg, setup.py 等文件。

MANIFEST.in 用来告诉 setup tools 哪些额外的文件应该被包含在发行包里,以及哪些文件则应该被排除掉。当然在我们这个简单的例子中,这个文件是可以被忽略的。

setup.py 中需要指明依赖项、版本号等等信息。由于我们已经使用了 requirements.txt 和 requirements_dev.txt 来管理依赖,所以,我们并不希望在 setup.py 中重复指定 – 我们希望只更新 requirements.txt,就可以自动更新 setup.py:

from setuptools import setup

with open('requirements.txt') as f:
    install_requires = f.read().splitlines()
with open('requirements_dev.txt') as f:
    extras_dev_requires = f.read().splitlines()

# SETUP 是一个有着庞大参数体的函数,这里只显示了部分相关参数
setup(
    name='foo',
    version='0.0.1',
    install_requires=install_requires,
    extras_require={'dev': extras_dev_requires},
    packages=['foo'],
)

看上去还算完美。但实际上,我们每一次发布时,还会涉及到修改版本号等问题,这都是容易出错的地方。而且,它还不涉及打包和发布。通常,我们还需要编写一个 makefile,通过 makefile 命令来实现打包和发布。

这些看上去都是很常规的操作,为什么不将它自动化呢?这是第四个问题,即如何简化打包和发布

这四个问题,就是我们这一章要讨论的主题。我们将以 Poetry 为主要工具,结合 semantic versioning 来串起这一话题的讨论。

在软件开发领域中,我们常常对同一软件进行不断的修补和更新,每次更新,我们都保留大部分原有的代码和功能,修复一些漏洞,引入一些新的构件。

有一个古老的思想实验,被称之为忒修斯船(The Ship of Theseus)问题,它描述的正是同样的场景:

忒修斯船问题最早出自公元一世纪普鲁塔克的记载。它描述的是一艘可以在海上航行几百年的船,只要一块木板腐烂了,它就会被替换掉,以此类推,直到所有的功能部件都不是最开始的那些了。现在的问题是,最后的这艘船是原来的那艘忒修斯之船呢,还是一艘完全不同的船?如果不是原来的船,那么从什么时候起它就不再是原来的船了?

忒修斯船之问,发生在很多领域。象 IBM 这样的百年老店,不仅 CEO 换了一任又一任,就连股权也在不停地变更。可能很少人有在意,今天的 IBM,跟百年之前的 IBM 还是不是同一家 IBM,就象我们很少关注,人类是从什么时候起,不再是动物一样。又比如,如果有一家创业公司,当初吸引你加入,后来创始人变现走人了,尽管公司名字可能没换,但公司新进了管理层和新同学,业务也可能发生了一些变化。这家公司,还是你当初加入的公司吗?你是要选择潇洒的离开,还是坚持留下来?

在软件开发领域中,我们更是常常遇到同样的问题。每遇到一个漏洞(bug),我们就更换一块"木板"。随着这种修补和替换越来越多,软件也必然出现忒修斯船之问:现在的软件还是不是当初的软件,如果不是,那它是在什么时候不再是原来的软件了呢?

当然,忒修斯船之问有着深刻的哲学内涵。我们在软件领域中,尽管也遇到同样的场景,但我们需要的回答就要简单很多:

软件应该如何向外界表明它已发生了实质性的变化;生态内依赖于该软件的其它软件,又应该如何识别软件的蜕变呢?

为了解决上述问题,Tom Preston-Werner(Github 的共同创始人)提出 Semantic versioning 方案,即基于语义的版本管理。Semantic version 表示法提出的初衷是:

Python能做大项目(5)基于语义的版本管理_第3张图片

Semantic versioning 简单地说,就是用版本号的变化向外界表明软件变更的剧烈程度。要理解 Semantic versioning,我们首先得了解软件的版本号。

当我们说起软件的版本号时,我们通常会意识到,软件的版本号一般由主版本号 (major),次版本号 (minor),修订号 (patch) 和构建编号 (build no.) 四部分组成。由于 Python 程序没有其它语言通常意义上的构建,所以,对 Python 程序而言,一般只用三段,即 major.minor.patch 来表示。

Python能做大项目(5)基于语义的版本管理_第4张图片

上述版本表示法没有反映出任何规则。在什么情况下,你的软件应该定义为 0.x,什么时候又应该定义为 1.x,什么时候递增主版本号,什么时候则只需要递增修订号呢?如果不同的软件生产商对以这些问题没有共识的话,会产生什么问题吗?

实际上,由于随意定义版本号引起的问题很多。在前面我们提到过 SQLAlchemy 的升级导致许多 Python 软件不能正常工作的例子。在讲述那个例子时,我指出,是 gino 的开发者承担了责任,发行了新的 gino 版本,解决了这个问题。但实际上,责任的根源在 SQLAlchemy 的开发者那里。

从 1.3.x 到 1.4.x, 出现了接口的变更,这是一种破坏性的更新,此时,新的 1.4 已不再是过去的忒修斯之船了,使用者如果不修改他们的调用方式,就无法使用 SQLAlchemy 的问题。gino 的开发者认为(这也是符合 semantic versioning 思想的),SQLAlchemy 从 1.2 到 2.0 之间的版本,可以增加接口,增强性能,修复安全漏洞,但不应该变更接口;因此,它声明为依赖 SQLAlchemy 小于 2.0 的版本是安全的。但可惜的是,SQLAlchemy 并没有遵循这个约定。

Sematic versioning 提议用一组简单的规则及条件来约束版本号的配置和增长。首先,你规划好公共 API,在此后的新版本发布中,通过修改相应的版本号来向大家说明你的修改的特性。考虑使用这样的版本号格式:X.Y.Z (主版本号. 次版本号. 修订号):修复问题但不影响 API 时,递增修订号;API 保持向下兼容的新增及修改时,递增次版本号;进行不向下兼容的修改时,递增主版本号。

我们在前面提到过 SQLAlchemy 从 1.x 升级到 1.4 的例子。实际上,由于引入了异步机制,这是个不能向下兼容的修改,因此,SQLAlchemy 本应该启用 2.x 的全新版本序列号,而把 1.4 留作 1.x 的后续修补发布版本号使用。如此一来,SQLAlchemy 的使用者就很容易明白,如果要使用最新的 SQLAlchemy 版本,则必须对他们的应用程序进行完全的适配和测试,而不能象之前的升级一样,简单地把最新版本安装上,就仍然期望它能像之前一样工作。不仅如此,一个定义了良好依赖关系的软件,还能自动从升级中排除掉升级到 SQLAlchemy 2.x,而始终只在 1.x,甚至更小的范围内进行升级。

Python能做大项目(5)基于语义的版本管理_第5张图片

一个正确地使用 semantic versioning 的例子是 aioredis 从 1.x 升级到 2.0。尽管 aioredis 升级到 2.0 时,大多数 API 并没有发生改变–只是在内部进行了性能增强,但它的确改变了初始化 aioredis 的方式,从而使得你的应用程序,不可能不加修改就直接更新到 2.0 版本。因此,aioredis 在这种情况下,将版本号更新为 2.0 是非常正确的。

事实上,如果你的程序的 API 发生了变化(函数签名发生改变),或者会导致旧版的数据无法继续使用,你都应该考虑主版本号的递增。

此外,从 0.1 到 1.0 之前的每一个 minor 版本,都被认为在 API 上是不稳定的,都可能是破坏性的更新。因此,如果你的程序使用了还未定型到 1.0 版本的第三方库,你需要谨慎地声明依赖关系。而我们自己如果作为开发者,在软件功能稳定下来之前,不要轻易地将版本发布为1.0。

本系列文章来自《Python能做大项目》(暂定名),将由机械工业出版社出版。
Python能做大项目(5)基于语义的版本管理_第6张图片【系列文章链接】

Python能做大项目(1)为什么要学Python之一
Python能做大项目(2) -开发环境构建
Python能做大项目(3) - 依赖地狱与Conda虚拟环境
Python能做大项目(4)项目布局与生成向导

你可能感兴趣的:(Python能做大项目,python,开发语言,virtualenv)