keres-sdk 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.
- keres_sdk/__init__.py +38 -0
- keres_sdk/_errors.py +115 -0
- keres_sdk/_expect.py +236 -0
- keres_sdk/_operations.py +604 -0
- keres_sdk/_protocol.py +94 -0
- keres_sdk/_resolution.py +91 -0
- keres_sdk/_transport.py +233 -0
- keres_sdk/async_api.py +113 -0
- keres_sdk/browser.py +118 -0
- keres_sdk/locator.py +182 -0
- keres_sdk/page.py +462 -0
- keres_sdk/py.typed +0 -0
- keres_sdk/sync_api.py +498 -0
- keres_sdk-0.1.0.dist-info/METADATA +121 -0
- keres_sdk-0.1.0.dist-info/RECORD +17 -0
- keres_sdk-0.1.0.dist-info/WHEEL +5 -0
- keres_sdk-0.1.0.dist-info/top_level.txt +1 -0
keres_sdk/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""keres-sdk — Playwright-compatible automation client for Keres Browser.
|
|
2
|
+
|
|
3
|
+
Connects to the local Keres Automation Gateway; Keres owns profiles, sessions,
|
|
4
|
+
and the browser runtime. See ``sync_api`` / ``async_api`` for entry points.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from ._errors import (
|
|
8
|
+
ConnectionError,
|
|
9
|
+
KeresError,
|
|
10
|
+
PermissionDeniedError,
|
|
11
|
+
ProfileError,
|
|
12
|
+
ServerBusyError,
|
|
13
|
+
TargetClosedError,
|
|
14
|
+
TimeoutError,
|
|
15
|
+
UnsupportedCapabilityError,
|
|
16
|
+
from_gateway_error,
|
|
17
|
+
)
|
|
18
|
+
from .sync_api import keres
|
|
19
|
+
|
|
20
|
+
__version__ = "0.1.0"
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"keres",
|
|
24
|
+
"KeresError",
|
|
25
|
+
"ConnectionError",
|
|
26
|
+
"TimeoutError",
|
|
27
|
+
"UnsupportedCapabilityError",
|
|
28
|
+
"PermissionDeniedError",
|
|
29
|
+
"TargetClosedError",
|
|
30
|
+
"ProfileError",
|
|
31
|
+
"ServerBusyError",
|
|
32
|
+
"from_gateway_error",
|
|
33
|
+
"__version__",
|
|
34
|
+
]
|
|
35
|
+
from ._expect import expect, KeresExpectationError
|
|
36
|
+
|
|
37
|
+
__all__ = [name for name in dir() if not name.startswith("_")]
|
|
38
|
+
|
keres_sdk/_errors.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Typed errors for the keres-playwright client.
|
|
2
|
+
|
|
3
|
+
Error names follow Playwright conventions (TimeoutError, ...) so existing
|
|
4
|
+
`try/except` code keeps working, while every error carries the original
|
|
5
|
+
structured Keres code under ``code`` for precise handling.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from ._protocol import ErrorShape
|
|
13
|
+
|
|
14
|
+
_CAPABILITY_CODES = frozenset(
|
|
15
|
+
{
|
|
16
|
+
"CAPABILITY_UNSUPPORTED",
|
|
17
|
+
"ENGINE_FEATURE_UNSUPPORTED",
|
|
18
|
+
"ENGINE_PROTOCOL_UNSUPPORTED",
|
|
19
|
+
"ENGINE_VARIANT_UNAVAILABLE",
|
|
20
|
+
}
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
_PROFILE_CODES = frozenset(
|
|
24
|
+
{
|
|
25
|
+
"PROFILE_NOT_FOUND",
|
|
26
|
+
"PROFILE_ACTIVE",
|
|
27
|
+
"PROFILE_LOCKED",
|
|
28
|
+
"PROFILE_DATA_MISSING",
|
|
29
|
+
"PROFILE_DATA_CORRUPTED",
|
|
30
|
+
"INVALID_PROFILE",
|
|
31
|
+
}
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
_TARGET_CLOSED_CODES = frozenset(
|
|
35
|
+
{
|
|
36
|
+
"SESSION_CLOSED",
|
|
37
|
+
"SESSION_NOT_READY",
|
|
38
|
+
"SESSION_INTERRUPTED",
|
|
39
|
+
"PAGE_CLOSED",
|
|
40
|
+
"PAGE_NOT_FOUND",
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class KeresError(Exception):
|
|
46
|
+
"""Base class for every error raised by this client."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, shape: ErrorShape) -> None:
|
|
49
|
+
super().__init__(shape.message)
|
|
50
|
+
self.name = "KeresError"
|
|
51
|
+
self.code = shape.code
|
|
52
|
+
self.category = shape.category
|
|
53
|
+
self.recoverable = shape.recoverable
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ConnectionError(KeresError):
|
|
57
|
+
def __init__(self, message: str) -> None:
|
|
58
|
+
super().__init__(
|
|
59
|
+
ErrorShape(code="GATEWAY_CONNECTION_FAILED", message=message, category="GATEWAY")
|
|
60
|
+
)
|
|
61
|
+
self.name = "ConnectionError"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class TimeoutError(KeresError):
|
|
65
|
+
def __init__(self, shape: ErrorShape) -> None:
|
|
66
|
+
super().__init__(shape)
|
|
67
|
+
self.name = "TimeoutError"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class UnsupportedCapabilityError(KeresError):
|
|
71
|
+
def __init__(self, shape: ErrorShape) -> None:
|
|
72
|
+
super().__init__(shape)
|
|
73
|
+
self.name = "UnsupportedCapabilityError"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class PermissionDeniedError(KeresError):
|
|
77
|
+
def __init__(self, shape: ErrorShape) -> None:
|
|
78
|
+
super().__init__(shape)
|
|
79
|
+
self.name = "PermissionDeniedError"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class TargetClosedError(KeresError):
|
|
83
|
+
def __init__(self, shape: ErrorShape) -> None:
|
|
84
|
+
super().__init__(shape)
|
|
85
|
+
self.name = "TargetClosedError"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class ProfileError(KeresError):
|
|
89
|
+
def __init__(self, shape: ErrorShape) -> None:
|
|
90
|
+
super().__init__(shape)
|
|
91
|
+
self.name = "ProfileError"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class ServerBusyError(KeresError):
|
|
95
|
+
def __init__(self, shape: ErrorShape) -> None:
|
|
96
|
+
super().__init__(shape)
|
|
97
|
+
self.name = "ServerBusyError"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def from_gateway_error(error: ErrorShape) -> KeresError:
|
|
101
|
+
"""Maps a structured gateway error onto the typed surface above."""
|
|
102
|
+
code = error.code
|
|
103
|
+
if code.endswith("_TIMEOUT"):
|
|
104
|
+
return TimeoutError(error)
|
|
105
|
+
if code in _CAPABILITY_CODES:
|
|
106
|
+
return UnsupportedCapabilityError(error)
|
|
107
|
+
if code in ("GATEWAY_PERMISSION_DENIED", "SESSION_PERMISSION_DENIED"):
|
|
108
|
+
return PermissionDeniedError(error)
|
|
109
|
+
if code == "GATEWAY_SERVER_BUSY":
|
|
110
|
+
return ServerBusyError(error)
|
|
111
|
+
if code in _PROFILE_CODES:
|
|
112
|
+
return ProfileError(error)
|
|
113
|
+
if code in _TARGET_CLOSED_CODES:
|
|
114
|
+
return TargetClosedError(error)
|
|
115
|
+
return KeresError(error)
|
keres_sdk/_expect.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Web-first auto-retrying assertions (Playwright ``expect`` shape).
|
|
2
|
+
|
|
3
|
+
Polling is client-side over governed read operations — no new wire semantics.
|
|
4
|
+
Works from both API flavors: probes transparently await async locator/page
|
|
5
|
+
methods when driven through :mod:`keres_playwright.async_api`, and run
|
|
6
|
+
synchronously through the sync facade.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import fnmatch
|
|
13
|
+
import inspect
|
|
14
|
+
import time
|
|
15
|
+
from typing import Any, Callable, Optional
|
|
16
|
+
|
|
17
|
+
DEFAULT_TIMEOUT_MS = 5_000
|
|
18
|
+
DEFAULT_INTERVAL_MS = 100
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class KeresExpectationError(AssertionError):
|
|
22
|
+
"""Raised when an assertion does not pass within its deadline."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, assertion: str, expected: str, received: str) -> None:
|
|
25
|
+
super().__init__(
|
|
26
|
+
f"expect({assertion}) failed\n\nExpected: {expected}\nReceived: {received}"
|
|
27
|
+
)
|
|
28
|
+
self.assertion = assertion
|
|
29
|
+
self.expected = expected
|
|
30
|
+
self.received = received
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def _poll_until(
|
|
34
|
+
assertion: str,
|
|
35
|
+
expected_description: str,
|
|
36
|
+
deadline_ms: float,
|
|
37
|
+
interval_ms: float,
|
|
38
|
+
probe: Callable[[], Any],
|
|
39
|
+
) -> None:
|
|
40
|
+
"""``probe`` returns an ``(ok, received)`` tuple or an awaitable of one."""
|
|
41
|
+
started_ms = time.monotonic() * 1000
|
|
42
|
+
received = "unknown"
|
|
43
|
+
while time.monotonic() * 1000 - started_ms < deadline_ms:
|
|
44
|
+
result = probe()
|
|
45
|
+
if inspect.isawaitable(result):
|
|
46
|
+
result = await result
|
|
47
|
+
ok, received = result
|
|
48
|
+
if ok:
|
|
49
|
+
return
|
|
50
|
+
await asyncio.sleep(interval_ms / 1000)
|
|
51
|
+
raise KeresExpectationError(assertion, expected_description, received)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _bool_probe(value: Any) -> Any:
|
|
55
|
+
"""Adapts a sync-or-async boolean into an ``(ok, received)`` tuple."""
|
|
56
|
+
if inspect.isawaitable(value):
|
|
57
|
+
|
|
58
|
+
async def runner() -> tuple:
|
|
59
|
+
got = bool(await value)
|
|
60
|
+
return got, str(got)
|
|
61
|
+
|
|
62
|
+
return runner()
|
|
63
|
+
got = bool(value)
|
|
64
|
+
return got, str(got)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _text_probe(value: Any, predicate: Callable[[str], bool]) -> Any:
|
|
68
|
+
"""Adapts a sync-or-async text into a predicate-checked tuple."""
|
|
69
|
+
if inspect.isawaitable(value):
|
|
70
|
+
|
|
71
|
+
async def runner() -> tuple:
|
|
72
|
+
text = (await value) or ""
|
|
73
|
+
return predicate(text), text
|
|
74
|
+
|
|
75
|
+
return runner()
|
|
76
|
+
text = value or ""
|
|
77
|
+
return predicate(text), text
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _opts(options: Optional[dict]) -> dict:
|
|
81
|
+
return options or {}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def expect(target: Any) -> Any:
|
|
85
|
+
"""Playwright-style entry point. Returns an assertion object."""
|
|
86
|
+
if target.__class__.__name__ in ("Page", "SyncPage"):
|
|
87
|
+
return PageAssertions(target)
|
|
88
|
+
return LocatorAssertions(target)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class LocatorAssertions:
|
|
92
|
+
def __init__(self, locator: Any) -> None:
|
|
93
|
+
self._locator = locator
|
|
94
|
+
|
|
95
|
+
def to_be_visible(self, options: Optional[dict] = None) -> Any:
|
|
96
|
+
o = _opts(options)
|
|
97
|
+
return _poll_until(
|
|
98
|
+
"locator.to_be_visible",
|
|
99
|
+
"visible",
|
|
100
|
+
o.get("timeoutMs", DEFAULT_TIMEOUT_MS),
|
|
101
|
+
o.get("intervalMs", DEFAULT_INTERVAL_MS),
|
|
102
|
+
lambda: _bool_probe(self._locator.is_visible()),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def to_be_hidden(self, options: Optional[dict] = None) -> Any:
|
|
106
|
+
o = _opts(options)
|
|
107
|
+
|
|
108
|
+
def probe() -> Any:
|
|
109
|
+
value = self._locator.is_visible()
|
|
110
|
+
if inspect.isawaitable(value):
|
|
111
|
+
|
|
112
|
+
async def runner() -> tuple:
|
|
113
|
+
visible = bool(await value)
|
|
114
|
+
return not visible, "hidden" if not visible else "visible"
|
|
115
|
+
|
|
116
|
+
return runner()
|
|
117
|
+
return not value, "hidden" if not value else "visible"
|
|
118
|
+
|
|
119
|
+
return _poll_until(
|
|
120
|
+
"locator.to_be_hidden",
|
|
121
|
+
"hidden",
|
|
122
|
+
o.get("timeoutMs", DEFAULT_TIMEOUT_MS),
|
|
123
|
+
o.get("intervalMs", DEFAULT_INTERVAL_MS),
|
|
124
|
+
probe,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def to_be_enabled(self, options: Optional[dict] = None) -> Any:
|
|
128
|
+
o = _opts(options)
|
|
129
|
+
return _poll_until(
|
|
130
|
+
"locator.to_be_enabled",
|
|
131
|
+
"enabled",
|
|
132
|
+
o.get("timeoutMs", DEFAULT_TIMEOUT_MS),
|
|
133
|
+
o.get("intervalMs", DEFAULT_INTERVAL_MS),
|
|
134
|
+
lambda: _bool_probe(self._locator.is_enabled()),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def to_be_checked(self, expected: bool = True, options: Optional[dict] = None) -> Any:
|
|
138
|
+
o = _opts(options)
|
|
139
|
+
return _poll_until(
|
|
140
|
+
"locator.to_be_checked",
|
|
141
|
+
str(expected),
|
|
142
|
+
o.get("timeoutMs", DEFAULT_TIMEOUT_MS),
|
|
143
|
+
o.get("intervalMs", DEFAULT_INTERVAL_MS),
|
|
144
|
+
lambda: _bool_probe(self._locator.is_checked() == expected),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def to_be_editable(self, options: Optional[dict] = None) -> Any:
|
|
148
|
+
o = _opts(options)
|
|
149
|
+
return _poll_until(
|
|
150
|
+
"locator.to_be_editable",
|
|
151
|
+
"editable",
|
|
152
|
+
o.get("timeoutMs", DEFAULT_TIMEOUT_MS),
|
|
153
|
+
o.get("intervalMs", DEFAULT_INTERVAL_MS),
|
|
154
|
+
lambda: _bool_probe(self._locator.is_editable()),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def to_have_text(self, text: str, options: Optional[dict] = None) -> Any:
|
|
158
|
+
o = _opts(options)
|
|
159
|
+
exact = o.get("exact", True)
|
|
160
|
+
return _poll_until(
|
|
161
|
+
"locator.to_have_text",
|
|
162
|
+
text,
|
|
163
|
+
o.get("timeoutMs", DEFAULT_TIMEOUT_MS),
|
|
164
|
+
o.get("intervalMs", DEFAULT_INTERVAL_MS),
|
|
165
|
+
lambda: _text_probe(
|
|
166
|
+
self._locator.text_content(options=o),
|
|
167
|
+
(lambda actual: actual == text) if exact else (lambda actual: text in actual),
|
|
168
|
+
),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def to_contain_text(self, text: str, options: Optional[dict] = None) -> Any:
|
|
172
|
+
merged = dict(_opts(options))
|
|
173
|
+
merged["exact"] = False
|
|
174
|
+
return self.to_have_text(text, merged)
|
|
175
|
+
|
|
176
|
+
def to_have_value(self, value: str, options: Optional[dict] = None) -> Any:
|
|
177
|
+
o = _opts(options)
|
|
178
|
+
return _poll_until(
|
|
179
|
+
"locator.to_have_value",
|
|
180
|
+
value,
|
|
181
|
+
o.get("timeoutMs", DEFAULT_TIMEOUT_MS),
|
|
182
|
+
o.get("intervalMs", DEFAULT_INTERVAL_MS),
|
|
183
|
+
lambda: _text_probe(
|
|
184
|
+
self._locator.get_property("value", options=o),
|
|
185
|
+
lambda actual: str(actual) == value,
|
|
186
|
+
),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
def to_have_attribute(self, name: str, value: str, options: Optional[dict] = None) -> Any:
|
|
190
|
+
o = _opts(options)
|
|
191
|
+
return _poll_until(
|
|
192
|
+
"locator.to_have_attribute",
|
|
193
|
+
f'{name}="{value}"',
|
|
194
|
+
o.get("timeoutMs", DEFAULT_TIMEOUT_MS),
|
|
195
|
+
o.get("intervalMs", DEFAULT_INTERVAL_MS),
|
|
196
|
+
lambda: _text_probe(
|
|
197
|
+
self._locator.get_attribute(name, options=o),
|
|
198
|
+
lambda actual: str(actual) == value,
|
|
199
|
+
),
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class PageAssertions:
|
|
204
|
+
def __init__(self, page: Any) -> None:
|
|
205
|
+
self._page = page
|
|
206
|
+
|
|
207
|
+
def to_have_title(self, title: str, options: Optional[dict] = None) -> Any:
|
|
208
|
+
o = _opts(options)
|
|
209
|
+
exact = o.get("exact", True)
|
|
210
|
+
return _poll_until(
|
|
211
|
+
"page.to_have_title",
|
|
212
|
+
title,
|
|
213
|
+
o.get("timeoutMs", DEFAULT_TIMEOUT_MS),
|
|
214
|
+
o.get("intervalMs", DEFAULT_INTERVAL_MS),
|
|
215
|
+
lambda: _text_probe(
|
|
216
|
+
self._page.title(options=o),
|
|
217
|
+
(lambda actual: actual == title) if exact else (lambda actual: title in actual),
|
|
218
|
+
),
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
def to_have_url(self, pattern: str, options: Optional[dict] = None) -> Any:
|
|
222
|
+
o = _opts(options)
|
|
223
|
+
use_glob = o.get("glob", "*" in pattern)
|
|
224
|
+
|
|
225
|
+
def matcher(candidate: str) -> bool:
|
|
226
|
+
if use_glob:
|
|
227
|
+
return fnmatch.fnmatchcase(candidate, pattern)
|
|
228
|
+
return candidate == pattern
|
|
229
|
+
|
|
230
|
+
return _poll_until(
|
|
231
|
+
"page.to_have_url",
|
|
232
|
+
pattern,
|
|
233
|
+
o.get("timeoutMs", DEFAULT_TIMEOUT_MS),
|
|
234
|
+
o.get("intervalMs", DEFAULT_INTERVAL_MS),
|
|
235
|
+
lambda: (matcher(self._page.url()), self._page.url()),
|
|
236
|
+
)
|