测试开发之Python核心笔记(16):模块与包

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模块使用方法参考官方文档

猜你喜欢

转载自blog.csdn.net/liuchunming033/article/details/107896540
今日推荐