pytest-dsl 0.4.0__py3-none-any.whl → 0.6.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.
Files changed (36) hide show
  1. pytest_dsl/cli.py +28 -33
  2. pytest_dsl/core/auto_decorator.py +72 -53
  3. pytest_dsl/core/auto_directory.py +8 -5
  4. pytest_dsl/core/dsl_executor.py +211 -53
  5. pytest_dsl/core/http_request.py +272 -221
  6. pytest_dsl/core/lexer.py +14 -13
  7. pytest_dsl/core/parser.py +27 -8
  8. pytest_dsl/core/parsetab.py +71 -66
  9. pytest_dsl/core/plugin_discovery.py +1 -8
  10. pytest_dsl/core/yaml_loader.py +96 -19
  11. pytest_dsl/examples/assert/assertion_example.auto +1 -1
  12. pytest_dsl/examples/assert/boolean_test.auto +2 -2
  13. pytest_dsl/examples/assert/expression_test.auto +1 -1
  14. pytest_dsl/examples/custom/test_advanced_keywords.auto +2 -2
  15. pytest_dsl/examples/custom/test_custom_keywords.auto +2 -2
  16. pytest_dsl/examples/custom/test_default_values.auto +2 -2
  17. pytest_dsl/examples/http/file_reference_test.auto +1 -1
  18. pytest_dsl/examples/http/http_advanced.auto +1 -1
  19. pytest_dsl/examples/http/http_example.auto +1 -1
  20. pytest_dsl/examples/http/http_length_test.auto +1 -1
  21. pytest_dsl/examples/http/http_retry_assertions.auto +1 -1
  22. pytest_dsl/examples/http/http_retry_assertions_enhanced.auto +2 -2
  23. pytest_dsl/examples/http/http_with_yaml.auto +1 -1
  24. pytest_dsl/examples/quickstart/api_basics.auto +1 -1
  25. pytest_dsl/examples/quickstart/assertions.auto +1 -1
  26. pytest_dsl/examples/quickstart/loops.auto +2 -2
  27. pytest_dsl/keywords/assertion_keywords.py +76 -62
  28. pytest_dsl/keywords/global_keywords.py +43 -4
  29. pytest_dsl/keywords/http_keywords.py +141 -139
  30. {pytest_dsl-0.4.0.dist-info → pytest_dsl-0.6.0.dist-info}/METADATA +266 -15
  31. pytest_dsl-0.6.0.dist-info/RECORD +68 -0
  32. {pytest_dsl-0.4.0.dist-info → pytest_dsl-0.6.0.dist-info}/WHEEL +1 -1
  33. {pytest_dsl-0.4.0.dist-info → pytest_dsl-0.6.0.dist-info}/entry_points.txt +1 -0
  34. pytest_dsl-0.4.0.dist-info/RECORD +0 -68
  35. {pytest_dsl-0.4.0.dist-info → pytest_dsl-0.6.0.dist-info}/licenses/LICENSE +0 -0
  36. {pytest_dsl-0.4.0.dist-info → pytest_dsl-0.6.0.dist-info}/top_level.txt +0 -0
@@ -13,30 +13,31 @@ from pytest_dsl.core.http_client import http_client_manager
13
13
 
14
14
  # 定义支持的比较操作符
15
15
  COMPARISON_OPERATORS = {
16
- "eq", "neq", "lt", "lte", "gt", "gte", "in", "not_in", "matches"
16
+ "eq", "neq", "lt", "lte", "gt", "gte", "in", "not_in", "matches",
17
+ "contains", "not_contains", "startswith", "endswith"
17
18
  }
18
19
 
19
20
  # 定义支持的断言类型
20
21
  ASSERTION_TYPES = {
21
- "exists", "not_exists", "contains", "not_contains", "startswith",
22
+ "exists", "not_exists", "contains", "not_contains", "startswith",
22
23
  "endswith", "matches", "type", "length", "schema", "value"
23
24
  }
24
25
 
25
26
  # 操作符和断言类型重叠的集合(可以作为二者使用的)
26
27
  DUAL_PURPOSE = {
27
- "contains", "not_contains", "matches", "in", "not_in"
28
+ "contains", "not_contains", "matches", "in", "not_in", "startswith", "endswith"
28
29
  }
29
30
 
30
31
 
31
32
  class HTTPRequest:
32
33
  """HTTP请求处理类
33
-
34
+
34
35
  负责处理HTTP请求、响应捕获和断言
35
36
  """
36
-
37
+
37
38
  def __init__(self, config: Dict[str, Any], client_name: str = "default", session_name: str = None):
38
39
  """初始化HTTP请求
39
-
40
+
40
41
  Args:
41
42
  config: 请求配置
42
43
  client_name: 客户端名称
@@ -47,13 +48,13 @@ class HTTPRequest:
47
48
  self.session_name = session_name
48
49
  self.response = None
49
50
  self.captured_values = {}
50
-
51
+
51
52
  def execute(self, disable_auth: bool = False) -> Response:
52
53
  """执行HTTP请求
53
-
54
+
54
55
  Args:
55
56
  disable_auth: 是否禁用认证
56
-
57
+
57
58
  Returns:
58
59
  Response对象
59
60
  """
@@ -62,7 +63,7 @@ class HTTPRequest:
62
63
  client = http_client_manager.get_session(self.session_name, self.client_name)
63
64
  else:
64
65
  client = http_client_manager.get_client(self.client_name)
65
-
66
+
66
67
  # 验证客户端有效性
67
68
  if client is None:
68
69
  error_message = f"无法获取HTTP客户端: {self.client_name}"
@@ -72,16 +73,16 @@ class HTTPRequest:
72
73
  attachment_type=allure.attachment_type.TEXT
73
74
  )
74
75
  raise ValueError(error_message)
75
-
76
+
76
77
  # 提取请求参数
77
78
  method = self.config.get('method', 'GET').upper()
78
79
  url = self.config.get('url', '')
79
-
80
+
80
81
  # 配置中是否禁用认证
81
82
  disable_auth = disable_auth or self.config.get('disable_auth', False)
82
-
83
+
83
84
  request_config = self.config.get('request', {})
84
-
85
+
85
86
  # 构建请求参数
86
87
  request_kwargs = {
87
88
  'params': request_config.get('params'),
@@ -98,23 +99,23 @@ class HTTPRequest:
98
99
  'proxies': request_config.get('proxies'),
99
100
  'disable_auth': disable_auth # 传递禁用认证标志
100
101
  }
101
-
102
+
102
103
  # 过滤掉None值
103
104
  request_kwargs = {k: v for k, v in request_kwargs.items() if v is not None}
104
-
105
+
105
106
  # 使用Allure记录请求信息
106
107
  self._log_request_to_allure(method, url, request_kwargs)
107
-
108
+
108
109
  try:
109
110
  # 发送请求
110
111
  self.response = client.make_request(method, url, **request_kwargs)
111
-
112
+
112
113
  # 使用Allure记录响应信息
113
114
  self._log_response_to_allure(self.response)
114
-
115
+
115
116
  # 处理捕获
116
117
  self.process_captures()
117
-
118
+
118
119
  return self.response
119
120
  except requests.exceptions.RequestException as e:
120
121
  # 记录请求异常到Allure
@@ -124,7 +125,7 @@ class HTTPRequest:
124
125
  name=f"HTTP请求失败: {method} {url}",
125
126
  attachment_type=allure.attachment_type.TEXT
126
127
  )
127
-
128
+
128
129
  # 重新抛出更有意义的异常
129
130
  raise ValueError(f"HTTP请求失败: {str(e)}") from e
130
131
  except Exception as e:
@@ -135,16 +136,16 @@ class HTTPRequest:
135
136
  name=f"HTTP请求执行错误: {method} {url}",
136
137
  attachment_type=allure.attachment_type.TEXT
137
138
  )
138
-
139
+
139
140
  # 重新抛出异常
140
141
  raise ValueError(f"HTTP请求执行错误: {str(e)}") from e
141
-
142
+
142
143
  def _ensure_response_exists(self, operation: str = "处理"):
143
144
  """确保响应对象存在
144
-
145
+
145
146
  Args:
146
147
  operation: 操作名称,用于错误消息
147
-
148
+
148
149
  Raises:
149
150
  ValueError: 如果响应对象不存在
150
151
  """
@@ -162,26 +163,26 @@ class HTTPRequest:
162
163
  attachment_type=allure.attachment_type.TEXT
163
164
  )
164
165
  raise ValueError(error_message)
165
-
166
+
166
167
  def process_captures(self) -> Dict[str, Any]:
167
168
  """处理响应捕获
168
-
169
+
169
170
  Returns:
170
171
  捕获的值字典
171
172
  """
172
173
  self._ensure_response_exists("捕获响应")
173
-
174
+
174
175
  captures_config = self.config.get('captures', {})
175
-
176
+
176
177
  for var_name, capture_spec in captures_config.items():
177
178
  if not isinstance(capture_spec, list):
178
179
  raise ValueError(f"无效的捕获规格: {var_name}: {capture_spec}")
179
-
180
+
180
181
  # 提取捕获信息
181
182
  try:
182
183
  extractor_type = capture_spec[0]
183
184
  extraction_path = capture_spec[1] if len(capture_spec) > 1 else None
184
-
185
+
185
186
  # 检查是否有length参数
186
187
  is_length_capture = False
187
188
  if len(capture_spec) > 2 and capture_spec[2] == "length":
@@ -189,16 +190,16 @@ class HTTPRequest:
189
190
  default_value = capture_spec[3] if len(capture_spec) > 3 else None
190
191
  else:
191
192
  default_value = capture_spec[2] if len(capture_spec) > 2 else None
192
-
193
+
193
194
  # 提取值
194
195
  captured_value = self._extract_value(extractor_type, extraction_path, default_value)
195
-
196
+
196
197
  # 特殊处理length
197
198
  if is_length_capture:
198
199
  try:
199
200
  original_value = captured_value
200
201
  captured_value = len(captured_value)
201
-
202
+
202
203
  # 记录长度到Allure
203
204
  allure.attach(
204
205
  f"变量名: {var_name}\n提取器: {extractor_type}\n路径: {extraction_path}\n原始值: {str(original_value)}\n长度: {captured_value}",
@@ -226,7 +227,7 @@ class HTTPRequest:
226
227
  name=f"捕获变量: {var_name}",
227
228
  attachment_type=allure.attachment_type.TEXT
228
229
  )
229
-
230
+
230
231
  self.captured_values[var_name] = captured_value
231
232
  except Exception as e:
232
233
  error_msg = (
@@ -241,12 +242,12 @@ class HTTPRequest:
241
242
  )
242
243
  # 设置默认值
243
244
  self.captured_values[var_name] = None
244
-
245
+
245
246
  return self.captured_values
246
-
247
- def _format_error_details(self,
248
- extractor_type: str,
249
- extraction_path: str,
247
+
248
+ def _format_error_details(self,
249
+ extractor_type: str,
250
+ extraction_path: str,
250
251
  assertion_type: str,
251
252
  compare_operator: str,
252
253
  actual_value: Any,
@@ -255,7 +256,7 @@ class HTTPRequest:
255
256
  error_message: str = None,
256
257
  additional_context: str = None) -> str:
257
258
  """格式化断言错误的详细信息
258
-
259
+
259
260
  Args:
260
261
  extractor_type: 提取器类型
261
262
  extraction_path: 提取路径
@@ -266,24 +267,24 @@ class HTTPRequest:
266
267
  original_actual_value: 原始值(用于length断言)
267
268
  error_message: 错误消息
268
269
  additional_context: 附加上下文信息
269
-
270
+
270
271
  Returns:
271
272
  格式化的错误详情字符串
272
273
  """
273
274
  error_details = []
274
-
275
+
275
276
  # 添加基本错误信息
276
277
  prefix = "断言执行错误" if additional_context else "断言失败"
277
278
  error_details.append(f"{prefix} [{extractor_type}]")
278
-
279
+
279
280
  # 添加异常类型信息(如果存在)
280
281
  if additional_context:
281
282
  error_details.append(f"异常类型: {additional_context}")
282
-
283
+
283
284
  # 添加路径信息
284
285
  if extraction_path:
285
286
  error_details.append(f"路径: {extraction_path}")
286
-
287
+
287
288
  # 添加断言类型和值信息
288
289
  if assertion_type == "length":
289
290
  error_details.append(f"断言类型: 长度比较")
@@ -293,93 +294,97 @@ class HTTPRequest:
293
294
  else:
294
295
  error_details.append(f"断言类型: {assertion_type}")
295
296
  error_details.append(f"实际值: {actual_value}")
296
-
297
+
297
298
  # 添加比较操作符
298
299
  if compare_operator:
299
300
  error_details.append(f"比较操作符: {compare_operator}")
300
-
301
+
301
302
  # 添加预期值
302
303
  if expected_value is not None:
303
304
  error_details.append(f"预期值: {expected_value}")
304
-
305
+
305
306
  # 添加类型信息
306
307
  error_details.append(f"实际类型: {type(actual_value).__name__}")
307
308
  if expected_value is not None:
308
309
  error_details.append(f"预期类型: {type(expected_value).__name__}")
309
-
310
+
310
311
  # 添加错误消息
311
312
  if error_message:
312
313
  error_details.append(f"错误信息: {error_message}")
313
-
314
+
314
315
  return "\n".join(error_details)
315
316
 
316
- def process_asserts(self, specific_asserts=None) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
317
+ def process_asserts(self, specific_asserts=None, index_mapping=None) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
317
318
  """处理响应断言
318
-
319
+
319
320
  Args:
320
321
  specific_asserts: 指定要处理的断言列表,如果为None则处理所有断言
321
-
322
+ index_mapping: 索引映射字典,用于将新索引映射到原始索引(用于重试场景)
323
+
322
324
  Returns:
323
325
  Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: 断言结果列表和失败需重试的断言列表
324
326
  """
325
327
  self._ensure_response_exists("进行断言")
326
-
328
+
327
329
  asserts_config = self.config.get('asserts', [])
328
330
  assert_results = []
329
331
  failed_retryable_assertions = []
330
332
  failed_assertions = [] # 收集所有失败的断言
331
-
333
+
332
334
  # 处理断言重试配置
333
335
  # 1. 只使用独立的retry_assertions配置
334
336
  retry_assertions_config = self.config.get('retry_assertions', {})
335
- has_dedicated_retry_config = bool(retry_assertions_config)
336
-
337
+
337
338
  # 2. 向后兼容: 检查全局retry配置(仅作为默认值使用)
338
339
  retry_config = self.config.get('retry', {})
339
340
  global_retry_enabled = bool(retry_config)
340
-
341
+
341
342
  # 3. 提取重试默认设置
342
343
  global_retry_count = retry_assertions_config.get('count', retry_config.get('count', 3))
343
344
  global_retry_interval = retry_assertions_config.get('interval', retry_config.get('interval', 1))
344
-
345
+
345
346
  # 4. 提取应该重试的断言索引列表
346
347
  retry_all_assertions = retry_assertions_config.get('all', global_retry_enabled)
347
348
  retry_assertion_indices = retry_assertions_config.get('indices', [])
348
-
349
+
349
350
  # 5. 提取特定断言的重试配置
350
351
  specific_assertion_configs = retry_assertions_config.get('specific', {})
351
-
352
+
352
353
  # 如果传入了specific_asserts,只处理指定的断言
353
354
  process_asserts = specific_asserts if specific_asserts is not None else asserts_config
354
-
355
+
355
356
  for assertion_idx, assertion in enumerate(process_asserts):
356
357
  if not isinstance(assertion, list) or len(assertion) < 2:
357
358
  raise ValueError(f"无效的断言配置: {assertion}")
358
-
359
+
359
360
  # 提取断言参数
360
361
  extractor_type = assertion[0]
361
-
362
+
363
+ # 获取原始索引(用于重试配置查找)
364
+ original_idx = index_mapping.get(assertion_idx, assertion_idx) if index_mapping else assertion_idx
365
+
362
366
  # 判断该断言是否应该重试(只使用retry_assertions配置)
363
367
  is_retryable = False
364
368
  assertion_retry_count = global_retry_count
365
369
  assertion_retry_interval = global_retry_interval
366
-
367
- # retry_assertions特定配置
368
- if str(assertion_idx) in specific_assertion_configs:
369
- spec_config = specific_assertion_configs[str(assertion_idx)]
370
+
371
+ # retry_assertions特定配置 - 使用原始索引和支持整数键
372
+ original_idx_str = str(original_idx)
373
+ if original_idx_str in specific_assertion_configs or original_idx in specific_assertion_configs:
374
+ spec_config = specific_assertion_configs.get(original_idx_str) or specific_assertion_configs.get(original_idx)
370
375
  is_retryable = True
371
376
  if isinstance(spec_config, dict):
372
377
  if 'count' in spec_config:
373
378
  assertion_retry_count = spec_config['count']
374
379
  if 'interval' in spec_config:
375
380
  assertion_retry_interval = spec_config['interval']
376
- # retry_assertions索引列表
377
- elif assertion_idx in retry_assertion_indices:
381
+ # retry_assertions索引列表 - 使用原始索引
382
+ elif original_idx in retry_assertion_indices:
378
383
  is_retryable = True
379
384
  # retry_assertions全局配置
380
385
  elif retry_all_assertions:
381
386
  is_retryable = True
382
-
387
+
383
388
  # 处理断言参数
384
389
  if len(assertion) == 2: # 简单存在性断言 ["header", "Location"]
385
390
  extraction_path = assertion[1]
@@ -391,7 +396,7 @@ class HTTPRequest:
391
396
  if assertion[1] in COMPARISON_OPERATORS or assertion[1] in DUAL_PURPOSE:
392
397
  # 这是操作符格式的断言 ["status", "eq", 200]
393
398
  extraction_path = None if extractor_type in ["status", "body", "response_time"] else assertion[1]
394
-
399
+
395
400
  # 特殊处理二者兼用的断言类型
396
401
  if assertion[1] in DUAL_PURPOSE:
397
402
  if extractor_type in ["jsonpath", "body", "header"]:
@@ -405,33 +410,64 @@ class HTTPRequest:
405
410
  else:
406
411
  assertion_type = "value" # 标记为值比较
407
412
  compare_operator = assertion[1]
408
-
413
+
409
414
  expected_value = assertion[2] # 预期值
410
415
  else:
411
- # 这是断言类型格式 ["jsonpath", "$.id", "exists"]
416
+ # 这可能是断言类型格式 ["jsonpath", "$.id", "exists"] 或者是简化的长度断言 ["body", "length", 10]
412
417
  extraction_path = assertion[1]
413
- assertion_type = assertion[2]
414
-
415
- # 检查断言类型是否有效
416
- if assertion_type not in ASSERTION_TYPES:
417
- raise ValueError(f"不支持的断言类型: {assertion_type} {assertion}")
418
-
419
- # 检查此断言类型是否需要预期值
420
- if assertion_type not in ["exists", "not_exists"]:
421
- # 特殊处理类型断言,它确实需要一个值但在这种格式中没有提供
422
- if assertion_type == "type":
423
- raise ValueError(f"断言类型 'type' 需要预期值(string/number/boolean/array/object/null),但未提供: {assertion}")
424
- # 断言类型作为操作符
425
- expected_value = None
426
- compare_operator = assertion_type # 使用断言类型作为操作符
418
+ potential_assertion_type = assertion[2]
419
+
420
+ # 特殊处理:如果第二个参数是"length",第三个参数是数字,这是简化的长度断言
421
+ if extraction_path == "length" and isinstance(potential_assertion_type, (int, float)):
422
+ # 这是 ["body", "length", 10] 格式的长度断言
423
+ extraction_path = None # body提取器不需要路径
424
+ assertion_type = "length"
425
+ compare_operator = "eq"
426
+ expected_value = potential_assertion_type
427
427
  else:
428
- expected_value = None
429
- compare_operator = assertion_type # 存在性断言的操作符就是断言类型本身
430
- elif len(assertion) == 4: # 带操作符的断言 ["jsonpath", "$.id", "eq", 1] 或特殊断言 ["jsonpath", "$.type", "type", "string"]
428
+ # 这是标准的断言类型格式 ["jsonpath", "$.id", "exists"]
429
+ assertion_type = potential_assertion_type
430
+
431
+ # 特殊处理schema断言,因为schema值可能是字典
432
+ if assertion_type == "schema" or isinstance(assertion_type, dict):
433
+ if isinstance(assertion_type, dict):
434
+ # 这是 ["body", "schema", {schema_dict}] 格式
435
+ assertion_type = "schema"
436
+ expected_value = potential_assertion_type
437
+ compare_operator = "schema"
438
+ extraction_path = None # body提取器不需要路径
439
+ else:
440
+ # 这是 ["jsonpath", "$.data", "schema"] 格式,需要在4参数中处理
441
+ expected_value = None
442
+ compare_operator = assertion_type
443
+ else:
444
+ # 检查断言类型是否有效
445
+ if assertion_type not in ASSERTION_TYPES:
446
+ raise ValueError(f"不支持的断言类型: {assertion_type} 在 {assertion}")
447
+
448
+ # 检查此断言类型是否需要预期值
449
+ if assertion_type not in ["exists", "not_exists"]:
450
+ # 特殊处理类型断言,它确实需要一个值但在这种格式中没有提供
451
+ if assertion_type == "type":
452
+ raise ValueError(f"断言类型 'type' 需要预期值(string/number/boolean/array/object/null),但未提供: {assertion}")
453
+ # 断言类型作为操作符
454
+ expected_value = None
455
+ compare_operator = assertion_type # 使用断言类型作为操作符
456
+ else:
457
+ expected_value = None
458
+ compare_operator = assertion_type # 存在性断言的操作符就是断言类型本身
459
+ elif len(assertion) == 4: # 带操作符的断言 ["jsonpath", "$.id", "eq", 1] 或特殊断言 ["jsonpath", "$.type", "type", "string"] 或长度断言 ["body", "length", "gt", 50]
431
460
  extraction_path = assertion[1]
432
-
461
+
462
+ # 特殊处理长度断言:["body", "length", "gt", 50]
463
+ if extraction_path == "length" and assertion[2] in COMPARISON_OPERATORS:
464
+ # 这是4参数的长度断言格式
465
+ extraction_path = None # body提取器不需要路径
466
+ assertion_type = "length"
467
+ compare_operator = assertion[2] # 比较操作符
468
+ expected_value = assertion[3] # 预期长度值
433
469
  # 检查第三个元素是否是操作符
434
- if assertion[2] in COMPARISON_OPERATORS or assertion[2] in DUAL_PURPOSE:
470
+ elif assertion[2] in COMPARISON_OPERATORS or assertion[2] in DUAL_PURPOSE:
435
471
  # 这是操作符形式的断言
436
472
  assertion_type = "value" # 标记为值比较
437
473
  compare_operator = assertion[2] # 比较操作符
@@ -440,15 +476,13 @@ class HTTPRequest:
440
476
  # 检查断言类型是否有效
441
477
  if assertion[2] not in ASSERTION_TYPES:
442
478
  raise ValueError(f"不支持的断言类型: {assertion[2]} 在 {assertion}")
443
-
479
+
444
480
  # 其他类型的断言,比如特殊断言
445
481
  assertion_type = assertion[2]
446
-
482
+
447
483
  # 根据断言类型决定如何处理操作符和期望值
448
484
  if assertion_type == "length":
449
- # 对于长度断言,总是需要有比较操作符和期望值
450
- if len(assertion) < 4:
451
- raise ValueError(f"长度断言需要提供预期值: {assertion}")
485
+ # 对于4参数的长度断言,第4个参数是期望值,默认使用eq比较
452
486
  compare_operator = "eq" # 默认使用相等比较
453
487
  expected_value = assertion[3] # 预期长度值
454
488
  else:
@@ -458,17 +492,17 @@ class HTTPRequest:
458
492
  else: # 5个参数,例如 ["jsonpath", "$", "length", "eq", 10]
459
493
  extraction_path = assertion[1]
460
494
  assertion_type = assertion[2]
461
-
495
+
462
496
  # 检查断言类型是否有效
463
497
  if assertion_type not in ASSERTION_TYPES:
464
498
  raise ValueError(f"不支持的断言类型: {assertion_type} 在 {assertion}")
465
-
499
+
466
500
  # 特殊处理长度断言
467
501
  if assertion_type == "length":
468
502
  # 验证第四个元素是有效的比较操作符
469
503
  if assertion[3] not in COMPARISON_OPERATORS:
470
504
  raise ValueError(f"长度断言的比较操作符必须是 {', '.join(COMPARISON_OPERATORS)} 之一: {assertion}")
471
-
505
+
472
506
  # 验证第五个元素是有效的长度值
473
507
  try:
474
508
  # 尝试将预期长度转换为整数
@@ -477,47 +511,54 @@ class HTTPRequest:
477
511
  raise ValueError(f"长度断言的预期值必须是非负整数: {assertion}")
478
512
  except (ValueError, TypeError):
479
513
  raise ValueError(f"长度断言的预期值必须是有效的整数: {assertion}")
480
-
514
+
481
515
  compare_operator = assertion[3]
482
516
  expected_value = assertion[4]
483
-
517
+
484
518
  # 提取实际值
485
519
  actual_value = self._extract_value(extractor_type, extraction_path)
486
-
520
+
487
521
  # 特殊处理"length"断言类型
488
522
  original_actual_value = actual_value
489
- if assertion_type == "length" and extractor_type != "response_time" and extractor_type != "status" and extractor_type != "body":
490
- try:
491
- # 记录长度断言的原始值信息
492
- allure.attach(
493
- f"提取器: {extractor_type}\n路径: {extraction_path}\n原始值: {original_actual_value}\n类型: {type(original_actual_value).__name__}",
494
- name=f"长度断言原始值: {extractor_type}",
495
- attachment_type=allure.attachment_type.TEXT
496
- )
497
-
498
- actual_value = len(actual_value)
499
-
500
- # 记录计算结果
501
- allure.attach(
502
- f"提取器: {extractor_type}\n路径: {extraction_path}\n长度: {actual_value}",
503
- name=f"长度断言计算结果: {extractor_type}",
504
- attachment_type=allure.attachment_type.TEXT
505
- )
506
- except Exception as e:
507
- # 记录长度计算失败的详细信息
508
- error_msg = (
509
- f"无法计算长度: {type(e).__name__}: {str(e)}\n"
510
- f"类型: {type(original_actual_value).__name__}\n"
511
- f"值: {original_actual_value}"
512
- )
513
- allure.attach(
514
- error_msg,
515
- name=f"长度计算失败: {extractor_type} {extraction_path}",
516
- attachment_type=allure.attachment_type.TEXT
517
- )
518
- # 抛出更具体的异常,而不是用0替代
519
- raise ValueError(f"断言类型'length'无法应用于值 '{original_actual_value}': {str(e)}")
520
-
523
+ if assertion_type == "length":
524
+ # 只对不支持长度计算的提取器类型跳过长度计算
525
+ # response_time和status返回数值,对数值计算长度没有意义
526
+ if extractor_type in ["response_time", "status"]:
527
+ # 对于这些类型,直接使用数值本身,不计算长度
528
+ pass
529
+ else:
530
+ # 对于其他类型(包括body、jsonpath、header等),尝试计算长度
531
+ try:
532
+ # 记录长度断言的原始值信息
533
+ allure.attach(
534
+ f"提取器: {extractor_type}\n路径: {extraction_path}\n原始值: {original_actual_value}\n类型: {type(original_actual_value).__name__}",
535
+ name=f"长度断言原始值: {extractor_type}",
536
+ attachment_type=allure.attachment_type.TEXT
537
+ )
538
+
539
+ actual_value = len(actual_value)
540
+
541
+ # 记录计算结果
542
+ allure.attach(
543
+ f"提取器: {extractor_type}\n路径: {extraction_path}\n长度: {actual_value}",
544
+ name=f"长度断言计算结果: {extractor_type}",
545
+ attachment_type=allure.attachment_type.TEXT
546
+ )
547
+ except Exception as e:
548
+ # 记录长度计算失败的详细信息
549
+ error_msg = (
550
+ f"无法计算长度: {type(e).__name__}: {str(e)}\n"
551
+ f"类型: {type(original_actual_value).__name__}\n"
552
+ f"值: {original_actual_value}"
553
+ )
554
+ allure.attach(
555
+ error_msg,
556
+ name=f"长度计算失败: {extractor_type} {extraction_path}",
557
+ attachment_type=allure.attachment_type.TEXT
558
+ )
559
+ # 抛出更具体的异常,而不是用0替代
560
+ raise ValueError(f"断言类型'length'无法应用于值 '{original_actual_value}': {str(e)}")
561
+
521
562
  # 执行断言
522
563
  assertion_result = {
523
564
  'type': extractor_type,
@@ -530,18 +571,18 @@ class HTTPRequest:
530
571
  'retryable': is_retryable,
531
572
  'retry_count': assertion_retry_count,
532
573
  'retry_interval': assertion_retry_interval,
533
- 'index': assertion_idx # 记录断言在原始列表中的索引
574
+ 'index': original_idx # 记录断言在原始列表中的索引
534
575
  }
535
-
576
+
536
577
  try:
537
578
  # 验证断言
538
579
  result = self._perform_assertion(assertion_type, compare_operator, actual_value, expected_value)
539
580
  assertion_result['result'] = result
540
-
581
+
541
582
  # 根据返回的布尔值确定断言是否通过
542
583
  if result:
543
584
  assertion_result['passed'] = True
544
-
585
+
545
586
  # 使用Allure记录断言成功
546
587
  allure.attach(
547
588
  self._format_assertion_details(assertion_result),
@@ -552,18 +593,18 @@ class HTTPRequest:
552
593
  # 断言失败但没有抛出异常
553
594
  assertion_result['passed'] = False
554
595
  assertion_result['error'] = "断言失败"
555
-
596
+
556
597
  # 使用Allure记录断言失败
557
598
  allure.attach(
558
599
  self._format_assertion_details(assertion_result) + "\n\n错误: 断言结果为False",
559
600
  name=f"断言失败: {extractor_type}",
560
601
  attachment_type=allure.attachment_type.TEXT
561
602
  )
562
-
603
+
563
604
  # 如果断言可重试,添加到失败且需要重试的断言列表
564
605
  if is_retryable:
565
606
  failed_retryable_assertions.append(assertion_result)
566
-
607
+
567
608
  # 构建格式化的错误消息
568
609
  formatted_error = self._format_error_details(
569
610
  extractor_type=extractor_type,
@@ -574,16 +615,16 @@ class HTTPRequest:
574
615
  expected_value=expected_value,
575
616
  original_actual_value=original_actual_value
576
617
  )
577
-
618
+
578
619
  # 收集断言失败,而不是立即抛出异常
579
620
  failed_assertions.append((assertion_idx, formatted_error))
580
-
621
+
581
622
  except AssertionError as e:
582
623
  # 处理上面可能抛出的断言错误,或者其他断言错误
583
624
  assertion_result['result'] = False
584
625
  assertion_result['passed'] = False
585
626
  assertion_result['error'] = str(e)
586
-
627
+
587
628
  # 如果异常已经是格式化的多行消息,直接使用
588
629
  if "\n" in str(e):
589
630
  formatted_error = str(e)
@@ -599,27 +640,27 @@ class HTTPRequest:
599
640
  original_actual_value=original_actual_value,
600
641
  error_message=str(e)
601
642
  )
602
-
643
+
603
644
  # 使用Allure记录断言失败
604
645
  allure.attach(
605
646
  self._format_assertion_details(assertion_result) + f"\n\n错误: {str(e)}",
606
647
  name=f"断言失败: {extractor_type}",
607
648
  attachment_type=allure.attachment_type.TEXT
608
649
  )
609
-
650
+
610
651
  # 如果断言可重试,添加到失败且需要重试的断言列表
611
652
  if is_retryable:
612
653
  failed_retryable_assertions.append(assertion_result)
613
-
654
+
614
655
  # 收集断言失败,而不是立即抛出异常
615
656
  failed_assertions.append((assertion_idx, formatted_error))
616
-
657
+
617
658
  except Exception as e:
618
659
  # 处理其他类型的异常
619
660
  assertion_result['result'] = False
620
661
  assertion_result['passed'] = False
621
662
  assertion_result['error'] = f"未预期的异常: {type(e).__name__}: {str(e)}"
622
-
663
+
623
664
  # 使用辅助方法构建详细的错误消息
624
665
  formatted_error = self._format_error_details(
625
666
  extractor_type=extractor_type,
@@ -632,24 +673,24 @@ class HTTPRequest:
632
673
  error_message=str(e),
633
674
  additional_context=type(e).__name__
634
675
  )
635
-
676
+
636
677
  # 使用Allure记录断言错误
637
678
  allure.attach(
638
679
  self._format_assertion_details(assertion_result) + f"\n\n错误: {assertion_result['error']}",
639
680
  name=f"断言执行错误: {extractor_type}",
640
681
  attachment_type=allure.attachment_type.TEXT
641
682
  )
642
-
683
+
643
684
  # 收集断言失败,而不是立即抛出异常
644
685
  failed_assertions.append((assertion_idx, formatted_error))
645
-
686
+
646
687
  assert_results.append(assertion_result)
647
-
688
+
648
689
  # 执行完所有断言后,如果有失败的断言,抛出异常
649
690
  if failed_assertions:
650
691
  # 检查是否只收集失败而不抛出异常(由重试机制设置)
651
692
  collect_only = self.config.get('_collect_failed_assertions_only', False)
652
-
693
+
653
694
  if not collect_only:
654
695
  if len(failed_assertions) == 1:
655
696
  # 只有一个断言失败时,直接使用该断言的错误消息
@@ -664,63 +705,63 @@ class HTTPRequest:
664
705
  extractor_type = error_msg.split("[", 1)[1].split("]")[0] if "[" in error_msg else "未知"
665
706
  else:
666
707
  extractor_type = "未知"
667
-
708
+
668
709
  # 生成简短的断言标题
669
710
  assertion_title = f"断言 #{assertion_idx+1} [{extractor_type}]"
670
-
711
+
671
712
  # 添加分隔线使错误更容易辨别
672
713
  error_summary += f"\n{'-' * 30}\n{idx}. {assertion_title}:\n{'-' * 30}\n{error_msg}"
673
-
714
+
674
715
  # 添加底部分隔线
675
716
  error_summary += f"\n{'-' * 50}"
676
-
717
+
677
718
  raise AssertionError(error_summary)
678
-
719
+
679
720
  # 返回断言结果和需要重试的断言
680
721
  return assert_results, failed_retryable_assertions
681
-
722
+
682
723
  def _format_assertion_details(self, assertion_result: Dict[str, Any]) -> str:
683
724
  """格式化断言详情,用于Allure报告
684
-
725
+
685
726
  Args:
686
727
  assertion_result: 断言结果字典
687
-
728
+
688
729
  Returns:
689
730
  格式化的断言详情字符串
690
731
  """
691
732
  details = f"类型: {assertion_result['type']}\n"
692
733
  if assertion_result['path']:
693
734
  details += f"路径: {assertion_result['path']}\n"
694
-
735
+
695
736
  if assertion_result['assertion_type'] == 'length':
696
737
  details += f"原始值: {assertion_result['original_value']}\n"
697
738
  details += f"长度: {assertion_result['actual_value']}\n"
698
739
  else:
699
740
  details += f"实际值: {assertion_result['actual_value']}\n"
700
-
741
+
701
742
  details += f"操作符: {assertion_result['operator']}\n"
702
-
743
+
703
744
  if assertion_result['expected_value'] is not None:
704
745
  details += f"预期值: {assertion_result['expected_value']}\n"
705
-
746
+
706
747
  details += f"结果: {'通过' if assertion_result['passed'] else '失败'}"
707
-
748
+
708
749
  return details
709
-
750
+
710
751
  def _extract_value(self, extractor_type: str, extraction_path: str = None, default_value: Any = None) -> Any:
711
752
  """从响应提取值
712
-
753
+
713
754
  Args:
714
755
  extractor_type: 提取器类型
715
756
  extraction_path: 提取路径
716
757
  default_value: 默认值
717
-
758
+
718
759
  Returns:
719
760
  提取的值
720
761
  """
721
762
  if not self.response:
722
763
  return default_value
723
-
764
+
724
765
  try:
725
766
  if extractor_type == "jsonpath":
726
767
  return self._extract_jsonpath(extraction_path, default_value)
@@ -753,23 +794,23 @@ class HTTPRequest:
753
794
  if default_value is not None:
754
795
  return default_value
755
796
  raise ValueError(error_message)
756
-
797
+
757
798
  def _extract_jsonpath(self, path: str, default_value: Any = None) -> Any:
758
799
  """使用JSONPath从JSON响应提取值
759
-
800
+
760
801
  Args:
761
802
  path: JSONPath表达式
762
803
  default_value: 默认值
763
-
804
+
764
805
  Returns:
765
806
  提取的值
766
807
  """
767
808
  try:
768
809
  json_data = self.response.json()
769
-
810
+
770
811
  jsonpath_expr = jsonpath.parse(path)
771
812
  matches = [match.value for match in jsonpath_expr.find(json_data)]
772
-
813
+
773
814
  if not matches:
774
815
  return default_value
775
816
  elif len(matches) == 1:
@@ -780,14 +821,14 @@ class HTTPRequest:
780
821
  if default_value is not None:
781
822
  return default_value
782
823
  raise ValueError(f"JSONPath提取失败: {str(e)}")
783
-
824
+
784
825
  def _extract_xpath(self, path: str, default_value: Any = None) -> Any:
785
826
  """使用XPath从HTML/XML响应提取值
786
-
827
+
787
828
  Args:
788
829
  path: XPath表达式
789
830
  default_value: 默认值
790
-
831
+
791
832
  Returns:
792
833
  提取的值
793
834
  """
@@ -795,10 +836,10 @@ class HTTPRequest:
795
836
  # 尝试解析响应内容
796
837
  parser = etree.HTMLParser()
797
838
  tree = etree.fromstring(self.response.content, parser)
798
-
839
+
799
840
  # 执行XPath
800
841
  result = tree.xpath(path)
801
-
842
+
802
843
  if not result:
803
844
  return default_value
804
845
  elif len(result) == 1:
@@ -809,14 +850,14 @@ class HTTPRequest:
809
850
  if default_value is not None:
810
851
  return default_value
811
852
  raise ValueError(f"XPath提取失败: {str(e)}")
812
-
853
+
813
854
  def _extract_regex(self, pattern: str, default_value: Any = None) -> Any:
814
855
  """使用正则表达式从响应提取值
815
-
856
+
816
857
  Args:
817
858
  pattern: 正则表达式模式
818
859
  default_value: 默认值
819
-
860
+
820
861
  Returns:
821
862
  提取的值
822
863
  """
@@ -826,9 +867,9 @@ class HTTPRequest:
826
867
  text = json.dumps(self.response.json())
827
868
  else:
828
869
  text = self.response.text
829
-
870
+
830
871
  matches = re.findall(pattern, text)
831
-
872
+
832
873
  if not matches:
833
874
  return default_value
834
875
  elif len(matches) == 1:
@@ -839,42 +880,42 @@ class HTTPRequest:
839
880
  if default_value is not None:
840
881
  return default_value
841
882
  raise ValueError(f"正则表达式提取失败: {str(e)}")
842
-
883
+
843
884
  def _extract_header(self, header_name: str, default_value: Any = None) -> Any:
844
885
  """从响应头提取值
845
-
886
+
846
887
  Args:
847
888
  header_name: 响应头名称
848
889
  default_value: 默认值
849
-
890
+
850
891
  Returns:
851
892
  提取的值
852
893
  """
853
894
  header_value = self.response.headers.get(header_name)
854
895
  return header_value if header_value is not None else default_value
855
-
896
+
856
897
  def _extract_cookie(self, cookie_name: str, default_value: Any = None) -> Any:
857
898
  """从响应Cookie提取值
858
-
899
+
859
900
  Args:
860
901
  cookie_name: Cookie名称
861
902
  default_value: 默认值
862
-
903
+
863
904
  Returns:
864
905
  提取的值
865
906
  """
866
907
  cookie = self.response.cookies.get(cookie_name)
867
908
  return cookie if cookie is not None else default_value
868
-
909
+
869
910
  def _perform_assertion(self, assertion_type: str, operator: str, actual_value: Any, expected_value: Any = None) -> bool:
870
911
  """执行断言
871
-
912
+
872
913
  Args:
873
914
  assertion_type: 断言类型
874
915
  operator: 比较操作符
875
916
  actual_value: 实际值
876
917
  expected_value: 预期值
877
-
918
+
878
919
  Returns:
879
920
  断言结果
880
921
  """
@@ -886,7 +927,7 @@ class HTTPRequest:
886
927
  if isinstance(expected_value, str):
887
928
  # 去除空白字符和换行符
888
929
  clean_expected = expected_value.strip()
889
-
930
+
890
931
  # 判断是否是整数
891
932
  if clean_expected.isdigit() or (clean_expected.startswith('-') and clean_expected[1:].isdigit()):
892
933
  expected_value = int(clean_expected)
@@ -904,12 +945,12 @@ class HTTPRequest:
904
945
  clean_expected[1:].replace('e', '').replace('E', '').replace('+', '', 1).replace('-', '', 1).replace('.', '', 1).isdigit())
905
946
  ):
906
947
  expected_value = float(clean_expected)
907
-
948
+
908
949
  # 将实际值转换为适当的类型
909
950
  if isinstance(actual_value, str):
910
951
  # 去除空白字符和换行符
911
952
  clean_actual = actual_value.strip()
912
-
953
+
913
954
  # 判断是否是整数
914
955
  if clean_actual.isdigit() or (clean_actual.startswith('-') and clean_actual[1:].isdigit()):
915
956
  actual_value = int(clean_actual)
@@ -934,7 +975,7 @@ class HTTPRequest:
934
975
  name="断言类型转换警告",
935
976
  attachment_type=allure.attachment_type.TEXT
936
977
  )
937
-
978
+
938
979
  # 记录断言参数
939
980
  allure.attach(
940
981
  f"断言类型: {assertion_type}\n"
@@ -944,7 +985,7 @@ class HTTPRequest:
944
985
  name="断言参数",
945
986
  attachment_type=allure.attachment_type.TEXT
946
987
  )
947
-
988
+
948
989
  # 基于断言类型执行断言
949
990
  if assertion_type == "value":
950
991
  # 直接使用操作符进行比较
@@ -1036,15 +1077,15 @@ class HTTPRequest:
1036
1077
  return False
1037
1078
  else:
1038
1079
  raise ValueError(f"不支持的断言类型: {assertion_type}")
1039
-
1080
+
1040
1081
  def _compare_values(self, actual_value: Any, expected_value: Any, operator: str) -> bool:
1041
1082
  """比较两个值
1042
-
1083
+
1043
1084
  Args:
1044
1085
  actual_value: 实际值
1045
1086
  expected_value: 预期值
1046
1087
  operator: 比较操作符
1047
-
1088
+
1048
1089
  Returns:
1049
1090
  比较结果
1050
1091
  """
@@ -1080,6 +1121,16 @@ class HTTPRequest:
1080
1121
  elif isinstance(actual_value, (list, tuple, dict)):
1081
1122
  return expected_value not in actual_value
1082
1123
  return True
1124
+ elif operator == "startswith":
1125
+ # startswith操作符检查actual是否以expected开头
1126
+ if not isinstance(actual_value, str):
1127
+ actual_value = str(actual_value) if actual_value is not None else ""
1128
+ return actual_value.startswith(str(expected_value))
1129
+ elif operator == "endswith":
1130
+ # endswith操作符检查actual是否以expected结尾
1131
+ if not isinstance(actual_value, str):
1132
+ actual_value = str(actual_value) if actual_value is not None else ""
1133
+ return actual_value.endswith(str(expected_value))
1083
1134
  elif operator == "matches":
1084
1135
  # matches操作符使用正则表达式进行匹配
1085
1136
  if not isinstance(actual_value, str):
@@ -1109,10 +1160,10 @@ class HTTPRequest:
1109
1160
  return False
1110
1161
  else:
1111
1162
  raise ValueError(f"不支持的比较操作符: {operator}")
1112
-
1163
+
1113
1164
  def _log_request_to_allure(self, method: str, url: str, request_kwargs: Dict[str, Any]) -> None:
1114
1165
  """使用Allure记录请求信息
1115
-
1166
+
1116
1167
  Args:
1117
1168
  method: HTTP方法
1118
1169
  url: 请求URL
@@ -1120,10 +1171,10 @@ class HTTPRequest:
1120
1171
  """
1121
1172
  # 创建请求信息摘要
1122
1173
  request_summary = f"{method} {url}"
1123
-
1174
+
1124
1175
  # 创建详细请求信息
1125
1176
  request_details = [f"Method: {method}", f"URL: {url}"]
1126
-
1177
+
1127
1178
  # 添加请求头
1128
1179
  if "headers" in request_kwargs and request_kwargs["headers"]:
1129
1180
  # 隐藏敏感信息
@@ -1136,13 +1187,13 @@ class HTTPRequest:
1136
1187
  request_details.append("Headers:")
1137
1188
  for key, value in safe_headers.items():
1138
1189
  request_details.append(f" {key}: {value}")
1139
-
1190
+
1140
1191
  # 添加查询参数
1141
1192
  if "params" in request_kwargs and request_kwargs["params"]:
1142
1193
  request_details.append("Query Parameters:")
1143
1194
  for key, value in request_kwargs["params"].items():
1144
1195
  request_details.append(f" {key}: {value}")
1145
-
1196
+
1146
1197
  # 添加请求体
1147
1198
  if "json" in request_kwargs and request_kwargs["json"]:
1148
1199
  request_details.append("JSON Body:")
@@ -1154,40 +1205,40 @@ class HTTPRequest:
1154
1205
  request_details.append("Form Data:")
1155
1206
  for key, value in request_kwargs["data"].items():
1156
1207
  request_details.append(f" {key}: {value}")
1157
-
1208
+
1158
1209
  # 添加文件信息
1159
1210
  if "files" in request_kwargs and request_kwargs["files"]:
1160
1211
  request_details.append("Files:")
1161
1212
  for key, value in request_kwargs["files"].items():
1162
1213
  request_details.append(f" {key}: <File object>")
1163
-
1214
+
1164
1215
  # 记录到Allure
1165
1216
  allure.attach(
1166
1217
  "\n".join(request_details),
1167
1218
  name=f"HTTP请求: {request_summary}",
1168
1219
  attachment_type=allure.attachment_type.TEXT
1169
1220
  )
1170
-
1221
+
1171
1222
  def _log_response_to_allure(self, response: Response) -> None:
1172
1223
  """使用Allure记录响应信息
1173
-
1224
+
1174
1225
  Args:
1175
1226
  response: 响应对象
1176
1227
  """
1177
1228
  # 创建响应信息摘要
1178
1229
  response_summary = f"{response.status_code} {response.reason} ({response.elapsed.total_seconds() * 1000:.2f}ms)"
1179
-
1230
+
1180
1231
  # 创建详细响应信息
1181
1232
  response_details = [
1182
1233
  f"Status: {response.status_code} {response.reason}",
1183
1234
  f"Response Time: {response.elapsed.total_seconds() * 1000:.2f}ms"
1184
1235
  ]
1185
-
1236
+
1186
1237
  # 添加响应头
1187
1238
  response_details.append("Headers:")
1188
1239
  for key, value in response.headers.items():
1189
1240
  response_details.append(f" {key}: {value}")
1190
-
1241
+
1191
1242
  # 添加响应体
1192
1243
  response_details.append("Body:")
1193
1244
  try:
@@ -1199,10 +1250,10 @@ class HTTPRequest:
1199
1250
  response_details.append(f"<{len(response.content)} bytes>")
1200
1251
  except Exception as e:
1201
1252
  response_details.append(f"<Error parsing body: {str(e)}>")
1202
-
1253
+
1203
1254
  # 记录到Allure
1204
1255
  allure.attach(
1205
1256
  "\n".join(response_details),
1206
1257
  name=f"HTTP响应: {response_summary}",
1207
1258
  attachment_type=allure.attachment_type.TEXT
1208
- )
1259
+ )