Python 工程化实践

学 Python 不应仅限于学编程,同样应该学习工程知识,比如虚拟环境、编码风格以及单元测试等。本文致力于介绍 Python 工程化所需的前置知识。

⚠️ 注意:以下教程适用于 MacOS。

部署环境

1. Anaconda

Anaconda 是一个包管理器,它能让你方便的管理 Python 版本和包版本。并且, Anaconda 聚合了 Jupyter notebook,使其大受数据科学家和人工智能工程师的欢迎。下面我们来介绍如何用 Anaconda 管理我们的 Python 环境。

如果你还没有 Anaconda,安装一个:

  • 海外用户直接访问 Anaconda 官网 下载即可
  • 国内用户无法访问Anaconda 官网(被墙),建议选择清华镜像替代。

安装完以后,在命令行界面输入 conda,会打印一个帮助文档。

帮助文档大概是说 conda 有哪些常用命令,比如下面这些:

$ conda help  # 打印帮助文档
$ conda info  # 查看conda信息,包括当前在哪个环境,环境路径,Python版本等信息
$ conda list  # 列出conda下安装了哪些包
$ conda install [package_name]  # 在conda环境中安装某个包
$ conda update [package_name]  # 升级conda环境中的某个包

Note:后文仅专注介绍虚拟环境相关内容,更多内容请在 Anaconda 官方文档 中查看。

Python 环境分为本地环境(lcoal env) 和全局环境 (global env) 。为一个项目配置环境,应该配置本地环境;为本机所有项目配置环境,则应该配置全局环境。

① 部署全局环境

📖 新建环境

$ conda create --name myenv

上述命令新建一个名为 myenv 的全局环境.

$ conda create -n myenv python=2.7

上述命令用于指定新建环境的 Python 版本,本例指定 Python 版本为 2.7。

$ conda create -n myenv python=2.7 [package1] [package2] [package3]  ...

上述命令指定新建环境下初始安装包。

👀 查看环境

$ conda info --envs

上述命令打印所有 Python 环境,包括全局环境和局域环境,在当前环境前会有一个 * 号。

🏀 激活环境

$ conda activate myenv

环境需要激活才能使用。上述命令将名为 myenv 的 conda 环境激活。

⚽️ 退出环境

$ conda deactivate

上述命令用于退出当前 conda 环境。在非 base 环境里,会退出到 base 环境;而在 base 环境里,则会退出到系统环境。

Note: 退到系统环境以后,在命令行输入 which python 会打印系统 Python 的路径。再次进入 conda 环境以后,输入 which python 则会打印 conda 下的 Python 路径。

鉴于今年(2020年)的 MacOS 系统依旧默认安装 Python2.7,因此建议把 conda 作为默认运行环境以享受 Python3 的便利!

🚮 移除环境

$ conda env remove --name myenv

上述命令移除名为 myenv 的全局环境。

② 部署局域环境

如果我们需要为某个项目配置特定的 Python 版本或包版本,就需要搭建局域环境(local environment)。

📖 新建环境

$ conda create --prefix ./envs python=2.7

上述命令在当前工作目录下,新建了一个名为 envs 的子目录。并在该子目录下新建了一个虚拟环境,并指定该环境的 Python 版本为 2.7。

🏀 激活环境

$ conda activate ./envs

⚽️ 退出环境

$ conda deactivate

⏬ 安装依赖

$ conda install [package_name]

上述命令用于为此环境安装各种包。

$ pip install [package_name]

有些包 conda 安装不了,可以使用 pip 安装

Note: conda 集成了 pip,所以在 conda 环境下也可以使用 pip 命令。在 conda 环境下,试试 pip list 吧~

🚮 移除环境

$ rm -rf ./envs

移除环境只需要删除对应的安装目录就可以了。注意,rm -rf 是一个极其危险的命令,使用时请务必小心。

2. Virtualenv

Note: Virtualenv 的好处在于体积小,适合开发机之类的环境。如果我们在笔记本电脑上做开发,还是用 conda 部署环境更方便~

Virtualenv 用于为 Python 项目创建独立的运行环境。

作为一门动态语言,Python 程序能否正确运行和环境配置密切相关。当我们下载好一个 Python 项目代码,通常需要先配置环境,然后才能运行。为了防止这个外来的 Python 项目污染我们的全局环境(global environment),我们应该为它配置独立的运行环境。这时 Virtualenv 就派上用场了。

在 Virtualenv 创建的虚拟环境中,我们可以指定特定的包版本甚至Python版本,而不影响全局环境。但需要注意的是,因为 Virtualenv 依赖于全局环境,因此如果你更新了 Python 全局环境,有可能对 Virtualenv 造成影响。

更多细节参见 Virtualenv 文档

如果你还没有 Virtualenv,打开命令行窗口,下载一个。

$ pip install virtualenv

📖 新建环境

安装好之后,如果你想在当前目录创建一个虚拟环境,只需要一行代码。

$ virtualenv my_project  # my_project 可替换为你项目的名称

Note: 如果需要安装特定版本的 Python,可使用 -p 选项指定 Python 版本。

$ virtualenv -p /usr/bin/python2.6 my_project

🏀 激活环境

ls 命令检查,你可以发现当前目录下创建了一个名为 my_project 的文件夹。但虚拟环境仅仅是被创建,要激活该环境,请使用如下命令:

$ source my_project/bin/activate

激活完以后,使用以下命令可看当前的 Python 路径:

$ which Python

使用以下命令可查看当前的 pip 路径。如果你的虚拟环境创建成功了,那么路径应当会变更到虚拟环境目录下。

$ which pip

使用命令

$ pip list

可查看当前 pip 安装的所有包。你会发现当前环境下仅仅只剩少数几个包,因为这是一个全新的环境。现在你可以在这个新环境下配置项目所需的包版本了!

在为你的项目安装完一系列库依赖之后,你可能想记录当前的环境配置,使用 pip 的 freeze 命令可以帮你做到这一点。

$ pip freeze --local > requirements.txt

这将把当前已安装的包和对应包版本记录在当前目录下的 requirements.txt 文件中。

Note: 在将来构建新环境时,只要使用如下命令,就可以移植该 txt 文件指定的环境配置。

$ pip install -r requirements.txt

⚽️ 退出环境

最后,当你想退出虚拟环境,回到全局环境时,可使用如下命令:

$ deactivate

通过 which Python 或者 which pip 检查一下是否回到了全局环境吧~

🚮 移除环境

my_project 所在目录下执行:

$ rm -rf my_project

请谨慎使用 rm -rf

3. Docker

Docker 是什么?

Docker 是一项虚拟化技术,它将软件和配置文件、依赖库封装在一起,放到一个操作系统级别的虚拟化环境中。这个环境我们称作容器 (container)。容器和容器之前是相互隔离的,但是也能通过优良定义的接口进行交互。因为容器运行在一个单独的操作系统内核上,因此它占用的资源比虚拟机更少。

原文:Docker is a set of platform as a service (PaaS) products that use OS-level virtualization to deliver software in packages called containers. Containers are isolated from one another and bundle their own software, libraries and configuration files; they can communicate with each other through well-defined channels. All containers are run by a single operating system kernel and therefore use fewer resources than virtual machines. – from wikipedia

✨ 下面我们来介绍如何使用 Docker。

1.首先去官网下载 Docker Desktop

2.安装 Docker,装完后执行 docker -v 以检查安装是否成功。

3.新建一个文件夹,然后打开它 mkdir myDocker; cd myDocker

4.随便在里面建点啥,比如计算数列元素的平均值:

# calc.py
# pip install numpy

import numpy as np

lst = np.array([2 ** i for i in range(5)])
print('list:', *lst)
print('mean:', np.mean(lst))

5.记录当前的依赖库及其版本,输出到 requirements.txt 文件上。

pip freeze > requirements.txt

6.然后,我们新建一个 Dockerfile,它相当于 Docker 的配置文件。

# Dockerfile

# Specify Python version
FROM python:3.8.5-buster

# Make a directory for our application
WORKDIR /app

# Install Dependences
COPY requirements.txt .
RUN pip install -r requirements.txt

# Copy our source code
COPY calc.py .

# Run the application
CMD ["python", "calc.py"]

PS: Python 版本名可以在 Docker Hub 中找。

7.运行 Dokcer 以创建容器。

docker build -t dockerproject .

8.运行容器中的应用。

docker run dockerproject

如果显示如下结果,说明容器构建成功!

视频教程:

  1. 基于node.js:别问,问就先干
  2. 基于Flask:How To Containerize Your Python Application using Docker (本章基于此教程改编)

文字教程:

  1. 菜鸟教程
  2. Docker Documentation

代码风格

引言:为什么工程化 Python 需要重视代码风格?

  1. 按某种约定风格写代码,可以让团队项目的风格更统一,有利于团队成员间交接工作。
  2. 工程项目通常代码量较大。随着代码量的增加,debug 变得愈发困难。按约定好的风格写代码,能有效预防代码发生未预期的错误。

建议通过 Google Python Style Guide 学习代码风格,该指南非常中肯地提出了每项建议的优缺点以及例外的情况。

下面列几条我认为重要的代码风格建议:

1. Docstrings

docstring 是 Python 为模块、类、函数书写帮助文档的一种方法。按约定,写在模块、类、函数之下的第一条声明就是 docstring.

def foo():
    """docstring here."""
    pass

可以通过对象的__doc__成员查看该对象的 docstring。假如你已经为函数foo()写好了 docstring,那么你可以使用如下命令查看它。

print(foo.__doc__)

关于书写docstring的具体规范,请参见 Google Python Style Guide,这里就不过分拾人牙慧了。

2. None 的判断

判断一个变量是否为空,请使用 if foo is None (或者 if foo is not None ). 避免使用 == 判断。

为什么这里要用 is 呢?下面是 Fluent Python 一书中的解释:

已折叠,点击展开

The == operator compares the values of objects (the data they hold), while is compares their identities.

We often care about values and not identities, so == appears more frequently than is in Python code.

However, if you are comparing a variable to a singleton, then it makes sense to use is. By far, the most common case is checking whether a variable is bound to None. This is the recommended way to do it:

x is None

And the proper way to write its negation is:

x is not None

The is operator is faster than ==, because it cannot be overloaded, so Python does not have to find and invoke special methods to evaluate it, and computing is as simple as comparing two integer IDs. In contrast, a == b is syntactic sugar for a.__eq__(b). The __eq__ method inherited from object compares object IDs, so it produces the same result as is. But most built-in types override __eq__ with more meaningful implementations that actually take into account the values of the object attributes. Equality may involve a lot of processing —- for example, when comparing large collections or deeply nested structures.

摘自 Fluent Python CHAPTER 8

总结来说就是,虽然 ==is 的运算结果相同,但 == 其实是 __eq__ 的语法糖,而 __eq__ 在实际调用中经常被重载,导致程序进入一系列繁琐的步骤中。而 is 则直接比较两个对象的整数ID得出结果,所以比 == 效率更高。

3. 命名

命名规范见下表:

Type Public Internal
Packages lower_with_under
Modules lower_with_under _lower_with_under
Classes CapWords _CapWords
Exceptions CapWords
Functions lower_with_under() _lower_with_under()
Global/Class Constants CAPS_WITH_UNDER _CAPS_WITH_UNDER
Global/Class Variables lower_with_under _lower_with_under
Instance Variables lower_with_under _lower_with_under (protected)
Method Names lower_with_under() _lower_with_under() (protected)
Function/Method Parameters lower_with_under
Local Variables lower_with_under

摘自 Google Python Style Guide


单元测试

关于 “The best way of unit test”,大家有不同的意见,有人觉得 nose 好用,有人觉得原生的 unittest 好用。但是我有不同的见解,个人觉得 Jupyter notebook 才是最好用的单测工具。

GitHub 项目地址:python-tips/test

当然言归正传,我们还是得正经地学习一下单测的。单测可以确保每个函数每个模块都按照我们预期的方式运行。如果你不是在写完代码以后,才写单测代码,而是一边开发一边测试,那么你其实遵循了测试驱动开发(Test-Driven Development)的开发方式。这种开发方式的好处是,在开发的全生命周期内,我们都能确保代码正确运行。

unittest

unittest 是 Python 原生支持(natively supported)的单元测试库。在使用这个库的时候,我们按照模版写就好了。模版可以参考 unittest 文档 中的 basic example 一节。

# file name: test_upper.py
import unittest

class TestStringMethods(unittest.TestCase):  # 继承 unittest.TestCase 是固定的写法

    def test_upper(self):  # 单测函数一般以test开头
        self.assertEqual('foo'.upper(), 'FOO')  # 使用assert断言判断测试结果是否符合预期

if __name__ == '__main__':
    unittest.main()  # 这个也是固定写法

在命令行运行 python test_upper.py,输出如下:

在上面这个例子中 assertEqual(a, b) 用于判断 ab 是否相等。像这样的 assert 方法还有很多,下面是它们的用法:

Method Checks that
assertEqual(a, b) a == b
assertNotEqual(a, b) a != b
assertTrue(x) bool(x) is True
assertFalse(x) bool(x) is False
assertIs(a, b) a is b
assertIsNot(a, b) a is not b
assertIsNone(x) x is None
assertIsNotNone(x) x is not None
assertIn(a, b) a in b
assertNotIn(a, b) a not in b
assertIsInstance(a, b) isinstance(a, b)
assertNotIsInstance(a, b) not isinstance(a, b)

Note: setUp() 方法运行在测试前,可将测试前需要运行的代码放在该函数内。tearDown() 方法运行在测试后,一般用于清理测试产生的变量。如果想要全面地了解单测,请阅读 unittest 文档,文档的作用是任何教程都无法替代的。


从命令行运行 Python

运行 Python 的方法很多,常用的有 Pycharm, Jupyter notebook, VSCode 等。但是,公司的 Python 环境通常搭建在开发机上,而开发机安装的是 Linux 的各种发行版。这就需要我们熟悉 bash 编程,以及 Python 的命令行选项。

GitHub 项目地址:python-tips/command_line

1. 命令行选项

最常用的从命令行执行 Python 的方法是,在 Terminal 中进入脚本所在的工作路径下,执行:

$ python srcipt.py  # script.py 可以换成你的 Python 文件

除了这种运行方法,Python 还提供 -c-m 两个接口选项(interface options)。

-c <command>

-c 选项后接 Python 代码,可在命令行直接执行。行前空格的规和脚本的规则相同。

# 例一

python -c '
print("hello")
print("world")'
# 例二

python -c '
for i in range(3):
    print(i)'

-m <module-name>

-m 选项后接 Python 模块。该命令会在 sys.path 下搜索该模块,并将该模块作为 __main__ 模块执行。

$ python -m script  # 将 script.py 作为模块执行

Note:

2. 从标准输入获取文本

Python 可以利用标准输入 sys.stdin,从命令行获取文本。

① 使用 echo 从命令行获取输入

在当前目录下新建一个 Python 文件 read_text.py

# read_text.py
import sys

for line in sys.stdin:
    line = line.strip()
    print(line + '......ok')

在该目录下,执行命令:

$ echo "hello" | python read_text.py

如命令行输出:

则说明我们已经成功地从命令行读取了字符串 "hello"

② 使用 cat 从指定文件获取输入

除使用 echo 外,我们还可以用 cat 指定文件,作为标准输入:

新建一个文本文件 info.txt,在里面随便写点什么,比如:

hello
are you ok
how are you
i fine, thx
that's great

在当前目录下,执行命令:

$ cat info.txt | python read_text.py

其输出是:

③ 从其他 Python 文件的输出流中获取输入

我们也可以从另一个 Python 文件的输出流中获取输入。

首先,在当前目录下新建一个 Python 文件 gen_text.py

# gen_text.py
import sys

for i in range(3):
    print(" ".join(["This is line", str(i)]))

在当前目录下执行:

$ python gen_text.py | python read_text.py

获得的输出是:

Windows 用户参见 How do you read from stdin?

3. 从命令行读入参数

Python 可以通过 sys.argv 从命令行读入参数。

在当前目录下新建 get_argv.py

import sys

print(sys.argv)

在命令行执行下述代码,看看是否能获得预期的结果:

$ python get_argv.py
# 输出是 ['get_argv.py']

$ python get_argv.py hi nihao
# 输出是 ['get_argv.py', 'hi', 'nihao']

4. 标准错误输出

Python 可以指定标准错误输出 (sys.stderr) 的内容。

① 将标准错误输出打印到命令行

# gen_error.py
import sys

# 正常信息
print('NORMAL INFO')

# 错误信息 Python2写法
print >> sys.stderr, 'ERROR INFO'

# 错误信息 Python3写法
print('ERROR INFO', file=sys.stderr)

直接将错误信息打印到命令行不会有什么特殊的提醒,看到的结果就和普通 print 函数一样。但是 Linux 能依次识别哪些是正常打印信息,哪些是错误信息。我们可以在 Linux 将这两类信息分别打入两个文件,详见下一节。

② 将标准错误输出打印到日志

因为我们在 Python 脚本中已经指定了哪些是正常信息,哪些是错误信息,因此 Linux 可以识别它们。

$ python gen_error.py 1>> result.log 2>> error.log

上述的代码通过 1>>(1可省略)将正常信息打印到日志 result.log;通过 2>> 将错误信息打印到日志 error.log

Note: 关于 Terminal 快捷键,Mac 用户可以看看 Keyboard shortcuts in Terminal on Mac.

参考:

  1. Python Tutorial: virtualenv and why you should use virtual environments
  2. Google Python Style Guide
  3. Google 开源项目风格指南 (中文版)
  4. Unit Testing in Python
  5. Quick Start Guide to Python unittest
  6. Command line and environment
  7. How do you read from stdin?