pytest-api-framework-alpha 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,111 @@
1
+ import pymysql
2
+ from dbutils.pooled_db import PooledDB
3
+
4
+ from framework.utils.log_util import logger
5
+ import json
6
+ from datetime import datetime
7
+ from decimal import Decimal
8
+
9
+
10
+ class MysqlDB(object):
11
+
12
+ def __init__(self, host, username, password, port, db):
13
+
14
+ self.pool = PooledDB(
15
+ creator=pymysql,
16
+ maxconnections=1,
17
+ mincached=1,
18
+ maxcached=1,
19
+ blocking=True,
20
+ maxusage=None,
21
+ setsession=[],
22
+ ping=0,
23
+ host=host,
24
+ user=username,
25
+ password=password,
26
+ db=db,
27
+ port=port,
28
+ charset='utf8'
29
+ )
30
+
31
+ def query(self, sql):
32
+ """查询,返回结果"""
33
+ connection = self.pool.connection() # 获取一个连接
34
+ cursor = connection.cursor(pymysql.cursors.DictCursor)
35
+ try:
36
+ cursor.execute(sql)
37
+ logger.info(f"执行SQL: {sql}")
38
+ result = cursor.fetchall()
39
+ if len(result) == 1:
40
+ result = result[0]
41
+ elif len(result) == 0:
42
+ result = None
43
+ if isinstance(result, dict) or isinstance(result, list):
44
+ logger.info(f"SQL执行结果: {json.dumps(result, default=MysqlDB.custom_serializer)}")
45
+ else:
46
+ logger.info(f"SQL执行结果: {result}")
47
+ return result
48
+ except pymysql.MySQLError as e:
49
+ logger.error(f"Error executing query: {e}")
50
+ return None
51
+ finally:
52
+ cursor.close() # 关闭游标
53
+ connection.close() # 将连接返回到连接池
54
+
55
+ def insert(self, sql):
56
+ """修改,新增,删除"""
57
+ connection = self.pool.connection() # 获取一个连接
58
+ cursor = connection.cursor(pymysql.cursors.DictCursor)
59
+ try:
60
+ result = cursor.execute(sql)
61
+ connection.commit()
62
+ inserted_id = cursor.lastrowid
63
+ print(f"插入的记录的主键ID: {inserted_id}")
64
+ return inserted_id
65
+ except pymysql.MySQLError as e:
66
+ print(e)
67
+ return None
68
+ finally:
69
+ cursor.close() # 关闭游标
70
+ connection.close() # 将连接返回到连接池
71
+
72
+ def execute(self, sql):
73
+ """修改,新增,删除"""
74
+ connection = self.pool.connection() # 获取一个连接
75
+ cursor = connection.cursor(pymysql.cursors.DictCursor)
76
+ try:
77
+ result = cursor.execute(sql)
78
+ logger.info(f"执行SQL: {sql}")
79
+ connection.commit()
80
+ logger.info(f"SQL执行结果: {result}")
81
+ return result
82
+ except pymysql.MySQLError as e:
83
+ logger.error(f"Error executing execute: {e}")
84
+ return None
85
+ finally:
86
+ cursor.close() # 关闭游标
87
+ connection.close() # 将连接返回到连接池
88
+
89
+ def executemany(self, sql, data):
90
+ """修改,新增,删除"""
91
+ connection = self.pool.connection() # 获取一个连接
92
+ cursor = connection.cursor(pymysql.cursors.DictCursor)
93
+ try:
94
+ cursor.executemany(sql, data)
95
+ logger.info(f"执行SQL: {sql}")
96
+ connection.commit()
97
+ logger.info(f"{cursor.rowcount} records inserted.")
98
+ except pymysql.MySQLError as e:
99
+ logger.error(f"Error executing execute: {e}")
100
+ return None
101
+ finally:
102
+ cursor.close() # 关闭游标
103
+ connection.close() # 将连接返回到连接池
104
+
105
+ @staticmethod
106
+ def custom_serializer(obj):
107
+ if isinstance(obj, datetime):
108
+ return obj.strftime('%Y-%m-%d %H:%M:%S') # 转换时间格式
109
+ elif isinstance(obj, Decimal):
110
+ return float(obj) # 转换 Decimal 为 float
111
+ raise TypeError(f"Type {type(obj)} not serializable")
@@ -0,0 +1,142 @@
1
+ import json
2
+ from redis import Redis
3
+ from redis.exceptions import RedisError
4
+
5
+ from framework.utils.log_util import logger
6
+
7
+
8
+ class RedisDB(Redis):
9
+
10
+ def __init__(self, host, port, password, db, max_connections=10):
11
+ super(RedisDB, self).__init__()
12
+ self.db = db
13
+ self.max_connections = max_connections
14
+ self.conn = Redis(
15
+ host=host,
16
+ port=port,
17
+ password=password,
18
+ db=self.db,
19
+ decode_responses=True
20
+ )
21
+
22
+ def __del__(self):
23
+ try:
24
+ self.conn.close()
25
+ except RedisError as e:
26
+ logger.info(e)
27
+ raise RedisError(f"redis close error>>>{e}")
28
+
29
+ def set_string(self, name, value, ex=None):
30
+ if ex:
31
+ res = self.conn.set(name, value, ex)
32
+ logger.info(f'set {name} {value} {ex} --->{res}')
33
+ else:
34
+ res = self.conn.set(name, value)
35
+ logger.info(f'set {name} {value} --->{res}')
36
+ if res:
37
+ return res
38
+ else:
39
+ return False
40
+
41
+ def set_hash(self, name, value):
42
+ res = self.conn.hmset(name, value)
43
+ logger.info(f'hmset {name} {value} --->{res}')
44
+ if res:
45
+ return res
46
+ else:
47
+ return False
48
+
49
+ def set_list(self, name, value):
50
+ """
51
+ value可以是字典;列表[字典];列表[数字];列表[字符串];空字典;空列表;空字符串;None
52
+ :param name:
53
+ :param value:
54
+ :return:
55
+ """
56
+
57
+ if value:
58
+ if isinstance(value, dict):
59
+ self.push(name, value)
60
+ elif isinstance(value, list):
61
+ if isinstance(value[0], dict):
62
+ value = [json.dumps(item, ensure_ascii=False) for item in value]
63
+ self.conn.lpush(name, *value)
64
+ elif isinstance(value[0], (int, str)):
65
+ self.conn.lpush(name, json.dumps(value, ensure_ascii=False))
66
+ else:
67
+ self.conn.lpush(name, str(value))
68
+ else:
69
+ self.conn.lpush(name, value)
70
+ else:
71
+ self.conn.lpush(name, json.dumps(value))
72
+
73
+ def get_string(self, name):
74
+ res = self.conn.get(name)
75
+ logger.info(f'get {name}')
76
+ if res:
77
+ return res
78
+ else:
79
+ return None
80
+
81
+ def get_hash(self, name):
82
+ res = self.conn.hgetall(name)
83
+ logger.info(f'hgetall {name}')
84
+ if res:
85
+ return res
86
+ else:
87
+ return None
88
+
89
+ def get_list(self, name):
90
+ res = self.conn.lrange(name, 0, -1)
91
+ if res:
92
+ return [json.loads(item) for item in res]
93
+
94
+ def get_ttl(self, name):
95
+ res = self.conn.ttl(name)
96
+ logger.info(f'ttl {name} ---> {res}')
97
+ if res:
98
+ return res
99
+ else:
100
+ return None
101
+
102
+ def set_ttl(self, name, time):
103
+ res = self.conn.expire(name, time)
104
+ logger.info(f'expire {name} {time} ---> {res}')
105
+ if res:
106
+ return res
107
+ else:
108
+ return False
109
+
110
+ def exists(self, name):
111
+ res = self.conn.exists(name)
112
+ logger.info(f'exists {name} ---> {res}')
113
+ if res:
114
+ return True
115
+ else:
116
+ return False
117
+
118
+ def delete(self, name):
119
+ res = self.conn.delete(name)
120
+ logger.info(f'delete {name} ---> {res}')
121
+ if res:
122
+ return True
123
+ else:
124
+ return False
125
+
126
+ def push(self, name, value):
127
+ """
128
+ 通过list实现队列,从左侧推消息,消息体只能字典
129
+ :param name:
130
+ :param value:
131
+ :return:
132
+ """
133
+ res = self.conn.lpush(name, json.dumps(value, ensure_ascii=False))
134
+ logger.info(f'lpush {name} ---> {value}')
135
+ if res:
136
+ return True
137
+ else:
138
+ return False
139
+
140
+
141
+ if __name__ == '__main__':
142
+ redis = RedisDB(db=0)
framework/exit_code.py ADDED
@@ -0,0 +1,19 @@
1
+ from enum import Enum
2
+
3
+
4
+ class ExitCode(Enum):
5
+ ENV_NOT_EXIST = 10001 # 测试环境不存在
6
+ APP_NOT_EXIST = 10002 # 测试系统不存在
7
+ CONTEXT_YAML_NOT_EXIST = 10003 # 未获取到全局配置文件
8
+ CONTEXT_YAML_DATA_FORMAT_ERROR = 10004 # 全局配置文件格式不正确
9
+ CASE_YAML_NOT_EXIST = 10005 # 未获取到对应的YAML测试数据文件
10
+ CASE_DATA_NOT_EXIST = 10006 # 未获取到用例对应测试数据
11
+ GLOBAL_ATTRIBUTE_NOT_EXIST = 10007 # 未从CONTEXT中获取到对应变量
12
+ FUNCTION_NOT_EXIST = 10008 # 未从common.py中或faker对象中获取到对应函数
13
+ EXTRACT_KEY_NOT_EXIST = 10009 # 未从响应中提取到指定变量
14
+ LOGIN_ERROR = 10010 # 系统登录失败
15
+ YAML_MISSING_FIELDS = 10011 # 缺少必填字段
16
+ MISSING_ASSERTIONS = 10012 # 缺少断言
17
+ APP_OR_ACCOUNT_NOT_EXIST = 10013 # 账号角色不存在
18
+ MORE_THAN_ONE_TEST_SUITE_SETUP = 10014 # test_suite_setup标签只能配置一个
19
+ LOAD_DATABASE_INFO_ERROR = 10015
framework/extract.py ADDED
@@ -0,0 +1,101 @@
1
+ import re
2
+ import traceback
3
+ from typing import List
4
+
5
+ import allure
6
+ import pytest
7
+ from box import Box
8
+ from jsonpath import jsonpath
9
+
10
+ from framework.utils.common import is_digit
11
+ from framework.utils.log_util import logger
12
+ from framework.exit_code import ExitCode
13
+ from framework.global_attribute import CONTEXT
14
+
15
+
16
+ class Extract(object):
17
+ def __init__(self, response, belong_app):
18
+ self.response = response
19
+ self.belong_app = belong_app
20
+ self.context = CONTEXT
21
+
22
+ def extracts(self, expressions: List[dict]):
23
+ for item in expressions:
24
+ key = list(item.keys())[0].strip()
25
+ expression = item.get(key).strip()
26
+ try:
27
+ # jsonpath表达式
28
+ if expression.lower().startswith("$."):
29
+ self.extract_by_jsonpath(key, expression)
30
+ # 正则表达式
31
+ elif expression.startswith("/") and expression.endswith("/"):
32
+ self.extract_by_regex(key, expression[1: -1])
33
+ # box句点表达式
34
+ else:
35
+ self.extract_by_box(key, expression)
36
+
37
+ except Exception as e:
38
+ with allure.step(f"后置提取变量{key}失败, 失败原因: {e}"):
39
+ logger.error(f"后置提取变量{key}失败, 失败原因: {e}")
40
+ traceback.print_exc()
41
+ pytest.exit(ExitCode.EXTRACT_KEY_NOT_EXIST)
42
+
43
+ def extract(self, key, expression):
44
+ return self.extracts([{key.strip(): expression.strip()}])
45
+
46
+ def extract_by_jsonpath(self, key, expression):
47
+ try:
48
+ if key.startswith(tuple(f"{app}." for app in self.context.all_app)):
49
+ self.belong_app, key = key.split(".", 1)
50
+
51
+ extract_value = jsonpath(self.response.json(), expression)[0]
52
+ with allure.step(f"后置提取变量: {key}: {extract_value}"):
53
+ self.context.set(key, extract_value, self.belong_app)
54
+ logger.info(f"后置提取变量: {key}: {extract_value}")
55
+
56
+ except Exception as e:
57
+ logger.error(f"jsonpath表达式错误或响应内容异常{e} 表达式: {expression};响应内容: {self.response.json()}")
58
+ raise Exception(f"jsonpath表达式错误或响应内容异常{e} 表达式: {expression};响应内容: {self.response.json()}")
59
+
60
+ def extract_by_regex(self, key, reg_expression):
61
+ try:
62
+ if key.startswith(tuple(f"{app}." for app in self.context.all_app)):
63
+ self.belong_app, key = key.split(".", 1)
64
+ extract_value = re.search(reg_expression, self.response.text, flags=re.S).group()
65
+ with allure.step(f"后置提取变量: {key}: {extract_value}"):
66
+ if is_digit(extract_value):
67
+ self.context.set(key, eval(extract_value), self.belong_app)
68
+ else:
69
+ self.context.set(key, extract_value, self.belong_app)
70
+ logger.info(f"后置提取变量: {key}: {extract_value}")
71
+
72
+ except Exception:
73
+ logger.error(f"正则表达式或响应内容异常。表达式: {reg_expression}; 响应内容: {self.response.text}")
74
+ raise Exception(f"正则表达式或响应内容异常。表达式: {reg_expression}; 响应内容: {self.response.text}")
75
+
76
+ def extract_by_box(self, key, expression):
77
+ if key.startswith(tuple(f"{app}." for app in self.context.all_app)):
78
+ self.belong_app, key = key.split(".", 1)
79
+ extract_value = self.get_nested_value(Box(self.response.json()), expression)
80
+ if not extract_value:
81
+ logger.error(f"box表达式或响应内容异常 表达式: {expression}; 响应内容: {self.response.json()}")
82
+ raise Exception(f"box表达式或响应内容异常 表达式: {expression}; 响应内容: {self.response.json()}")
83
+ else:
84
+ with allure.step(f"后置提取变量: {key}: {extract_value}"):
85
+ self.context.set(key, extract_value, self.belong_app)
86
+ logger.info(f"后置提取变量: {key}: {extract_value}")
87
+
88
+ @staticmethod
89
+ def get_nested_value(obj, attr_path):
90
+ """通过字符串路径(如 'a.b[0].c')获取嵌套属性值"""
91
+ # 使用正则表达式分解路径,支持属性和索引的组合
92
+ path_elements = re.findall(r'(\w+)|\[(\d+)]', attr_path)
93
+ try:
94
+ for attr, index in path_elements:
95
+ if attr: # 属性部分
96
+ obj = getattr(obj, attr)
97
+ if index: # 索引部分
98
+ obj = obj[int(index)]
99
+ return obj
100
+ except Exception:
101
+ return None
@@ -0,0 +1,100 @@
1
+ import os
2
+ import traceback
3
+
4
+ import pytest
5
+ from box import Box
6
+ from box.exceptions import BoxError
7
+
8
+ from framework.utils.log_util import logger
9
+ from framework.utils.common import singleton
10
+ from config.settings import ROOT_DIR
11
+ from framework.exit_code import ExitCode
12
+
13
+
14
+ class GlobalAttribute(object):
15
+ def __setattr__(self, key, value):
16
+ super().__setattr__(
17
+ key,
18
+ Box(value) if isinstance(value, dict) else self.list2box(value) if isinstance(value, list) else value
19
+ )
20
+
21
+ def __str__(self):
22
+ return Box(self.__dict__).to_json(indent=2)
23
+
24
+ def get(self, key, app=None):
25
+ if app:
26
+ obj = getattr(self, app, None)
27
+ else:
28
+ obj = self
29
+ value = getattr(obj, key, None)
30
+ return Box(value) if isinstance(value, dict) else self.list2box(value) if isinstance(value, list) else value
31
+
32
+ def set(self, key, value, app=None):
33
+ if app:
34
+ key = f"{app}.{key}"
35
+ self.set_by_chain(key, value)
36
+ else:
37
+ setattr(self, key, value)
38
+
39
+ def set_by_chain(self, key_chain, value):
40
+ """
41
+ 链式格式的key进行set
42
+ :param key_chain:
43
+ :param value:
44
+ :return:
45
+ """
46
+ keys = key_chain.split(".")
47
+ for key in keys[:-1]:
48
+ if not hasattr(self, key):
49
+ setattr(self, key, Box()) # 创建一个空对象属性
50
+ self = getattr(self, key)
51
+ setattr(self, keys[-1], value)
52
+
53
+ def set_from_dict(self, dic, app=None):
54
+ for k, v in dic.items():
55
+ self.set(k, v, app)
56
+
57
+ def init_test_case_data_dict(self):
58
+ new_dict = dict()
59
+ new_dict['test_case_datas'] = dict()
60
+ for k, v in new_dict.items():
61
+ self.set(k, v)
62
+
63
+ def set_from_yaml(self, filename, env, app=None):
64
+ try:
65
+ file = os.path.join(ROOT_DIR, filename)
66
+ if not os.path.exists(file):
67
+ logger.error(f"{file}文件不存在")
68
+ pytest.exit(ExitCode.CONTEXT_YAML_NOT_EXIST)
69
+ self.set_from_dict(dict(Box().from_yaml(filename=file).get(env)), app)
70
+ except BoxError as e:
71
+ logger.error(f"{file}文件内容不是字典类型:{e}")
72
+ traceback.print_exc()
73
+ pytest.exit(ExitCode.CONTEXT_YAML_DATA_FORMAT_ERROR)
74
+
75
+ def delete(self, key):
76
+ delattr(self, key)
77
+
78
+ def list2box(self, array: list):
79
+ for index, item in enumerate(array):
80
+ if isinstance(item, dict):
81
+ array[index] = Box(item)
82
+ elif isinstance(item, list):
83
+ array[index] = self.list2box(item)
84
+ return array
85
+
86
+
87
+ @singleton
88
+ class Context(GlobalAttribute):
89
+ ...
90
+
91
+
92
+ @singleton
93
+ class Config(GlobalAttribute):
94
+ ...
95
+
96
+
97
+ # 创建管理变量的全局对象,用于存储临时变量
98
+ CONTEXT = Context()
99
+ # 创建配置内容管理的全局对象
100
+ CONFIG = Config()
@@ -0,0 +1,163 @@
1
+ import re
2
+ from typing import List
3
+ from urllib.parse import urljoin
4
+
5
+ import allure
6
+ import requests
7
+ from box import Box
8
+ import json as built_json
9
+ from jsonpath import jsonpath as jp
10
+ from requests.models import Response
11
+ from framework.extract import Extract
12
+ from framework.utils.log_util import logger
13
+ from framework.global_attribute import CONTEXT
14
+ from requests_toolbelt import MultipartEncoder
15
+
16
+
17
+ class ResponseUtil(object):
18
+ def __init__(self, response: Response):
19
+ self.response = response
20
+ self.box = Box(response.json())
21
+
22
+ def __getattr__(self, name):
23
+ try:
24
+ return getattr(self.box, name)
25
+ except AttributeError:
26
+ return getattr(self.response, name)
27
+
28
+ def __str__(self):
29
+ return self.text
30
+
31
+ def jsonpath(self, expr, headers=False):
32
+ """
33
+ 使用jsonpath语法从响应体中提取内容
34
+ @param expr: jsonpath语法
35
+ @param headers: 从响应头中提取内容
36
+ @return:
37
+ """
38
+ result = jp(dict(self.response.headers), expr) if headers else jp(self.json(), expr)
39
+ if result is False:
40
+ return ""
41
+ if len(result) == 1:
42
+ return result[0]
43
+ return result
44
+
45
+ @property
46
+ def jsonp(self):
47
+ """返回json"""
48
+ jsonp_re = re.compile(r".*?\((?P<text>.*)\)")
49
+ return built_json.loads(jsonp_re.match(self.response.text).group("text"))
50
+
51
+ def json(self):
52
+ return self.response.json()
53
+
54
+ def extract(self, app, key, expression):
55
+ """
56
+ 进行变量提取
57
+ :param app:
58
+ :param key: 保存变量的名称
59
+ :param expression: 提取变量表达式
60
+ :return:
61
+ """
62
+ Extract(self.response, app).extract(key, expression)
63
+
64
+ def extracts(self, app, expressions: List[dict]):
65
+ """
66
+ 进行变量提取
67
+ :param app:
68
+ :param expressions: 提取变量表达式
69
+ :return:
70
+ """
71
+ Extract(self.response, app).extracts(expressions)
72
+
73
+
74
+ class HttpClient(object):
75
+
76
+ def __init__(self, headers=None):
77
+ """
78
+
79
+ @param headers: 请求头
80
+ @param cookies: cookies
81
+ """
82
+
83
+ self.headers = {
84
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
85
+ "Content-Type": "application/json"
86
+ }
87
+ self.headers.update(headers or {})
88
+ self.response = None
89
+
90
+ def __send_request(self, data):
91
+ _is_multipart = data.get("_is_multipart")
92
+ request_obj = data.get("request")
93
+ with allure.step("发送请求"):
94
+ if _is_multipart:
95
+ m = MultipartEncoder(fields=request_obj["data"])
96
+ request_obj["data"] = m
97
+ request_obj["headers"] = {'Content-Type': m.content_type}
98
+ else:
99
+ request_obj["headers"].update(self.headers)
100
+ self.response = ResponseUtil(requests.request(**request_obj))
101
+
102
+ with allure.step(f"请求url: {request_obj.get('url')}"):
103
+ logger.info(f"请求url: {request_obj.get('url')}")
104
+ with allure.step(f"请求method: {request_obj.get('method')}"):
105
+ logger.info(f"请求method: {request_obj.get('method')}")
106
+ with allure.step(f"请求headers: {request_obj.get('headers')}"):
107
+ logger.info(f"请求headers: {request_obj.get('headers')}")
108
+ if request_obj.get('request'):
109
+ with allure.step(f"请求参数params: {request_obj.get('params')}"):
110
+ logger.info(f"请求参数params: {request_obj.get('params')}")
111
+ if request_obj.get('data'):
112
+ with allure.step(f"请求参数data: {request_obj.get('data')}"):
113
+ logger.info(f"请求参数data: {request_obj.get('data')}")
114
+ if request_obj.get('json'):
115
+ with allure.step(f"请求参数json: {request_obj.get('json')}"):
116
+ logger.info(f"请求参数json: {request_obj.get('json')}")
117
+
118
+ with allure.step("响应结果"):
119
+ with allure.step(f"响应状态码: {self.response.status_code}"):
120
+ logger.info(f"响应状态码: {self.response.status_code}")
121
+ with allure.step(f"响应headers: {self.response.headers}"):
122
+ logger.info(f"响应headers: {self.response.headers}")
123
+ with allure.step(f"响应body: {self.response.json()}"):
124
+ logger.info(f"响应body: {self.response.json()}")
125
+
126
+ # # 断言
127
+ # validates = params.get("validate")
128
+ # if validates:
129
+ # with allure.step("结果断言"):
130
+ # Validate(self.response).valid(validates)
131
+
132
+ # 提取变量
133
+ expressions = data.get("extract")
134
+ belong_app = data.get("_belong_app")
135
+ if expressions:
136
+ with allure.step("提取变量"):
137
+ Extract(self.response, belong_app).extracts(expressions)
138
+
139
+ return self.response
140
+
141
+ def request(self, data, **kwargs):
142
+ if data:
143
+ return self.__send_request(data)
144
+
145
+ return ResponseUtil(requests.request(headers=self.headers, **kwargs))
146
+
147
+ def post(self, app, url, data=None, json=None, **kwargs):
148
+ return self.request(method="post", url=urljoin(CONTEXT.get(app=app, key="domain"), url), data=data,
149
+ json=json, **kwargs)
150
+
151
+ def get(self, app, url, params=None, **kwargs):
152
+ return self.request(method="get", url=urljoin(CONTEXT.get(app=app, key="domain"), url), params=params,
153
+ **kwargs)
154
+
155
+ def put(self, app, url, data=None, **kwargs):
156
+ return self.request(method="put", url=urljoin(CONTEXT.get(app=app, key="domain"), url), data=data,
157
+ **kwargs)
158
+
159
+ def delete(self, app, url, **kwargs):
160
+ return self.request(method="delete", url=urljoin(CONTEXT.get(app=app, key="domain"), url), **kwargs)
161
+
162
+ def update_headers(self, headers):
163
+ self.headers.update(headers)