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 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
+ )