Leetao's Blog

Talk is cheap, show me the code

0%

为了保证 Python 的代码规范,在使用 git commit 提交代码之前,需要使用 blake、isort 工具对提交的文件进行格式化,如果提交的代码符合规法则 commit 成功,否则自动格式化文件,然后重新 commit

整个工作流大概是这样子:

其中 black 是代码格式化工具,可以通过 pip install black 后直接使用,使用方法如下:

1
black {source_file_or_directory}...

同时也支持配置文件自定义规则,详细内容可以参考官方文档 The uncompromising code formatter — Black

isort 则是用来规范 python 库的引入的,按字母顺序对 packages 进行排序,并自动分为不同的部分和类型,同样可以通过 pip install isort 后直接使用,使用方法如下:

1
isort mypythonfile.py mypython file2.py

black 一样也支持配置文件自定义规则,具体内容参考官网 isort (pycqa.github.io)

pre-commit 是整个工作流最重要的一环,pre-commitgit-hooks 中的一个重要的钩子,它在键入提交信息前运行。可以用于检查即将提交的快照,例如,检查是否有所遗漏,确保测试运行,以及核查代码。 如果该钩子以非零值退出,Git 将放弃此次提交。 上面提到的机制是整个工作流可以进行的关键。

完全可以自定义 pre-commit 钩子的,但是如果只是为了检验的话,可以使用现成的方案 pre-commit/pre-commit,用 Python 构建,支持多语言的管理器。通过 pre-commit 这个库,简单地几步就可以实现自动化工作流。

  1. 安装 pre-commit
1
pip install pre-commit

然后通过 pre-commit --version 确定是否安装成功

1
2
$ pre-commit --version
pre-commit 2.16.0
  1. 添加 .pre-commit-config.yaml 的配置文件

可以通过 pre-commit sample-config 生成一个默认的配置文件,这里贴一下关于 blackisort 的配置文件

1
2
3
4
5
6
7
8
9
10
repos:  
- repo: https://github.com/psf/black
rev: 21.12b0
hooks:
- id: black

- repo: https://github.com/PyCQA/isort
rev: 5.10.1
hooks:
- id: isort

支持的配置项很多,具体参考 plugins

  1. 安装 git hooks 脚本
1
2
$ pre-commit install
pre-commit installed at .git/hooks/pre-commit

然后就就大功告成了

在 3.6 之前可以通过 meta class 去实现,3.6 之后可以通过 __init_subclass__ 实现

meta class

通过 meta class__new__ 方法可以实现自动注册 class 的功能,原理很简单,就是通过元类去控制类的创建,在调用 __new__ 方法的时候自动将 class 注册

1
2
3
4
5
6
7
8
class MetaClass(type):
def __new__(cls, clsname, bases, attrs):
newclass = super(MetaClass, cls).__new__(cls, clsname, bases, attrs)
register(newclass) # 注册函数
return newclass

class MyClass(metaclass=MetaClass):
pass

__init_subclass__

__init_subclass__ 是 3.6 后引入的一个新的特性,一个 hook,可以让所有的子类在创建之后执行一些初始化的操作,通过这个特性我们就可以更简单地实现上述通过元类实现的功能。

1
2
3
4
5
6
7
8
9
class ParentClass:
def __init_subclass_(cls, **kwargs):
super().__init_subclass(**kwargs)
register(cls)


class ChildClass(ParentClass):
pass

需要注意的是 __init_subclass__ 没有非关键字参数

应用

代码存在如下的逻辑:

1
2
3
4
5
6
7
8
if category == 'a':
handle_a()
elif cateogory == 'b':
handle_b()
elif category == 'c':
handle_c()
else:
handle_else()

存在很多都处理逻辑,每个逻辑可能由不同的开发人员编写,全部放到一个代码块中,可以预见随着分支的增多,这部分代码会变得越来越庞大,不利于后期维护,所以最简单的方案,存在一个 dict 保存 category 和 处理方法的映射关系:

1
2
3
4
category_to_func = {
'a': handle_a
...
}

然后就可以将最初的代码简化为:

1
return category_to_func.get(category)()

实现

方法一

基于上面的思路,可以写出最简单的方法,让大家自行编写自己的处理方法,然后在 category_to_func 中统一注册自己的方法。

1
2
3
4
5
category_to_func = {
'a': handle_a # A 撰写注册
'b': handle_b # B 撰写注册
...
}

这样子没有任何问题,但是不够优雅,开发人员撰写了自己的处理方法之后,还要去指定的地方注册自己的方法,开发体验不太好。
如果存在一种方式,开发人员只需要撰写处理方法,代码可以自定注册就更好了。

方法二

通过上面的自注册的方法,让子类继承父类,然后将其自动注册到全局当中,这样子开发人员只需要关注自己的业务实现就可以了。

参考链接

PEP 487 – Simpler customisation of class creation
python - How to auto register a class when it’s defined - Stack Overflow

前言

Django 自带的 SearchFilter 是不支持空格搜索的,如果需要支持空格搜索并且保留之前的搜索功能则需要自定义 SearchFilter。

自定义 SearchFilter

继承 SeachFilter 然后实现 get_search_terms 方法,如果同时支持 space 然后又可以保持之前的搜索特性呢?

简单的做法就是,对输入的参数进行校验,如果只有含有空格就返回空格,否则就执行之前的搜索逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class CanSerachBothCharFilter(SearchFilter):

def get_search_terms(self, request):
params = request.query_params.get(self.search_param, '')
# 判断原来的参数是否有只有空格
only_space = False
if len(params) != 0 and len(params.strip()) == 0:
only_space = True

params = params.replace('\x00', '') # strip null characters
params = params.replace(',', ' ')
if only_space:
return [params]
return params.split()

前言

PEP 484 中引入的 Typing hints (类型提示) 可以用来为静态类型检查器和其他第三方工具指定类型元数据。但是在,PEP 484 只指定了名义子类型的语义。在这个 PEP 544 中,指定了协议类的静态和运行时语义,这将为结构性子类型(静态鸭子类型)提供一个支持。

什么是鸭子类型(Duck Typing)?

If it walks like a duck and it quacks like a duck, then it must be a duck。

简单地说,”如果它走路像鸭子,叫起来像鸭子,那它一定是鸭子“。

在编程中这就意味着当我们编写接收特定输入的函数时,我们只需要关心该函数输入的行为、属性,而不是该函数输入的显式类型。

例如,在不使用鸭子类型的语言中,我们可以编写一个函数,它接受一个类型为”鸭子”的对象,并调用它的”走”和”叫”方法。在使用鸭子类型的语言中,这样的一个函数可以接受一个任意类型的对象,并调用它的”走”和”叫”方法。— 百度百科

那么如何在 Python 中实现“鸭子类型”呢?

鸭子类型

现在有这样子场景,我们需要编写一个函数去计算一个拥有边长(len_side)的物体的周长,伪代码如下:

1
2
3
4
5
function calcute_circumference(shape):
circumference = 0
for side in shape.len_side: /// 遍历边长
circumference += side
return circumference /// 周长

从伪代码不难看出,我们不关心是什么的物体,我们只需要保证这个物体可以有一个 len_side(列表) 字段就可以了。

现在我们可以借由 Protocol 去实现它。

1
2
3
4
5
6
7
8
9
10
from typing import Protocol

class ObjWithSideLen(Protocol):
side_len: list

def calcute_circumference(shape: ObjWithSideLen):
circumference = 0
for side in shape.side_len:
circumference += side
return circumference

从上面的例子不难看出 Protocol 有点类似 Java 中的接口,只需要在函数上使用它,并不需要去关注输入的参数的具体类型。

泛型

我们还可以配合使用 typing 中的 TypeVar 实现泛型参数化,让我们的函数更加抽象化,只要符合协议,无论什么类型的输入都可以。

1
2
3
4
5
6
7
8
9
10
11
12
13
from typing import Protocol

class ObjWithSideLen(Protocol):
side_len: list

T = TypeVar('T', bound=ObjWithSideLen)

def calcute_circumference(shape: T) -> T:
circumference = 0
for side in shape.side_len:
circumference += side
shape.circumference = circumference
return shape

延伸

上述的例子都可以借由 Python 中的 ABCs 去实现的,但是两者的侧重点有所不同,由于篇幅有限,在下一篇文章,让我们仔细对比一下两者的的区别。

前言

在使用 Git 作为版本控制工具时,每次文件发生修改的时候提交都需要 git commit 命令去记录本次的修改,否则就不允许提交,显然 git commit 是一个重要的环节,因此制定一个 Git Commit 规范是有必要的,否则就会出现混乱的提交信息,这里腾讯某团队的规范作为例子,然后通过一定的手段去帮助我们把这个规范落到实处。

目的:

  • 统一团队 Git commit 日志标准,便于后续代码 review,版本发布以及日志自动化生成等等。
  • 统一团队的 Git 工作流,包括分支使用、tag 规范、issue 等

Git commit 日志参考案例

总体方案

来自 feflow 的 git commit 规范

Git commit日志基本规范

1
2
3
4
5
<type>(<scope>): <subject>
<BLANK LINE>
<body>
<BLANK LINE>
<footer>

type 类型

type代表某次提交的类型,比如是修复一个bug还是增加一个新的feature

  • feat: 新增 feature
  • fix: 修复 bug
  • docs: 仅仅修改了文档,比如 README, CHANGELOG, CONTRIBUTE等等
  • style: 仅仅修改了空格、格式缩进、逗号等等,不改变代码逻辑
  • refactor: 代码重构,没有加新功能或者修复 bug
  • perf: 优化相关,比如提升性能、体验
  • test: 测试用例,包括单元测试、集成测试等
  • chore: 改变构建流程、或者增加依赖库、工具等
  • revert: 回滚到上一个版本

格式要求

1
2
3
4
5
6
7
8
9
# 标题行:50个字符以内,描述主要变更内容
#
# 主体内容:更详细的说明文本,建议72个字符以内。 需要描述的信息包括:
#
# * 为什么这个变更是必须的? 它可能是用来修复一个bug,增加一个feature,提升性能、可靠性、稳定性等等
# * 他如何解决这个问题? 具体描述解决问题的步骤
# * 是否存在副作用、风险?
#
# 尾部:如果需要的化可以添加一个链接到issue地址或者其它文档,或者关闭某个issue。

校验方案

node 项目已经有比较成熟的方案,这里以 Python 项目为例,考虑实际使用的便利性,希望可以可以实现以下的目的:

  1. 成员在本地执行 git commit 的命令时就完成校验,通过则允许执行 git push 否则则需要重新提交 commit 的信息。
  2. 提交到 gitlab,由服务端完成再次校验

本地校验

git hooks

Git 在执行 git init 进行初始化的时候,会在 .git/hooks 目录下生成一系列 hooks 脚本:

git-hooks

从上图可以看到每个脚本的后缀都是以 .sample 结尾的,在这个时候,脚本是不会自动执行的。我们需要把后缀去掉之后才会生效,即将 pre-commit.sample 变成 pre-commit 才会起作用。由于只是对 commit msg 做校验,所以只需要使用 commit-msg 脚本即可。

commit-msg 脚本

commit-msg hooks 中完成对 commit 消息校验

1
2
3
4
5
6
7
8
9
10
11
12
13
#!/bin/sh

# 获取当前提交的 commit msg
commit_msg=`cat $1`

msg_re="^(feat|fix|docs|style|refactor|perf|test|workflow|build|ci|chore|release|workflow)(\(.+\))?: .{1,100}"

if [[ ! $commit_msg =~ $msg_re ]]
then
echo "\n不合法的 commit 消息提交格式,请使用正确的格式\n <type>(<scope>): <subject>"
# 异常退出
exit 1
fi

将上述脚本保持在项目所在对 .git/hooks 目录下命名为 commit-msg,然后执行 chmod 命令:

1
chmod +x .git/hooks/commit-msg

验证结果

  1. 不符合规法的 commit msg

不合规的git-commit-msg

  1. 符合规范的 commit-msg

合规的git-commit-msg

服务端校验

Git 在服务端也同样有一些 hooks:

  • pre-receive
  • update
  • post-receive

每个 hooks 的具体功能可以参考 Server-Side Hooks ,其中 pre-receiveupdate 均符合使用场景,唯一区别是用户同时推送到多个分支时, update 针对每个分支都会被触发执行,而 pre-receive 只执行一次。

pre-receive

说明

在任何文件被更新时,如果$GIT_DIR/hooks/pre-receive 存在并且是可执行的文件,则 pre-receive 会被无参数触发执行一次,正常 pre-receive 触发执行的时候会接收如下的参数:

1
sha1-old SP sha1-new SP refname LF

其中 sha1-old 为多次 commit 的最早一次的 commit 的 id,而 sha1-new 则是最新一次的 id。除此之外,git push 的时候还会传递其他的信息,可以参考 pre-receive-hooks

校验方法

通过 git log old-commit-ID new-commit-ID -pretty=format:%s 提取出俩个 commit 之间的所有 commit-msg 然后逐一校验。

考虑易用性,用 golang 构建校验脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
package main

import (
"encoding/json"
"fmt"
"io/ioutil"
"os"
"os/exec"
"regexp"
"strings"
)

type CommitTypeConfig struct {
commitTypeList []string
strictMode bool // 严格模式下将校验所有的提交信息格式(多 commit 下)
}

const checkFailedMeassge = `
Commit message 格式校验失败❌
Commit message 格式必须符合下述规则:
^(\w+)(\(\w+\))*: (.+)|^Merge\ branch(.*)|^Merge\ remote-tracking branch(.*)
Example:
feat(test): test commit style check.`

const ZERO_COMMIT = "0000000000000000000000000000000000000000"

func main() {
config := CommitTypeConfig{
commitTypeList: []string{"feat", "fix", "docs", "style", "refactor", "test", "chore", "perf", "hotfix"},
strictMode: true}
input, _ := ioutil.ReadAll(os.Stdin)
param := strings.Fields(string(input))
// allow branch/tag delete
if param[1] == ZERO_COMMIT {
os.Exit(0)
}
if isExists("pre-receive.json") {
fmt.Println("检测到存在配置文件,加载配置文件规则💪")
file, _ := ioutil.ReadFile("pre-receive.json")
tmpConfig := CommitTypeConfig{}
err := json.Unmarshal([]byte(file), &tmpConfig)
if err == nil {
fmt.Println("成功加载配置文件规则😊")
config = tmpConfig
} else {
fmt.Println("加载配置文件规则失败,使用默认规则文件😭")
}
} else {
fmt.Println("开始加载默认配置文件规则💪")
}
commitMsg := getCommitMsg(param[0], param[1])
checkCommitMsg(commitMsg, config)
}

func getCommitMsg(oldCommitID string, commitID string) []string {
s := fmt.Sprintf("git log %s..%s --pretty=format:%%s", oldCommitID, commitID)
if oldCommitID == ZERO_COMMIT {
s = fmt.Sprintf("git rev-list --pretty=format:%%s $(git for-each-ref --format='%%(refname)' refs/heads/* | sed 's/^/\\^/g') %s | grep -v ^commit", commitID)
}
commitMsg := strings.Split(runCmd(s), "\n")
return commitMsg
}

func checkFailed(tmpStr string) {
fmt.Fprintln(os.Stderr, "===================================")
fmt.Fprintln(os.Stderr, tmpStr)
fmt.Fprintln(os.Stderr, "===================================")
fmt.Fprintln(os.Stderr, checkFailedMeassge)
os.Exit(1)
}

func isExists(path string) bool {
_, err := os.Stat(path)
if err == nil {
return false
}
if os.IsNotExist(err) {
return false
}
return true
}

/// 规则校验
/// @commitMsg []string 提交的信息数组
/// @config 规则配置
func checkCommitMsg(commitMsg []string, config CommitTypeConfig) {
var CommitMessagePattern = `^(\w+)(\(\w+\))*: (.+)|^Merge\ branch(.*)|^Merge\ remote-tracking branch(.*)`
var commitMsgReg = regexp.MustCompile(CommitMessagePattern)
for _, tmpStr := range commitMsg {
if strings.Trim(tmpStr, " ") == "" {
continue
}
commitTypes := commitMsgReg.FindAllStringSubmatch(tmpStr, -1)
if len(commitTypes) != 1 {
checkFailed(tmpStr)
} else {
if !strings.HasPrefix(tmpStr, "Merge") && !stringInSlice(commitTypes[0][1], config.commitTypeList) {
checkFailed(tmpStr)
}
}
if !config.strictMode {
fmt.Println("恭喜🎉,commit message 校验通过✅")
os.Exit(0)
}
}
fmt.Println("恭喜🎉,commit message 校验通过✅")
}
func stringInSlice(a string, list []string) bool {
for _, b := range list {
if b == a {
return true
}
}
return false
}

func runCmd(s string) string {
cmd := exec.Command("/bin/bash", "-c", s)
cmd.Stdin = os.Stdin
cmd.Stderr = os.Stderr
r, err := cmd.Output()
if err != nil {
fmt.Printf("Run command failed, [%s]\nerr: %s", s, err)
}
return string(r)
}

同时支持添加配置文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"commitTypeList":[ // 支持的 commitType
"feat",
"fix",
"docs",
"style",
"refactor",
"test",
"chore",
"perf",
"hotfix"
],
"strictMode": true // 严格模式会校验多次 commit 的所有信息
}

服务器配置 pre-receive

参考 Server Hooks 需要将编译后 pre-receive 放到制定的 repository 的钩子目录即可,具体步骤如下:

  1. 找到对应 repository 的 .git 目录
  2. 在该目录下创建 custom_hooks 的目录
  3. 将编译后的 pre-receive 放到该目录下(如果有配置文件也上传到该目录下)
  4. 通过 chmod +x pre-receive 让该文件可执行,同时将该文件的用户组切换为 git:git
  5. 推送代码验证结果

pre-receive-校验通过结果

分享一个很久之前写的一个关于滴滴行程的脚本,脚本的主要功能是根据滴滴行程分享的 url 获取订单情况,通过定时轮询还可以获取乘客到目的地的距离和时间

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
#!/usr/bin/env python
# -*- encoding: utf-8 -*-
"""
@File : didi.py
@Description:滴滴行程分享 Api
@Modify Time @Author @Version @Description
------------ ------- -------- -----------
2021/5/29 8:47 上午 leetao 1.0 None
"""
import requests
import urllib.parse as urlparse
from urllib.parse import parse_qs
from dataclasses import dataclass, asdict

_header = {
'Accept': 'application/json, text/plain, */*',
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.77 Safari/537.36',
'Accept-Language': 'zh-CN,zh;q=0.9'
}


@dataclass
class DidiOrderParams:
uid: str
oid: str
sign: str
productType: str
carProductid: str


def is_didi(url: str) -> bool:
return url.startswith("https://z.didi.cn")


def get_order_params(url: str) -> DidiOrderParams:
resp = requests.get(url, allow_redirects=False, headers=_header)
if resp.status_code == 302:
redirect_url = resp.headers['location']
parsed = urlparse.urlparse(redirect_url)
params = parse_qs(parsed.query)
return DidiOrderParams(uid=params['uid'], oid=params['oid'], sign=params['sign'],
productType=params['productType'], carProductid=params['carProductid'])
return None


def get_order_info(order_param: DidiOrderParams) -> str:
resp = requests.get('https://common.diditaxi.com.cn/webapp/sharetrips/page/getOrderStatus',
params=asdict(order_param), headers=_header)
assert resp.status_code == 200
resp_json = resp.json()
assert resp_json['errno'] == 0
return f'本次行程 \n 起点:{resp_json["fromAddress"]} \n 终点: {resp_json["toAddress"]} \n 您的行程管家将定时为您播报行程情况'


def get_driver_loc(order_param: DidiOrderParams):
resp = requests.get('https://common.diditaxi.com.cn/webapp/sharetrips/page/getDriverLoc',params=asdict(order_param),
headers=_header)
assert resp.status_code == 200
resp_json = resp.json()
assert resp_json['errno'] == 0
if 'etaDistance' not in resp_json:
return f'本次行程已经结束'
return f'距离目的地还有:{resp_json["etaDistance"]} 公里,预计还有:{resp_json["etaTime"]} 分钟'

要不要换个新的城市开始新的生活?

这是今年一直盘旋在我脑海里的想法。想法到落地中间并不是简单的 1 + 1 的问题,俩者之间隔着巨大的鸿沟,因为这意味着可能一切都需要重新开始。

从零开始并不是想象的那么简单,需要离开从大学开始就一直生活的城市,一个对我来说生活了八年多的地方,离开熟悉的天空、熟悉的街道、熟悉的人和熟悉的工作环境。记得上次有这种想法的时候,是刚毕业一年多的时候,也就是四年前,很遗憾,那个时候一个人的勇气并不能够支撑着我背起行囊潇洒地离开这里。

是的,没错,这次离开,是两个人一起。两个人能够在这种事情上达成一致的想法,是何其幸运的事情。在这里这些年最大的收获,就是收获了爱情,在开始新的阶段的时候,有人与我同行。

勇气是打开新的阶段的钥匙的话,那么找工作就是到达终点前的“拦路虎”了。

由于自己还处于在职状态,所以所有的面试基本上都只能约在下班之后了,自然面试前都准备工作也同样只能放在下班之后了。人生最让人觉得(有趣)无奈的事情就是,你越不想事情多的时候,基本上你的事情就会越来越多。恰巧开始准备复习的时候,负责的项目需要演示汇报,而这个时候郑州又赶上了暴雨加疫情,所谓的“涝疫结核”,一个多月的足不出户的生活,还有爷爷的离开。

因为疫情的原因,有一两年没有回老家了,没想到再次回去就是收到爷爷病重的消息,紧接着就是在暴雨和疫情期间被告知爷爷离开的消息,我记得那天的没有下雨,天比较阴沉,我坐在沙发上接到老爸打来的电话,说的什么我已经记不清了,我只记得挂断电话之后,眼泪不由自主地流了下来。

那个时候,我才切身体会道什么叫做紧绷着的弦,所有的事情都在不停地拉扯着神经。整个人变得有点焦虑,焦虑到夜里睡不着,焦虑到一个多月瘦了6-7斤,焦虑到茶饭不思。很难想象如果这个时候没有我的另一半一直陪着我,我该如何度过这一阶段。

潮起日落,日子总是一天一天在指尖划过。度过最难过的一段时光,后面的面试除了时间上比较紧凑,面试后面还是比较顺利的。工作了五年之后,再次收获了一份满意的 offer,很快就要去新的城市开始新的生活了。你说我现在的心情是什么样的,有向往,也有紧张,向往全新的生活,对新的开始难免有点彷徨和紧张。

最近处于离职的阶段,也算是闲了下来,可以偶尔跑跑步、看看书,前几天晚上还把藏在柜子里深处的尤克里里翻了出来,久违的轻松惬意,希望 2021 年接下来的日子都顺顺利利的。

原文连接: When to use assert

前言

assert 又称为断言,在 Python 代码中经常被使用,但是显然也存在滥用的情况。那么在什么时候使用 assert 呢?又或者 assert 的最佳实践是怎么样的呢?

assert 的使用

Python 的 assert 通常用来检查一个条件,如果它是真的,则不做任何事情,如果它是假的,则引发一个 AssertionError,并给出一个可选的错误信息:

1
2
3
4
5
6
py> x = 23
py> assert x > 0, "x is not zero or negative"
py> assert x%2 == 0, "x is not an even number"
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AssertionError: x is not an even number

许多人喜欢通过 assert 来简单地快速地触发异常,比如下面的代码:

1
2
if not isinstance(x, int):
raise AssertionError("not an int")

通过检验参数,并抛出 AssertionError ,实际上,这种做法是错误的,并且还很危险。正确的做法是应该抛出一个 TypeError

之所以危险是英文,assert 有一个特性:使用 -O-OO 优化指令去运行 Python 的话,它会被被编译掉,而永远不会被执行。当你能够正确使用 assert 时,这是会是一个 feature,但当使用不当的话,则会导致代码在使用 -O 标志运行时出问题。

所以应该在什么情况下使用 assert 呢

什么时候使用 assert

在下面这四种情况下都可以考虑使用 assert:

  • 防御性编程。
  • 对程序逻辑进行运行时检查。
  • 契约检查(如前条件和后条件)。
  • 程序不变性
  • 检查文件

对断言的看法各不相同,它可以看作是一种对代码正确性的信心的表现形式吧。如果你确定你的代码毫无问题的话,显然断言(assert)是没有任何意义的,完全可以把这些断言移除;如果你确定断言可能会被触发,那你也完全可以不用断言(assert),毕竟它在某些情况下肯定会编译掉,导致你的检查被跳过。

介于这两种情况之间的情况才是有趣的,当你确定代码是正确的,但又不完全确定的时候。
你确定代码是正确的,但又不是绝对确定。有可能漏掉一些场景,在这种情况下,通过断言(assert)进行运行时检查有助于我们尽早的发现错误。

断言的另一个用途是检查程序的不变量。所谓的不变量就是一些你可以依赖的真实条件,除非一个错误导致它变成假的。如果有一个bug,最好能尽早发现所以我们要对它进行测试,但我们不想因为这样的测试而降低代码的速度。但我们不想因为这些测试而使代码变慢。因此我们可以通过可在开发中打开而在生产中关闭断言(assert)。

不变量的一个例子是,如果你的函数预期一个数据库连接是 open,并承诺它在返回时仍然是open,这就是函数的一个不变式。open 就是该函数的一个不变量。

1
2
3
4
5
def some_function(arg):
assert not DB.closed()
... # code goes here
assert not DB.closed()
return result

断言也是很好的检查性评论,而不是写一个评论:

1
2
3
# when we reach here, we know that n > 2
# 我们可以通过将其转换为断言来确保在运行时对其进行检查
assert n > 2

断言也是防御性编程的一种形式。你不是在保护现在的代码中的错误,而是在保护以后引入错误的变化。理想情况下,单元测试会发现这些错误,但是实际上,即使存在测试,它们也是往往是不完整的。有可能在几周内都没有人注意到构建机器人,或者有的时候在提交代码前忘记运行测试。有一个内部检查是防止错误潜入的另一道防线,特别是阻止那些会导致代码故障和返回错误结果的静默错误。

假设你有个代码中有 if…elif 代码块,这个时候你是知道这些分支对应了哪些变量,并且了解逻辑是什么:

1
2
3
4
5
6
7
# target is expected to be one of x, y, or z, and nothing else.
if target == x:
run_x_code()
elif target == y:
run_y_code()
else:
run_z_code()

假设这个代码现在是完全正确的。但它会一直正确吗?需求改变了,代码也会改变。现在要求改变为当 target == w,执行 run_w_code。如果我们改变了设置目标的代码,但忽略了改变这个代码块,它将错误地调用 run_z_code():

1
2
3
4
5
6
7
8
target = w
# ...
if target == x:
run_x_code()
elif target == y:
run_y_code()
else:
run_z_code()

最好是把这段代码写成防御性的,哪怕之后有变化,它也是要么是正确的,要么立即失败。

写代码块一开始加注释是很好的习惯,但是通常时间久了,我们就没有阅读和更新注释的习惯。有可能注释很快就会被淘汰。但通过一个断言,我们既可以记录这个块的假设,也可以在断言检验没通过的时候直接抛出错误。

1
2
3
4
5
6
7
8
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()

断言既是防御性编程,也是经过检查的文档,下面这个代码会更好一点:

1
2
3
4
5
6
7
8
9
if target == x:
run_x_code()
elif target == y:
run_y_code()
elif target == z:
run_z_code()
else:
# This can never happen. But just in case it does...
raise RuntimeError("an unexpected error occurred")

契约设计是另一种对断言的不错的应用。在按契约设计中,我们认为函数与它们的调用者签订了 “契约”。
例如,”如果你传递给我一个非空的字符串,我保证返回该字符串的第一个的第一个字符转换成大写字母”。

如果这个契约被函数或调用它的代码所破坏,代码就是有问题的。我们说,函数有预设条件(即
参数的约束)和后置条件(返回结果的约束) 因此,这个函数可能被代码为:

1
2
3
4
5
6
7
def first_upper(astring):
assert isinstance(astring, str) and len(astring) > 0
result = astring[0].upper()
assert isinstance(result, str) and len(result) == 1
assert result == result.upper()
return result

契约设计的目的是,在一个正确的程序中,前条件和后条件总是成立的。当我们发布无错误的程序并将其投入生产时,我们可以安全地删除这些断言。

什么时候不使用assert

  • 永远不要用它们来测试用户提供的数据,或者用于在任何情况下必须进行检查的地方。
  • 不要用断言来检查任何你认为在你的程序的正常使用中可能失败的东西。断言是为特殊失败条件。你的用户不应该看到 AssertionError。如果他们看到了,这就是一个需要修复的错误。
  • 特别是,不要因为 assert 比一个测试和 raise 短就使用它。
  • 不要用它们来检查公共库的输入参数函数的输入参数,因为你无法控制调用者,也不能保证它不会破坏函数的契约。
  • 不要将 assert 用于任何你期望恢复的错误。 换句话说,你没有理由在生产代码中捕捉一个AssertionError 异常。
  • 不要使用太多的断言,以至于它们掩盖了代码。

前言

Python3.7 引入了一个新的模块那就是 dataclasses,早在 3.6 版本的时候我就通过安装 dataclasses 三方库体验了一波,那么为什么要用 dataclasses 呢?

为什么使用 dataclasses

一个简单的场景,当你想定义一个对象的属性的时候,比如一本书,通常你会这样

1
2
3
4
5
class Book:
def __init__(self, name: str, price: float, author:str = "佚名"):
self.name = name
self.price = price
self.author = author

如果在不定义 __repr__ 的情况下,初始化这个对象,并输出的话:

1
2
3
4
5
6
7
>>> book = Book('桃子',10.0,author='桃子')
>>> book
<__main__.Book object at 0x10b69cbb0>
>>> str(book)
'<__main__.Book object at 0x10b69cbb0>'
>>> repr(book)
'<__main__.Book object at 0x10b69cbb0>'

显然输出不够友好,对于属性比较少的对象,定义一个 __repr__ 并不太麻烦,但是一旦需要定义的对象很多或者属性很多的情况下,这样子做就显得略微麻烦了一点。这个时候 dataclasses 就派上用场了。

dataclasses

我们用 dataclassesBook 改造一下

1
2
3
4
5
6
7
from dataclasses import dataclass

@dataclass
class Book:
name:str
price:float
author:str = '桃子'

然后简单验证一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
>>> from dataclasses import dataclass,asdict,astuple
>>> @dataclass
... class Book:
... name:str
... price:float
... author:str
...
>>> book1 = Book(name='桃子1',price=10.0,author='桃子1')
>>> book1
Book(name='桃子1', price=10.0, author='桃子1')
>>> asdict(book1)
{'name': '桃子1', 'price': 10.0, 'author': '桃子1'}
>>> astuple(b1)
('桃子1', 12.0, '桃子')

dataclasses 甚至还具备 asdict 函数可以将对象转成 dict,也存在 astuple 可以将对象转成tupple ,是不是很方便,但是还不够,有时候我们对不同对参数进行一定对校验,很遗憾 dataclasses 并不能做到,这个时候就需要看 attrspydantic 了。

除此之外,attrspydantic 还有其他的 dataclasses 不具备的特性,见下表:

attrs vs pydantic

attrs 和 pydantic 都需要通过 pip 安装

1
2
pip install attrs
pip install pydantic

validator

attrs 中的 validators

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import attr

@attr.s
class Book:
name: str = attr.ib(default=None)
price:float = attr.ib(default=None,validator=attr.validators.instance_of(float))
author: str = attr.ib(default='桃子')

@price.validator
def more_than_zero(self, attribute, value):
if not value > 0:
raise ValueError("价格不能少于 0 元!")

book = Book(name='attrs',price=-1.0,author='hynek Hynek Schlawack')

运行一下,验证一下结果:

1
2
3
4
5
6
7
8
9
Traceback (most recent call last):
File "test_attrs.py", line 14, in <module>
book = Book(name='attrs',price=-1.0,author='hynek Hynek Schlawack')
File "<attrs generated init __main__.Book>", line 6, in __init__
File "D:\workspace\python\test\venv\lib\site-packages\attr\_make.py", line 2975, in __call__
v(inst, attr, value)
File "test_attrs.py", line 12, in more_than_zero
raise ValueError("价格不能少于 0 元!")
ValueError: 价格不能少于 0 元!

pydantic 中的 validators

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from pydantic import BaseModel,validator, ValidationError

class Book(BaseModel):
name:str
price:float
author:str = '桃子'

@validator("price")
def more_than_zero(cls, v):
assert isinstance(v,float)
if v <= 0:
raise ValueError("价格不能少于 0 元!")
return v

book = Book(name='attrs',price=-1.0,author='hynek Hynek Schlawack')

同样的验证一下结果:

1
2
3
4
5
6
7
Traceback (most recent call last):
File "test_pydantic.py", line 15, in <module>
book = Book(name='attrs',price=-1.0,author='hynek Hynek Schlawack')
File "pydantic\main.py", line 406, in pydantic.main.BaseModel.__init__
pydantic.error_wrappers.ValidationError: 1 validation error for Book
price
价格不能少于 0 元! (type=value_error)

除了 validators ,attrs 和 pydantic 还同时具有 converters(转换器) 功能。

converters

转换器也是一个十分实用的功能,所谓的转换器就是在将参数在传递到 __init__ 之前,将它按照需求转换成所需格式的数据。

最常见的就是将 str 转成 datetime 格式,attrs 和 pydantic 在转换器的实现上有所区别:

attrs 中 的转换器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import attr
from datetime import datetime

def conver_to_datetime(user_input) -> datetime:
if isinstance(user_input,str):
return datetime.strptime(user_input, '%Y-%m-%d %H:%M:%S')
elif isinstance(user_input,datetime):
return user_input
raise TypeError("不支持的参数类型,仅支持:str 和 datetime")

@attr.s
class Book:
# ... 省略
publish_date: datetime = attr.ib(default=None, converter=conver_to_datetime)

book = Book(name='attrs',price=1.0,author='hynek Hynek Schlawack',publish_date='2021-09-13 00:00:00')
print(book)

运行程序

1
Book(name='attrs', price=1.0, author='hynek Hynek Schlawack', publish_date=datetime.datetime(2021, 9, 13, 0, 0))

不难看出,str 类型的字符串被成功转化了。

pydantic 中的转换器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from pydantic import BaseModel,validator
from datetime import datetime

class Book(BaseModel):
# 省略...
publish_date: datetime

@validator("publish_date")
def conver_to_datetime(cls, user_input):
if isinstance(user_input,str):
return datetime.strptime(user_input, '%Y-%m-%d %H:%M:%S')
elif isinstance(user_input,datetime):
return user_input
raise TypeError("不支持的参数类型,仅支持:str 和 datetime")

book = Book(name='attrs',price=1.0,author='hynek Hynek Schlawack', publish_date='2021-09-13 16:03:00')
print(book)

验证结果:

1
name='attrs' price=1.0 author='hynek Hynek Schlawack' publish_date=datetime.datetime(2021, 9, 13, 16, 3)

从上面的代码不难看出,attrs 本身内置 converter 参数,可以通过传递内置函数或者自定义函数实现转化器的功能,而 pydantic 则需要借助 validator 去实现,在这一点上 pydantic 还是稍微逊色 attrs。

除了上面提到的两个功能,attrs 和 pydantic 都具备 immutable 的方法用来修饰属性,从而实现属性不可修改。

attrs 还具备 slotsprogrammatic creation 有兴趣的可以进一步阅读官方文档。

总结

从上面的例子,不难看出 pydantic 有下面几个问题:

  1. pydantic 不支持位置参数
  2. pydantic 的输出有点奇怪,没有带上类名
  3. pydantic 不支持 slotsprogrammatic creation
  4. pydantic 不支持 Collection 类型

在参考文章中还提到了 pydantic 对 unions 的策略有问题,不容易定制,并且对定制的(非)结构化的支持很弱。

所以如果有复杂的需求的话,建议使用 attrs ,只是想简单的呈现对象的属性的话,可以考虑用 dataclasses

参考文章

dataclasses vs attrs vs Pydantic

attrs 和 Python3.7 的 dataclasses

Why I use attrs instead of pydantic

前言

默认情况下 es 是不支持 pdf、doc 等文档的搜索的,但是可以通过安装 Ingest attachment plugin 插件来使得 es 提取通用格式的文件,从而可以实现搜索的功能。

安装与使用

安装 Ingest attachment plugin

安装很简单,通过 elasticsearch-plugin 可以直接进行安装

1
bin/elasticsearch-plugin install ingest-attachment

Ingest attachment plugin 允许 Elasticsearch 通过使用 Apache 文本提取库 Tika 提取通用格式(例如:PPT,XLS 和 PDF)的文件附件。Apache Tika 工具包可从一千多种不同的文件类型中检测并提取元数据和文本。所有这些文件类型都可以通过一个界面进行解析,从而使 Tika 对搜索引擎索引,内容分析,翻译等有用。需要注意的是,源字段必须是 Base64 编码的二进制,如果不想增加在 Base64 之间来回转换的开销,则可以使用 CBOR 格式而不是 JSON,并将字段指定为字节数组而不是字符串表示形式,这样处理器将跳过 Base64 解码。

创建 attachment pipeline

通过 kibana 的开发工具进行请求

1
2
3
4
5
6
7
8
9
10
11
PUT _ingest/pipeline/pdfattachment
{
"description": "Extract attachment information encoded in Base64 with UTF-8 charset",
"processors": [
{
"attachment": {
"field": "file"
}
}
]
}

返回结果:

1
2
3
{
"acknowledged" : true
}

表示创建成功,接下来就是验证上传 pdf 以及搜索功能了。

转换并上传PDF文件的内容到Elasticsearch中

对于 Ingest attachment plugin 来说,它的数据必须是 Base64 的。这里为了快速创建,我们通过一个 bash 脚本去处理用来测试的 pdf

1
2
3
4
5
6
7
8
9
!/bin/bash

encodedPdf=`cat sample.pdf | base64`

json="{\"file\":\"${encodedPdf}\"}"

echo "$json" > json.file

curl -X POST 'http://localhost:9200/pdf-test1/_doc?pipeline=pdfattachment&pretty' -H 'Content-Type: application/json' -d @json.file

上传成功会返回如下的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"_index" : "pdf-test1",
"_type" : "_doc",
"_id" : "1oVSxHsB1ubIHqCXIbPU",
"_version" : 1,
"result" : "created",
"_shards" : {
"total" : 2,
"successful" : 1,
"failed" : 0
},
"_seq_no" : 0,
"_primary_term" : 1
}

如果出现提示下述提示

1
2
3
4
5
{
"statusCode": 400,
"error": "Bad Request",
"message": "Payload content length greater than maximum allowed: 1048576"
}

说明上传的文件大小超过了 Kibana 默认的上限(默认 1M),修改 kibana 的配置 kibana.yml

1
server.maxPayloadBytes: "209715200"

查看索引并搜索

查看索引

通过下面的命令可以查看 pdf-test1 的索引情况:

1
GET pdf-test1/_search

结果如下图

其中 _source 里有个 content 字段,就是 pdf 的内容,结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
{
"_source": {
"file":"...",
"attachment": {
"date": "2021-08-18T07:29:34Z",
"content_type": "application/pdf",
"author": "author",
"language": "lt",
"title": "2021-07(copy)",
"content": "..."
}
}

其中 file 就是 base64 格式的内容,content 则包含了 pdf 的内容,如果不想要 file 则可以通过 remove processor 去除这个字段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
PUT _ingest/pipeline/pdfattachment
{
"description": "Extract attachment information encoded in Base64 with UTF-8 charset",
"processors": [
{
"attachment": {
"field": "file"
}
},
{
"remove": {
"field": "file"
}
}
]
}

搜索

通过在 kibana 的开发工具执行下述命令,验证一下搜索:

1
2
3
4
5
6
7
8
GET pdf-test1/_search
{
"query": {
"match": {
"attachment.content": "5G"
}
}
}

参考链接

Elasticsearch:如何对PDF文件进行搜索