sefrone-api-e2e 1.0.0__tar.gz → 1.0.2__tar.gz

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.
@@ -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
@@ -0,0 +1,384 @@
1
+ import yaml
2
+ import requests
3
+ import re
4
+ import os
5
+ import datetime
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Any, Dict, List
9
+
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
+
21
+ @staticmethod
22
+ def run_all_tests(folder_path, env_file_path, is_verbose=False):
23
+ print("\nRunning API E2E tests...")
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:
38
+ print(f"\n--- Running scenario: {filename} ---")
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
92
+ print("\nAPI E2E tests completed.")
93
+ ApiE2ETestsManager.print_summary(total_elapsed)
94
+
95
+ @staticmethod
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)
122
+
123
+ @staticmethod
124
+ def check_type(value, expected_type):
125
+ type_map = {
126
+ "string": str,
127
+ "number": (int, float),
128
+ "boolean": bool,
129
+ "datetime": str
130
+ }
131
+ if expected_type not in type_map:
132
+ raise ValueError(f"Unknown type in YAML: {expected_type}")
133
+ if expected_type == "datetime":
134
+ try:
135
+ datetime.datetime.fromisoformat(value)
136
+ return True
137
+ except Exception:
138
+ try:
139
+ if re.match(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{1,6}$", value):
140
+ datetime.datetime.strptime(value, "%Y-%m-%dT%H:%M:%S.%f")
141
+ return True
142
+ except Exception:
143
+ return False
144
+ return isinstance(value, type_map[expected_type])
145
+
146
+ @staticmethod
147
+ def validate_structure(data, expected):
148
+ if isinstance(expected, dict):
149
+ if not isinstance(data, dict):
150
+ raise AssertionError(f"Expected dict but got {type(data)}")
151
+ for k, v in expected.items():
152
+ if k not in data:
153
+ raise AssertionError(f"Missing key: {k} (Data keys: {list(data.keys())})")
154
+ ApiE2ETestsManager.validate_structure(data[k], v)
155
+ elif isinstance(expected, list):
156
+ if not isinstance(data, list):
157
+ raise AssertionError(f"Expected list but got {type(data)}")
158
+ if len(expected) > 0:
159
+ for item in data:
160
+ ApiE2ETestsManager.validate_structure(item, expected[0])
161
+ elif isinstance(expected, str):
162
+ if not ApiE2ETestsManager.check_type(data, expected):
163
+ raise AssertionError(f"Expected {expected}, got {data} ({type(data)})")
164
+ else:
165
+ raise ValueError(f"Invalid expected type: {expected}")
166
+
167
+ @staticmethod
168
+ def render_template(template_str, context, add_quotes=False):
169
+ def resolve_path(expr, ctx):
170
+ parts = expr.split(".")
171
+ val = ctx
172
+ for p in parts:
173
+ if isinstance(val, dict) and p in val:
174
+ val = val[p]
175
+ else:
176
+ raise KeyError(f"Cannot resolve '{expr}' in context: {p} not found")
177
+ return val
178
+
179
+ def replacer(match):
180
+ expr = match.group(1).strip()
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)
194
+
195
+ @staticmethod
196
+ def substitute_stored(expr, scenario_name):
197
+ def repl(match):
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
281
+
282
+ @staticmethod
283
+ def run_yaml_test(spec, scenario_name, is_verbose=False):
284
+ base_url = spec.get("base_url", "")
285
+ steps = spec.get("steps", [])
286
+ step_metrics = []
287
+
288
+ print(f"Running test: {spec.get('name', scenario_name)}")
289
+ print("=" * 60)
290
+ # use a session for connection pooling and to set a default timeout
291
+ session = requests.Session()
292
+ 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()
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", {})
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
345
+ print("=" * 60)
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
@@ -5,7 +5,7 @@ with open("README.md", "r", encoding="utf-8") as fh:
5
5
 
6
6
  setup(
7
7
  name="sefrone_api_e2e",
8
- version="1.0.0",
8
+ version="1.0.2",
9
9
  author="Sefrone",
10
10
  author_email="contact@sefrone.com",
11
11
  description="A Python package to provide e2e testing helpers for sefrone API projects",
@@ -21,5 +21,6 @@ setup(
21
21
  python_requires=">=3.7",
22
22
  install_requires=[
23
23
  "PyYAML>=6.0.1",
24
+ "requests"
24
25
  ],
25
26
  )
@@ -1,168 +0,0 @@
1
- import yaml
2
- import requests
3
- import re
4
- import os
5
- import datetime
6
-
7
- class ApiE2ETestsManager:
8
- @staticmethod
9
- def run_all_tests(folder_path, is_verbose=False):
10
- 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)
14
- print(f"\n--- Running scenario: {filename} ---")
15
- ApiE2ETestsManager.run_yaml_test(file_path, is_verbose)
16
- print("\nAPI E2E tests completed.")
17
-
18
- @staticmethod
19
- def load_yaml(path):
20
- with open(path, "r", encoding="utf-8") as f:
21
- return yaml.safe_load(f)
22
-
23
- @staticmethod
24
- def check_type(value, expected_type):
25
- """Check if value matches expected type keyword."""
26
- type_map = {
27
- "string": str,
28
- "number": (int, float),
29
- "boolean": bool,
30
- "datetime": str # basic check; could extend with parsing
31
- }
32
- if expected_type not in type_map:
33
- raise ValueError(f"Unknown type in YAML: {expected_type}")
34
- if expected_type == "datetime":
35
- try:
36
- datetime.datetime.fromisoformat(value)
37
- return True
38
- except Exception:
39
- try:
40
- if re.match(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{1,6}$", value):
41
- datetime.datetime.strptime(value, "%Y-%m-%dT%H:%M:%S.%f")
42
- return True
43
- except Exception:
44
- return False
45
- return isinstance(value, type_map[expected_type])
46
-
47
- @staticmethod
48
- def validate_structure(data, expected):
49
- """Recursively validate structure & type expectations."""
50
- if isinstance(expected, dict):
51
- if not isinstance(data, dict):
52
- raise AssertionError(f"Expected dict but got {type(data)}")
53
- for k, v in expected.items():
54
- if k not in data:
55
- raise AssertionError(f"Missing key: {k} (Data keys: {list(data.keys())})")
56
- ApiE2ETestsManager.validate_structure(data[k], v)
57
- elif isinstance(expected, list):
58
- if not isinstance(data, list):
59
- raise AssertionError(f"Expected list but got {type(data)}")
60
- if len(expected) > 0:
61
- for item in data:
62
- ApiE2ETestsManager.validate_structure(item, expected[0])
63
- elif isinstance(expected, str): # type keyword
64
- if not ApiE2ETestsManager.check_type(data, expected):
65
- raise AssertionError(f"Expected {expected}, got {data} ({type(data)})")
66
- else:
67
- raise ValueError(f"Invalid expected type: {expected}")
68
-
69
- @staticmethod
70
- def render_template(template_str, context, add_quotes=False):
71
- def resolve_path(expr, ctx):
72
- parts = expr.split(".")
73
- val = ctx
74
- for p in parts:
75
- if isinstance(val, dict) and p in val:
76
- val = val[p]
77
- else:
78
- raise KeyError(f"Cannot resolve '{expr}' in context: {p} not found")
79
- return val
80
-
81
- def replacer(match):
82
- 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)
99
-
100
- @staticmethod
101
- def substitute_stored(endpoint, stored):
102
- """Substitute {$stored.key} placeholders."""
103
- 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)
107
-
108
- @staticmethod
109
- def run_yaml_test(path, is_verbose=False):
110
- spec = ApiE2ETestsManager.load_yaml(path)
111
- base_url = spec.get("base_url", "")
112
- steps = spec.get("steps", [])
113
- stored = {}
114
-
115
- print(f"Running test: {spec['name']}")
116
- print("=" * 60)
117
-
118
- 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
- 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")
166
-
167
- print("=" * 60)
168
- print("🎉 All steps passed successfully!")