Python 项目工程化最佳实践指南

为了帮助内部的同学更好的解决 Python 工程化问题和分享下个人的开发习惯和代码管理思路写下这篇文章。

依赖管理

在 PEP 518 和 pyproject.toml 引入之前。一个项目无法告诉一个像 pip 这样的工具它需要什么样的构建工具来构建。现在 setuptools 有一个 setup_require 参数来指定构建项目所需的东西,但是除非你安装了 setuptools,否则你无法读取该设置,这意味着你不能声明你需要 setuptools 来读取 setuptools 中的设置。这个鸡和蛋的问题就是为什么像 virtualenv 这样的工具会默认安装 setuptools,以及为什么 pip 在运行一个 setup.py 时总是会注入 setuptools 和wheel,不管你是否显式地安装了它。你甚至不要尝试依赖于 setuptools 的一个特定版本来构建你的项目,因为你没有办法来指定版本; 不管用户碰巧安装了什么,你都得将就使用。

在 PEP 518 之后你可以声明你的构建工具以及要求的版本。

在过去我们可能会经常使用 requirements.txt 之类的文件来保存我们项目所需的依赖,但是它并没有很好的办法去区分我们在生产环境、开发环境、测试环境所需要的依赖必须要分成多份文件单独声明,通过一些新的构建工具我们就可以解决我们的问题。而且单独用  requirements.txt  也不能声明我们需要的 python 版本、系统环境等等。

在这次的内部项目开发中,我选择的是 PDM,PDM 旨在成为下一代 Python 软件包管理工具。它最初是为个人兴趣而诞生的。如果你觉得 pipenv 或者 poetry 用着非常好,并不想引入一个新的包管理器,那么继续使用它们吧;但如果你发现有些东西这些工具不支持,那么你很可能可以在 pdm 中找到。

Poetry 看起来也是个不错的选择,Poetry 和 Pipenv 、PDM 类似,是一个 Python 虚拟环境和依赖管理工具,另外它还提供了包管理功能,比如打包和发布。你可以把它看做是 Pipenv 和 Flit 这些工具的超集。它可以让你用 Poetry 来同时管理 Python 库和 Python 程序。

如果你用的是 PDM 或者 Poetry 请先在项目目录中,通过 virtualenv 创建一个叫 .venv 或者类似的文件夹。为什么我推荐 virtualenv 而不是 PEP582 呢?在很多系统下都依赖了一个默认的 python 版本,如果用 PEP582 的话,默认会使用系统自带的 python 版本,如果不用的话我们又必须额外的要生成一个需要的 python 版本对应的虚拟环境;其次是目前 vscode 并不支持。

我们可以利用这些构建工具,将我们的开发环境、测试环境、生产环境的依赖都区分开来。

我们同样可以在 pyproject.toml  中增加构建工具的额外命令,比如可以增加用于启动服务的 start 命令、测试用的 test 命令等等。类似 PDM Scripts 所描述的一样。通过构建工具启动服务能很有效解决包所在位置的问题,强制让所有的包的运行目录都为项目根目录。

项目结构

推荐的项目结构如下:

Dockerfile

这个文件主要用于给 Docker 构建镜像使用,建议在生产环境部署时通过 Docker 进行部署。

docs

专门用于保存文档的文件夹。

LICENSE

如果这个是一个开源项目,那么这个文件一般用于放置使用的开源协议。

pyproject.toml

基于 PEP518 规范的配置文件,保存了项目介绍、作者联系方式、所依赖的包、使用的构建工具等等。

README.md

主要用于放项目介绍、使用说明的 MarkDown 文档。

{project_name}

放实际项目代码的文件夹,你可以取任意的名字,但需要确保不跟你所依赖的其它第三方的包名字重复。之所以不用 src 是因为你在编写测试用例、或者把它作为 python 的包给其它项目用时会更方便。

{project_name}-stubs

{project_name}-stubs中的 project_name需要跟上面那个文件夹的名字一致,然后拼接 -stubs,比如项目叫 andy,那么这个文件夹叫 andy-stubs。如果你的项目是一个 python 的库的话,你可以在这个文件夹中存放 mypy 生成的类型描述文件。mypy 会自动读取这个项目中的类型描述,方便做类型判断。详情见 mypy 文档

tests

用来存放单元测试等的文件夹。

tox.ini

tox 的配置文件。

.gitignore

用于告诉 git 应该忽略哪些文件。

模块引用

Python 模块是最主要的抽象层之一,并且很可能是最自然的一个。抽象层允许将代码分为 不同部分,每个部分包含相关的数据与功能。

例如在项目中,一层控制用户操作相关接口,另一层处理底层数据操作。最自然分开这两 层的方式是,在一份文件里重组所有功能接口,并将所有底层操作封装到另一个文件中。 这种情况下,接口文件需要导入封装底层操作的文件,可通过 importfrom ... import 语句完成。一旦你使用 import 语句,就可以使用这个模块。 既可以是内置的模块包括 os 和 sys,也可以是已经安装的第三方的模块,或者项目 内部的模块。

为遵守风格指南中的规定,模块名称要短、使用小写,并避免使用特殊符号,比如点(.) 和问号(?)。如 my.spam.py 这样的名字是必须不能用的!该方式命名将妨碍 Python 的模块查找功能。就 my.spam.py 来说,Python 认为需要在 my 文件夹 中找到 spam.py 文件,实际并不是这样。如果愿意你可以将模块命名为 my_spam.py, 不过并不推荐在模块名中使用下划线。但是,在模块名称中使用其他字符(空格或连字号) 将阻止导入(-是减法运算符),因此请尽量保持模块名称简单,以无需分开单词。 最重要的是,不要使用下划线命名空间,而是使用子模块。

# OK
import library.plugin.foo
# not OK
import library.foo_plugin

除了以上的命名限制外,Python文件成为模块没有其他特殊的要求,但为了合理地使用这 个观念并避免问题,你需要理解 import 的原理机制。具体来说,import modu 语句将 寻找合适的文件,即调用目录下的 modu.py 文件(如果该文件存在)。如果没有 找到这份文件,Python 解释器递归地在 "PYTHONPATH" 环境变量中查找该文件,如果仍没 有找到,将抛出 ImportError 异常。

一旦找到 modu.py,Python 解释器将在隔离的作用域内执行这个模块。所有顶层 语句都会被执行,包括其他的引用。方法与类的定义将会存储到模块的字典中。然后,这个 模块的变量、方法和类通过命名空间暴露给调用方,这是Python中特别有用和强大的核心概念。

在很多其他语言中,include file 指令被预处理器用来获取文件里的所有代码并‘复制’ 到调用方的代码中。Python 则不一样:include 代码被独立放在模块命名空间里,这意味着您 一般不需要担心 include 的代码可能造成不好的影响,例如重载同名方法。

也可以使用import语句的特殊形式 from modu import * 模拟更标准的行为。但 import * 通常 被认为是不好的做法。使用 from modu import * 的代码较难阅读而且依赖独立性不足。 使用 from modu import func 能精确定位您想导入的方法并将其放到全局命名空间中。 比 from modu import * 要好些,因为它明确地指明往全局命名空间中导入了什么方法,它和 import modu 相比唯一的优点是之后使用方法时可以少打点儿字。

import modu
[...]
x = modu.sqrt(4)

其次是如果引用自己项目的的模块时,加入你的项目叫 my,模块叫 modu,那么不建议使用 from my import modu来引用,强烈推荐使用 from . import modu

其次是建议如果需要给其它模块引用某些类的时候,请在这个模块的 __init__.py 中暴露并且加上 as 代替一些语言中的 export。

from .config import Config as Config

并且完全不建议在 __init__.py  放置大量代码,建议只用于代替 export。

__init__.py 中加了过多代码,随着项目的复杂度增长, 目录结构越来越深,子包和更深嵌套的子包可能会出现。在这种情况下,导入多层嵌套 的子包中的某个部件需要执行所有通过路径里碰到的 __init__.py 文件。如果 包内的模块和子包没有代码共享的需求,使用空白的 __init__.py 文件是正常甚至好的做法。

类型检查

Python是一门动态语言,很多时候我们可能不清楚函数参数类型或者返回值类型,很有可能导致一些类型没有指定方法,在写完代码一段时间后回过头看代码,很可能忘记了自己写的函数需要传什么参数,返回什么类型的结果,就不得不去阅读代码的具体内容,降低了阅读的速度,typing 模块可以很好的解决这个问题。

自 python3.5 开始,PEP484 为 python 引入了类型注解 (type hints)。

Mypy 是 Python 中的静态类型检查器。Mypy 具有强大且易于使用的类型系统,具有很多优秀的特性,例如类型推断、泛型、可调用类型、元组类型、联合类型和结构子类型。推荐使用 mypy 作为类型检查工具并且每个方法必须声明清楚参数、参数的类型、返回值类型。

def register(
    self, factory: Optional[PooledObjectFactory] = None, name: Optional[str] = None
) -> None:

	pass

如果这个参数或者返回值可以为空,应当标注 Optional 或者使用 3.11 的语法 类型 | None。如 PooledObjectFactory | None。

在 vscode 中你可以安装 mypy 的插件,这样可以直接在 vscode 中完成类型检查。

代码格式化和风格检查

为了帮助开发者统一代码风格,Python 社区提出了 PEP8 代码编码风格,它并没有强制要求大家必须遵循,Python 官方同时推出了一个检查代码风格是否符合 PEP8 的工具,名字也叫 pep8。

Black 自称“零妥协代码格式化工具(The uncompromising code formatter)”。

Black 号称是不妥协的 Python 代码格式化工具。之所以成为“不妥协”是因为它检测到不符合规范的代码风格直接就帮你全部格式化好,根本不需要你确定,直接替你做好决定。而作为回报,Black 提供了快速的速度。 Black 通过产生最小的差异来更快地进行代码审查。 Black 的使用非常简单,安装成功后,和其他系统命令一样使用,只需在 black 命令后面指定需要格式化的文件或者目录即可。

某种意义上来说一个可配置很低的代码格式化和检查工具在团队中比一个可以大量自定义配置的更好。现代的 IDE 一般都提供了对 Black 的支持。

配置管理

建议将配置放在 {project_name}/{project_name} 文件夹中,使用 yaml 格式进行保存。之所以不用 toml 之类的格式是因为如果用 k8s 之类的配置映射功能的话就没法使用了,yaml 则可以很好的与其它系统保持兼容。

你可以将配置所在的 yaml 文件读取出来并且反序列化成一个配置对象。这个配置对象可以是 python 中的 dataclass 也可以就是一个普通的类,并且上面声明配置的每个字段。

配置是一种可能经常会增删字段的东西,我们不应该通过类似 dict 的方式进行操作。

异常管理

几乎所有编程语言中都有异常。异常可以快速指出程序出现的问题,便于排查。开发人员也可以根据情况抛出自定义异常, 以指示期望的内容和实际不相符。良好的异常设计和使用习惯,可以提高程序的质量。

在逻辑中,可能出现不符合预期的逻辑,会抛出相关异常。此时在编码时,为了逻辑的正常运行,需要对逻辑进行处理,捕获异常。

捕获异常是,使用 try...except 代码块包裹需要处理异常的代码。 expect 捕获指定的异常类型,如果出现,进入 对应的代码逻辑。对于一些不想处理的,通过 raise 抛出异常。

在捕获时尽量不要捕获宽泛的异常基类如 Exception,而是捕获具体的异常,如 ValueError。

处理异常时,如果没有继续抛出异常,需要输入日志信息。除非你知道不输出任何信息不会造成拍错困难。项目异常要以 ERROR 结尾。和标准异常命名类似。

测试

在 Python 中除了有语言内置的测试框架之外,还有许多第三方测试框架,一些非测试框架内部也会内置测试框架。其目的都是在内置测试框架的基础上 增加了一些特性,让编写测试更加方便,测试过程更加顺畅。

为了方便测试框架查找测试用例,在编写测试时应遵循一定的规范:

  • 测试模块要以 test_ 开头
  • 测试方法要以 test_ 开头
  • 测试类名要以 Test 开头

测试都放到 tests 文件夹下面。

Pytest 是在 unittest 的基础上 增加了大量语法糖,让测试更加简便和灵活。并且带有插件功能,方便集成其他功能。

由于 Pytest 能兼容其他大多数测试框架,而且它也具有强大的功能,所以推荐使用 Pytest 作为主要测试框架使用。

tox 是通用的虚拟环境管理和测试命令行工具。tox 能够让我们在同一个 Host 上自定义出多套相互独立且隔离的 python 环境,如果你的项目需要兼容多个 python 版本的话强烈推荐使用它。