16.1 概念
- 函数、类、常量拆分到不同的.py文件,这些文件就是模块
- 将多个模块放到一个文件夹中,这个文件夹就是包,包中通常还包含一个文件
__init__.py
,表述包对外暴露的模块接口。内容也可以为空,并不是必须的。
通过模块和包组织的项目,组织结构看起来更加清晰,例如基于Pytest的自动化测试项目的结构:
├── Pipfile
├── Pipfile.lock
├── config
│ ├── prod
│ └── test
├── conftest.py
├── data
│ └── test_in_theaters.yaml
├── pytest.ini
├── readme.md
├── tests
│ ├── test_assertions.py
│ ├── test_in_theaters.py
│ ├── test_json_path.py
│ ├── test_json_schema.py
│ ├── test_marks.py
│ ├── test_smtpsimple.py
│ └── test_time.py
└── utils
├── __init__.py
├── assertions.py
└── commlib.py
16.2 导入模块
可以导入单个模块,也可以导入包中所有模块。import语句放在py文件的最顶端。建议从项目根路径下面的目录开始导入。例如上面的自动化项目目录,在其他包中导入utils包中的模块,最好从utils目录开始查找要导入的模块。
16.2.1 导入单个模块
导入单个模块的方法,语法是:import package.module_name。就是包名+点号+模块名,例如,上面的项目中从commlib.py中导入assertions模块,则可以这样写:
import utils.assertions
也可以用from形式,from+包名+import+模块名:
from utils import assertions
更推荐大家用后面这种形式,因为前面那种形式在使用模块里面的函数时,必须使用它的全名,比如:
utils.assertions.assertsion(1,1)
写起来会比较麻烦,特别是模块路径比较长的时候。而使用from导入的包,在使用其中的函数时,直接从模块名字开始就好了:
from utils import assertions
assertions.ENV
assertions.A
assertions.assertsion(1,1)
还有一种导入方式,导入模块中的属性、方法和类,星号代表可以导入的所有属性、方法和类。不过不建议这么做。
from utils.assertions import *
此时被导入模块中若定义了__all__
属性,则只有__all__
内指定的属性、方法、类可被导入;若没定义__all__
属性,则导入模块内的所有公有属性,方法和类。带下划线开头的属性、方法和类是属于模块私有的,最好不要在模块外边使用,虽然使用也不会报错,但是Pycharm IDE会有提醒。
测试报告框架allure的allure.py文件中就有这个__all__
变量,查看源码,__all__
内容如下所示:
__all__ = [
'title',
'description',
'description_html',
'label',
'severity',
'suite',
'parent_suite',
'sub_suite',
'tag',
'id',
'epic',
'feature',
'story',
'link',
'issue',
'testcase',
'step',
'dynamic',
'severity_level',
'attach',
'attachment_type'
]
从Pycharm IDE中,import allure
会后,通过点号可以使用的方法,可以看到就是上面__all__
变量的内容:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-L009TTyh-1596963840405)(pics/all.png)]
这也体现了一种设计思想,当你自己开发的Python包含有很多模块时,为了方便大家的使用,可以将很多模块中的函数汇总到一个模块中,就像allure.py这样,在allure模块中从其他模块导入函数,并设定一个__all__
变量,从而让使用者仅导入allure这一个模块即可。
同样的设计思路体现在pytest单元测试框架中,当我们导入pytest模块时,我们就可以使用pytest模块中__all__
变量列出来的函数了,这些函数都是来自内部包_pytest
包中的各个模块。
16.2.2 批量导入模块
从一个包中批量导入模块语法是:from package_name import *
。相比导入单个模块,区别是用星号代替模块名称。不建议批量导入。
例如导入自动化项目utils包中所有的模块,形式是from+包名+import+*,例如:
from utils import *
可以在包的 __init__.py
文件中定义一个名为 __all__
的变量。 在这个变量中规定通过上面的方法导入时,可以导入哪些模块。例如,在utils包的 __init__.py
文件,如果定义了 __all__
变量的值如下:
__all__ = ['assertions']
那么在使用from utils import *
导入模块时或者直接import utils
导入包时,则只会导入assertions模块。
前面提到,pytest模块代码中__all__
变量中列出来的函数都是来自_pytest
内部包。这个内部包_pytest
的__init__.py
文件中也有一个__all__
变量,不过这里面只有一个__version__
,因此工作中很少使用_pytest
包。
在Pycharm中导入模块,可以先写业务代码,在用到某个模块但还没有导入时,可以在模块名称上面,按快捷键alt+enter自动将其导入。
16.3 解决找不到模块的问题
虽然我们导入了模块,但是有时候还是会有找不到模块的提示,找不到模块时会报ImportError: No module named xxx
提示。当导入某个包时,Python搜索 sys.path
里的目录列表,按顺序查找包中的模块。如果找不到就会报ImportError错误。查看sys.path的办法:
>>> import sys
>>> print(sys.path)
['', '/Users/chunming.liu/.pyenv/versions/3.7.7/lib/python37.zip', '/Users/chunming.liu/.pyenv/versions/3.7.7/lib/python3.7', '/Users/chunming.liu/.pyenv/versions/3.7.7/lib/python3.7/lib-dynload', '/Users/chunming.liu/.pyenv/versions/3.7.7/lib/python3.7/site-packages']
知道了模块的查找路径,那么,我们有两种办法可以解决ImportError:
- 第一个办法,可以把模块路径加入到PYTHONPATH中
export PYTHONPATH="/Users/chunming.liu/learm/api_pytest"
因为PYTHONPATH会自动加入到sys.path中。所以,模块路径加入到PYTHONPATH后,也就加入到sys.path了。
- 第二个办法,在代码中,可以手动添加模块路径到sys.path中,sys.path.append(“模块文件所在目录”)
这也是为什么建议从项目根路径开始导入模块的原因,这样只需要导入项目根目录到PYTHONPATH就好了。
16.4 安装模块
Python自带的包管理软件,通常是从python官方PyPI源下载安装的,地址是 https://pypi.python.org/simple。我们国内用户访问下载的时候会很慢。可以手动指定国内的包镜像地址,加快安装速度:
$ pip install flask -i https://pypi.douban.com/simple
国内的Python包镜像主要有:
- 阿里云 http://mirrors.aliyun.com/pypi/simple/
- 中国科技大学 https://pypi.mirrors.ustc.edu.cn/simple/
- 豆瓣(douban) http://pypi.douban.com/simple/
- 清华大学 https://pypi.tuna.tsinghua.edu.cn/simple/
- 中国科学技术大学 http://pypi.mirrors.ustc.edu.cn/simple/
上面的方式是临时修改Python包镜像地址,如果想永久修改Python镜像地址,可以新建~/.pip/pip.conf文件,并写入下面的内容:
$ cat ~/.pip/pip.conf
[global]
index-url = http://pypi.douban.com/simple/
trusted-host = pypi.douban.com
如果公司内部搭建内部镜像,那使用公司内部的镜像更好了。
16.5 模块的名字
假如有一个叫做common的py文件,内容如下:
# content of commmon.py
data = dict() # 保存中间结果
def walk(n):
if n == 1:
return 1
if n == 2:
return 2
if n > 100:
raise RecursionError("recursion depth exceed 100")
if n in data: # 如果在中间结果中,则直接返回,不用进入递推公式再次计算
print(n, data)
return data[n]
result = walk(n - 1) + walk(n - 2)
data[n] = result
return result
walk(6)
当使用import 导入模块common.py的时候,会自动把模块内可以执行的代码data=dict()
和 (walk(6)
)执行一遍。
# content of mywalk.py
from test.test_suite1 import func
print(func.walk(2))
## 输出
3 {
3: 3, 4: 5}
4 {
3: 3, 4: 5, 5: 8}
13
2
上面的输出中13就是common.py模块中walk(6)
的输出。这不是我们希望的,我们希望的是单独执行模块common.py时walk(6)
可以被执行。当模块被导入到其他py文件中执行时,模块内的可执行代码walk(6)
不被执行。
想要达到这个效果,可以在模块中添加 if __name__ == '__main__'
语句,将模块内的可以执行代码,例如walk(6)
放在里面。
if __name__ == '__main__':
print(walk(6))
这样会有两个效果:
__name__
作为 Python 的魔术内置参数,本质上是模块对象的一个属性。当单独执行模块时,__name__ == '__main__'
条件成立,print(walk(6))
会被执行。- 当模块被 import 时,
__name__ == '__main__'
条件不成立,print(walk(6))
不会被执行。
因此,通常的做法是,将模块的测试代码放到__name__ == '__main__'
条件语句里面,这样单独执行模块时,就执行测试代码。
16.6 命令行工具
有些时候,我们会在命令行上执行模块,例如执行pytest框架编写的测试用例,可以这样python -m pytest
,注意pytest后面不要加
.py后缀,通过这种方式执行测试时,一个好处是可以自动将当前目录加入到 sys.path
中。不用手动将自动化项目目录加入到PYTHONPATH变量中了,避免找不到模块的情况。
还有,在测试工作中,有时候会开发很多命令行的小工具配合测试工作。所以在编写和执行命令行工具,在测试工作中还是很常见的。
编写命令行工具,常见的一种是使用自带的argparse模块,另外一种是使用第三方库fire。
16.6.1 argparse模块
argparse模块是Python自带的,可以从 sys.argv
解析和处理命令行选项和参数。还会自动生成帮助和使用手册,并在用户给程序传入无效参数时报出错误信息。
使用argparse模块制作命令行工具一共分四步,看下面代码:
# content of argparse_demo.py
import argparse
# 1. 设置解析器
parser = argparse.ArgumentParser("命令行工具描述。")
# 2. 定义参数
# 添加位置参数
parser.add_argument('integers', #位置参数名
metavar='N', # -h查看帮助信息时,显示为 N
type=int, # 输入的参数必须是int
nargs='+', # +表示至少要有一个参数,?表示0或1个参数,*表示0或多个参数,也可以是数值,表示参数个数。
help='an integer for the accumulator')
# 添加选项参数
parser.add_argument('--sum', #--开头的是个命令行选项
dest='accumulate', # 这个选项解析后对应的属性
action='store_const', # action表示对属性做的操作,这里的store_const表示将const的值存到dest属性中。
const=sum, # 指定了--sum 参数时,accumulate将是 sum() 函数
default=max, # 若不提供 --sum,accumulate默认值为 max 函数
help='sum the integers (default: find the max)')
# 3. 解析参数
args = parser.parse_args(['--sum', '1', '2', '3'])
print(args) # Namespace(accumulate=<built-in function sum>, integers=[1, 2, 3])
# 4. 业务逻辑
if args.accumulate:
print(args.accumulate(args.integers))
首先设置一个解析器,并添加该命令行工具的描述信息。
上面的代码中添加了一个位置参数,一个选项参数。位置参数接收的是int类型的数据,可以接收1个或多个参数,这些参数会存入integers属性中。选项参数存储在accumulate属性中,在有–sum命令行选项时,这个属性中的值是sum函数,当没有这个命令行选项时,这个属性中的默认值是max函数。
所有的命令行参数经过解析后,存储在了args变量里,这个变量的内容如下:
Namespace(accumulate=<built-in function sum>, integers=[1, 2, 3])
argparse模块自动添加-h选项,用来展示命令行工具的使用手册:
$ python argparse_demo.py -h
usage: 命令行工具描述。 [-h] [--sum] N [N ...]
positional arguments:
N an integer for the accumulator
optional arguments:
-h, --help show this help message and exit
--sum sum the integers (default: find the max)
通常我们不会给parser.parse_args()函数传参,使用这个命令行工具的正常方式是:
$ python argparse_demo.py 1 2 3
3
$ python argparse_demo.py 1 2 3 --sum
6
如果不想每次都写python这个命令,而是直接执行py文件,可以在Python文件第一行,写明该文件打算用什么软件执行,因为我们的py文件肯定是想用Python执行,因此可以在py文件第一行写下下面的代码:
#!/usr/bin/env python3
然后再给py文件添加可执行权限:
chmod +x argparse_demo.py
现在就可以下面这样的方式,在命令行上执行py模块了:
$ argparse_demo.py 1 2 3 --sum
这个模块的更多使用方法,可以参考官方文档。
16.6.2 fire模块
fire是Google开源的一款制作命令行软件的工具。相比argparse,fire模块使用起来相对方便。同样是实现上面的累加功能,用fire模块只需要定义一个函数:
#!/usr/bin/env python3
# content of fire_demo.py
import fire
def accumulate(*numbers):
return sum(numbers)
if __name__ == '__main__':
fire.Fire()
如果是多个功能,可以将多个函数放入一个类中:
#!/usr/bin/env python3
# content of tcp_demo.py
import socket
from concurrent.futures.thread import ThreadPoolExecutor
import fire
class TCPDemo:
def server(self, host='0.0.0.0', port=5555):
pool = ThreadPoolExecutor(8)
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind((host, port))
s.listen(5)
print("服务器监听在{}:{},等待客户端连接...".format(host, port))
while True:
client_sock, client_address = s.accept()
pool.submit(self.handler, client_sock, client_address)
def client(self, server_ip, server_port):
tcp_client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
tcp_client.connect((server_ip, server_port))
except socket.error:
print('fail to setup socket connection')
tcp_client.close()
while True:
data = input("> ")
if not data:
break
tcp_client.send(data.encode("utf-8"))
msg = tcp_client.recv(1024)
print("服务端说:" + msg.decode('utf-8'))
@staticmethod
def handler(client_sock, client_address):
"""
处理收到的data。原样返回给客户端
"""
print('Got connection from {}:{}'.format(client_address[0], client_address[1]))
with client_sock:
while True:
data = client_sock.recv(1024).decode("utf-8")
# 收到的数据不为空
if data:
response = data
print("客户端说:" + response)
client_sock.send(response.encode("utf-8"))
if __name__ == '__main__':
fire.Fire(TCPDemo)
使用方法与argparse类似:
$ python tcp_demo.py server 10.110.215.104 23900 # 如果不填写后面的参数,则默认是本地 5555端口
$ ./tcp_demo.py client 10.110.215.104 23900
在实际工作中,开发命令行工具辅助我们测试的情况非常多,比如测试一个向kafka中生产数据的服务,需要验证kafka的Topic中确实有数据,就可以开发一个命令行工具来消费Topic,例如下面这样提供kafka地址和topic名称,当有topic中有消息了就能在打印到控制台,从而进行校验。
$ python kafka_client.py bootstrap_server topic_test-vehicle_data
想要实现嵌套命令,可将多个类组织起来,示例如下:
#!/usr/bin/env python3
# content of fire_demo.py
import fire
class IngestionStage(object):
def run(self):
return 'Ingesting! Nom nom nom...'
class DigestionStage(object):
def run(self, volume=1):
return ' '.join(['Burp!'] * volume)
def status(self):
return 'Satiated.'
class Pipeline(object):
def __init__(self):
self.ingestion = IngestionStage()
self.digestion = DigestionStage()
def run(self):
self.ingestion.run()
self.digestion.run()
if __name__ == '__main__':
fire.Fire(Pipeline)
因此整个命令行程序支持如下命令:
$ python fire_demo.py run
$ python fire_demo.py ingestion run
$ python fire_demo.py digestion run
$ python fire_demo.py digestion status
想要查看帮助信息,需要指明类的实例:$ python fire_demo.py ingestion --help
,将输出如下帮助信息:
NAME
fire_demo.py ingestion
SYNOPSIS
fire_demo.py ingestion COMMAND
COMMANDS
COMMAND is one of the following:
run
更全面的fire模块使用方法参考官方文档。