pyplaykit 0.1.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.
utils/observability.py ADDED
@@ -0,0 +1,164 @@
1
+ """Observability utilities for execution metrics, failure classification, and KPI summaries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+
10
+ class ObservabilityTracker:
11
+ """Track test execution outcomes and produce observability reports."""
12
+
13
+ def __init__(self) -> None:
14
+ self.results: list[dict[str, Any]] = []
15
+
16
+ @staticmethod
17
+ def classify_failure(message: str) -> str:
18
+ """Classify failure message into high-level category."""
19
+ normalized = (message or "").lower()
20
+ if any(token in normalized for token in ("timeout", "connection", "network", "dns", "refused")):
21
+ return "infrastructure"
22
+ if any(token in normalized for token in ("assert", "expected", "mismatch")):
23
+ return "test_logic"
24
+ if any(token in normalized for token in ("data", "schema", "json", "csv")):
25
+ return "test_data"
26
+ return "application"
27
+
28
+ def record_test_result(
29
+ self,
30
+ nodeid: str,
31
+ outcome: str,
32
+ duration_seconds: float,
33
+ failure_message: str = "",
34
+ ) -> None:
35
+ """Record one test result entry."""
36
+ entry = {
37
+ "nodeid": nodeid,
38
+ "outcome": outcome,
39
+ "duration_seconds": float(duration_seconds),
40
+ "failure_type": self.classify_failure(failure_message) if failure_message else None,
41
+ }
42
+ self.results.append(entry)
43
+
44
+ def summarize(self) -> dict[str, Any]:
45
+ """Compute execution summary metrics."""
46
+ total = len(self.results)
47
+ passed = sum(1 for item in self.results if item["outcome"] == "passed")
48
+ failed = sum(1 for item in self.results if item["outcome"] == "failed")
49
+ skipped = sum(1 for item in self.results if item["outcome"] == "skipped")
50
+ total_duration = sum(float(item["duration_seconds"]) for item in self.results)
51
+ pass_rate = round((passed / total) * 100, 2) if total else 0.0
52
+ average_duration = round(total_duration / total, 4) if total else 0.0
53
+ flaky_tests = self._identify_flaky_tests()
54
+
55
+ return {
56
+ "total": total,
57
+ "passed": passed,
58
+ "failed": failed,
59
+ "skipped": skipped,
60
+ "pass_rate": pass_rate,
61
+ "total_duration_seconds": round(total_duration, 4),
62
+ "average_duration_seconds": average_duration,
63
+ "flaky_tests": flaky_tests,
64
+ "flaky_test_count": len(flaky_tests),
65
+ }
66
+
67
+ def build_kpi_summary(self, automation_coverage: float | None = None) -> dict[str, Any]:
68
+ """Generate KPI-oriented summary for leadership reporting."""
69
+ summary = self.summarize()
70
+ denominator = summary["total"] if summary["total"] else 1
71
+ failure_rate = round((summary["failed"] / denominator) * 100, 2)
72
+ flaky_rate = round((summary["flaky_test_count"] / denominator) * 100, 2)
73
+ return {
74
+ "execution_success_rate": summary["pass_rate"],
75
+ "execution_failure_rate": failure_rate,
76
+ "flaky_test_rate": flaky_rate,
77
+ "mean_test_duration_seconds": summary["average_duration_seconds"],
78
+ "automation_coverage_percent": automation_coverage,
79
+ }
80
+
81
+ def write_json_report(
82
+ self,
83
+ summary_path: str,
84
+ kpi_path: str,
85
+ automation_coverage: float | None = None,
86
+ ) -> tuple[str, str]:
87
+ """Write summary and KPI reports to JSON files."""
88
+ summary = self.summarize()
89
+ kpi_summary = self.build_kpi_summary(automation_coverage=automation_coverage)
90
+
91
+ summary_output = Path(summary_path)
92
+ kpi_output = Path(kpi_path)
93
+ summary_output.parent.mkdir(parents=True, exist_ok=True)
94
+ kpi_output.parent.mkdir(parents=True, exist_ok=True)
95
+
96
+ summary_output.write_text(json.dumps(summary, indent=2), encoding="utf-8")
97
+ kpi_output.write_text(json.dumps(kpi_summary, indent=2), encoding="utf-8")
98
+ return str(summary_output.resolve()), str(kpi_output.resolve())
99
+
100
+ def build_persona_reports(self, automation_coverage: float | None = None) -> dict[str, dict[str, Any]]:
101
+ """Build persona-specific report payloads from shared execution state."""
102
+ summary = self.summarize()
103
+ kpi = self.build_kpi_summary(automation_coverage=automation_coverage)
104
+ failed_tests = [
105
+ {
106
+ "nodeid": item["nodeid"],
107
+ "failure_type": item["failure_type"],
108
+ "duration_seconds": item["duration_seconds"],
109
+ }
110
+ for item in self.results
111
+ if item["outcome"] == "failed"
112
+ ]
113
+
114
+ return {
115
+ "engineering": {
116
+ "report_type": "engineering",
117
+ "failed_tests": failed_tests,
118
+ "flaky_tests": summary["flaky_tests"],
119
+ "total_duration_seconds": summary["total_duration_seconds"],
120
+ },
121
+ "qa": {
122
+ "report_type": "qa_functional",
123
+ "total": summary["total"],
124
+ "passed": summary["passed"],
125
+ "failed": summary["failed"],
126
+ "skipped": summary["skipped"],
127
+ "pass_rate": summary["pass_rate"],
128
+ "flaky_test_count": summary["flaky_test_count"],
129
+ },
130
+ "leadership": {
131
+ "report_type": "leadership_kpi",
132
+ "execution_success_rate": kpi["execution_success_rate"],
133
+ "execution_failure_rate": kpi["execution_failure_rate"],
134
+ "flaky_test_rate": kpi["flaky_test_rate"],
135
+ "automation_coverage_percent": kpi["automation_coverage_percent"],
136
+ },
137
+ }
138
+
139
+ def write_persona_reports(
140
+ self,
141
+ engineering_path: str,
142
+ qa_path: str,
143
+ leadership_path: str,
144
+ automation_coverage: float | None = None,
145
+ ) -> dict[str, str]:
146
+ """Write engineering, QA, and leadership persona reports to JSON files."""
147
+ reports = self.build_persona_reports(automation_coverage=automation_coverage)
148
+ targets = {
149
+ "engineering": Path(engineering_path),
150
+ "qa": Path(qa_path),
151
+ "leadership": Path(leadership_path),
152
+ }
153
+
154
+ for key, output_path in targets.items():
155
+ output_path.parent.mkdir(parents=True, exist_ok=True)
156
+ output_path.write_text(json.dumps(reports[key], indent=2), encoding="utf-8")
157
+
158
+ return {name: str(path.resolve()) for name, path in targets.items()}
159
+
160
+ def _identify_flaky_tests(self) -> list[str]:
161
+ outcomes_by_test: dict[str, set[str]] = {}
162
+ for item in self.results:
163
+ outcomes_by_test.setdefault(item["nodeid"], set()).add(item["outcome"])
164
+ return sorted(nodeid for nodeid, outcomes in outcomes_by_test.items() if {"passed", "failed"}.issubset(outcomes))
@@ -0,0 +1,58 @@
1
+ """Reusable response validation helpers for API testing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from utils.api_client import ApiResponse
8
+
9
+
10
+ class ResponseValidator:
11
+ """Collection of assertion-style helpers for API response checks."""
12
+
13
+ @staticmethod
14
+ def assert_status_code(response: ApiResponse, expected_status: int) -> None:
15
+ """Assert response status code equals expected value."""
16
+ if response.status_code != expected_status:
17
+ raise AssertionError(
18
+ f"Expected status code {expected_status}, found {response.status_code}. Response body: {response.text}"
19
+ )
20
+
21
+ @staticmethod
22
+ def assert_json_has_keys(response: ApiResponse, required_keys: list[str]) -> None:
23
+ """Assert response JSON object contains required top-level keys."""
24
+ payload = response.json()
25
+ missing = [key for key in required_keys if key not in payload]
26
+ if missing:
27
+ raise AssertionError(f"Response JSON is missing required keys: {missing}. Payload: {payload}")
28
+
29
+ @staticmethod
30
+ def assert_json_value(response: ApiResponse, path: str, expected_value: Any) -> None:
31
+ """Assert JSON value at dot-notation path equals expected value."""
32
+ actual_value = ResponseValidator._read_path(response.json(), path)
33
+ if actual_value != expected_value:
34
+ raise AssertionError(
35
+ f"Expected JSON path '{path}' to be '{expected_value}', found '{actual_value}'."
36
+ )
37
+
38
+ @staticmethod
39
+ def assert_response_time_less_than(response: ApiResponse, max_elapsed_ms: float) -> None:
40
+ """Assert API response duration is within threshold."""
41
+ if response.elapsed_ms > max_elapsed_ms:
42
+ raise AssertionError(
43
+ f"Expected response time <= {max_elapsed_ms}ms, found {response.elapsed_ms:.2f}ms."
44
+ )
45
+
46
+ @staticmethod
47
+ def _read_path(payload: Any, path: str) -> Any:
48
+ """Read nested JSON data by dot-notation path, supporting list indexes."""
49
+ current = payload
50
+ for token in path.split("."):
51
+ if isinstance(current, list):
52
+ index = int(token)
53
+ current = current[index]
54
+ continue
55
+ if not isinstance(current, dict) or token not in current:
56
+ raise AssertionError(f"JSON path '{path}' not found in payload: {payload}")
57
+ current = current[token]
58
+ return current
@@ -0,0 +1,17 @@
1
+ """Screenshot helper utilities for failure diagnostics."""
2
+
3
+ from datetime import UTC, datetime
4
+ from pathlib import Path
5
+ import re
6
+
7
+ from playwright.sync_api import Page
8
+
9
+
10
+ def capture_screenshot(page: Page, test_name: str, output_dir: str = "reports/screenshots") -> str:
11
+ """Capture and persist screenshot; return absolute file path."""
12
+ timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S_%f")
13
+ Path(output_dir).mkdir(parents=True, exist_ok=True)
14
+ safe_test_name = re.sub(r"[^a-zA-Z0-9_.-]", "_", test_name)
15
+ file_path = Path(output_dir) / f"{safe_test_name}_{timestamp}.png"
16
+ page.screenshot(path=str(file_path), full_page=True)
17
+ return str(file_path.resolve())
utils/tdm.py ADDED
@@ -0,0 +1,213 @@
1
+ """Test Data Management (TDM) utilities for synthetic data, masking, reset, and provisioning."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from typing import Any, Callable
7
+
8
+
9
+ _SUPPORTED_SYNTHETIC_TYPES = {"string", "int", "float", "bool", "email"}
10
+ _SUPPORTED_MASKING_STRATEGIES = {"full", "partial"}
11
+ _SUPPORTED_RESET_MODES = {"none", "list", "dict", "callable"}
12
+
13
+
14
+ def generate_synthetic_record(
15
+ schema: dict[str, str],
16
+ seed: str = "default",
17
+ ) -> dict[str, Any]:
18
+ """Generate deterministic synthetic data based on schema and seed.
19
+
20
+ Supported schema value types: string, int, float, bool, email.
21
+ """
22
+ record: dict[str, Any] = {}
23
+ for field, field_type in schema.items():
24
+ normalized_type = field_type.strip().lower()
25
+ basis = _stable_number(f"{seed}:{field}")
26
+
27
+ if normalized_type == "string":
28
+ record[field] = f"{field}_{basis % 10000}"
29
+ elif normalized_type == "int":
30
+ record[field] = basis % 10000
31
+ elif normalized_type == "float":
32
+ record[field] = round((basis % 100000) / 100.0, 2)
33
+ elif normalized_type == "bool":
34
+ record[field] = bool(basis % 2)
35
+ elif normalized_type == "email":
36
+ record[field] = f"user{basis % 10000}@example.test"
37
+ else:
38
+ raise ValueError(f"Unsupported synthetic field type: '{field_type}'")
39
+
40
+ return record
41
+
42
+
43
+ def mask_value(
44
+ value: Any,
45
+ strategy: str = "partial",
46
+ visible_prefix: int = 2,
47
+ visible_suffix: int = 2,
48
+ mask_char: str = "*",
49
+ ) -> str:
50
+ """Mask a value using full or partial strategy."""
51
+ text = "" if value is None else str(value)
52
+ normalized_strategy = strategy.strip().lower()
53
+
54
+ if normalized_strategy == "full":
55
+ return mask_char * len(text)
56
+
57
+ if normalized_strategy == "partial":
58
+ if len(text) <= visible_prefix + visible_suffix:
59
+ return mask_char * len(text)
60
+ middle_len = len(text) - (visible_prefix + visible_suffix)
61
+ return f"{text[:visible_prefix]}{mask_char * middle_len}{text[-visible_suffix:]}"
62
+
63
+ raise ValueError(f"Unsupported masking strategy: '{strategy}'")
64
+
65
+
66
+ def mask_record(
67
+ record: dict[str, Any],
68
+ field_rules: dict[str, dict[str, Any]],
69
+ ) -> dict[str, Any]:
70
+ """Mask selected fields in a record according to field-level rules."""
71
+ masked = dict(record)
72
+ for field, rules in field_rules.items():
73
+ if field not in masked:
74
+ continue
75
+ masked[field] = mask_value(
76
+ masked[field],
77
+ strategy=rules.get("strategy", "partial"),
78
+ visible_prefix=int(rules.get("visible_prefix", 2)),
79
+ visible_suffix=int(rules.get("visible_suffix", 2)),
80
+ mask_char=str(rules.get("mask_char", "*")),
81
+ )
82
+ return masked
83
+
84
+
85
+ def reset_data_store(store: Any) -> bool:
86
+ """Reset in-memory data store structures used by tests.
87
+
88
+ Supported stores:
89
+ - list: cleared in place
90
+ - dict: cleared in place
91
+ - callable: invoked and expected to return truthy on success
92
+ """
93
+ if isinstance(store, list):
94
+ store.clear()
95
+ return True
96
+
97
+ if isinstance(store, dict):
98
+ store.clear()
99
+ return True
100
+
101
+ if callable(store):
102
+ return bool(store())
103
+
104
+ raise TypeError(f"Unsupported data store type for reset: {type(store)}")
105
+
106
+
107
+ def provision_environment_data(
108
+ environment: str,
109
+ provisioners: dict[str, Callable[[], dict[str, Any]]],
110
+ default_provisioner: Callable[[], dict[str, Any]] | None = None,
111
+ ) -> dict[str, Any]:
112
+ """Provision environment-specific data using configured provisioners."""
113
+ key = environment.strip().lower()
114
+ if key in provisioners:
115
+ return provisioners[key]()
116
+
117
+ if default_provisioner is not None:
118
+ return default_provisioner()
119
+
120
+ supported = ", ".join(sorted(provisioners.keys()))
121
+ raise KeyError(f"No provisioner found for environment '{environment}'. Supported: {supported}")
122
+
123
+
124
+ def _stable_number(text: str) -> int:
125
+ digest = hashlib.sha256(text.encode("utf-8")).hexdigest()
126
+ return int(digest[:12], 16)
127
+
128
+
129
+ def parse_tdm_policy(raw_policy: dict[str, Any] | None, environment: str | None = None) -> dict[str, Any]:
130
+ """Parse and normalize a TDM policy loaded from configuration."""
131
+ source = dict(raw_policy or {})
132
+ parsed = {
133
+ "enabled": bool(source.get("enabled", False)),
134
+ "synthetic_schema": dict(source.get("synthetic_schema", {})),
135
+ "masking_rules": dict(source.get("masking_rules", {})),
136
+ "reset_mode": str(source.get("reset_mode", "none")).strip().lower(),
137
+ "provisioning_by_env": {
138
+ str(env_key).strip().lower(): str(profile).strip()
139
+ for env_key, profile in dict(source.get("provisioning_by_env", {})).items()
140
+ },
141
+ "default_provisioner": source.get("default_provisioner"),
142
+ }
143
+
144
+ env_key = (environment or "").strip().lower()
145
+ active_profile = parsed["provisioning_by_env"].get(env_key)
146
+ if active_profile is None and parsed["default_provisioner"] is not None:
147
+ active_profile = str(parsed["default_provisioner"]).strip()
148
+ parsed["active_provisioner"] = active_profile
149
+
150
+ return validate_tdm_policy(parsed)
151
+
152
+
153
+ def validate_tdm_policy(policy: dict[str, Any]) -> dict[str, Any]:
154
+ """Validate normalized TDM policy and raise clear errors for invalid values."""
155
+ schema = policy.get("synthetic_schema", {})
156
+ if not isinstance(schema, dict):
157
+ raise ValueError("tdm.synthetic_schema must be a dictionary")
158
+ for field, field_type in schema.items():
159
+ normalized_type = str(field_type).strip().lower()
160
+ if normalized_type not in _SUPPORTED_SYNTHETIC_TYPES:
161
+ raise ValueError(
162
+ f"Unsupported synthetic field type '{field_type}' for field '{field}'. "
163
+ f"Supported: {sorted(_SUPPORTED_SYNTHETIC_TYPES)}"
164
+ )
165
+
166
+ masking_rules = policy.get("masking_rules", {})
167
+ if not isinstance(masking_rules, dict):
168
+ raise ValueError("tdm.masking_rules must be a dictionary")
169
+ for field, rules in masking_rules.items():
170
+ if not isinstance(rules, dict):
171
+ raise ValueError(f"tdm.masking_rules['{field}'] must be a dictionary")
172
+
173
+ strategy = str(rules.get("strategy", "partial")).strip().lower()
174
+ if strategy not in _SUPPORTED_MASKING_STRATEGIES:
175
+ raise ValueError(
176
+ f"Unsupported masking strategy '{strategy}' for field '{field}'. "
177
+ f"Supported: {sorted(_SUPPORTED_MASKING_STRATEGIES)}"
178
+ )
179
+
180
+ for key in ("visible_prefix", "visible_suffix"):
181
+ if key in rules and int(rules[key]) < 0:
182
+ raise ValueError(f"tdm.masking_rules['{field}'].{key} must be >= 0")
183
+
184
+ reset_mode = str(policy.get("reset_mode", "none")).strip().lower()
185
+ if reset_mode not in _SUPPORTED_RESET_MODES:
186
+ raise ValueError(
187
+ f"Unsupported reset_mode '{reset_mode}'. "
188
+ f"Supported: {sorted(_SUPPORTED_RESET_MODES)}"
189
+ )
190
+
191
+ provisioning_by_env = policy.get("provisioning_by_env", {})
192
+ if not isinstance(provisioning_by_env, dict):
193
+ raise ValueError("tdm.provisioning_by_env must be a dictionary")
194
+ for env_key, profile in provisioning_by_env.items():
195
+ if not str(profile).strip():
196
+ raise ValueError(f"tdm.provisioning_by_env['{env_key}'] must not be empty")
197
+
198
+ default_provisioner = policy.get("default_provisioner")
199
+ if default_provisioner is not None and not str(default_provisioner).strip():
200
+ raise ValueError("tdm.default_provisioner must not be empty when provided")
201
+
202
+ policy["enabled"] = bool(policy.get("enabled", False))
203
+ policy["synthetic_schema"] = {
204
+ str(field): str(field_type).strip().lower() for field, field_type in schema.items()
205
+ }
206
+ policy["masking_rules"] = masking_rules
207
+ policy["reset_mode"] = reset_mode
208
+ policy["provisioning_by_env"] = {
209
+ str(env_key).strip().lower(): str(profile).strip() for env_key, profile in provisioning_by_env.items()
210
+ }
211
+ policy["default_provisioner"] = None if default_provisioner is None else str(default_provisioner).strip()
212
+ policy["active_provisioner"] = policy.get("active_provisioner")
213
+ return policy
@@ -0,0 +1,104 @@
1
+ """
2
+ Module for reading and managing framework configuration.
3
+ This includes loading global settings from config.yaml and environment-specific
4
+ settings from environments.yaml.
5
+ """
6
+
7
+ import yaml
8
+ import os
9
+ from pathlib import Path
10
+ from typing import Any, Dict, Optional
11
+
12
+ class ConfigReader:
13
+ """
14
+ Handles reading configuration from config.yaml and environments.yaml.
15
+ Provides methods to access framework settings and environment-specific data.
16
+ """
17
+ _instance = None
18
+ _config: Dict[str, Any] = {}
19
+ _environments: Dict[str, Any] = {}
20
+ _current_env: str = "qa" # Default environment
21
+
22
+ def __new__(cls, config_file: str = "config.yaml", env_file: str = "environments.yaml"):
23
+ if cls._instance is None:
24
+ cls._instance = super(ConfigReader, cls).__new__(cls)
25
+ cls._instance._load_configs(config_file, env_file)
26
+ return cls._instance
27
+
28
+ def _load_configs(self, config_file: str, env_file: str):
29
+ """Loads configuration from specified YAML files."""
30
+ config_path = Path(os.path.join(os.path.dirname(__file__), "..", "config", config_file))
31
+ env_path = Path(os.path.join(os.path.dirname(__file__), "..", "config", env_file))
32
+
33
+ if not config_path.is_file():
34
+ raise FileNotFoundError(f"Config file not found: {config_path}")
35
+ if not env_path.is_file():
36
+ raise FileNotFoundError(f"Environments file not found: {env_path}")
37
+
38
+ with config_path.open("r", encoding="utf-8") as f:
39
+ self._config = yaml.safe_load(f)
40
+
41
+ with env_path.open("r", encoding="utf-8") as f:
42
+ self._environments = yaml.safe_load(f)
43
+
44
+ # Set initial environment from config.yaml, can be overridden by pytest --pyplaykit-env
45
+ self._current_env = self._config.get("execution", {}).get("environment", "qa")
46
+
47
+ def get(self, key: str, default: Any = None) -> Any:
48
+ """
49
+ Retrieves a configuration value from config.yaml.
50
+ Supports dot notation for nested keys (e.g., "execution.browser").
51
+ """
52
+ keys = key.split('.')
53
+ val = self._config
54
+ for k in keys:
55
+ if isinstance(val, dict):
56
+ val = val.get(k, default)
57
+ else:
58
+ return default
59
+ return val
60
+
61
+ def get_environment_settings(self, env: Optional[str] = None) -> Dict[str, Any]:
62
+ """
63
+ Retrieves all settings for a specific environment.
64
+ If env is None, uses the current environment.
65
+ """
66
+ target_env = env if env else self.get_env()
67
+ return self._environments.get("environments", {}).get(target_env, {})
68
+
69
+ def get_base_url(self, env: Optional[str] = None) -> str:
70
+ """Retrieves the base URL for a specific environment."""
71
+ settings = self.get_environment_settings(env)
72
+ return settings.get("base_url", "")
73
+
74
+ def get_env(self) -> str:
75
+ """Returns the current active environment."""
76
+ return self._current_env
77
+
78
+ def set_env(self, env: str):
79
+ """Sets the current active environment (e.g., from a pytest fixture)."""
80
+ self._current_env = env
81
+
82
+ def get_browser(self) -> str:
83
+ """Retrieves the browser type from config.yaml."""
84
+ return self.get("execution.browser", "chromium")
85
+
86
+ def get_headless_mode(self) -> bool:
87
+ """Retrieves the headless mode setting from config.yaml."""
88
+ return self.get("execution.headless", True)
89
+
90
+ def get_timeout(self) -> int:
91
+ """Retrieves the default timeout from config.yaml."""
92
+ return self.get("execution.timeout_ms", 30000)
93
+
94
+ def get_navigation_timeout(self) -> int:
95
+ """Retrieves the navigation timeout from config.yaml."""
96
+ return self.get("execution.navigation_timeout_ms", 45000)
97
+
98
+ def get_screenshot_on_failure(self) -> bool:
99
+ """Retrieves the screenshot on failure setting from config.yaml."""
100
+ return self.get("execution.screenshot_on_failure", True)
101
+
102
+ def get_video_on_failure(self) -> bool:
103
+ """Retrieves the video on failure setting from config.yaml."""
104
+ return self.get("execution.video_on_failure", True)