CloudApiRequest 1.0.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.
Potentially problematic release.
This version of CloudApiRequest might be problematic. Click here for more details.
- cloud/CloudAPIRequest.py +426 -0
- cloud/CloudApiConfig.py +311 -0
- cloud/CloudLogUtil.py +36 -0
- cloud/CloudRequestUtil.py +107 -0
- cloud/CloudSignUtil.py +117 -0
- cloud/CloudYamlUtil.py +197 -0
- cloud/__init__.py +9 -0
- cloud/cli.py +62 -0
- cloudapirequest-1.0.0.dist-info/LICENSE +21 -0
- cloudapirequest-1.0.0.dist-info/METADATA +21 -0
- cloudapirequest-1.0.0.dist-info/RECORD +13 -0
- cloudapirequest-1.0.0.dist-info/WHEEL +5 -0
- cloudapirequest-1.0.0.dist-info/top_level.txt +1 -0
cloud/CloudAPIRequest.py
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cloud接口测试核心类
|
|
3
|
+
"""
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
import allure
|
|
11
|
+
import jsonpath
|
|
12
|
+
from requests_toolbelt import MultipartEncoder
|
|
13
|
+
from cloud.CloudLogUtil import log
|
|
14
|
+
from cloud.CloudRequestUtil import CloudHttpClient
|
|
15
|
+
from cloud.CloudSignUtil import CloudSignUtil
|
|
16
|
+
from cloud.CloudYamlUtil import CloudReadYaml, CloudPlaceholderYaml
|
|
17
|
+
from cloud.CloudApiConfig import config
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CloudAPIRequest:
|
|
21
|
+
"""
|
|
22
|
+
cloud接口测试核心类
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, commonCase=None):
|
|
26
|
+
"""
|
|
27
|
+
:param commonCase: 公共用例文件
|
|
28
|
+
"""
|
|
29
|
+
self.commonCase = commonCase
|
|
30
|
+
# 延迟初始化配置,避免循环导入
|
|
31
|
+
self._config = None
|
|
32
|
+
self.baseUrl = None
|
|
33
|
+
self.token = None
|
|
34
|
+
self.globalBean = None
|
|
35
|
+
self.assertFail = 'stop'
|
|
36
|
+
self.tenv = 'base'
|
|
37
|
+
|
|
38
|
+
def _get_config(self):
|
|
39
|
+
"""延迟获取配置实例"""
|
|
40
|
+
if self._config is None:
|
|
41
|
+
from cloud import config
|
|
42
|
+
self._config = config
|
|
43
|
+
# 正确获取配置值,而不是property对象
|
|
44
|
+
self.baseUrl = str(config._baseUrl) if hasattr(config, '_baseUrl') and config._baseUrl else None
|
|
45
|
+
self.token = str(config._token) if hasattr(config, '_token') and config._token else None
|
|
46
|
+
self.globalBean = config
|
|
47
|
+
self.assertFail = str(config._assertFail) if hasattr(config, '_assertFail') and config._assertFail else 'stop'
|
|
48
|
+
self.tenv = str(config._tEnv) if hasattr(config, '_tEnv') and config._tEnv else 'base'
|
|
49
|
+
return self._config
|
|
50
|
+
|
|
51
|
+
def doRequest(self, file, bean):
|
|
52
|
+
"""
|
|
53
|
+
执行API请求测试
|
|
54
|
+
"""
|
|
55
|
+
requestParameter = None
|
|
56
|
+
dataSaveBean = bean
|
|
57
|
+
yaml = CloudReadYaml(file).load_yaml()
|
|
58
|
+
yamlId = yaml.get('id')
|
|
59
|
+
yamlName = yaml.get('name')
|
|
60
|
+
yamlTestcase = yaml.get('testcases')
|
|
61
|
+
|
|
62
|
+
log.info(f"开始执行测试用例name: {yamlName}, id: {yamlId}")
|
|
63
|
+
config_instance = self._get_config()
|
|
64
|
+
clientSession = config_instance.Session
|
|
65
|
+
|
|
66
|
+
for testcase in yamlTestcase:
|
|
67
|
+
with allure.step(testcase.get('name')):
|
|
68
|
+
if testcase.get('skip'):
|
|
69
|
+
log.info(f"用例: {testcase.get('name')}跳过")
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
sleeps = testcase.get('sleep')
|
|
73
|
+
if sleeps:
|
|
74
|
+
time.sleep(int(sleeps))
|
|
75
|
+
log.info(f"当前用例: {testcase.get('name')}执行前等待{sleeps}秒")
|
|
76
|
+
|
|
77
|
+
# 处理公共用例
|
|
78
|
+
config_instance = self._get_config()
|
|
79
|
+
if testcase.get('kind') and testcase.get('kind').lower() == 'common' and hasattr(config_instance, '_commonTestCasePath') and config_instance._commonTestCasePath is not None:
|
|
80
|
+
testcase = self.getCommonTestCase(testcase, config_instance._commonTestCasePath, testcase.get('id'))
|
|
81
|
+
elif testcase.get('kind') and testcase.get('kind').lower() == 'common' and (not hasattr(config_instance, '_commonTestCasePath') or config_instance._commonTestCasePath is None):
|
|
82
|
+
log.error(f"commonPath路径未配置,请检查配置文件")
|
|
83
|
+
raise Exception(f"commonPath路径未配置,请检查配置文件")
|
|
84
|
+
|
|
85
|
+
# 参数替换
|
|
86
|
+
if testcase.get('requestType') is None:
|
|
87
|
+
requestType = 'json'
|
|
88
|
+
else:
|
|
89
|
+
requestType = testcase.get('requestType')
|
|
90
|
+
repParameter = self.replaceParameterAttr(dataSaveBean, testcase.get('parameter'), requestType)
|
|
91
|
+
repApi = self.replaceParameterAttr(dataSaveBean, testcase.get('api'))
|
|
92
|
+
headers = self.replaceParameterAttr(dataSaveBean, testcase.get('headers'))
|
|
93
|
+
|
|
94
|
+
# 鉴权处理
|
|
95
|
+
requestParameter, requestUrl, authHeaders = self.authType(testcase.get('authType'), repApi, testcase.get('method'), repParameter)
|
|
96
|
+
|
|
97
|
+
# 请求类型处理
|
|
98
|
+
dataRequestParameter, jsonRequestParameter, paramsData, ModelData, requestType = self.requestType(requestType, requestParameter)
|
|
99
|
+
|
|
100
|
+
# 合并请求头
|
|
101
|
+
if headers:
|
|
102
|
+
headers.update(authHeaders)
|
|
103
|
+
else:
|
|
104
|
+
headers = authHeaders
|
|
105
|
+
|
|
106
|
+
# 执行请求
|
|
107
|
+
log.info(f"开始请求地址: {requestUrl}")
|
|
108
|
+
log.info(f"开始请求方式: {testcase.get('method')}")
|
|
109
|
+
|
|
110
|
+
if dataRequestParameter is not None and requestType.lower() in ['form-data', 'form-file']:
|
|
111
|
+
headers['Content-Type'] = dataRequestParameter.content_type
|
|
112
|
+
|
|
113
|
+
if testcase.get('stream_check'):
|
|
114
|
+
response = self.handle_stream_response(clientSession, testcase.get('method'), requestUrl, dataRequestParameter, jsonRequestParameter, paramsData, ModelData, headers)
|
|
115
|
+
else:
|
|
116
|
+
response = clientSession.request(method=testcase.get('method'), url=requestUrl, data=dataRequestParameter, json=jsonRequestParameter, params=paramsData, files=ModelData, headers=headers)
|
|
117
|
+
|
|
118
|
+
# 记录响应
|
|
119
|
+
try:
|
|
120
|
+
log.info(f"当前用例response: {json.dumps(response.json(), indent=4, ensure_ascii=False)}")
|
|
121
|
+
except:
|
|
122
|
+
log.info(f"当前用例response: {response.text}")
|
|
123
|
+
|
|
124
|
+
# 处理断言
|
|
125
|
+
if testcase.get('assertFail'):
|
|
126
|
+
failtype = testcase.get('assertFail')
|
|
127
|
+
else:
|
|
128
|
+
failtype = self.assertFail
|
|
129
|
+
self.assertType(testcase.get('assert'), response, dataSaveBean, failtype)
|
|
130
|
+
|
|
131
|
+
# 保存数据
|
|
132
|
+
try:
|
|
133
|
+
self.addAttrSaveBean(dataSaveBean, self.globalBean, testcase.get('saveData'), response.json())
|
|
134
|
+
except:
|
|
135
|
+
self.addAttrSaveBean(dataSaveBean, self.globalBean, testcase.get('saveData'), response.text)
|
|
136
|
+
|
|
137
|
+
return clientSession
|
|
138
|
+
|
|
139
|
+
def assertType(self, assertType, response, bean, failType):
|
|
140
|
+
"""
|
|
141
|
+
处理断言
|
|
142
|
+
"""
|
|
143
|
+
if assertType is None:
|
|
144
|
+
log.info(f"断言为空,跳过断言")
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
for ass in assertType:
|
|
148
|
+
key = list(ass.keys())[0]
|
|
149
|
+
log.info(f"开始判断{key}断言: {ass.get(key)}")
|
|
150
|
+
|
|
151
|
+
if 'status_code' in ass:
|
|
152
|
+
if ass.get('status_code'):
|
|
153
|
+
self.assertChoose(str(response.status_code) == str(ass.get('status_code')),
|
|
154
|
+
f"status_code断言失败: {ass.get('status_code')} ,response结果: {response.status_code}", failType)
|
|
155
|
+
continue
|
|
156
|
+
|
|
157
|
+
jsonpathResults = jsonpath.jsonpath(response.json(), ass.get(key)[0])
|
|
158
|
+
if jsonpathResults is False and 'not_found' not in ass:
|
|
159
|
+
self.assertChoose(1 > 2, f"提取{ass.get(key)[0]}失败,断言失败", failType)
|
|
160
|
+
continue
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
if 'eq' in ass:
|
|
165
|
+
expectedResults = CloudPlaceholderYaml(attrObj=bean, reString=ass.get('eq')[1]).replace().replaced_str
|
|
166
|
+
assResults = str(expectedResults) in [str(item) for item in jsonpathResults]
|
|
167
|
+
self.assertChoose(assResults is True, f"eq断言失败: {jsonpathResults} 不等于 {expectedResults}", failType)
|
|
168
|
+
elif 'sge' in ass:
|
|
169
|
+
expectedResults = CloudPlaceholderYaml(attrObj=bean, reString=ass.get('sge')[1]).replace().replaced_str
|
|
170
|
+
self.assertChoose(len(jsonpathResults) >= int(expectedResults), f"sge断言失败: {jsonpathResults} 小于 {expectedResults}", failType)
|
|
171
|
+
elif 'nn' in ass:
|
|
172
|
+
self.assertChoose(jsonpathResults is not None, f"not none断言失败: {ass.get('nn')[0]}", failType)
|
|
173
|
+
elif 'none' in ass:
|
|
174
|
+
self.assertChoose(jsonpathResults is True, f"none断言失败: {ass.get('none')[0]}", failType)
|
|
175
|
+
elif 'not_found' in ass:
|
|
176
|
+
self.assertChoose(jsonpathResults is False, f"not_found断言失败,字段存在", failType)
|
|
177
|
+
elif 'in' in ass:
|
|
178
|
+
expectedResults = CloudPlaceholderYaml(attrObj=bean, reString=ass.get('in')[1]).replace().replaced_str
|
|
179
|
+
self.assertChoose(str(expectedResults) in str(jsonpathResults), f"断言in失败: {expectedResults} 不在 {jsonpathResults} 内", failType)
|
|
180
|
+
elif 'len' in ass:
|
|
181
|
+
expectedResults = CloudPlaceholderYaml(attrObj=bean, reString=ass.get('in')[1]).replace().replaced_str
|
|
182
|
+
jsonpathResults_len = len(jsonpathResults[0])
|
|
183
|
+
self.assertChoose(jsonpathResults_len == int(expectedResults), f"断言len失败: {jsonpathResults_len} 长度不等于 {expectedResults}", failType)
|
|
184
|
+
|
|
185
|
+
def addAttrSaveBean(self, bean, globalBean, data: list, response):
|
|
186
|
+
"""
|
|
187
|
+
保存响应数据到Bean
|
|
188
|
+
"""
|
|
189
|
+
if data is None:
|
|
190
|
+
return
|
|
191
|
+
for d in data:
|
|
192
|
+
if 'json' in d:
|
|
193
|
+
jsonPath = d.get('json')[1]
|
|
194
|
+
value = jsonpath.jsonpath(response, jsonPath)
|
|
195
|
+
if value is False:
|
|
196
|
+
value = None
|
|
197
|
+
|
|
198
|
+
saveBean = bean
|
|
199
|
+
if d.get('json').__len__() == 3 and d.get('json')[2].lower() == 'global':
|
|
200
|
+
saveBean = globalBean
|
|
201
|
+
|
|
202
|
+
key_parts = d.get('json')[0].split(':')
|
|
203
|
+
d.get('json')[0] = key_parts[0]
|
|
204
|
+
|
|
205
|
+
if value is not None and len(value) > 1:
|
|
206
|
+
setattr(saveBean, d.get('json')[0], list(value))
|
|
207
|
+
elif value is not None and len(value) == 1:
|
|
208
|
+
if len(key_parts) > 1 and key_parts[1].lower() == 'str':
|
|
209
|
+
value[0] = str(value[0])
|
|
210
|
+
setattr(saveBean, d.get('json')[0], value[0])
|
|
211
|
+
|
|
212
|
+
def replaceParameterAttr(self, bean, parameter, requestType='json'):
|
|
213
|
+
"""
|
|
214
|
+
替换参数中的占位符
|
|
215
|
+
"""
|
|
216
|
+
if parameter is None:
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
# 获取配置实例
|
|
220
|
+
config_instance = self._get_config()
|
|
221
|
+
|
|
222
|
+
if requestType.lower() == 'json-text':
|
|
223
|
+
repParameter = CloudPlaceholderYaml(yaml_str=parameter, attrObj=bean, methObj=config_instance.methObj).replace().textLoad()
|
|
224
|
+
else:
|
|
225
|
+
repParameter = CloudPlaceholderYaml(yaml_str=parameter, attrObj=bean, methObj=config_instance.methObj).replace().jsonLoad()
|
|
226
|
+
|
|
227
|
+
return repParameter
|
|
228
|
+
|
|
229
|
+
def requestType(self, requestType, data):
|
|
230
|
+
"""
|
|
231
|
+
处理请求类型
|
|
232
|
+
"""
|
|
233
|
+
jsonRequestParameter = None
|
|
234
|
+
dataRequestParameter = None
|
|
235
|
+
paramsData = None
|
|
236
|
+
ModelData = None
|
|
237
|
+
|
|
238
|
+
if isinstance(data, dict) and data.get('MIME'):
|
|
239
|
+
MIME = data.get('MIME')
|
|
240
|
+
else:
|
|
241
|
+
MIME = 'application/octet-stream'
|
|
242
|
+
|
|
243
|
+
if requestType is None:
|
|
244
|
+
jsonRequestParameter = data
|
|
245
|
+
elif requestType.lower() in ["json", "json-text"]:
|
|
246
|
+
jsonRequestParameter = data
|
|
247
|
+
elif requestType.lower() == "form-data":
|
|
248
|
+
dataRequestParameter = MultipartEncoder(fields=data)
|
|
249
|
+
elif requestType.lower() == "form-model":
|
|
250
|
+
filename = data['filename']
|
|
251
|
+
file_name = data[filename].split('\\')
|
|
252
|
+
data[filename] = (file_name[-1], open(data[filename], 'rb'), MIME)
|
|
253
|
+
for k, v in data.items():
|
|
254
|
+
if type(v) == dict:
|
|
255
|
+
data[k] = (None, json.dumps(data[k]))
|
|
256
|
+
ModelData = data
|
|
257
|
+
elif requestType.lower() == "form-file":
|
|
258
|
+
filename = data['filename']
|
|
259
|
+
data[filename] = (os.path.basename(data[filename]), open(data[filename], 'rb'), MIME)
|
|
260
|
+
dataRequestParameter = MultipartEncoder(fields=data)
|
|
261
|
+
elif requestType == "PARAMS":
|
|
262
|
+
paramsData = data
|
|
263
|
+
elif requestType == "DATA":
|
|
264
|
+
dataRequestParameter = data
|
|
265
|
+
else:
|
|
266
|
+
log.error("请求方式不支持")
|
|
267
|
+
|
|
268
|
+
return dataRequestParameter, jsonRequestParameter, paramsData, ModelData, requestType
|
|
269
|
+
|
|
270
|
+
def authType(self, authType, url, method, parameter):
|
|
271
|
+
"""
|
|
272
|
+
处理鉴权方式
|
|
273
|
+
"""
|
|
274
|
+
if self.baseUrl is None or self.isValidUrl(url):
|
|
275
|
+
requestUrl = url
|
|
276
|
+
else:
|
|
277
|
+
requestUrl = self.baseUrl + url
|
|
278
|
+
requestParameter = None
|
|
279
|
+
authHeaders = {}
|
|
280
|
+
|
|
281
|
+
if authType == "SIGN":
|
|
282
|
+
# 云服务MD5签名鉴权
|
|
283
|
+
config_instance = self._get_config()
|
|
284
|
+
# 从config中获取企业ID
|
|
285
|
+
enterprise_id = config_instance._enterpriseId if hasattr(config_instance, '_enterpriseId') else None
|
|
286
|
+
if not enterprise_id:
|
|
287
|
+
log.error("企业ID未配置,请在环境配置中设置enterpriseId")
|
|
288
|
+
raise ValueError("企业ID未配置,请在环境配置中设置enterpriseId")
|
|
289
|
+
|
|
290
|
+
if method.upper() == "GET":
|
|
291
|
+
# GET请求:将签名参数添加到URL参数中
|
|
292
|
+
# 添加默认的validateType参数
|
|
293
|
+
if parameter is None:
|
|
294
|
+
parameter = {}
|
|
295
|
+
if 'validateType' not in parameter:
|
|
296
|
+
parameter['validateType'] = config_instance._validateType if hasattr(config_instance, '_validateType') else '2' # 从配置中获取验证类型
|
|
297
|
+
|
|
298
|
+
signed_params = CloudSignUtil.generate_params_with_signature(
|
|
299
|
+
enterprise_id=enterprise_id,
|
|
300
|
+
token=config_instance._token if hasattr(config_instance, '_token') else None,
|
|
301
|
+
additional_params=parameter
|
|
302
|
+
)
|
|
303
|
+
# 构建带签名的URL
|
|
304
|
+
from urllib.parse import urlencode
|
|
305
|
+
query_string = urlencode(signed_params)
|
|
306
|
+
if '?' in requestUrl:
|
|
307
|
+
requestUrl += '&' + query_string
|
|
308
|
+
else:
|
|
309
|
+
requestUrl += '?' + query_string
|
|
310
|
+
requestParameter = None
|
|
311
|
+
log.info(f"GET请求URL: {requestUrl}")
|
|
312
|
+
else:
|
|
313
|
+
# POST/PUT/PATCH请求:签名信息放在请求头中
|
|
314
|
+
authHeaders = CloudSignUtil.generate_headers(
|
|
315
|
+
method=method,
|
|
316
|
+
url=requestUrl,
|
|
317
|
+
params=None,
|
|
318
|
+
enterprise_id=enterprise_id,
|
|
319
|
+
token=config_instance._token if hasattr(config_instance, '_token') else None,
|
|
320
|
+
body=parameter
|
|
321
|
+
)
|
|
322
|
+
requestParameter = parameter
|
|
323
|
+
|
|
324
|
+
return requestParameter, requestUrl, authHeaders
|
|
325
|
+
elif authType == "COOKIE" or authType is None:
|
|
326
|
+
requestParameter = parameter
|
|
327
|
+
return requestParameter, requestUrl, authHeaders
|
|
328
|
+
else:
|
|
329
|
+
log.error("鉴权方式不支持")
|
|
330
|
+
return requestParameter, requestUrl, authHeaders
|
|
331
|
+
|
|
332
|
+
def getCommonTestCase(self, testcase, commonFile, caseId):
|
|
333
|
+
"""
|
|
334
|
+
获取公共测试用例
|
|
335
|
+
"""
|
|
336
|
+
if self.commonCase is None:
|
|
337
|
+
commonFile = commonFile
|
|
338
|
+
else:
|
|
339
|
+
commonFile = os.path.join(commonFile.split('common')[0], f'common/{self.commonCase}')
|
|
340
|
+
|
|
341
|
+
yaml = CloudReadYaml(commonFile).load_yaml()
|
|
342
|
+
commonCase = yaml.get('testcases')
|
|
343
|
+
|
|
344
|
+
for case in commonCase:
|
|
345
|
+
if case.get('id') == caseId:
|
|
346
|
+
case['assert'] = [item for item in (case.get('assert') or []) + (testcase.get('assert') or []) if item is not None]
|
|
347
|
+
case['saveData'] = [item for item in (case.get('saveData') or []) + (testcase.get('saveData') or []) if item is not None]
|
|
348
|
+
return case
|
|
349
|
+
|
|
350
|
+
raise ValueError("Case with id {} not found".format(caseId))
|
|
351
|
+
|
|
352
|
+
def assertChoose(self, ass, tips, type):
|
|
353
|
+
"""
|
|
354
|
+
断言选择器
|
|
355
|
+
"""
|
|
356
|
+
if type == 'stop':
|
|
357
|
+
assert ass, tips
|
|
358
|
+
elif type == 'continue':
|
|
359
|
+
pytest.assume(ass, tips)
|
|
360
|
+
|
|
361
|
+
def isValidUrl(self, url):
|
|
362
|
+
"""
|
|
363
|
+
验证URL是否有效
|
|
364
|
+
"""
|
|
365
|
+
try:
|
|
366
|
+
result = urlparse(url)
|
|
367
|
+
return all([result.scheme, result.netloc])
|
|
368
|
+
except ValueError:
|
|
369
|
+
return False
|
|
370
|
+
|
|
371
|
+
def handle_stream_response(self, clientSession, method, url, data, json_data, params, files, headers):
|
|
372
|
+
"""
|
|
373
|
+
处理流式响应
|
|
374
|
+
"""
|
|
375
|
+
try:
|
|
376
|
+
response_dict = {}
|
|
377
|
+
answer_count = 0
|
|
378
|
+
complete_message = ""
|
|
379
|
+
|
|
380
|
+
with clientSession.request(
|
|
381
|
+
method=method,
|
|
382
|
+
url=url,
|
|
383
|
+
data=data,
|
|
384
|
+
json=json_data,
|
|
385
|
+
params=params,
|
|
386
|
+
files=files,
|
|
387
|
+
headers=headers,
|
|
388
|
+
stream=True
|
|
389
|
+
) as response:
|
|
390
|
+
for chunk in response.iter_lines():
|
|
391
|
+
if chunk:
|
|
392
|
+
data = chunk.decode('utf-8')
|
|
393
|
+
if data.startswith('data: '):
|
|
394
|
+
try:
|
|
395
|
+
json_str = data[6:]
|
|
396
|
+
if json_str.strip() == '[DONE]':
|
|
397
|
+
if complete_message:
|
|
398
|
+
response_dict["complete_message"] = complete_message
|
|
399
|
+
continue
|
|
400
|
+
|
|
401
|
+
json_data = json.loads(json_str)
|
|
402
|
+
|
|
403
|
+
if 'answer' in json_data:
|
|
404
|
+
answer_count += 1
|
|
405
|
+
key = f"answer{answer_count}"
|
|
406
|
+
response_dict[key] = json_data
|
|
407
|
+
|
|
408
|
+
answer_content = json_data.get('answer', '')
|
|
409
|
+
if isinstance(answer_content, list):
|
|
410
|
+
answer_content = ''.join(str(item) for item in answer_content)
|
|
411
|
+
elif not isinstance(answer_content, str):
|
|
412
|
+
answer_content = str(answer_content)
|
|
413
|
+
|
|
414
|
+
complete_message += answer_content
|
|
415
|
+
|
|
416
|
+
except json.JSONDecodeError as e:
|
|
417
|
+
log.error(f"JSON解析错误: {e}")
|
|
418
|
+
continue
|
|
419
|
+
|
|
420
|
+
response.json = lambda: response_dict
|
|
421
|
+
response._content = json.dumps(response_dict).encode()
|
|
422
|
+
return response
|
|
423
|
+
|
|
424
|
+
except Exception as e:
|
|
425
|
+
log.error(f"流式处理错误: {e}")
|
|
426
|
+
raise
|
cloud/CloudApiConfig.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
from faker import Faker
|
|
2
|
+
import string
|
|
3
|
+
import time
|
|
4
|
+
import random
|
|
5
|
+
import datetime
|
|
6
|
+
import pytz
|
|
7
|
+
import hashlib
|
|
8
|
+
import base64
|
|
9
|
+
import hmac
|
|
10
|
+
import json
|
|
11
|
+
from urllib.parse import urlencode, urlparse
|
|
12
|
+
from Crypto.Cipher import AES
|
|
13
|
+
from Crypto.Util.Padding import pad, unpad
|
|
14
|
+
|
|
15
|
+
from cloud.CloudRequestUtil import CloudHttpClient
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CloudTimes:
|
|
19
|
+
def __init__(self, dt=None, tz=pytz.timezone('Asia/Shanghai')):
|
|
20
|
+
"""
|
|
21
|
+
初始化时间工具类
|
|
22
|
+
|
|
23
|
+
:param dt: datetime对象,默认使用当前时间
|
|
24
|
+
:param tz: 时区,默认使用本地时区
|
|
25
|
+
"""
|
|
26
|
+
self.tz = tz
|
|
27
|
+
self.dt = dt or datetime.datetime.now(self.tz)
|
|
28
|
+
|
|
29
|
+
def to_datetime(self):
|
|
30
|
+
"""
|
|
31
|
+
返回datetime对象
|
|
32
|
+
|
|
33
|
+
:return: datetime对象
|
|
34
|
+
"""
|
|
35
|
+
return self.dt
|
|
36
|
+
|
|
37
|
+
def to_str(self, fmt='%Y-%m-%dT%H:%M:%SZ'):
|
|
38
|
+
"""
|
|
39
|
+
将datetime对象转换为指定格式的字符串
|
|
40
|
+
|
|
41
|
+
:param fmt: 时间格式,默认为'%Y-%m-%d %H:%M:%S'
|
|
42
|
+
:return: 格式化后的时间字符串
|
|
43
|
+
"""
|
|
44
|
+
return self.dt.strftime(fmt)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class CloudDataGenerator:
|
|
48
|
+
"""
|
|
49
|
+
生成中文数据的工具类
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self):
|
|
53
|
+
self.fake = Faker(locale='zh_CN')
|
|
54
|
+
|
|
55
|
+
def generate_name(self):
|
|
56
|
+
"""
|
|
57
|
+
生成中文姓名,返回字符串。
|
|
58
|
+
"""
|
|
59
|
+
return self.fake.name()
|
|
60
|
+
|
|
61
|
+
def generate_address(self):
|
|
62
|
+
"""
|
|
63
|
+
生成中文地址,返回字符串。
|
|
64
|
+
"""
|
|
65
|
+
return self.fake.address()
|
|
66
|
+
|
|
67
|
+
def generate_phone_number(self):
|
|
68
|
+
"""
|
|
69
|
+
生成中文手机号,返回字符串。
|
|
70
|
+
"""
|
|
71
|
+
return self.fake.phone_number()
|
|
72
|
+
|
|
73
|
+
def generate_id_number(self):
|
|
74
|
+
"""
|
|
75
|
+
生成中文身份证号码,返回字符串。
|
|
76
|
+
"""
|
|
77
|
+
return self.fake.ssn()
|
|
78
|
+
|
|
79
|
+
def random_number(self, digits=4):
|
|
80
|
+
"""
|
|
81
|
+
生成一个4位随机整数并转换为字符串类型
|
|
82
|
+
如果生成的整数不足4位,则在左侧用0进行填充
|
|
83
|
+
"""
|
|
84
|
+
digits = int(digits)
|
|
85
|
+
return "{:04d}".format(self.fake.random_number(digits=digits))
|
|
86
|
+
|
|
87
|
+
def get_cloud_password(self):
|
|
88
|
+
"""
|
|
89
|
+
获取初始化的SK 加密后的密码
|
|
90
|
+
:return:
|
|
91
|
+
"""
|
|
92
|
+
# 这里不能直接引用config,因为config类还没有定义
|
|
93
|
+
# 在实际使用时,会通过实例方法调用
|
|
94
|
+
return "default_cloud_password"
|
|
95
|
+
|
|
96
|
+
@staticmethod
|
|
97
|
+
def start_of_day():
|
|
98
|
+
"""
|
|
99
|
+
获取当前时间开始时间戳:eg:2023-06-01 00:00:00
|
|
100
|
+
"""
|
|
101
|
+
now = datetime.datetime.now()
|
|
102
|
+
return datetime.datetime(now.year, now.month, now.day)
|
|
103
|
+
|
|
104
|
+
@staticmethod
|
|
105
|
+
def end_of_day():
|
|
106
|
+
"""
|
|
107
|
+
获取当前时间开始时间戳:eg:2023-06-01 23:59:59
|
|
108
|
+
"""
|
|
109
|
+
return CloudDataGenerator.start_of_day() + datetime.timedelta(days=1) - datetime.timedelta(seconds=1)
|
|
110
|
+
|
|
111
|
+
def start_of_day_s(self):
|
|
112
|
+
"""
|
|
113
|
+
获取当前时间开始时间戳:eg:1685548800 秒级
|
|
114
|
+
"""
|
|
115
|
+
return int(time.mktime(CloudDataGenerator.start_of_day().timetuple()))
|
|
116
|
+
|
|
117
|
+
def end_of_day_s(self):
|
|
118
|
+
"""
|
|
119
|
+
获取当前时间结束时间戳:eg:1685635199 秒级
|
|
120
|
+
"""
|
|
121
|
+
return int(time.mktime(CloudDataGenerator.end_of_day().timetuple()))
|
|
122
|
+
|
|
123
|
+
def random_externalId(self):
|
|
124
|
+
"""
|
|
125
|
+
生成唯一性数据,crm用于 外部企业客户id
|
|
126
|
+
"""
|
|
127
|
+
num = str(random.randint(1000, 9999))
|
|
128
|
+
src_uppercase = string.ascii_uppercase # string_大写字母
|
|
129
|
+
src_lowercase = string.ascii_lowercase # string_小写字母
|
|
130
|
+
chrs = random.sample(src_lowercase + src_uppercase, 3)
|
|
131
|
+
for i in chrs:
|
|
132
|
+
num += i
|
|
133
|
+
return num
|
|
134
|
+
|
|
135
|
+
def encryptPassword(self, plain_text='Aa112233'):
|
|
136
|
+
"""
|
|
137
|
+
加密 - 使用默认密钥
|
|
138
|
+
"""
|
|
139
|
+
# 使用默认密钥进行加密
|
|
140
|
+
password = "default_encryption_key"
|
|
141
|
+
# 设置随机数生成器的种子
|
|
142
|
+
secure_random = hashlib.sha1(password.encode()).digest()
|
|
143
|
+
# 创建对称加密密钥生成器
|
|
144
|
+
kgen = hashlib.sha1(secure_random).digest()[:16]
|
|
145
|
+
# 创建密码器并初始化
|
|
146
|
+
cipher = AES.new(kgen, AES.MODE_ECB)
|
|
147
|
+
# 加密明文(使用PKCS7填充)
|
|
148
|
+
padded_plain_text = pad(plain_text.encode(), AES.block_size)
|
|
149
|
+
encrypted_bytes = cipher.encrypt(padded_plain_text)
|
|
150
|
+
# 将加密结果转换为16进制字符串
|
|
151
|
+
encrypted_text = base64.b16encode(encrypted_bytes).decode().lower()
|
|
152
|
+
return encrypted_text
|
|
153
|
+
|
|
154
|
+
def decrypt(self, encrypted_text, password):
|
|
155
|
+
"""
|
|
156
|
+
# 解密
|
|
157
|
+
"""
|
|
158
|
+
# 设置随机数生成器的种子
|
|
159
|
+
secure_random = hashlib.sha1(password.encode()).digest()
|
|
160
|
+
# 创建对称加密密钥生成器
|
|
161
|
+
kgen = hashlib.sha1(secure_random).digest()[:16]
|
|
162
|
+
# 创建密码器并初始化
|
|
163
|
+
cipher = AES.new(kgen, AES.MODE_ECB)
|
|
164
|
+
# 解密密文(parseHexStr2Byte方法为将16进制字符串转为二进制字节数组)
|
|
165
|
+
encrypted_bytes = base64.b16decode(encrypted_text)
|
|
166
|
+
decrypted_bytes = cipher.decrypt(encrypted_bytes)
|
|
167
|
+
decrypted_text = unpad(decrypted_bytes, AES.block_size).decode()
|
|
168
|
+
return decrypted_text
|
|
169
|
+
|
|
170
|
+
def generate_cloud_signature(self, method, url, params, access_key_id, access_key_secret):
|
|
171
|
+
"""
|
|
172
|
+
生成云服务签名
|
|
173
|
+
"""
|
|
174
|
+
# 获取当前时间戳
|
|
175
|
+
timestamp = int(time.time())
|
|
176
|
+
|
|
177
|
+
# 构建签名字符串
|
|
178
|
+
# 1. 请求方法
|
|
179
|
+
string_to_sign = method.upper() + "\n"
|
|
180
|
+
|
|
181
|
+
# 2. 请求路径
|
|
182
|
+
parsed_url = urlparse(url)
|
|
183
|
+
string_to_sign += parsed_url.path + "\n"
|
|
184
|
+
|
|
185
|
+
# 3. 查询参数(按字典序排序)
|
|
186
|
+
if params:
|
|
187
|
+
sorted_params = sorted(params.items())
|
|
188
|
+
query_string = "&".join([f"{k}={v}" for k, v in sorted_params])
|
|
189
|
+
string_to_sign += query_string + "\n"
|
|
190
|
+
else:
|
|
191
|
+
string_to_sign += "\n"
|
|
192
|
+
|
|
193
|
+
# 4. 时间戳
|
|
194
|
+
string_to_sign += str(timestamp)
|
|
195
|
+
|
|
196
|
+
# 使用HMAC-SHA256进行签名
|
|
197
|
+
signature = hmac.new(
|
|
198
|
+
access_key_secret.encode('utf-8'),
|
|
199
|
+
string_to_sign.encode('utf-8'),
|
|
200
|
+
hashlib.sha256
|
|
201
|
+
).hexdigest()
|
|
202
|
+
|
|
203
|
+
# 构建Authorization头
|
|
204
|
+
auth_header = f"Cloud {access_key_id}:{signature}"
|
|
205
|
+
|
|
206
|
+
return auth_header, timestamp
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class config:
|
|
210
|
+
"""
|
|
211
|
+
配置文件
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
def __init__(self, baseUrl=None, token=None, CloudPassword=None,
|
|
215
|
+
commonTestCasePath=None, methObj=None, Session: CloudHttpClient = CloudHttpClient(),
|
|
216
|
+
assertFail='stop', tEnv='base', enterpriseId=None, validateType='2'):
|
|
217
|
+
"""
|
|
218
|
+
初始化配置文件
|
|
219
|
+
"""
|
|
220
|
+
self._baseUrl = baseUrl
|
|
221
|
+
self._token = token
|
|
222
|
+
self._enterpriseId = enterpriseId
|
|
223
|
+
self._validateType = validateType
|
|
224
|
+
# 加密后的密码
|
|
225
|
+
self._CloudPassword = CloudPassword
|
|
226
|
+
self._commonTestCasePath = commonTestCasePath
|
|
227
|
+
self._methObj = methObj
|
|
228
|
+
self._assertFail = assertFail
|
|
229
|
+
self._tEnv = tEnv
|
|
230
|
+
# 构建全局session
|
|
231
|
+
self._Session = Session
|
|
232
|
+
|
|
233
|
+
@property
|
|
234
|
+
def Session(self):
|
|
235
|
+
return self._Session
|
|
236
|
+
|
|
237
|
+
@Session.setter
|
|
238
|
+
def Session(self, value):
|
|
239
|
+
self._Session = value
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def methObj(self):
|
|
243
|
+
return self._methObj
|
|
244
|
+
|
|
245
|
+
@methObj.setter
|
|
246
|
+
def methObj(self, value):
|
|
247
|
+
self._methObj = value
|
|
248
|
+
|
|
249
|
+
@property
|
|
250
|
+
def CloudPassword(self):
|
|
251
|
+
return self._CloudPassword
|
|
252
|
+
|
|
253
|
+
@CloudPassword.setter
|
|
254
|
+
def CloudPassword(self, value):
|
|
255
|
+
self._CloudPassword = value
|
|
256
|
+
|
|
257
|
+
@property
|
|
258
|
+
def commonTestCasePath(self):
|
|
259
|
+
return self._commonTestCasePath
|
|
260
|
+
|
|
261
|
+
@commonTestCasePath.setter
|
|
262
|
+
def commonTestCasePath(self, value):
|
|
263
|
+
self._commonTestCasePath = value
|
|
264
|
+
|
|
265
|
+
@property
|
|
266
|
+
def baseUrl(self):
|
|
267
|
+
return self._baseUrl
|
|
268
|
+
|
|
269
|
+
@baseUrl.setter
|
|
270
|
+
def baseUrl(self, value):
|
|
271
|
+
self._baseUrl = value
|
|
272
|
+
|
|
273
|
+
@property
|
|
274
|
+
def assertFail(self):
|
|
275
|
+
return self._assertFail
|
|
276
|
+
|
|
277
|
+
@assertFail.setter
|
|
278
|
+
def assertFail(self, value):
|
|
279
|
+
self._assertFail = value
|
|
280
|
+
|
|
281
|
+
@property
|
|
282
|
+
def enterpriseId(self):
|
|
283
|
+
return self._enterpriseId
|
|
284
|
+
|
|
285
|
+
@enterpriseId.setter
|
|
286
|
+
def enterpriseId(self, value):
|
|
287
|
+
self._enterpriseId = value
|
|
288
|
+
|
|
289
|
+
@property
|
|
290
|
+
def tEnv(self):
|
|
291
|
+
return self._tEnv
|
|
292
|
+
|
|
293
|
+
@tEnv.setter
|
|
294
|
+
def tEnv(self, value):
|
|
295
|
+
self._tEnv = value
|
|
296
|
+
|
|
297
|
+
@property
|
|
298
|
+
def validateType(self):
|
|
299
|
+
return self._validateType
|
|
300
|
+
|
|
301
|
+
@validateType.setter
|
|
302
|
+
def validateType(self, value):
|
|
303
|
+
self._validateType = value
|
|
304
|
+
|
|
305
|
+
@property
|
|
306
|
+
def token(self):
|
|
307
|
+
return self._token
|
|
308
|
+
|
|
309
|
+
@token.setter
|
|
310
|
+
def token(self, value):
|
|
311
|
+
self._token = value
|
cloud/CloudLogUtil.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
cloud日志工具类:
|
|
6
|
+
定义日志输出
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CloudLogConfig:
|
|
11
|
+
|
|
12
|
+
@staticmethod
|
|
13
|
+
def logger_set():
|
|
14
|
+
# 第一步:创建一个日志收集器
|
|
15
|
+
log = logging.getLogger()
|
|
16
|
+
|
|
17
|
+
# 第二步:设置收集器收集的等级
|
|
18
|
+
log.setLevel(logging.INFO)
|
|
19
|
+
|
|
20
|
+
# 第三步:设置输出渠道以及输出渠道的等级
|
|
21
|
+
curTime = time.strftime("%Y-%m-%d %H-%M-%S", time.localtime())
|
|
22
|
+
|
|
23
|
+
sh = logging.StreamHandler()
|
|
24
|
+
sh.setLevel(logging.INFO)
|
|
25
|
+
log.addHandler(sh)
|
|
26
|
+
|
|
27
|
+
# 创建一个输出格式对象
|
|
28
|
+
formats = '%(thread)d: %(asctime)s -- [%(filename)s-->line:%(lineno)d] - %(levelname)s: %(message)s'
|
|
29
|
+
form = logging.Formatter(formats)
|
|
30
|
+
# 将输出格式添加到输出渠道
|
|
31
|
+
sh.setFormatter(form)
|
|
32
|
+
|
|
33
|
+
return log
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
log = CloudLogConfig.logger_set()
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import jsonpath
|
|
2
|
+
import requests
|
|
3
|
+
from urllib.parse import urlparse
|
|
4
|
+
from requests import Session
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class CloudHttpClient:
|
|
8
|
+
"""
|
|
9
|
+
对 requests 库进行简单封装的类,可以方便地进行 HTTP 请求和处理响应。
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def __init__(self, base_url=None):
|
|
13
|
+
"""
|
|
14
|
+
初始化方法,接受一个基础URL参数,并创建一个Session对象
|
|
15
|
+
:param base_url: str 基础URL,例如 https://api.cloud.com
|
|
16
|
+
"""
|
|
17
|
+
if base_url is not None:
|
|
18
|
+
self.base_url = base_url
|
|
19
|
+
|
|
20
|
+
self.session: Session = requests.Session()
|
|
21
|
+
|
|
22
|
+
def request(self, method, url, **kwargs):
|
|
23
|
+
"""
|
|
24
|
+
发送HTTP请求,并返回响应对象
|
|
25
|
+
:param method: str 请求方法,例如 GET、POST、PUT等
|
|
26
|
+
:param url: str 请求URL,会和base_url拼接成完整的URL
|
|
27
|
+
:param kwargs: dict 其他requests.request方法支持的参数
|
|
28
|
+
:return: requests.Response 响应对象
|
|
29
|
+
"""
|
|
30
|
+
response = self.session.request(method, url, **kwargs)
|
|
31
|
+
# 判断请求url 是否包含 openapi_login 用于处理 单点登录到系统后进行系统的接口请求鉴权处理
|
|
32
|
+
# if 'openapi_login' in url:
|
|
33
|
+
# # 需要请求接口 获取 Tsessionid
|
|
34
|
+
# # 先从url 中解析出请求域名
|
|
35
|
+
# parsed_url = urlparse(url)
|
|
36
|
+
# domain = parsed_url.scheme + '://' + parsed_url.netloc
|
|
37
|
+
# # 进行接口请求 拿到响应
|
|
38
|
+
# personalResponse = self.session.request(method='GET', url=domain + '/api/personal/info/get')
|
|
39
|
+
# # 拿响应的$.result.authToken
|
|
40
|
+
# authToken = jsonpath.jsonpath(personalResponse.json(), '$.result.authToken')[0]
|
|
41
|
+
# # 后续的请求头上都需要带上 Tsessionid
|
|
42
|
+
# self.session.headers.update({'Tsessionid': authToken})
|
|
43
|
+
|
|
44
|
+
# 处理HTTP错误
|
|
45
|
+
http_error_msg = None
|
|
46
|
+
if 400 <= response.status_code < 500:
|
|
47
|
+
http_error_msg = (
|
|
48
|
+
f"{response.status_code} 客户端错误 Error: {response.text} for url: {response.url}"
|
|
49
|
+
)
|
|
50
|
+
elif 500 <= response.status_code < 600:
|
|
51
|
+
http_error_msg = (
|
|
52
|
+
f"{response.status_code} 服务端错误 Error: {response.text} for url: {response.url}"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
return response
|
|
56
|
+
|
|
57
|
+
def get(self, url, params=None, **kwargs):
|
|
58
|
+
"""
|
|
59
|
+
发送GET请求,并返回响应对象
|
|
60
|
+
:param url: str 请求URL,会和base_url拼接成完整的URL
|
|
61
|
+
:param params: dict 查询参数,会被转换为?key=value&key=value的形式拼接到URL后面
|
|
62
|
+
:param kwargs: dict 其他requests.get方法支持的参数
|
|
63
|
+
:return: requests.Response 响应对象
|
|
64
|
+
"""
|
|
65
|
+
return self.request('GET', url, params=params, **kwargs)
|
|
66
|
+
|
|
67
|
+
def post(self, url, json=None, data=None, **kwargs):
|
|
68
|
+
"""
|
|
69
|
+
发送POST请求,并返回响应对象
|
|
70
|
+
:param url: str 请求URL,会和base_url拼接成完整的URL
|
|
71
|
+
:param data: dict 请求体参数,以表单形式提交
|
|
72
|
+
:param json: dict 请求体参数,以JSON格式提交
|
|
73
|
+
:param kwargs: dict 其他requests.post方法支持的参数
|
|
74
|
+
:return: requests.Response 响应对象
|
|
75
|
+
"""
|
|
76
|
+
return self.request('POST', url, data=data, json=json, **kwargs)
|
|
77
|
+
|
|
78
|
+
def put(self, url, json=None, data=None, **kwargs):
|
|
79
|
+
"""
|
|
80
|
+
发送PUT请求,并返回响应对象
|
|
81
|
+
:param url: str 请求URL,会和base_url拼接成完整的URL
|
|
82
|
+
:param data: dict 请求体参数,以表单形式提交
|
|
83
|
+
:param json: dict 请求体参数,以JSON格式提交
|
|
84
|
+
:param kwargs: dict 其他requests.put方法支持的参数
|
|
85
|
+
:return: requests.Response 响应对象
|
|
86
|
+
"""
|
|
87
|
+
return self.request('PUT', url, data=data, json=json, **kwargs)
|
|
88
|
+
|
|
89
|
+
def delete(self, url, **kwargs):
|
|
90
|
+
"""
|
|
91
|
+
发送DELETE请求,并返回响应对象
|
|
92
|
+
:param url: str 请求URL,会和base_url拼接成完整的URL
|
|
93
|
+
:param kwargs: dict 其他requests.delete方法支持的参数
|
|
94
|
+
:return: requests.Response 响应对象
|
|
95
|
+
"""
|
|
96
|
+
return self.request('DELETE', url, **kwargs)
|
|
97
|
+
|
|
98
|
+
def patch(self, url, json=None, data=None, **kwargs):
|
|
99
|
+
"""
|
|
100
|
+
发送PATCH请求,并返回响应对象
|
|
101
|
+
:param url: str 请求URL,会和base_url拼接成完整的URL
|
|
102
|
+
:param data: dict 请求体参数,以表单形式提交
|
|
103
|
+
:param json: dict 请求体参数,以JSON格式提交
|
|
104
|
+
:param kwargs: dict 其他requests.patch方法支持的参数
|
|
105
|
+
:return: requests.Response 响应对象
|
|
106
|
+
"""
|
|
107
|
+
return self.request('PATCH', url, data=data, json=json, **kwargs)
|
cloud/CloudSignUtil.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import json
|
|
3
|
+
import time
|
|
4
|
+
from urllib.parse import urlparse
|
|
5
|
+
|
|
6
|
+
from cloud.CloudLogUtil import log
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CloudSignUtil:
|
|
10
|
+
"""
|
|
11
|
+
cloud签名工具类 - 基于MD5的签名算法
|
|
12
|
+
鉴权方式:MD5({enterpriseId}+{timestamp}+{token})
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
@staticmethod
|
|
16
|
+
def generate_md5_signature(enterprise_id, timestamp, token):
|
|
17
|
+
"""
|
|
18
|
+
生成MD5签名
|
|
19
|
+
:param enterprise_id: 企业ID
|
|
20
|
+
:param timestamp: 时间戳
|
|
21
|
+
:param token: 访问token
|
|
22
|
+
:return: 32位小写MD5签名
|
|
23
|
+
"""
|
|
24
|
+
# 构建签名字符串:{enterpriseId}+{timestamp}+{token}
|
|
25
|
+
sign_string = f"{enterprise_id}{timestamp}{token}"
|
|
26
|
+
log.info(f"签名字符串: {sign_string}")
|
|
27
|
+
|
|
28
|
+
# 生成MD5签名
|
|
29
|
+
signature = hashlib.md5(sign_string.encode('utf-8')).hexdigest()
|
|
30
|
+
log.info(f"生成的MD5签名: {signature}")
|
|
31
|
+
|
|
32
|
+
return signature
|
|
33
|
+
|
|
34
|
+
@staticmethod
|
|
35
|
+
def generate_headers(method, url, params, enterprise_id, token, body=None):
|
|
36
|
+
"""
|
|
37
|
+
生成云服务请求头 - 基于MD5签名
|
|
38
|
+
:param method: 请求方法
|
|
39
|
+
:param url: 请求URL
|
|
40
|
+
:param params: 查询参数
|
|
41
|
+
:param enterprise_id: 企业ID
|
|
42
|
+
:param token: 访问token
|
|
43
|
+
:param body: 请求体(用于POST/PUT请求)
|
|
44
|
+
:return: 请求头字典
|
|
45
|
+
"""
|
|
46
|
+
# 获取当前时间戳(秒级)
|
|
47
|
+
timestamp = int(time.time())
|
|
48
|
+
|
|
49
|
+
# 生成MD5签名
|
|
50
|
+
signature = CloudSignUtil.generate_md5_signature(enterprise_id, timestamp, token)
|
|
51
|
+
|
|
52
|
+
# 构建请求头
|
|
53
|
+
headers = {
|
|
54
|
+
'Authorization': f"Cloud {token}:{signature}",
|
|
55
|
+
'X-Timestamp': str(timestamp),
|
|
56
|
+
'Content-Type': 'application/json',
|
|
57
|
+
'User-Agent': 'CloudAPISDK/1.0'
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# 添加企业ID到请求头(如果需要)
|
|
61
|
+
headers['X-Enterprise-Id'] = str(enterprise_id)
|
|
62
|
+
|
|
63
|
+
log.info(f"生成的请求头: {headers}")
|
|
64
|
+
|
|
65
|
+
return headers
|
|
66
|
+
|
|
67
|
+
@staticmethod
|
|
68
|
+
def generate_params_with_signature(enterprise_id, token, additional_params=None):
|
|
69
|
+
"""
|
|
70
|
+
生成带签名的请求参数
|
|
71
|
+
:param enterprise_id: 企业ID
|
|
72
|
+
:param token: 访问token
|
|
73
|
+
:param additional_params: 额外参数
|
|
74
|
+
:return: 包含签名的参数字典
|
|
75
|
+
"""
|
|
76
|
+
# 获取当前时间戳(秒级)
|
|
77
|
+
timestamp = int(time.time())
|
|
78
|
+
|
|
79
|
+
# 生成MD5签名
|
|
80
|
+
signature = CloudSignUtil.generate_md5_signature(enterprise_id, timestamp, token)
|
|
81
|
+
|
|
82
|
+
# 构建基础参数
|
|
83
|
+
params = {
|
|
84
|
+
'enterpriseId': enterprise_id,
|
|
85
|
+
'timestamp': timestamp,
|
|
86
|
+
'sign': signature
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
# 添加额外参数
|
|
90
|
+
if additional_params:
|
|
91
|
+
params.update(additional_params)
|
|
92
|
+
|
|
93
|
+
log.info(f"生成的请求参数: {params}")
|
|
94
|
+
|
|
95
|
+
return params
|
|
96
|
+
|
|
97
|
+
@staticmethod
|
|
98
|
+
def validate_signature(enterprise_id, timestamp, token, received_signature):
|
|
99
|
+
"""
|
|
100
|
+
验证签名
|
|
101
|
+
:param enterprise_id: 企业ID
|
|
102
|
+
:param timestamp: 时间戳
|
|
103
|
+
:param token: 访问token
|
|
104
|
+
:param received_signature: 接收到的签名
|
|
105
|
+
:return: 验证结果
|
|
106
|
+
"""
|
|
107
|
+
# 生成期望的签名
|
|
108
|
+
expected_signature = CloudSignUtil.generate_md5_signature(enterprise_id, timestamp, token)
|
|
109
|
+
|
|
110
|
+
# 比较签名
|
|
111
|
+
is_valid = expected_signature == received_signature
|
|
112
|
+
|
|
113
|
+
log.info(f"签名验证结果: {is_valid}")
|
|
114
|
+
log.info(f"期望签名: {expected_signature}")
|
|
115
|
+
log.info(f"接收签名: {received_signature}")
|
|
116
|
+
|
|
117
|
+
return is_valid
|
cloud/CloudYamlUtil.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import yaml
|
|
3
|
+
import json
|
|
4
|
+
from cloud.CloudLogUtil import log
|
|
5
|
+
from cloud.CloudApiConfig import config
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CloudPlaceholderYaml:
|
|
9
|
+
"""
|
|
10
|
+
用于替换yaml文件中的占位符
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, yaml_str=None, reString=None, attrObj=config, methObj=None, gloObj=config):
|
|
14
|
+
if yaml_str:
|
|
15
|
+
self.yaml_str = json.dumps(yaml_str)
|
|
16
|
+
else:
|
|
17
|
+
self.yaml_str = str(reString)
|
|
18
|
+
|
|
19
|
+
self.attrObj = attrObj
|
|
20
|
+
|
|
21
|
+
# 修复methObj的初始化逻辑
|
|
22
|
+
if methObj is not None:
|
|
23
|
+
self.methObj = methObj
|
|
24
|
+
elif hasattr(config, 'methObj') and config.methObj is not None:
|
|
25
|
+
self.methObj = config.methObj
|
|
26
|
+
else:
|
|
27
|
+
# 如果都没有,创建一个默认的CloudDataGenerator实例
|
|
28
|
+
from cloud.CloudApiConfig import CloudDataGenerator
|
|
29
|
+
self.methObj = CloudDataGenerator()
|
|
30
|
+
log.warning("methObj未配置,使用默认的CloudDataGenerator实例")
|
|
31
|
+
|
|
32
|
+
self.gloObj = gloObj
|
|
33
|
+
|
|
34
|
+
def replace(self):
|
|
35
|
+
# 定义正则表达式模式
|
|
36
|
+
# 用于匹配 ${attr} 和 #{method} 这样的占位符
|
|
37
|
+
# $() #() 如果不匹配则报错
|
|
38
|
+
pattern_attr = re.compile(r'\$\{(\w+)\}')
|
|
39
|
+
pattern_method = re.compile(r'\#\{(.*?)\}')
|
|
40
|
+
pattern_glo = re.compile(r'\$\$\{(\w+)\}')
|
|
41
|
+
|
|
42
|
+
# 定义全局替换函数
|
|
43
|
+
def replace_glo(match):
|
|
44
|
+
# 获取占位符中的属性名
|
|
45
|
+
attr_name = match.group(1)
|
|
46
|
+
# 如果对象中有该属性,则返回该属性的值
|
|
47
|
+
if hasattr(self.gloObj, attr_name):
|
|
48
|
+
# 获取属性的值
|
|
49
|
+
attr_value = getattr(self.gloObj, attr_name)
|
|
50
|
+
# 如果属性的值是字符串,则返回该字符串
|
|
51
|
+
if isinstance(attr_value, str):
|
|
52
|
+
return str(attr_value)
|
|
53
|
+
# 如果属性的值是可调用对象,则返回方法名
|
|
54
|
+
elif callable(attr_value):
|
|
55
|
+
return match.group(0)
|
|
56
|
+
# 如果属性的值是字典,则返回该字典的字符串表示
|
|
57
|
+
elif isinstance(attr_value, dict):
|
|
58
|
+
return str(attr_value)
|
|
59
|
+
# 如果属性的值是列表,则返回该列表的字符串表示
|
|
60
|
+
elif isinstance(attr_value, list):
|
|
61
|
+
return str(",".join(str(x) for x in attr_value))
|
|
62
|
+
# 否则,返回属性的值(将其转换为字符串)
|
|
63
|
+
else:
|
|
64
|
+
return str(attr_value)
|
|
65
|
+
# 否则返回原字符串
|
|
66
|
+
return match.group(0)
|
|
67
|
+
|
|
68
|
+
# 定义替换函数
|
|
69
|
+
def replace_attr(match):
|
|
70
|
+
# 获取占位符中的属性名
|
|
71
|
+
attr_name = match.group(1)
|
|
72
|
+
# 如果对象中有该属性,则返回该属性的值
|
|
73
|
+
if hasattr(self.attrObj, attr_name):
|
|
74
|
+
# 获取属性的值
|
|
75
|
+
attr_value = getattr(self.attrObj, attr_name)
|
|
76
|
+
# 如果属性的值是字符串,则返回该字符串
|
|
77
|
+
if isinstance(attr_value, str):
|
|
78
|
+
return str(attr_value)
|
|
79
|
+
# 如果属性的值是可调用对象,则返回方法名
|
|
80
|
+
elif callable(attr_value):
|
|
81
|
+
return match.group(0)
|
|
82
|
+
# 如果属性的值是字典,则返回该字典的字符串表示
|
|
83
|
+
elif isinstance(attr_value, dict):
|
|
84
|
+
return str(attr_value)
|
|
85
|
+
# 如果属性的值是列表,则返回该列表的字符串表示
|
|
86
|
+
elif isinstance(attr_value, list):
|
|
87
|
+
return str(",".join(str(x) for x in attr_value))
|
|
88
|
+
# 否则,返回属性的值(将其转换为字符串)
|
|
89
|
+
else:
|
|
90
|
+
return str(attr_value)
|
|
91
|
+
# 否则返回原字符串
|
|
92
|
+
return match.group(0)
|
|
93
|
+
|
|
94
|
+
# 定义替换函数
|
|
95
|
+
def replace_method(match):
|
|
96
|
+
# 获取占位符中的方法名
|
|
97
|
+
method_name = match.group(1)
|
|
98
|
+
args = None
|
|
99
|
+
if '(' in match.group(1):
|
|
100
|
+
# 获取占位符中的方法名
|
|
101
|
+
method_name = match.group(1).split('(')[0]
|
|
102
|
+
# 获取参数列表
|
|
103
|
+
args_str = match.group(1).split('(')[1][:-1]
|
|
104
|
+
args = [arg.strip() for arg in args_str.split(',')]
|
|
105
|
+
|
|
106
|
+
# 如果对象中有该方法,并且该方法是可调用的,则返回该方法的返回值
|
|
107
|
+
if hasattr(self.methObj, method_name):
|
|
108
|
+
# 获取方法
|
|
109
|
+
method = getattr(self.methObj, method_name)
|
|
110
|
+
# 如果方法是可调用对象,则调用该方法并返回其返回值的字符串表示
|
|
111
|
+
if callable(method):
|
|
112
|
+
try:
|
|
113
|
+
if args:
|
|
114
|
+
method_value = method(*args)
|
|
115
|
+
else:
|
|
116
|
+
method_value = method()
|
|
117
|
+
if isinstance(method_value, str):
|
|
118
|
+
return str(method_value)
|
|
119
|
+
else:
|
|
120
|
+
return str(method_value)
|
|
121
|
+
except Exception as e:
|
|
122
|
+
log.error(f"调用方法 {method_name} 时出错: {e}")
|
|
123
|
+
return match.group(0)
|
|
124
|
+
# 否则,返回方法的字符串表示
|
|
125
|
+
else:
|
|
126
|
+
return str(method)
|
|
127
|
+
else:
|
|
128
|
+
log.warning(f"方法 {method_name} 在 methObj 中不存在,methObj类型: {type(self.methObj)}")
|
|
129
|
+
log.warning(f"methObj 可用方法: {[attr for attr in dir(self.methObj) if not attr.startswith('_')]}")
|
|
130
|
+
# 否则返回原字符串
|
|
131
|
+
return match.group(0)
|
|
132
|
+
|
|
133
|
+
# 判断是否有需要替换的再进行替换
|
|
134
|
+
log.info(f"开始替换str中的占位符: {self.yaml_str}")
|
|
135
|
+
log.info(f"使用的methObj: {type(self.methObj)}")
|
|
136
|
+
# 先进行全局替换
|
|
137
|
+
replaced_str = pattern_glo.sub(replace_glo, self.yaml_str)
|
|
138
|
+
# 替换占位符中的属性
|
|
139
|
+
replaced_str = pattern_attr.sub(replace_attr, replaced_str)
|
|
140
|
+
# 替换占位符中的方法
|
|
141
|
+
replaced_str = pattern_method.sub(replace_method, replaced_str)
|
|
142
|
+
self.replaced_str = replaced_str
|
|
143
|
+
log.info("替换后的str内容为:{}".format(replaced_str))
|
|
144
|
+
return self
|
|
145
|
+
|
|
146
|
+
def jsonLoad(self):
|
|
147
|
+
# 把replaced_str中的"[]"替换为[] "{}"替换为{} "'"替换为"/"" 'None换为 null'
|
|
148
|
+
replaced_str = self.replaced_str.replace('"[', '[').replace(']"', ']').replace('"{', '{').replace('}"', '}').replace("'", "\"").replace('None','null')
|
|
149
|
+
try:
|
|
150
|
+
replaced_str = json.loads(replaced_str)
|
|
151
|
+
log.info("替换后jsonLoad的str内容为:{}".format(replaced_str))
|
|
152
|
+
return replaced_str
|
|
153
|
+
except:
|
|
154
|
+
log.info(f'*************替换失败-YAML,请检查格式{replaced_str}******************')
|
|
155
|
+
|
|
156
|
+
def textLoad(self):
|
|
157
|
+
return json.loads(self.replaced_str)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class CloudReadYaml:
|
|
161
|
+
"""
|
|
162
|
+
用于读取yaml文件的工具类
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
def __init__(self, yaml_file):
|
|
166
|
+
self.yaml_file = yaml_file
|
|
167
|
+
|
|
168
|
+
def load_yaml(self):
|
|
169
|
+
"""
|
|
170
|
+
读取yaml文件,并返回其中的数据
|
|
171
|
+
:return: dict
|
|
172
|
+
"""
|
|
173
|
+
with open(self.yaml_file, encoding='utf-8') as f:
|
|
174
|
+
data = yaml.safe_load(f)
|
|
175
|
+
return data if data is not None else {} # 如果data为None,则返回空字典
|
|
176
|
+
|
|
177
|
+
def get(self, key, default=None):
|
|
178
|
+
"""
|
|
179
|
+
获取yaml文件中的数据
|
|
180
|
+
:param key: 数据的键
|
|
181
|
+
:param default: 如果获取失败,则返回该默认值
|
|
182
|
+
:return: dict
|
|
183
|
+
"""
|
|
184
|
+
# 读取yaml文件
|
|
185
|
+
data = self.load_yaml()
|
|
186
|
+
# 获取数据
|
|
187
|
+
return data.get(key, default) if data is not None else default
|
|
188
|
+
|
|
189
|
+
def get_all(self):
|
|
190
|
+
"""
|
|
191
|
+
获取yaml文件中的所有数据
|
|
192
|
+
:return: dict
|
|
193
|
+
"""
|
|
194
|
+
# 读取yaml文件
|
|
195
|
+
data = self.load_yaml()
|
|
196
|
+
# 如果data为None,则返回空字典
|
|
197
|
+
return data if data is not None else {}
|
cloud/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from cloud.CloudAPIRequest import CloudAPIRequest
|
|
2
|
+
from cloud.CloudApiConfig import config, CloudDataGenerator
|
|
3
|
+
from cloud.CloudRequestUtil import CloudHttpClient
|
|
4
|
+
|
|
5
|
+
# 创建config实例
|
|
6
|
+
config = config()
|
|
7
|
+
# 初始化配置实例
|
|
8
|
+
config.methObj = CloudDataGenerator()
|
|
9
|
+
config.Session = CloudHttpClient()
|
cloud/cli.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Cloud API SDK 命令行工具
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import sys
|
|
9
|
+
import os
|
|
10
|
+
from cloud_api_sdk import config, CloudAPIRequest
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main():
|
|
14
|
+
"""
|
|
15
|
+
主函数
|
|
16
|
+
"""
|
|
17
|
+
parser = argparse.ArgumentParser(description='Cloud API SDK 测试工具')
|
|
18
|
+
parser.add_argument('testcase', help='测试用例文件路径')
|
|
19
|
+
parser.add_argument('--base-url', help='基础URL')
|
|
20
|
+
parser.add_argument('--access-key-id', help='访问密钥ID')
|
|
21
|
+
parser.add_argument('--access-key-secret', help='访问密钥Secret')
|
|
22
|
+
parser.add_argument('--cloud-password', help='云服务密码')
|
|
23
|
+
parser.add_argument('--env', default='test', help='运行环境')
|
|
24
|
+
parser.add_argument('--common-path', help='公共用例路径')
|
|
25
|
+
parser.add_argument('--assert-fail', default='stop', choices=['stop', 'continue'], help='断言失败处理模式')
|
|
26
|
+
|
|
27
|
+
args = parser.parse_args()
|
|
28
|
+
|
|
29
|
+
# 配置环境
|
|
30
|
+
if args.base_url:
|
|
31
|
+
config.baseUrl = args.base_url
|
|
32
|
+
if args.access_key_id:
|
|
33
|
+
config.accessKeyId = args.access_key_id
|
|
34
|
+
if args.access_key_secret:
|
|
35
|
+
config.accessKeySecret = args.access_key_secret
|
|
36
|
+
if args.cloud_password:
|
|
37
|
+
config.CloudPassword = args.cloud_password
|
|
38
|
+
if args.common_path:
|
|
39
|
+
config.commonTestCasePath = args.common_path
|
|
40
|
+
config.assertFail = args.assert_fail
|
|
41
|
+
config.tEnv = args.env
|
|
42
|
+
|
|
43
|
+
# 检查测试用例文件是否存在
|
|
44
|
+
if not os.path.exists(args.testcase):
|
|
45
|
+
print(f"错误: 测试用例文件 {args.testcase} 不存在")
|
|
46
|
+
sys.exit(1)
|
|
47
|
+
|
|
48
|
+
# 执行测试
|
|
49
|
+
try:
|
|
50
|
+
class TestRunner:
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
test_runner = TestRunner()
|
|
54
|
+
CloudAPIRequest().doRequest(args.testcase, test_runner)
|
|
55
|
+
print("测试执行完成")
|
|
56
|
+
except Exception as e:
|
|
57
|
+
print(f"测试执行失败: {e}")
|
|
58
|
+
sys.exit(1)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
if __name__ == "__main__":
|
|
62
|
+
main()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) [2025] [天润-测试]
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: CloudApiRequest
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: 天润cloud接口测试库
|
|
5
|
+
Home-page: https://www.ti-net.com.cn/
|
|
6
|
+
Author: 天润-测试
|
|
7
|
+
Author-email: wangwd@ti-net.com.cn
|
|
8
|
+
License: MIT
|
|
9
|
+
Requires-Python: >=3.7
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: Faker==18.4.0
|
|
12
|
+
Requires-Dist: jsonpath==0.82
|
|
13
|
+
Requires-Dist: pytz==2023.3
|
|
14
|
+
Requires-Dist: PyYAML==6.0
|
|
15
|
+
Requires-Dist: requests==2.28.2
|
|
16
|
+
Requires-Dist: allure-pytest==2.13.2
|
|
17
|
+
Requires-Dist: allure-python-commons==2.13.2
|
|
18
|
+
Requires-Dist: requests-toolbelt==1.0.0
|
|
19
|
+
Requires-Dist: pycryptodome==3.17
|
|
20
|
+
Requires-Dist: pytest-assume==2.4.3
|
|
21
|
+
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
cloud/CloudAPIRequest.py,sha256=bji5WJJ4UQPO7EhQ2IsFMAOcG1aHtxjRU-0zwzNek2U,19441
|
|
2
|
+
cloud/CloudApiConfig.py,sha256=6mF-guPi92UyjE0o7pXVd5cZKZAJHmohv16CvziVkGs,9138
|
|
3
|
+
cloud/CloudLogUtil.py,sha256=EXnU6QXFxl_npCcJgQvhtlG8uR57NmbOovFjVMHPOpQ,935
|
|
4
|
+
cloud/CloudRequestUtil.py,sha256=bsbI74p1iu4weXbDKqwhXvH1KwezvxYXdrXBMc9L3sE,4827
|
|
5
|
+
cloud/CloudSignUtil.py,sha256=3D2etwvk1LYRE3GzoU6qXfTIM4FhEFjmT-i458Q2uCA,3849
|
|
6
|
+
cloud/CloudYamlUtil.py,sha256=_WmtzLOBznwYNY02JOQxOB3B8WvgJONk-PV7grw2C_U,8392
|
|
7
|
+
cloud/__init__.py,sha256=RgNAN_vKhCHnwcBy0hc1ttrr5x58bO_u6TNICZDSfFw,306
|
|
8
|
+
cloud/cli.py,sha256=VId1s29djJu7QrkXJc2je4fRV030vqD3nwuI23AN8Ig,1986
|
|
9
|
+
cloudapirequest-1.0.0.dist-info/LICENSE,sha256=hMjjUYpALQY0E8hzysmZszKwYy2mt8tUvAFQfIIbwV4,1099
|
|
10
|
+
cloudapirequest-1.0.0.dist-info/METADATA,sha256=3uFgDtx9S2odQlKmBxEKJULLQKq-rxcix41RpnET33I,608
|
|
11
|
+
cloudapirequest-1.0.0.dist-info/WHEEL,sha256=iAkIy5fosb7FzIOwONchHf19Qu7_1wCWyFNR5gu9nU0,91
|
|
12
|
+
cloudapirequest-1.0.0.dist-info/top_level.txt,sha256=lBiHHi76sKOBY-DmE0iDypxZxv-TTCnvHeiFleC_PuA,6
|
|
13
|
+
cloudapirequest-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cloud
|