sefrone-api-e2e 1.0.0__py3-none-any.whl → 1.0.2__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.
@@ -3,31 +3,130 @@ import requests
3
3
  import re
4
4
  import os
5
5
  import datetime
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Any, Dict, List
6
9
 
7
10
  class ApiE2ETestsManager:
11
+ # Precompiled patterns and defaults
12
+ VAR_PATTERN = re.compile(r"\$var\(([A-Za-z0-9_]+)\)")
13
+ STORED_PATTERN = re.compile(r"\{\$stored\.([a-zA-Z0-9_.]+)\}")
14
+ TEMPLATE_PATTERN = re.compile(r"\{\{\s*([^}]+?)\s*\}\}")
15
+ DEFAULT_TIMEOUT = 10 # seconds for HTTP requests
16
+
17
+ # Mutable state (reset at run start)
18
+ global_store: Dict[str, Any] = {}
19
+ scenario_results: List[Dict[str, Any]] = [] # holds timing + status info for all scenarios
20
+
8
21
  @staticmethod
9
- def run_all_tests(folder_path, is_verbose=False):
22
+ def run_all_tests(folder_path, env_file_path, is_verbose=False):
10
23
  print("\nRunning API E2E tests...")
11
- for filename in os.listdir(folder_path):
12
- if filename.endswith(".yaml") or filename.endswith(".yml"):
13
- file_path = os.path.join(folder_path, filename)
24
+
25
+ # Reset shared state to avoid leakage between runs
26
+ ApiE2ETestsManager.global_store = {}
27
+ ApiE2ETestsManager.scenario_results = []
28
+
29
+ p = Path(folder_path)
30
+ scenario_files = sorted([f.name for f in p.iterdir() if f.is_file() and f.suffix == ".yaml"])
31
+ total_start = time.time()
32
+
33
+ for filename in scenario_files:
34
+ yaml_file_path = os.path.join(folder_path, filename)
35
+ scenario_name = os.path.splitext(filename)[0]
36
+
37
+ try:
14
38
  print(f"\n--- Running scenario: {filename} ---")
15
- ApiE2ETestsManager.run_yaml_test(file_path, is_verbose)
39
+ spec = ApiE2ETestsManager.parse_and_load_yaml(yaml_file_path, env_file_path)
40
+ start_time = time.time()
41
+ step_metrics = ApiE2ETestsManager.run_yaml_test(spec, scenario_name, is_verbose)
42
+ elapsed = time.time() - start_time
43
+
44
+ if all(step["result"] == "PASS" for step in step_metrics):
45
+ ApiE2ETestsManager.scenario_results.append({
46
+ "scenario": scenario_name,
47
+ "status": "[PASS]",
48
+ "steps": step_metrics,
49
+ "total_time": elapsed
50
+ })
51
+ else:
52
+ ApiE2ETestsManager.scenario_results.append({
53
+ "scenario": scenario_name,
54
+ "status": "[FAIL]",
55
+ "steps": step_metrics,
56
+ "total_time": elapsed
57
+ })
58
+ # mark remaining scenarios as skipped
59
+ remaining = scenario_files[scenario_files.index(filename)+1:]
60
+ for skipped_file in remaining:
61
+ skipped_name = os.path.splitext(skipped_file)[0]
62
+ ApiE2ETestsManager.scenario_results.append({
63
+ "scenario": skipped_name,
64
+ "status": "[SKIP]",
65
+ "steps": [],
66
+ "total_time": 0
67
+ })
68
+ break
69
+
70
+ except AssertionError:
71
+ # mark the failed one
72
+ failed_elapsed = time.time() - start_time if 'start_time' in locals() else 0
73
+ ApiE2ETestsManager.scenario_results.append({
74
+ "scenario": scenario_name,
75
+ "status": "[FAIL]",
76
+ "steps": step_metrics if 'step_metrics' in locals() else [],
77
+ "total_time": failed_elapsed
78
+ })
79
+ # mark remaining scenarios as skipped
80
+ remaining = scenario_files[scenario_files.index(filename)+1:]
81
+ for skipped_file in remaining:
82
+ skipped_name = os.path.splitext(skipped_file)[0]
83
+ ApiE2ETestsManager.scenario_results.append({
84
+ "scenario": skipped_name,
85
+ "status": "[SKIP]",
86
+ "steps": [],
87
+ "total_time": 0
88
+ })
89
+ break # stop all further execution
90
+
91
+ total_elapsed = time.time() - total_start
16
92
  print("\nAPI E2E tests completed.")
93
+ ApiE2ETestsManager.print_summary(total_elapsed)
17
94
 
18
95
  @staticmethod
19
- def load_yaml(path):
20
- with open(path, "r", encoding="utf-8") as f:
21
- return yaml.safe_load(f)
96
+ def read_env_value_from_file(env_file_path, env_var):
97
+ try:
98
+ with open(env_file_path, 'r', encoding='utf-8') as file:
99
+ for raw in file:
100
+ line = raw.strip()
101
+ if not line or line.startswith('#'):
102
+ continue
103
+ if line.startswith(f'{env_var}='):
104
+ return line[len(f'{env_var}='):].strip()
105
+ except FileNotFoundError:
106
+ print(f"[WARN] Environment file not found: {env_file_path}")
107
+ return ""
108
+ print(f"[WARN] Environment variable '{env_var}' not found in {env_file_path}.")
109
+ return ""
110
+
111
+ @staticmethod
112
+ def parse_and_load_yaml(yaml_file_path, env_file_path):
113
+ with open(yaml_file_path, "r", encoding="utf-8") as f:
114
+ content = f.read()
115
+
116
+ def replacer(match):
117
+ key = match.group(1)
118
+ return ApiE2ETestsManager.read_env_value_from_file(env_file_path, key)
119
+
120
+ resolved_content = ApiE2ETestsManager.VAR_PATTERN.sub(replacer, content)
121
+ return yaml.safe_load(resolved_content)
22
122
 
23
123
  @staticmethod
24
124
  def check_type(value, expected_type):
25
- """Check if value matches expected type keyword."""
26
125
  type_map = {
27
126
  "string": str,
28
127
  "number": (int, float),
29
128
  "boolean": bool,
30
- "datetime": str # basic check; could extend with parsing
129
+ "datetime": str
31
130
  }
32
131
  if expected_type not in type_map:
33
132
  raise ValueError(f"Unknown type in YAML: {expected_type}")
@@ -46,7 +145,6 @@ class ApiE2ETestsManager:
46
145
 
47
146
  @staticmethod
48
147
  def validate_structure(data, expected):
49
- """Recursively validate structure & type expectations."""
50
148
  if isinstance(expected, dict):
51
149
  if not isinstance(data, dict):
52
150
  raise AssertionError(f"Expected dict but got {type(data)}")
@@ -60,7 +158,7 @@ class ApiE2ETestsManager:
60
158
  if len(expected) > 0:
61
159
  for item in data:
62
160
  ApiE2ETestsManager.validate_structure(item, expected[0])
63
- elif isinstance(expected, str): # type keyword
161
+ elif isinstance(expected, str):
64
162
  if not ApiE2ETestsManager.check_type(data, expected):
65
163
  raise AssertionError(f"Expected {expected}, got {data} ({type(data)})")
66
164
  else:
@@ -80,89 +178,207 @@ class ApiE2ETestsManager:
80
178
 
81
179
  def replacer(match):
82
180
  expr = match.group(1).strip()
83
- for root_key in context:
84
- if expr.startswith(root_key + "."):
85
- val = resolve_path(expr, context)
86
- if isinstance(val, str):
87
- return repr(val) if add_quotes else str(val) # adds quotes safely
88
- elif val is True:
89
- return "True"
90
- elif val is False:
91
- return "False"
92
- elif val is None:
93
- return "None"
94
- else:
95
- return str(val)
96
- raise KeyError(f"Unknown variable: {expr}")
97
-
98
- return re.sub(r"\{\{\s*([^}]+?)\s*\}\}", replacer, template_str)
181
+ val = resolve_path(expr, context)
182
+ if isinstance(val, str):
183
+ return repr(val) if add_quotes else str(val)
184
+ elif val is True:
185
+ return "True"
186
+ elif val is False:
187
+ return "False"
188
+ elif val is None:
189
+ return "None"
190
+ else:
191
+ return str(val)
192
+
193
+ return ApiE2ETestsManager.TEMPLATE_PATTERN.sub(replacer, template_str)
99
194
 
100
195
  @staticmethod
101
- def substitute_stored(endpoint, stored):
102
- """Substitute {$stored.key} placeholders."""
196
+ def substitute_stored(expr, scenario_name):
103
197
  def repl(match):
104
- key = match.group(1)
105
- return str(stored.get(key, f"<MISSING:{key}>"))
106
- return re.sub(r"\{\$stored\.([a-zA-Z0-9_]+)\}", repl, endpoint)
198
+ key = match.group(1).strip()
199
+ parts = key.split(".")
200
+ if len(parts) == 1:
201
+ subkey = parts[0]
202
+ return str(ApiE2ETestsManager.global_store.get(f"{scenario_name}.{subkey}", f"<MISSING:{subkey}>"))
203
+ elif len(parts) == 2:
204
+ scen, subkey = parts
205
+ return str(ApiE2ETestsManager.global_store.get(f"{scen}.{subkey}", f"<MISSING:{key}>"))
206
+ else:
207
+ raise ValueError(f"Invalid stored key reference: {key} (Too many dots)")
208
+
209
+ return ApiE2ETestsManager.STORED_PATTERN.sub(repl, expr)
210
+
211
+ @staticmethod
212
+ def store_value(key, value, scenario_name):
213
+ namespaced_key = f"{scenario_name}.{key}"
214
+ ApiE2ETestsManager.global_store[namespaced_key] = value
215
+ print(f" Saved: {namespaced_key} = {value}")
216
+
217
+ @staticmethod
218
+ def try_eval(rendered, expr):
219
+ # Basic supported operators
220
+ operators = [">=", "<=", ">", "<", "==", "!=", "in", "not in", "include"]
221
+
222
+ matched_op = None
223
+ for op in operators:
224
+ if f" {op} " in rendered:
225
+ matched_op = op
226
+ left, right = [part.strip() for part in rendered.split(f" {op} ", 1)]
227
+ break
228
+
229
+ if not matched_op:
230
+ raise AssertionError(f"Unsupported assertion format: '{expr}'")
231
+
232
+ # Try to evaluate left and right sides
233
+ def try_eval_part(value):
234
+ # Try to interpret as int/float/bool/None/string
235
+ if re.match(r"^-?\d+\.\d+$", value):
236
+ return float(value)
237
+ elif re.match(r"^-?\d+$", value):
238
+ return int(value)
239
+ elif value.lower() == "true":
240
+ return True
241
+ elif value.lower() == "false":
242
+ return False
243
+ elif value.lower() == "none":
244
+ return None
245
+ elif value.startswith('"') and value.endswith('"'):
246
+ return value.strip('"')
247
+ elif value.startswith("'") and value.endswith("'"):
248
+ return value.strip("'")
249
+ else:
250
+ return value # treat as literal string
251
+
252
+ left_val = try_eval_part(left)
253
+ right_val = try_eval_part(right)
254
+
255
+ # Comparison logic
256
+ result = False
257
+ if matched_op == "==":
258
+ result = left_val == right_val
259
+ elif matched_op == "!=":
260
+ result = left_val != right_val
261
+ elif matched_op == ">":
262
+ result = left_val > right_val
263
+ elif matched_op == "<":
264
+ result = left_val < right_val
265
+ elif matched_op == ">=":
266
+ result = left_val >= right_val
267
+ elif matched_op == "<=":
268
+ result = left_val <= right_val
269
+ elif matched_op == "in":
270
+ result = left_val in right_val
271
+ elif matched_op == "not in":
272
+ result = left_val not in right_val
273
+ elif matched_op == "include":
274
+ result = right_val in left_val
275
+
276
+ if not result:
277
+ raise AssertionError(
278
+ f"Assertion failed: {expr} => evaluated as {left_val} {matched_op} {right_val}"
279
+ )
280
+ return True
107
281
 
108
282
  @staticmethod
109
- def run_yaml_test(path, is_verbose=False):
110
- spec = ApiE2ETestsManager.load_yaml(path)
283
+ def run_yaml_test(spec, scenario_name, is_verbose=False):
111
284
  base_url = spec.get("base_url", "")
112
285
  steps = spec.get("steps", [])
113
- stored = {}
286
+ step_metrics = []
114
287
 
115
- print(f"Running test: {spec['name']}")
288
+ print(f"Running test: {spec.get('name', scenario_name)}")
116
289
  print("=" * 60)
117
-
290
+ # use a session for connection pooling and to set a default timeout
291
+ session = requests.Session()
118
292
  for step in steps:
119
- print(f"Step: {step['name']}")
120
- url = base_url + ApiE2ETestsManager.substitute_stored(step['endpoint'], stored)
121
- method = step['method'].upper()
122
- body = step.get("body")
123
- expect = step.get("expect", {})
124
-
125
- # Make HTTP request
126
- resp = requests.request(method, url, json=body)
127
- print(f" → {method} {url} -> {resp.status_code}")
128
-
129
- # Validate status code
130
- expected_status = expect.get("status")
131
- if expected_status and resp.status_code != expected_status:
132
- if is_verbose:
133
- print(f"Response body: {resp.text}")
134
- raise AssertionError(f"Expected {expected_status}, got {resp.status_code}")
135
-
136
- # Parse response JSON
137
293
  try:
138
- resp_json = resp.json()
139
- except Exception:
140
- raise AssertionError("Response is not valid JSON")
141
-
142
- # Validate body structure
143
- if "body" in expect:
144
- if is_verbose:
145
- print(f"Response body: {resp_json}")
146
- ApiE2ETestsManager.validate_structure(resp_json, expect["body"])
147
-
148
- # Save variables
149
- if "save" in step:
150
- for key, path_expr in step["save"].items():
151
- rendered = ApiE2ETestsManager.render_template(path_expr, {"body": resp_json, "stored": stored})
152
- stored[key] = rendered
153
- print(f" Saved: {key} = {rendered}")
154
-
155
- # Assertions
156
- if "assertions" in step:
157
- for expr in step["assertions"]:
158
- rendered = ApiE2ETestsManager.render_template(expr, {"body": resp_json, "stored": stored}, add_quotes=True)
159
- try:
160
- if not eval(rendered):
161
- raise AssertionError(f"Assertion failed: {expr}, rendered as '{rendered}'")
162
- except Exception as e:
163
- raise AssertionError(f"Assertion error in '{expr}', rendered as '{rendered}': {e}")
164
-
165
- print(" ✅ Step passed.\n")
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()
298
+
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", {})
166
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}")
316
+
317
+ 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:
341
+ 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")
344
+ break
167
345
  print("=" * 60)
168
- print("🎉 All steps passed successfully!")
346
+ return step_metrics
347
+
348
+ @staticmethod
349
+ def print_summary(total_time):
350
+ print("\n[SUMMARY] TEST RESULTS")
351
+ print("=" * 80)
352
+
353
+ headers = ["Scenario", "Status", "Time (s)", "Steps"]
354
+ col_widths = [30, 7, 8, 50]
355
+
356
+ header_line = f"{headers[0]:<{col_widths[0]}} | {headers[1]:<{col_widths[1]}} | {headers[2]:<{col_widths[2]}} | {headers[3]}"
357
+ print(header_line)
358
+ print("-" * len(header_line))
359
+
360
+ for result in ApiE2ETestsManager.scenario_results:
361
+ print(f"{result['scenario']:<{col_widths[0]}} | "
362
+ f"{result['status']:<{col_widths[1]}} | "
363
+ f"{result['total_time']:<{col_widths[2]}.2f} | ")
364
+
365
+ if result["steps"]:
366
+ for step in result["steps"]:
367
+ parsed = f"[{step['result']}] {step['name']} ({step['time']:.2f}s)"
368
+ print(f"{'':<{col_widths[0]}} | "
369
+ f"{'':<{col_widths[1]}} | "
370
+ f"{'':<{col_widths[2]}} | "
371
+ f"{parsed:<{col_widths[3]}}")
372
+ if step['result'] == "FAIL" and 'error' in step:
373
+ print(f"{'':<{col_widths[0]}} | "
374
+ f"{'':<{col_widths[1]}} | "
375
+ f"{'':<{col_widths[2]}} | "
376
+ f"===> {step['error']}")
377
+ else:
378
+ print(f"{'':<{col_widths[0]}} | "
379
+ f"{'':<{col_widths[1]}} | "
380
+ f"{'':<{col_widths[2]}} | "
381
+ f"{'(no steps run)':<{col_widths[3]}}")
382
+
383
+ print("=" * 80)
384
+ print(f"Total Execution Time: {total_time:.2f}s\n")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: sefrone-api-e2e
3
- Version: 1.0.0
3
+ Version: 1.0.2
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
@@ -13,6 +13,7 @@ Classifier: Operating System :: OS Independent
13
13
  Requires-Python: >=3.7
14
14
  Description-Content-Type: text/markdown
15
15
  Requires-Dist: PyYAML>=6.0.1
16
+ Requires-Dist: requests
16
17
 
17
18
  This is not a usable Python package, but the name is reserved by SARL Sefrone.
18
19
 
@@ -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=i49u0l1r_07ndG4jG1HxI5OGUvNXCqBFHiZd9WE90_c,16463
3
+ sefrone_api_e2e-1.0.2.dist-info/METADATA,sha256=xg-cB7oM_tGaBlHuaYGIx05MM8IUcN9mgnk8iNVRvhg,721
4
+ sefrone_api_e2e-1.0.2.dist-info/WHEEL,sha256=tZoeGjtWxWRfdplE7E3d45VPlLNQnvbKiYnx7gwAy8A,92
5
+ sefrone_api_e2e-1.0.2.dist-info/top_level.txt,sha256=19WO3CsUWUiGtZBotT587N-tkxxjctKOPDEHWpHpS8M,16
6
+ sefrone_api_e2e-1.0.2.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=3LDA-kIZLyhVTvxZFE4huJdVTKQfsZmIdiTpeGwoVPs,6984
3
- sefrone_api_e2e-1.0.0.dist-info/METADATA,sha256=k4CtrauVJStV0vCz3tb5hkNM552nz5KyMro1wG7AK4U,696
4
- sefrone_api_e2e-1.0.0.dist-info/WHEEL,sha256=tZoeGjtWxWRfdplE7E3d45VPlLNQnvbKiYnx7gwAy8A,92
5
- sefrone_api_e2e-1.0.0.dist-info/top_level.txt,sha256=19WO3CsUWUiGtZBotT587N-tkxxjctKOPDEHWpHpS8M,16
6
- sefrone_api_e2e-1.0.0.dist-info/RECORD,,