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.
- core/__init__.py +0 -0
- core/base_page.py +94 -0
- core/base_test.py +27 -0
- core/browser_factory.py +30 -0
- core/playwright_manager.py +40 -0
- dist_pkg/__init__.py +5 -0
- dist_pkg/distribution.py +51 -0
- integrations/__init__.py +21 -0
- integrations/adapters.py +155 -0
- orchestration/__init__.py +5 -0
- orchestration/planner.py +137 -0
- pages/__init__.py +0 -0
- pages/dashboard_page.py +24 -0
- pages/login_page.py +31 -0
- pages/orangehrm_login_page.py +58 -0
- pages/saucedemo_inventory_page.py +100 -0
- pages/saucedemo_login_page.py +39 -0
- plugins/__init__.py +16 -0
- plugins/api_plugin.py +11 -0
- plugins/base.py +21 -0
- plugins/data_plugin.py +11 -0
- plugins/registry.py +77 -0
- plugins/security_plugin.py +11 -0
- pyplaykit-0.1.0.dist-info/METADATA +340 -0
- pyplaykit-0.1.0.dist-info/RECORD +42 -0
- pyplaykit-0.1.0.dist-info/WHEEL +5 -0
- pyplaykit-0.1.0.dist-info/top_level.txt +8 -0
- resilience/__init__.py +5 -0
- resilience/locator_recovery.py +72 -0
- utils/__init__.py +0 -0
- utils/api_client.py +125 -0
- utils/assertion_helper.py +21 -0
- utils/data_loader.py +36 -0
- utils/data_validator.py +426 -0
- utils/environment_validator.py +105 -0
- utils/file_reader.py +35 -0
- utils/logger.py +36 -0
- utils/observability.py +164 -0
- utils/response_validator.py +58 -0
- utils/screenshot_helper.py +17 -0
- utils/tdm.py +213 -0
- utils/yaml_config_reader.py +104 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
core/base_page.py,sha256=90M-QMTblKIU7RHILzR0ZPFRuVStaQ80cS9mzMERJxA,3978
|
|
3
|
+
core/base_test.py,sha256=xz4bD7V1mFkQ6S-ZEOzv9iQGBqrpRk1SWtyD9Jd2qSQ,779
|
|
4
|
+
core/browser_factory.py,sha256=_uuGdY-e4n39QzMOZDwzlp9R_8jopxCByq980tp2CLU,1067
|
|
5
|
+
core/playwright_manager.py,sha256=oFUBouOclpdSmn7Z2daxfbvdSKBz8EU_9Y9076ixKfo,1302
|
|
6
|
+
dist_pkg/__init__.py,sha256=KVhzkLGG8RdxNk9El-8JruA7L4WBljy7f5JtkgrMhHM,197
|
|
7
|
+
dist_pkg/distribution.py,sha256=RZA-XOGQrBpG6nXnIZeDvri_cRTL-Nv7TBZsevyxPqs,1598
|
|
8
|
+
integrations/__init__.py,sha256=82mpeM9c1NLsbMxylOpjdknnpnavDEePql40Bi3lnxY,466
|
|
9
|
+
integrations/adapters.py,sha256=3tlHQu0UD8mTE1iISpXhly4JzQ_BeYfg9xXZxIHsCXI,5449
|
|
10
|
+
orchestration/__init__.py,sha256=omP4kquqZ2lueb2xBN8Bu1F-zHfqYjsqlvPjQgje9hI,156
|
|
11
|
+
orchestration/planner.py,sha256=BPbfkTpnxaGkeqcdU0Vm-byOfNoOWAgfb2X4m7eqpXw,5933
|
|
12
|
+
pages/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
+
pages/dashboard_page.py,sha256=2ywYZOV1pcv7GrV9kGdPFmHtq9pE-yNCEycofkZljBE,849
|
|
14
|
+
pages/login_page.py,sha256=KOkRHRDLP5Xj7ClsM1Y1abOlzveoJigpRuXjAJVMXLw,1065
|
|
15
|
+
pages/orangehrm_login_page.py,sha256=h5weajsblILbPKQt6KFYh-vX2BZHAzDSZfj5BhexLic,2849
|
|
16
|
+
pages/saucedemo_inventory_page.py,sha256=ga1MngZPQqAGmA34i8zCRyErFOK-upvoaECh4Zk0GtA,4036
|
|
17
|
+
pages/saucedemo_login_page.py,sha256=X3P_pFfOeMlsRAO53kcQ0G0saVIRzODS_WFIawszL2s,1403
|
|
18
|
+
plugins/__init__.py,sha256=k8WKwbK-5HeQ0Jhgw6fRwRs6OrLym3jHU4iMsSFUaaI,418
|
|
19
|
+
plugins/api_plugin.py,sha256=qOnit0CI1N7BQKrFW4h8v03JUCiBzimw7TmoMdUGwLU,255
|
|
20
|
+
plugins/base.py,sha256=6kkRgGWMBoRaYi5Y7QwBjB72OxxqL5cASmPxED1HsM8,643
|
|
21
|
+
plugins/data_plugin.py,sha256=kihGM5eKblgdzzg7exd0jvNk3RB9YpkhG6P_NIid1NQ,259
|
|
22
|
+
plugins/registry.py,sha256=DoQ-UqRYnACWfn42CshwM6mZG3619HuWC-GCE53v_XE,2783
|
|
23
|
+
plugins/security_plugin.py,sha256=-rxIbVFe2vChLysmdpXN75YiHorb9vrnDuG1bS8LzF4,275
|
|
24
|
+
resilience/__init__.py,sha256=HSIH-phgvPmf_28n0mtEuVmPQT0lHWhjrdDro1LZoM4,222
|
|
25
|
+
resilience/locator_recovery.py,sha256=Dnz0s4hC4YXNEcdv1K-QaP_lEhZ3axTYmUh8-AVl4SI,2920
|
|
26
|
+
utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
27
|
+
utils/api_client.py,sha256=1IxoHGYeSkYfzuUiYmdP9RRkKSEDjhEgBG5rX9ARtpM,4826
|
|
28
|
+
utils/assertion_helper.py,sha256=0u2KNlxY4ZFVtuiVgbP57Uv8SjZ-QnxfHklwcnAydyg,837
|
|
29
|
+
utils/data_loader.py,sha256=h0mpoutvnfMnWwb1344VGxK6MmM_ChEWP_yriyehU2s,1147
|
|
30
|
+
utils/data_validator.py,sha256=H4zCvgN3X6SKoyjxIokPPuH7-RjGVcaP3447oVahB0g,17251
|
|
31
|
+
utils/environment_validator.py,sha256=GQ1BZRGKmgK8pbycISwG5kfy5woxpH1WJPBKfiiteH4,3594
|
|
32
|
+
utils/file_reader.py,sha256=lBd9TbGDJJDzJaDY71XFFHoorfB0pr6MCLg4WcS6v3s,1236
|
|
33
|
+
utils/logger.py,sha256=ExRn7PrgdHfUbS4bEao4WHJsNSD4UcvL_LzbIdq3JSk,973
|
|
34
|
+
utils/observability.py,sha256=-n-rsKL3OP-L3fXrQJ1OZLFCck7REuDN8qryyM1fBHM,6860
|
|
35
|
+
utils/response_validator.py,sha256=tIGN_R9jgUsza6XK7QbxuptV_QAdBRr8ewbDOQ-OLcA,2466
|
|
36
|
+
utils/screenshot_helper.py,sha256=C5oucA5Q4xyB1s-8mX65Im2oaoBSG2tiNV_jtRiq2qc,686
|
|
37
|
+
utils/tdm.py,sha256=0BNeKORfIDlLtcFFIJUew2FVEv5MFBkdTSH106ZOZCE,8215
|
|
38
|
+
utils/yaml_config_reader.py,sha256=neiri3N6QgzDtUD7buzHR7dYWo_2MbW0GE1YRpvPBQ8,4123
|
|
39
|
+
pyplaykit-0.1.0.dist-info/METADATA,sha256=C1zl3R8zedpkQSNTyiWvQQFePlRDovCsROqp_AAXXYg,10975
|
|
40
|
+
pyplaykit-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
41
|
+
pyplaykit-0.1.0.dist-info/top_level.txt,sha256=DROtyBRVqfRJXUjXj3TYe5SfSTmIVKm16b7YhMD6ZI0,72
|
|
42
|
+
pyplaykit-0.1.0.dist-info/RECORD,,
|
resilience/__init__.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Locator self-healing helpers with fallback chains and DOM retries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Callable
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def resolve_visible_locator(
|
|
11
|
+
*,
|
|
12
|
+
locator_getter: Callable[[str], Any],
|
|
13
|
+
visible_assertion: Callable[[Any], None],
|
|
14
|
+
primary_selector: str,
|
|
15
|
+
fallback_selectors: list[str] | None = None,
|
|
16
|
+
dom_retry_count: int = 0,
|
|
17
|
+
confidence_decay_per_hop: float = 0.15,
|
|
18
|
+
confidence_decay_per_retry: float = 0.1,
|
|
19
|
+
) -> tuple[Any, str, int, dict[str, Any]]:
|
|
20
|
+
"""Resolve the first visible locator using optional fallback selectors and retry loops."""
|
|
21
|
+
candidates = [primary_selector] + [selector for selector in (fallback_selectors or []) if selector]
|
|
22
|
+
if not candidates:
|
|
23
|
+
raise ValueError("At least one selector must be provided")
|
|
24
|
+
|
|
25
|
+
retries = max(0, int(dom_retry_count))
|
|
26
|
+
hop_decay = max(0.0, float(confidence_decay_per_hop))
|
|
27
|
+
retry_decay = max(0.0, float(confidence_decay_per_retry))
|
|
28
|
+
last_error: Exception | None = None
|
|
29
|
+
attempted_actions: list[dict[str, Any]] = []
|
|
30
|
+
|
|
31
|
+
for attempt in range(retries + 1):
|
|
32
|
+
for selector_index, selector in enumerate(candidates):
|
|
33
|
+
locator = locator_getter(selector)
|
|
34
|
+
try:
|
|
35
|
+
visible_assertion(locator)
|
|
36
|
+
confidence = max(0.0, min(1.0, 1.0 - (selector_index * hop_decay) - (attempt * retry_decay)))
|
|
37
|
+
event = {
|
|
38
|
+
"selected_selector": selector,
|
|
39
|
+
"attempt": attempt,
|
|
40
|
+
"selector_index": selector_index,
|
|
41
|
+
"confidence_score": round(confidence, 4),
|
|
42
|
+
"used_fallback": selector_index > 0,
|
|
43
|
+
"used_retry": attempt > 0,
|
|
44
|
+
"attempted_actions": attempted_actions,
|
|
45
|
+
}
|
|
46
|
+
return locator, selector, attempt, event
|
|
47
|
+
except Exception as error:
|
|
48
|
+
last_error = error
|
|
49
|
+
attempted_actions.append(
|
|
50
|
+
{
|
|
51
|
+
"attempt": attempt,
|
|
52
|
+
"selector": selector,
|
|
53
|
+
"error": str(error),
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
details = ", ".join(candidates)
|
|
58
|
+
raise AssertionError(
|
|
59
|
+
f"Unable to resolve visible element after {retries + 1} attempt(s) using selectors: {details}"
|
|
60
|
+
) from last_error
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def write_resilience_audit_event(audit_log_path: str, event: dict[str, Any]) -> str:
|
|
64
|
+
"""Append one resilience audit event as JSON line and return the file path."""
|
|
65
|
+
if not audit_log_path or not str(audit_log_path).strip():
|
|
66
|
+
raise ValueError("audit_log_path must be a non-empty string")
|
|
67
|
+
|
|
68
|
+
output = Path(audit_log_path)
|
|
69
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
with output.open("a", encoding="utf-8") as stream:
|
|
71
|
+
stream.write(json.dumps(event, sort_keys=True) + "\n")
|
|
72
|
+
return str(output.resolve())
|
utils/__init__.py
ADDED
|
File without changes
|
utils/api_client.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Reusable API client built on Python standard library for framework-level API testing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
from urllib.error import HTTPError, URLError
|
|
10
|
+
from urllib.parse import urlencode, urljoin
|
|
11
|
+
from urllib.request import Request, urlopen
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class ApiResponse:
|
|
16
|
+
"""Normalized API response wrapper for assertions and reporting."""
|
|
17
|
+
|
|
18
|
+
status_code: int
|
|
19
|
+
headers: dict[str, str]
|
|
20
|
+
text: str
|
|
21
|
+
elapsed_ms: float
|
|
22
|
+
|
|
23
|
+
def json(self) -> Any:
|
|
24
|
+
"""Parse response body as JSON."""
|
|
25
|
+
return json.loads(self.text) if self.text else {}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ApiClient:
|
|
29
|
+
"""Simple API client supporting retries and JSON payload handling."""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
base_url: str = "",
|
|
34
|
+
default_headers: dict[str, str] | None = None,
|
|
35
|
+
timeout_seconds: int = 30,
|
|
36
|
+
retries: int = 1,
|
|
37
|
+
retry_status_codes: set[int] | None = None,
|
|
38
|
+
logger=None,
|
|
39
|
+
) -> None:
|
|
40
|
+
self.base_url = base_url
|
|
41
|
+
self.default_headers = default_headers or {}
|
|
42
|
+
self.timeout_seconds = timeout_seconds
|
|
43
|
+
self.retries = retries
|
|
44
|
+
self.retry_status_codes = retry_status_codes or {429, 500, 502, 503, 504}
|
|
45
|
+
self.logger = logger
|
|
46
|
+
|
|
47
|
+
def request(
|
|
48
|
+
self,
|
|
49
|
+
method: str,
|
|
50
|
+
endpoint: str,
|
|
51
|
+
params: dict[str, Any] | None = None,
|
|
52
|
+
headers: dict[str, str] | None = None,
|
|
53
|
+
json_body: dict[str, Any] | list[Any] | None = None,
|
|
54
|
+
data: bytes | None = None,
|
|
55
|
+
) -> ApiResponse:
|
|
56
|
+
"""Send an HTTP request and return a normalized response."""
|
|
57
|
+
url = self._build_url(endpoint, params)
|
|
58
|
+
merged_headers = {**self.default_headers, **(headers or {})}
|
|
59
|
+
|
|
60
|
+
payload = data
|
|
61
|
+
if json_body is not None:
|
|
62
|
+
payload = json.dumps(json_body).encode("utf-8")
|
|
63
|
+
merged_headers.setdefault("Content-Type", "application/json")
|
|
64
|
+
|
|
65
|
+
request = Request(url=url, method=method.upper(), headers=merged_headers, data=payload)
|
|
66
|
+
|
|
67
|
+
attempt = 0
|
|
68
|
+
while attempt <= self.retries:
|
|
69
|
+
attempt += 1
|
|
70
|
+
start = time.perf_counter()
|
|
71
|
+
try:
|
|
72
|
+
if self.logger:
|
|
73
|
+
self.logger.info("API request %s %s (attempt %s)", method.upper(), url, attempt)
|
|
74
|
+
with urlopen(request, timeout=self.timeout_seconds) as response:
|
|
75
|
+
elapsed_ms = (time.perf_counter() - start) * 1000
|
|
76
|
+
body = response.read().decode("utf-8")
|
|
77
|
+
return ApiResponse(
|
|
78
|
+
status_code=response.getcode(),
|
|
79
|
+
headers={k: v for k, v in response.headers.items()},
|
|
80
|
+
text=body,
|
|
81
|
+
elapsed_ms=elapsed_ms,
|
|
82
|
+
)
|
|
83
|
+
except HTTPError as error:
|
|
84
|
+
elapsed_ms = (time.perf_counter() - start) * 1000
|
|
85
|
+
body = error.read().decode("utf-8") if error.fp else ""
|
|
86
|
+
response = ApiResponse(
|
|
87
|
+
status_code=error.code,
|
|
88
|
+
headers={k: v for k, v in error.headers.items()} if error.headers else {},
|
|
89
|
+
text=body,
|
|
90
|
+
elapsed_ms=elapsed_ms,
|
|
91
|
+
)
|
|
92
|
+
if self._should_retry(error.code, attempt):
|
|
93
|
+
continue
|
|
94
|
+
return response
|
|
95
|
+
except URLError as error:
|
|
96
|
+
if attempt <= self.retries:
|
|
97
|
+
continue
|
|
98
|
+
raise RuntimeError(f"Request failed after retries for URL '{url}': {error.reason}") from error
|
|
99
|
+
|
|
100
|
+
raise RuntimeError(f"Request retries exhausted for URL '{url}'.")
|
|
101
|
+
|
|
102
|
+
def get(self, endpoint: str, params: dict[str, Any] | None = None, headers: dict[str, str] | None = None) -> ApiResponse:
|
|
103
|
+
"""Shortcut for GET requests."""
|
|
104
|
+
return self.request("GET", endpoint, params=params, headers=headers)
|
|
105
|
+
|
|
106
|
+
def post(
|
|
107
|
+
self,
|
|
108
|
+
endpoint: str,
|
|
109
|
+
json_body: dict[str, Any] | list[Any] | None = None,
|
|
110
|
+
headers: dict[str, str] | None = None,
|
|
111
|
+
) -> ApiResponse:
|
|
112
|
+
"""Shortcut for POST requests with optional JSON body."""
|
|
113
|
+
return self.request("POST", endpoint, headers=headers, json_body=json_body)
|
|
114
|
+
|
|
115
|
+
def _build_url(self, endpoint: str, params: dict[str, Any] | None = None) -> str:
|
|
116
|
+
base = self.base_url.rstrip("/") + "/" if self.base_url else ""
|
|
117
|
+
url = urljoin(base, endpoint.lstrip("/")) if base else endpoint
|
|
118
|
+
if not params:
|
|
119
|
+
return url
|
|
120
|
+
query = urlencode(params, doseq=True)
|
|
121
|
+
separator = "&" if "?" in url else "?"
|
|
122
|
+
return f"{url}{separator}{query}"
|
|
123
|
+
|
|
124
|
+
def _should_retry(self, status_code: int, attempt: int) -> bool:
|
|
125
|
+
return attempt <= self.retries and status_code in self.retry_status_codes
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Reusable assertion helpers with enterprise-friendly failure messaging."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def assert_equal(actual, expected, message: str = "") -> None:
|
|
5
|
+
"""Assert equality with optional custom message."""
|
|
6
|
+
if actual != expected:
|
|
7
|
+
default_message = f"Expected '{expected}', but found '{actual}'."
|
|
8
|
+
raise AssertionError(message or default_message)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def assert_true(condition: bool, message: str) -> None:
|
|
12
|
+
"""Assert condition is true with custom message."""
|
|
13
|
+
if not condition:
|
|
14
|
+
raise AssertionError(message)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def assert_in(member, container, message: str = "") -> None:
|
|
18
|
+
"""Assert member exists in container with optional custom message."""
|
|
19
|
+
if member not in container:
|
|
20
|
+
default_message = f"Expected '{member}' to be present in '{container}'."
|
|
21
|
+
raise AssertionError(message or default_message)
|
utils/data_loader.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Data loading utilities for JSON and CSV with environment placeholder support."""
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
ENV_PATTERN = re.compile(r"^\$\{(?P<name>[A-Z0-9_]+)\}$")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _resolve_env_placeholders(value: Any) -> Any:
|
|
15
|
+
if isinstance(value, dict):
|
|
16
|
+
return {k: _resolve_env_placeholders(v) for k, v in value.items()}
|
|
17
|
+
if isinstance(value, list):
|
|
18
|
+
return [_resolve_env_placeholders(item) for item in value]
|
|
19
|
+
if isinstance(value, str):
|
|
20
|
+
match = ENV_PATTERN.match(value)
|
|
21
|
+
if match:
|
|
22
|
+
return os.getenv(match.group("name"), "")
|
|
23
|
+
return value
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load_json(file_path: str) -> Any:
|
|
27
|
+
"""Load and return JSON content from a file path."""
|
|
28
|
+
with Path(file_path).open("r", encoding="utf-8") as file:
|
|
29
|
+
return _resolve_env_placeholders(json.load(file))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def load_csv(file_path: str) -> list[dict[str, str]]:
|
|
33
|
+
"""Load and return CSV rows as list of dictionaries."""
|
|
34
|
+
with Path(file_path).open("r", encoding="utf-8", newline="") as file:
|
|
35
|
+
rows = list(csv.DictReader(file))
|
|
36
|
+
return _resolve_env_placeholders(rows)
|