Python 项目开发规范

Published by rcdfrd on 2022-05-23

Python 项目开发规范

1 Python 项目开发规范

本文旨在提供 Python 项目的开发规范参考以及工具指南, 一些最佳实践以及通用的编程原则.

本文对于风格规范/工具/流程的选择不可避免的有偏向性, 不同的项目组可以根据自身的情况去调整.

每个团队和项目都有自己的代码风格与编程约定,重点不在于哪种风格规范/工具/流程更好, 而在于开发者可以在 项目中使用一致的风格规范/工具/流程. 这有助于开发者理解项目以及提高项目的可维护性.

1.1 阅读指南

由于 Python 2 将于 2020 年正式停止维护, 新的工程项目禁止使用 Python 2, 应当使用 Python 3, 推荐使用 >= Python 3.6.5 的版本.

请注意以下所有的讨论都是基于 Python 3, 或者是在忽略/没有考虑兼容 Python 2 的情况下进行.

2 代码风格

    代码风格应该遵循 PEP8 风格

代码风格指南是关于代码格式的细致规定, 但是幸运的是我们不需要记住这些多代码风格的规则. 使用代码格式器(formatter)可以帮助我们自动格式 化代码以遵循代码风格.

流行的 Python 风格有

2.1 代码格式器

formatter 选择使用 Black.

流行的代码格式器有

  • Black PEP8

  • yapf 对应 Google style

  • autopep8 PEP8

    Black 使用的代码风格可以视为比 PEP8 风格还严格的一个子集, 格式化之后的代码风格可以通过 PEP8 风格的检查.

2.1.1 black 的使用

black 可以通用命令行使用对单个文件或者整个文件夹的文件进行格式化. 建议将 black 添加到 PyCharm 的 external tool 中并绑定快捷键.

配置 PyCharm

File -> Settings -> Tools -> External Tools

the Click + icon to add a new external tool with the following values:
- Name: Black
- Description:
- Programe: 选择安装的 Black.exe
- Arguments: $FilePath$

使用 右击文件 -> External Tools -> Black

绑定快捷键

File -> Settings -> Keymap
搜索 external tool 找到 Balck 对应的条目, 点击编程绑定一个快捷键 Alt+Shift+F

2.2 import

使用独立的一行导入一个模块

Yes

import os
import sys

from extres.vcenter.datatype import SaltCallFailedException
from extres.vcenter.datatype import VCenterInitializeOptions
from extres.vcenter.datatype import VCenterVMCloneSpec

No

import os, sys

from extres.vcenter.datatype import SaltCallFailedException, VCenterInitializeOptions, VCenterVMCloneSpec
# or
from extres.vcenter.datatype import (
    SaltCallFailedException,
    VCenterInitializeOptions,
    VCenterVMCloneSpec,
)

2.2.1 禁止使用 import *

原则上禁止避免使用 import *, 应该显式地列出每一个需要导入的模块

使用 import * 会污染当前命名空间的变量, 无法找到变量的定义是来哪个模块, 在被 import 的模块上的改动可 能会在预期外地影响到其它模块, 可能会引起难以排查的问题.

在某些必须需要使用或者是惯用法 from foo import * 的场景下, 应该在模块 foo 的末尾使用 __all__ 控制被导出的变量.

# foo.py
CONST_VALUE = 1
class Apple:
    ...

__all__ = ("CONST_VALUE", "Apple")

# bar.py
# noinspection PyUnresolvedReferences
from foo import *

2.2.2 import 语句的顺序

使用 PyCharm 的 Optimize imports 功能格式化导入语句, 避免手动管理 import 语句的顺序, 除非 某些包的 import 需要有明确的先后顺序.

注意 Optimize imports 功能会将模块内没有使用的 import 语句删除, 需要小心这一点以避免误删 import 引起问题.

在这种情况下需要加上注释以说明没有使用到的 import 语句需要保留.

# noinspection PyUnresolvedReferences
from foo import *

2.3 命名

名字(变量名,函数名,类名,方法名)本身是表达代码信息的重要组成部分, 选取合适的名字就是选取了合适的抽象.

合适的名字可以让阅读代码本身就得到清晰的信息, 减少需要注释或者外部文档进行说明的需求.

2.3.1 描述

函数名, 类方法以动词开头; 类名, 类属性以名词开头.

2.3.2 避免使用的命名

  • 在任何地方都避免使用短横线 -
  • 避免使用 __xxx__ 形式的名字, 这种形式的名字(magic method)保留给 Python 解析器使用
  • 避免使用单个字符作为作为变量名(异常或者迭代器除外)

2.3.3 命名约定

  • 不要使用双下划线开头的形式(__XXX), 使用单下划线开头(_XXX)的形式以表示是私有变量.

    虽然双下划线开关的变量 Python 解释器会有特殊的处理(name mangling), 但是 Python 语言没有真正的私有变量, 使用双下划线变量名会影响可读性以及让该变量相关的单元测试更难编写, 我们强烈不推荐使用这种形式, 更推 荐统一使用单下划线开头的形式

  • 将相互关联的类以及函数放在一个模块中, 不需要限制一个模块只能有一个类

  • 类的命名使用 CamelCase 命名(例如 CapWords), 但是类所在的文件名使用 snake_case(例如 cap_words.py)

  • 单元测试中的方法名可以使用下划线, 这样的模式 test<MethoUnderTest>_<state> 是允许的, 例如 testPop_EmptyStack

2.3.4 变量名形式

名字 公开变量 内部变量
lower_with_under
模块 lower_with_under _lower_with_under
CapWords _CapWords
异常 CapWords
函数 lower_with_under() _lower_with_under()
全局常量/类常量 CAPS_WITH_UNDER _CAPS_WITH_UNDER
全局变量/类变量 lower_with_under _lower_with_under
对象变量 lower_with_under _lower_with_under (protected)
方法名 lower_with_under _lower_with_under (protected)
函数/方法 参数名 lower_with_under
局部变量名 lower_with_under

2.4 Main 方法

    在编写可执行的 Python 脚本时, 必须使用 if __name__ == '__main__'

单元测试, 自动文档生成以及静态代码分析等工具都会 import 模块, 必须使用 __name__ 检查以防止该脚本被 import 时会被运行,这通常会造成严重的后果!

可执行脚本必须提供=main=方法作为入口.

def main():
    ...

if __name__ == "__main__":
  main()

2.5 注释

2.5.1 解释为什么

一些非直观的代码需要进行注释, 注释应当从解释代码的意义, 和为什么这么实现的角度出发, 简单和不言自明的代码不需要进行注释.

bad

# 遍历 items 输出 i
for i in items:
    print(str(i))

good

# true if and only if i is a power of 2
if i & (i-1) == 0:
   ...

2.5.2 错误的注释比没有注释还糟糕

当代码发生变化时, 相关的注释也需要同步更新. 错误的注释往往比没有注释还具有误导性.

2.5.3 docstring

公开的类与函数应当使用 docstring 描述作用, 入参与返回值的作用.

2.5.4 inline comment

 避免将代码与注释写在同一行, 在代码块之前进行注释

inline comment 是指在代码与注释在同一行, 注释在行尾.

if i & (i-1) == 0:  # true if i is a power of 2

不少编辑器以及风格检查器都会有行长限制检查, inline comment 容易使行长超过限制以及带来阅读上的障碍.

No

_referring_method_schema = getattr(referring_method, "_swagger_auto_schema", None)  # add swagger documentation of download_media_type
_swagger_auto_scema = swagger_auto_schema(  # generate GET action swagger schema
    ...
)
if "post" in bind_to_methos:  # generate POST action swagger schema
    ...

Yes

# add swagger documentation of download_media_type
_referring_method_schema = getattr(referring_method, "_swagger_auto_schema", None)

# generate GET action swagger schema
_swagger_auto_scema = swagger_auto_schema(
    ...
)

# generate POST action swagger schema
if "post" in bind_to_methos:
   ...

2.6 类型注解

 强烈推荐尽可能多地使用类型注解

Python 3 PEP484 加入 type hint 的特性为 Python 添加了一个类型系统. 该特性可以帮助开发者在开发阶段提前发现问题, 以及帮助大型项目使用 type checker 对代码进行分析.

Python 3 使用类型注解(type annotations)的方法来加入类型信息, Python 3 依旧是一门动态语言, 解析器在运行 时(runtime)会忽略所有的类型注解.

我们鼓励尽可能多地使用类型注释, 请熟悉 PEP484 的内容.

示例

def hello(name: str) -> None:
    print(f"Hello, {name}")

类型注释规范请参考 Type Annotated Code , 考虑到目前采用 type hint 特性的项目还不多, 对于类型注释的规 范暂不作规定.

在使用类型注解的同时应该使用 type checker 工具来进行静态代码分析.

可供选择的工具有:

  • mypy from dropbox

  • pytype from google

  • pyright from microsoft

  • pyre-check from facebook

    我们的选择是使用 mypy 1

    使用 PyCharm 开发的团队可以使用 mypy plugin .

    我们推荐在持续集成(CI)流程中加入对代码类型检查的步骤.

3 语言规范

3.1 导入

仅对包和模板使用导入

使用一致的方式来表明对象的来源, x.Obj 表示 Obj 对象定义在模块 x 中.

使用 import x 来导入包和模块.

使用 from x import y , 其中 x 是包前缀, y 是不带前缀的模块名.

使用 from x import y as z, 如果两个要导入的模块都叫做 y 或者 y 太长了.

例如, 模块 sound.effects.echo 可以用如下方式导入后, 使用 EchoFilter

from sound.effects import echo
...
echo.EchoFilter(input, output, delay=0.7, atten=4)

不使用直接 import 模板内成员的做法. 如:

from sound.effects.echo import EchoFilter
...
EchoFilter(input, output, delay=0.7, atten=4)

3.1.1 例外

在使用 typing 模块时, 不需要遵守这条规则

from typing import Any, List, Dict

3.2 包

使用模块的全路径名来导入每个模块

使用绝对导入(absolute import), 不使用相对导入(relative import), 即使模块在相同的文件夹中.

避免包名冲突, 或者由于路径的问题导入错误的包. 更容易定位到模块.

使用相对导入依赖于文件之间的相对位置, 在大型项目中不同的包内也许有同名的模块, 这可能会在代码重构移动 文件时带来潜在的问题.

3.3 类

  类没有必要继承 object, 除非你需要考虑兼容 Python 2

Yes

class MyClass:
  pass

NO

# only for pyton2 compatibility
class MyClass(object):
 pass
  在 __init__ 方法中定义所有的实例变量

Yes

class Square:
  def __init__(self, side):
      self._side = side
      self.area = side ** 2

No

class Square:
  def __init__(self, side):
      self._side = side

  def get_area(self):
      # 在 __init__ 之外定义了实例变量 self.area
      self.area = self._side ** 2
      return self.area

3.4 异常

谨慎地使用异常

3.4.1 自定义异常

自定义异常类名以 Error 结尾, 继承 Exception

模块可以定义自己的异常类, 必须继承已经存在的异常类, 名字必须以 Error 结尾.

大多数情况下如果没有特殊的处理要求, 类的 body 简单地使用 pass 即可.

class MyError(Exception):
  pass

3.4.2 异常抛出的形式

使用抛出异常实例的形式

Yes

raise MyError()

# or

raise MyError("Error Message")

No

raise MyError

3.4.3 注意异常捕获的范围

不要使用 except: 捕获所有异常

No

try
 ...
except:
 ...

# or

try
  ...
except Exception as e:
  ...

除非:

  • 你会再次抛出异常

  • 你明确地知道你不会再次抛出异常, 你会在这里正确地处理这个异常. 比如在 thread 内记录下错误并阻止这个异常向上传递后引起 thread 退出

    在异常这方面, Python 非常宽容, except: 会捕获包括 Python 语法错误在内的任何错误. 使用 except: 很 容易隐藏真正的 bug

3.4.4 尽量减少 try except 语法块内代码的数量

try except 语法块内代码的数量越多, 在你预期之外的代码抛出异常的机会就会越大, 在这种情况下 try except 会将真正有可能出错的代码隐藏起来.

3.4.5 使用 try except else finally 结构

使用完整的 try/except/else/finally 结构, 会让代码逻辑变得更清晰

try:
  # 可能的异常抛出点
  ...
except MyError as e:
  # 处理异常情况
  ...
else:
  # 正常情况
  ...
finally:
  # 无论正常或者异常情况下都需要运行的代码
  # 比如说释放资源
  ...

3.4.6 assert

assert 只用于确保代码内部的正确性, 不能用于确保调用是正确的或者有异常发生

不要使用 assert 来进行参数校验, 合适的情况下使用 build-in 的异常.

assert 不能代替异常, 如果后继需要检查出错的情况, 使用异常而不是 assert.

而且 assert 语句在运行时可能会被关闭, 比如 python -O 解析器会忽略所有 assert 语句以提高性能.

Yes

def connect_to_next_port(self, minimum):
"""Connects to the next available port.

Args:
  minimum: A port value greater or equal to 1024.

Returns:
  The new minimum port.

Raises:
  ConnectionError: If no available port is found.
"""
if minimum < 1024:
  # Note that this raising of ValueError is not mentioned in the doc
  # string's "Raises:" section because it is not appropriate to
  # guarantee this specific behavioral reaction to API misuse.
  raise ValueError('Minimum port must be at least 1024, not %d.' % (minimum,))
port = self._find_next_open_port(minimum)
if not port:
  raise ConnectionError('Could not connect to service on %d or higher.' % (minimum,))
assert port >= minimum, 'Unexpected port %d when minimum was %d.' % (port, minimum)
return port

No

def connect_to_next_port(self, minimum):
"""Connects to the next available port.

Args:
  minimum: A port value greater or equal to 1024.

Returns:
  The new minimum port.
"""
assert minimum >= 1024, 'Minimum port must be at least 1024.'
port = self._find_next_open_port(minimum)
assert port is not None
return port

3.5 全局变量

尽量避免使用全局变量

尽量避免使用全局变量, 全局变量的生命周期是永久的, 只有在程序退出时占用的内存才会被清除.

使用类变量来代替, 但也有一些例外情况.

  • 脚本的默认选项

  • 模块级的常量. 例如 PI = 3.14.159, 常量应用使用全大写, 使用下划线连接单词.

  • 有时候用全局变量来缓存值或者作为函数返回值很有用.

  • 如果有需要, 全局变量应该仅在模块内部使用, (命名使用下划线开头 _XXX), 外部模块通过该模块的公 共方法来访问全局变量.

3.6 列表表达式 (list compreshensions)

适用于简单的情况, 禁止在列表表达式中使用多重 for 循环或者多重过滤

列表表达式以及生成器表达式(generator expressions)提供了一种简洁的方式来创建列表和生成器, 而不必使用 map filter 或者 lambda 2.

创建简单的过滤器表示式以及映射表达式, 使用列表表达式, 原则是表达式的内容不过度复杂.

复杂的逻辑请使用 for 循环.

Yes

evens = [str(x) for x in range(0, 100) if x % 2 == 0]

result = ((x, complicated_transform(x))
        for x in some_function(paramter)
        if x is not None)

No

evens = list(map(str, filter(lambda x: x% 2 == 0, range(0, 100))))

result = ((x, y, z) for x in range(10) for y in range(5) if x != y for z in range(5) if y != z)

3.7 默认迭代器和操作符

如果类型支持, 就使用默认迭代器和操作符. 比如列表, 字典及文件等.

容器类型, 像字典和列表, 定义了默认的迭代器和关系测试操作符(in 和 not in).

如果可能的话使用默认的迭代器和操作符, 大多数 build-in 类型定义了迭代器方法.

需要注意的一点是, 这样去遍历容器的话, 不能在遍历时修改容器.

Yes

for key in adict: ...
if key not in adict: ...
for k, v in adict.items(): ...

if obj in alist: ...

for line in afile: ...

No

for key in adict.keys(): ...
if not adict.hash_key(key): ...

for line in afile.readlines(): ...

3.8 lambda 表达式

lambda 方法体为一行表达式的情况下可以使用

例: sorted(data, key=lambda x: x.key)

当方法体比一行表达式复杂应该使用def定义的函数, 通常可以将这些帮助函数定义在需要使用 lambda 的函数内, 作为嵌套函数.

示例

def sort_data(data):
 def get_key(item):
     # some logic to extract key from item
     ...

 sorted(data, key=get_key)

标准库的operator有一些常用的函数, 优先考虑使用. 如可以用 operator.mul 来代替 lambda x, y: x * y.

3.9 条件表达式

简单的情况下适用, 复杂情况或多重条件判断时使用完整的 if else 语句.

条件表达式类于 C/Java 里的三元操作符, 可以用来简化简单的 if 语句.

if cond:
  x = 2
else:
  x = 1
return x

# 简化为
return x = 2 if cond else 1

当 if 判断条件太复杂或者有多重判断的情况使用条件表达式会破坏可读性

No

bad_example = 1 if predicate1(value) else 2 if predicate2(value) else 3

3.10 函数参数默认值

禁止使用可变对象作为函数参数的默认值

函数参数的默认值为可变对象,比 [], 函数如果修改了该变量, 会影响默认值的值.

我们视这种用法是一种会引起潜在问题的错误用法.

Yes

def foo(a, b=None):
  if b is None:
      b = []

No

def foo(a, b=[]):
  ...

示例:

def foo(a=[]):
  a.append(1)
  print(a)

foo()  # 输出 1
foo()  # 输出 1 1
foo()  # 输出 1 1 1

3.11 True/False 求值

在可能的情况下使用隐式的 False 值

在 Python 语言中以下的值在被作为布尔值使用时候, 会被认为是 False.

内建类型的"空值":

0, None, [], {}, ''

Python 的惯用法是使用这些隐式的 False 值, 例如 if foo: 而不是 if foo != []:.

优点是可读性强, 速度更快, 大多数情况下都不会引起问题, 但是需要注意以下几点:

  • '0' 字符串 0 是被当作 True 对待.

  • 永远不要直接比较布尔值, 如 if value == False:, 使用 if not value:. 如果需要区分 FalseNone 的情况, 使用类似这样的语句 if not x and x is not None:.

  • 检查 None 时必须使用 if foo is None: 或者 is not None. 当检查某个值是否为 None 时如果使用 if not foo: 不要忘记 foo 如果为其它的"空值"也会通过检查.

  • 判断 seqences 类型(strings, lists, tuples)是否为空, 使用 if seq: or if not seq, 而不是 if len(seq): or if not len(seq):.

  • 数字 0 会被当作 False, 在判断是否为数字=0=的时候, 需要直接比较数字 0: if foo == 0:

3.12 格式化字符串

在 Python 3 中推荐使用 f-string, 不使用老式的 % 以及 str.format 进行格式化.

bad

name = "World!"
print("Hello, %s" % name)
print("Hello, {}".format(name))

better

name = "World!"
print(f"Hello, {name}")

f-string tutorial

3.12.1 例外

在使用标准库 logging 进行日志输出时, 仍然推荐使用 %s 的格式化风格,而不是 f-string.

logger = logging.getLoggerName(__name__)
name = "World!"
logger.debug("Hello, %s", name)

# instead of
# logger.debug("Hello, {name}")

这是因为包含 %s 的字符串和变量是作为参数传递给 logger 的方法,logging 类库对于 %s 有特殊的优化, 以及对于变量的字符串化会推迟到最后 3.

而直接使用 f-string 作为第一个变量传递给 logger 的方法, 相当于先将变量字符串化然后再传递给 logger 的方法.

4 编程原则与最佳实践

4.1 使用 pylint

使用 pylint

pylint 官网

pylint 是一个在 Python 源代码中查找 bug 的工具. 对于 C 和 C++ 这样的强类型静态语言来说, 这些 bug 通常由编译器来捕获. 由于 Python 的动态特性, 有些警告可能不对. 不过虚报的情况应该比较少.

确保对你的代码运行 pylint. 在 CI 流程中加入 pylint 检查的步骤.

抑制不准确的警告, 以便其他正确的警告可以暴露出来。

使用 PyCharm 开发的团队可以使用 pylint plugin .

Pylint 的介绍与入门

4.2 自底向上编程

自底向上编程(bottom up): 从最底层,依赖最少的地方开始设计结构及编写代码, 再编写调用这些代码的逻辑, 自底向上构造程序.

  • 采取自底向上的设计方式会让代码更少以及开发过程更加敏捷.

  • 自底向上的设计更容易产生符合单一责任原则(SRP) 的代码.

  • 组件之间的调用关系清晰, 组件更易复用, 更易编写单元测试案例.

    举一个简单的例子: 现需要编写调用外部系统 API 获取数据来完成业务逻辑的代码.

    应该先编写一个独立的模块将调用外部系统 API 获取数据的接口封装在一些函数中, 然后再编写如何调用这些函数 来完成业务逻辑. 而不是先写业务逻辑, 然后在需要调用外部 API 时再去实现相关代码, 这会产生调用 API 的代码直 接耦合在业务逻辑中的代码.

4.3 防御式编程

使用 assert 语句确保程序处于的正确状态

不要过度使用 assert, assert 应该只用于确保核心的部分.

注意 assert 不能代替运行时的异常, 不要忘记 assert 语句可能会被解析器忽略.

assert 语句通常可用于以下场景: - 确保公共类或者函数被正确地调用

  例如一个公共函数可以处理 list 或 dict 类型参数, 在函数开头使用 `assert isinstance(param, (list, dict))`
  确保函数接受的参数是 list 或 dict
  • assert 用于确保不变量. 防止需求改变时引起代码行为的改变

    if target == x:
      run_x_code()
    elif target == y:
      run_y_code()
    else:
      run_z_code()
    

    假设该代码上线时是正确的, target 只会是 x, y, z 三种情况, 但是稍后如果需求改变了, target 允许 w 的 情况出现. 当 target 为 w 时该代码就会错误地调用 run_z_code, 这通常会引起糟糕的后果.

    我们可以使用 assert 来确保不变量

    assert target in (x, y, z)
    if target == x:
      run_x_code()
    elif target == y:
      run_y_code()
    else:
      assert target == z
      run_z_code()
    

    不要使用 assert 的场景:

  • 不要使用 assert 在校验用户输入的数据, 需要校验的情况下应该抛出异常

  • 不要将 assert 用于允许正常失败的情况, 将 assert 用于检查不允许失败的情况.

  • 用户不应该直接看到 AssertionError, 如果用户可以看到, 将这种情况视为一个 BUG

4.4 避免使用 magic number

赋予特殊的常量一个名字, 避免重复地直接使用它们的字面值. 合适的时候使用枚举值 Enum.

使用常量在重构时只需要修改一个地方, 如果直接使用字面值在重构时将修改所有使用到的地方.

No

def get_potential_energy(mass, height):
  return mass * height * 9.81

# Django ORM
Config.objects.filter(enabled=1)

Yes

GRAVITATIONAL_CONSTANT = 9.81

def get_potential_energy(mass, height):
  return mass * height * GRAVITATIONAL_CONSTANT

class ConfigStatus:
  ENABLED = 1
  DISABLED = 0

Config.objects.filter(enabled=ConfigStatus.ENABLED)

4.5 处理字典 key 不存在时的默认值

  使用 dict.setdefault 或者 defaultdict

例子

# group words by frequency
words = [(1, 'apple'), (2, 'banana'), (1, 'cat')]
frequency = {}

No

for freq, word in words:
  if freq not in frequency:
      frequency[freq] = []
  frequency[freq].append(word)

Yes

for freq, word in words:
  frequency.setdefault(freq, []).append(word)

或者使用 defaultdict

from collections import defaultdict

frequency = defaultdict(list)

for freq, word in words:
  frequency[freq].append(word)

5 References

Footnotes

1

mypy 的开发直接影响了 PEP484 的诞生, 而且有非常成功的使用案例, 例如 dropbox 如何对 4 百万行 Python 代码进行类型检查

2

注意在 Python 3 中 map filter 返回的是生成器而不是列表, 在隋性计算方面有所区别.

3

logging optimization

Generated using Emacs 26.3 (Org mode 9.2.6)

相关文章