sefrone-api-e2e 1.0.1__py3-none-any.whl → 1.0.3__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.
@@ -27,9 +27,11 @@ class ApiE2ETestsManager:
27
27
  ApiE2ETestsManager.scenario_results = []
28
28
 
29
29
  p = Path(folder_path)
30
- scenario_files = sorted([f.name for f in p.iterdir() if f.is_file() and f.suffix == ".yaml"])
30
+ scenario_files = sorted([f.name for f in p.iterdir() if f.is_file() and f.suffix == ".yaml"])
31
31
  total_start = time.time()
32
32
 
33
+ all_tests_passed = True
34
+
33
35
  for filename in scenario_files:
34
36
  yaml_file_path = os.path.join(folder_path, filename)
35
37
  scenario_name = os.path.splitext(filename)[0]
@@ -55,6 +57,7 @@ class ApiE2ETestsManager:
55
57
  "steps": step_metrics,
56
58
  "total_time": elapsed
57
59
  })
60
+ all_tests_passed = False
58
61
  # mark remaining scenarios as skipped
59
62
  remaining = scenario_files[scenario_files.index(filename)+1:]
60
63
  for skipped_file in remaining:
@@ -76,6 +79,7 @@ class ApiE2ETestsManager:
76
79
  "steps": step_metrics if 'step_metrics' in locals() else [],
77
80
  "total_time": failed_elapsed
78
81
  })
82
+ all_tests_passed = False
79
83
  # mark remaining scenarios as skipped
80
84
  remaining = scenario_files[scenario_files.index(filename)+1:]
81
85
  for skipped_file in remaining:
@@ -90,7 +94,10 @@ class ApiE2ETestsManager:
90
94
 
91
95
  total_elapsed = time.time() - total_start
92
96
  print("\nAPI E2E tests completed.")
93
- ApiE2ETestsManager.print_summary(total_elapsed)
97
+ ApiE2ETestsManager.print_summary(total_elapsed, all_tests_passed)
98
+
99
+ if not all_tests_passed:
100
+ exit(1)
94
101
 
95
102
  @staticmethod
96
103
  def read_env_value_from_file(env_file_path, env_var):
@@ -167,13 +174,100 @@ class ApiE2ETestsManager:
167
174
  @staticmethod
168
175
  def render_template(template_str, context, add_quotes=False):
169
176
  def resolve_path(expr, ctx):
170
- parts = expr.split(".")
177
+ # Parse path with array indexing support: body.items[0].id and count(): body.items.count()
171
178
  val = ctx
172
- for p in parts:
173
- if isinstance(val, dict) and p in val:
174
- val = val[p]
179
+ current_path = ""
180
+ i = 0
181
+
182
+ while i < len(expr):
183
+ # Check for array index
184
+ if expr[i] == '[':
185
+ # Find closing bracket
186
+ end = expr.find(']', i)
187
+ if end == -1:
188
+ raise ValueError(f"Unclosed bracket in path: {expr}")
189
+
190
+ # Extract index
191
+ index_str = expr[i+1:end].strip()
192
+ try:
193
+ index = int(index_str)
194
+ except ValueError:
195
+ raise ValueError(f"Invalid array index '{index_str}' in path: {expr}")
196
+
197
+ # Apply index
198
+ if not isinstance(val, list):
199
+ raise KeyError(f"Cannot index non-list at '{current_path}' in path: {expr}")
200
+ if index < 0 or index >= len(val):
201
+ raise KeyError(f"Index {index} out of range at '{current_path}' in path: {expr}")
202
+
203
+ val = val[index]
204
+ current_path += f"[{index}]"
205
+ i = end + 1
206
+
207
+ # Skip dot after bracket if present
208
+ if i < len(expr) and expr[i] == '.':
209
+ i += 1
210
+
211
+ # Check for dot separator
212
+ elif expr[i] == '.':
213
+ i += 1
214
+
215
+ # Read property name or function call
175
216
  else:
176
- raise KeyError(f"Cannot resolve '{expr}' in context: {p} not found")
217
+ # Find next special char
218
+ next_dot = expr.find('.', i)
219
+ next_bracket = expr.find('[', i)
220
+ next_paren = expr.find('(', i)
221
+
222
+ # Determine end of property name
223
+ candidates = [next_dot, next_bracket, next_paren]
224
+ valid_candidates = [c for c in candidates if c != -1]
225
+
226
+ if not valid_candidates:
227
+ end_pos = len(expr)
228
+ else:
229
+ end_pos = min(valid_candidates)
230
+
231
+ prop = expr[i:end_pos].strip()
232
+ if prop:
233
+ # Check if it's a function call
234
+ if end_pos < len(expr) and expr[end_pos] == '(':
235
+ # Handle count() function
236
+ if prop == "count":
237
+ # Find closing parenthesis
238
+ close_paren = expr.find(')', end_pos)
239
+ if close_paren == -1:
240
+ raise ValueError(f"Unclosed parenthesis in function call: {expr}")
241
+
242
+ # Check if there are arguments (should be empty for count)
243
+ args = expr[end_pos+1:close_paren].strip()
244
+ if args:
245
+ raise ValueError(f"count() function does not accept arguments: {expr}")
246
+
247
+ # Apply count on lists or dicts
248
+ if isinstance(val, (list, dict)):
249
+ val = len(val)
250
+ else:
251
+ raise KeyError(f"Cannot call count() on type {type(val).__name__} at '{current_path}' in path: {expr}")
252
+
253
+ current_path += ".count()"
254
+ i = close_paren + 1
255
+
256
+ # Skip dot after parenthesis if present
257
+ if i < len(expr) and expr[i] == '.':
258
+ i += 1
259
+ else:
260
+ raise ValueError(f"Unsupported function '{prop}()' in path: {expr}")
261
+ # Regular property access
262
+ elif isinstance(val, dict) and prop in val:
263
+ val = val[prop]
264
+ current_path += f".{prop}" if current_path and not current_path.endswith(']') else prop
265
+ i = end_pos
266
+ else:
267
+ raise KeyError(f"Cannot resolve '{expr}' in context: property '{prop}' not found at '{current_path}'")
268
+ else:
269
+ i = end_pos
270
+
177
271
  return val
178
272
 
179
273
  def replacer(match):
@@ -208,6 +302,20 @@ class ApiE2ETestsManager:
208
302
 
209
303
  return ApiE2ETestsManager.STORED_PATTERN.sub(repl, expr)
210
304
 
305
+ @staticmethod
306
+ def substitute_stored_in_body(body, scenario_name):
307
+ """Recursively substitute stored variables in request body."""
308
+ if body is None:
309
+ return None
310
+ elif isinstance(body, dict):
311
+ return {k: ApiE2ETestsManager.substitute_stored_in_body(v, scenario_name) for k, v in body.items()}
312
+ elif isinstance(body, list):
313
+ return [ApiE2ETestsManager.substitute_stored_in_body(item, scenario_name) for item in body]
314
+ elif isinstance(body, str):
315
+ return ApiE2ETestsManager.substitute_stored(body, scenario_name)
316
+ else:
317
+ return body
318
+
211
319
  @staticmethod
212
320
  def store_value(key, value, scenario_name):
213
321
  namespaced_key = f"{scenario_name}.{key}"
@@ -275,7 +383,7 @@ class ApiE2ETestsManager:
275
383
 
276
384
  if not result:
277
385
  raise AssertionError(
278
- f"Assertion failed: {expr} evaluated as {left_val} {matched_op} {right_val}"
386
+ f"Assertion failed: {expr} -> evaluated as {left_val} {matched_op} {right_val}"
279
387
  )
280
388
  return True
281
389
 
@@ -286,67 +394,103 @@ class ApiE2ETestsManager:
286
394
  step_metrics = []
287
395
 
288
396
  print(f"Running test: {spec.get('name', scenario_name)}")
397
+ print(f"Base URL: {base_url}")
289
398
  print("=" * 60)
290
399
  # use a session for connection pooling and to set a default timeout
291
400
  session = requests.Session()
292
401
  for step in steps:
293
- try:
294
- step_start = time.time()
295
- print(f"Step: {step['name']}")
296
- url = base_url + ApiE2ETestsManager.substitute_stored(step['endpoint'], scenario_name)
297
- method = step['method'].upper()
402
+ # Get retry configuration
403
+ retry_config = step.get("retry", {})
404
+ max_attempts = retry_config.get("attempts", 1)
405
+ delay_seconds = retry_config.get("delay_seconds", 0)
298
406
 
299
- request_headers = {}
300
- if "request_headers" in step:
301
- for header in step["request_headers"]:
302
- for header_key, header_value in header.items():
303
- if isinstance(header_value, str):
304
- header_value = ApiE2ETestsManager.substitute_stored(header_value, scenario_name)
305
- request_headers[header_key] = header_value
306
-
307
- body = step.get("body")
308
- expect = step.get("expect", {})
309
-
310
- resp = session.request(method, url, json=body, headers=request_headers, timeout=ApiE2ETestsManager.DEFAULT_TIMEOUT)
311
- print(f" -> {method} {url} -> {resp.status_code}")
312
-
313
- expected_status = expect.get("status")
314
- if expected_status and resp.status_code != expected_status:
315
- raise AssertionError(f"Expected {expected_status}, got {resp.status_code}")
407
+ last_error = None
408
+ step_start = time.time()
316
409
 
410
+ for attempt in range(max_attempts):
317
411
  try:
318
- resp_json = resp.json()
319
- except Exception:
320
- raise AssertionError("Response is not valid JSON")
321
-
322
- if "body" in expect:
323
- if is_verbose:
324
- print(f"Response body: {resp_json}")
325
- ApiE2ETestsManager.validate_structure(resp_json, expect["body"])
326
-
327
- if "save" in step:
328
- for key, path_expr in step["save"].items():
329
- rendered = ApiE2ETestsManager.render_template(path_expr, {"body": resp_json})
330
- ApiE2ETestsManager.store_value(key, rendered, scenario_name)
331
-
332
- if "assertions" in step:
333
- for expr in step["assertions"]:
334
- rendered = ApiE2ETestsManager.render_template(expr, {"body": resp_json}, add_quotes=True)
335
- ApiE2ETestsManager.try_eval(rendered, expr)
336
-
337
- elapsed = time.time() - step_start
338
- step_metrics.append({"name": step["name"], "time": elapsed, "result": "PASS"})
339
- print(f"[OK ] Step passed in {elapsed:.2f}s\n")
340
- except Exception as e:
412
+ if attempt > 0:
413
+ print(f" Retry attempt {attempt + 1}/{max_attempts} after {delay_seconds}s delay...")
414
+ time.sleep(delay_seconds)
415
+
416
+ if attempt == 0:
417
+ print(f"Step: {step['name']}")
418
+
419
+ url = base_url + ApiE2ETestsManager.substitute_stored(step['endpoint'], scenario_name)
420
+ method = step['method'].upper()
421
+
422
+ request_headers = {}
423
+ if "request_headers" in step:
424
+ for header in step["request_headers"]:
425
+ for header_key, header_value in header.items():
426
+ if isinstance(header_value, str):
427
+ header_value = ApiE2ETestsManager.substitute_stored(header_value, scenario_name)
428
+ request_headers[header_key] = header_value
429
+
430
+ body = step.get("body")
431
+ if body is not None:
432
+ body = ApiE2ETestsManager.substitute_stored_in_body(body, scenario_name)
433
+ expect = step.get("expect", {})
434
+
435
+ resp = session.request(method, url, json=body, headers=request_headers, timeout=ApiE2ETestsManager.DEFAULT_TIMEOUT)
436
+ print(f" -> {method} {url} -> {resp.status_code}")
437
+
438
+ expected_status = expect.get("status")
439
+ if expected_status and resp.status_code != expected_status:
440
+ raise AssertionError(f"Expected {expected_status}, got {resp.status_code}")
441
+
442
+ try:
443
+ resp_json = resp.json()
444
+ except Exception:
445
+ raise AssertionError("Response is not valid JSON")
446
+
447
+ if "body" in expect:
448
+ if is_verbose:
449
+ print(f"Response body: {resp_json}")
450
+ ApiE2ETestsManager.validate_structure(resp_json, expect["body"])
451
+
452
+ if "assertions" in step:
453
+ for expr in step["assertions"]:
454
+ try:
455
+ rendered = ApiE2ETestsManager.render_template(expr, {"body": resp_json}, add_quotes=True)
456
+ ApiE2ETestsManager.try_eval(rendered, expr)
457
+ except Exception as e:
458
+ raise AssertionError(f"Assertion failed for expression\n -->'{expr}'\n -->{str(e)}")
459
+
460
+ if "save" in step:
461
+ for key, path_expr in step["save"].items():
462
+ try:
463
+ rendered = ApiE2ETestsManager.render_template(path_expr, {"body": resp_json})
464
+ ApiE2ETestsManager.store_value(key, rendered, scenario_name)
465
+ except Exception as e:
466
+ raise AssertionError(f"Failed to render save path\n -->'{path_expr}'\n -->{str(e)}")
467
+
468
+ elapsed = time.time() - step_start
469
+ step_metrics.append({"name": step["name"], "time": elapsed, "result": "PASS"})
470
+ if attempt > 0:
471
+ print(f"[OK ] Step passed on retry attempt {attempt + 1} in {elapsed:.2f}s\n")
472
+ else:
473
+ print(f"[OK ] Step passed in {elapsed:.2f}s\n")
474
+ last_error = None
475
+ break # Success, exit retry loop
476
+
477
+ except Exception as e:
478
+ last_error = e
479
+ if attempt < max_attempts - 1:
480
+ print(f"[RETRY] Attempt {attempt + 1} failed: {str(e)}")
481
+ # Continue to next attempt
482
+
483
+ # Check if all attempts failed
484
+ if last_error is not None:
341
485
  failed_elapsed = time.time() - step_start
342
- step_metrics.append({"name": step["name"], "time": failed_elapsed, "result": "FAIL", "error": str(e)})
343
- print(f"[FAIL] Step failed in {failed_elapsed:.2f}s error: {str(e)}\n")
486
+ step_metrics.append({"name": step["name"], "time": failed_elapsed, "result": "FAIL", "error": str(last_error)})
487
+ print(f"[FAIL] Step failed after {max_attempts} attempt(s) in {failed_elapsed:.2f}s\n Error: {str(last_error)}\n")
344
488
  break
345
489
  print("=" * 60)
346
490
  return step_metrics
347
491
 
348
492
  @staticmethod
349
- def print_summary(total_time):
493
+ def print_summary(total_time, all_tests_passed):
350
494
  print("\n[SUMMARY] TEST RESULTS")
351
495
  print("=" * 80)
352
496
 
@@ -382,3 +526,8 @@ class ApiE2ETestsManager:
382
526
 
383
527
  print("=" * 80)
384
528
  print(f"Total Execution Time: {total_time:.2f}s\n")
529
+
530
+ if all_tests_passed:
531
+ print("ALL TESTS PASSED")
532
+ else:
533
+ print("SOME TESTS FAILED")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: sefrone-api-e2e
3
- Version: 1.0.1
3
+ Version: 1.0.3
4
4
  Summary: A Python package to provide e2e testing helpers for sefrone API projects
5
5
  Home-page: https://bitbucket.org/sefrone/sefrone_pypi
6
6
  Author: Sefrone
@@ -0,0 +1,6 @@
1
+ sefrone_api_e2e/__init__.py,sha256=NNUHigTCBkjdwCfQXVEA7urxF1-A8HF1_IuWJ8LuO-k,91
2
+ sefrone_api_e2e/api_e2e_manager.py,sha256=vAA0-H3dVKIUptyxXbYaiZP_9PYu58z9LCj_vi53vwY,23584
3
+ sefrone_api_e2e-1.0.3.dist-info/METADATA,sha256=cHEc8npSLW23SM7hxt0GYKY6q8vHCghpPv7oUB-IhUQ,721
4
+ sefrone_api_e2e-1.0.3.dist-info/WHEEL,sha256=tZoeGjtWxWRfdplE7E3d45VPlLNQnvbKiYnx7gwAy8A,92
5
+ sefrone_api_e2e-1.0.3.dist-info/top_level.txt,sha256=19WO3CsUWUiGtZBotT587N-tkxxjctKOPDEHWpHpS8M,16
6
+ sefrone_api_e2e-1.0.3.dist-info/RECORD,,
@@ -1,6 +0,0 @@
1
- sefrone_api_e2e/__init__.py,sha256=NNUHigTCBkjdwCfQXVEA7urxF1-A8HF1_IuWJ8LuO-k,91
2
- sefrone_api_e2e/api_e2e_manager.py,sha256=3oA9JqDQR9L_Fj5YRgeXpPcFxl28FRLNN_hdjB1017E,16464
3
- sefrone_api_e2e-1.0.1.dist-info/METADATA,sha256=-qjd_wb2tI9jnSQKe7rt0fIMe4Is7-012z8SGutmlCw,721
4
- sefrone_api_e2e-1.0.1.dist-info/WHEEL,sha256=tZoeGjtWxWRfdplE7E3d45VPlLNQnvbKiYnx7gwAy8A,92
5
- sefrone_api_e2e-1.0.1.dist-info/top_level.txt,sha256=19WO3CsUWUiGtZBotT587N-tkxxjctKOPDEHWpHpS8M,16
6
- sefrone_api_e2e-1.0.1.dist-info/RECORD,,