acob-client 0.1.0__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.
- acob_client-0.1.0/.gitignore +13 -0
- acob_client-0.1.0/PKG-INFO +91 -0
- acob_client-0.1.0/README.md +84 -0
- acob_client-0.1.0/acob/__init__.py +26 -0
- acob_client-0.1.0/acob/client.py +430 -0
- acob_client-0.1.0/acob/py.typed +0 -0
- acob_client-0.1.0/pyproject.toml +24 -0
- acob_client-0.1.0/tests/test_client.py +281 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: acob-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for Agent Controlled Browser
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
|
|
8
|
+
# ACOB Python Client
|
|
9
|
+
|
|
10
|
+
The ACOB Python client controls one Chromium installation through an ACOB
|
|
11
|
+
server. It uses only the Python standard library.
|
|
12
|
+
|
|
13
|
+
Install it from the repository:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install ./client
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Create a client with the browser ID shown in the extension popup. The endpoint
|
|
20
|
+
defaults to `http://127.0.0.1:58347`:
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from acob import ACOBClient
|
|
26
|
+
|
|
27
|
+
client = ACOBClient("0123456789ab4def8123456789abcdef")
|
|
28
|
+
|
|
29
|
+
tabs = client.tabs(operation="list")
|
|
30
|
+
tab = client.tabs(operation="navigate", url="https://example.com")
|
|
31
|
+
tid = tab["tid"]
|
|
32
|
+
|
|
33
|
+
client.click(tid, "a")
|
|
34
|
+
client.keyboard(tid, text="ACOB")
|
|
35
|
+
client.keyboard(tid, key="Enter")
|
|
36
|
+
title = client.javascript(tid, "document.title")
|
|
37
|
+
|
|
38
|
+
capture = client.screenshot(tid, full_page=True)
|
|
39
|
+
png = client.download_screenshot(capture["download_url"])
|
|
40
|
+
Path("screenshot.png").write_bytes(png)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Use a different server and operation timeout when needed:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
client = ACOBClient(
|
|
47
|
+
"0123456789ab4def8123456789abcdef",
|
|
48
|
+
endpoint="http://127.0.0.1:8000",
|
|
49
|
+
timeout=90,
|
|
50
|
+
)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Action methods map directly to the API actions and payload fields. They submit
|
|
54
|
+
an instruction, poll until Chromium completes it, and return the action's
|
|
55
|
+
`result`.
|
|
56
|
+
|
|
57
|
+
The `tabs()` method mirrors the four tab operations:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
tabs = client.tabs(operation="list")
|
|
61
|
+
tab = client.tabs(
|
|
62
|
+
operation="navigate",
|
|
63
|
+
tid=123,
|
|
64
|
+
url="https://example.com",
|
|
65
|
+
)
|
|
66
|
+
tab = client.tabs(operation="focus", tid=123)
|
|
67
|
+
closed = client.tabs(operation="close", tid=123)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`screenshot()` returns the API's screenshot metadata unchanged. Its
|
|
71
|
+
`download_url` is single-use, so pass it directly to `download_screenshot()`
|
|
72
|
+
when ready to consume the PNG.
|
|
73
|
+
|
|
74
|
+
For lower-level queue control, use `submit()`, `wait()`, and `execute()`:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
instruction = client.submit("tabs", operation="list")
|
|
78
|
+
terminal_response = client.wait(instruction["id"])
|
|
79
|
+
|
|
80
|
+
result = client.execute("tabs", operation="list")
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`wait()` returns the complete terminal response because that response is
|
|
84
|
+
single-use. `execute()` and the action helpers raise `ACOBInstructionError`
|
|
85
|
+
when Chromium reports a failed instruction. HTTP validation errors raise
|
|
86
|
+
`ACOBHTTPError`; connection, protocol, and timeout failures derive from
|
|
87
|
+
`ACOBError`.
|
|
88
|
+
|
|
89
|
+
If an operation times out, its accepted instruction can still finish on the
|
|
90
|
+
server. `ACOBTimeoutError.instruction_id` retains its ID so it can be passed to
|
|
91
|
+
`wait()` again.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# ACOB Python Client
|
|
2
|
+
|
|
3
|
+
The ACOB Python client controls one Chromium installation through an ACOB
|
|
4
|
+
server. It uses only the Python standard library.
|
|
5
|
+
|
|
6
|
+
Install it from the repository:
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install ./client
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Create a client with the browser ID shown in the extension popup. The endpoint
|
|
13
|
+
defaults to `http://127.0.0.1:58347`:
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from acob import ACOBClient
|
|
19
|
+
|
|
20
|
+
client = ACOBClient("0123456789ab4def8123456789abcdef")
|
|
21
|
+
|
|
22
|
+
tabs = client.tabs(operation="list")
|
|
23
|
+
tab = client.tabs(operation="navigate", url="https://example.com")
|
|
24
|
+
tid = tab["tid"]
|
|
25
|
+
|
|
26
|
+
client.click(tid, "a")
|
|
27
|
+
client.keyboard(tid, text="ACOB")
|
|
28
|
+
client.keyboard(tid, key="Enter")
|
|
29
|
+
title = client.javascript(tid, "document.title")
|
|
30
|
+
|
|
31
|
+
capture = client.screenshot(tid, full_page=True)
|
|
32
|
+
png = client.download_screenshot(capture["download_url"])
|
|
33
|
+
Path("screenshot.png").write_bytes(png)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Use a different server and operation timeout when needed:
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
client = ACOBClient(
|
|
40
|
+
"0123456789ab4def8123456789abcdef",
|
|
41
|
+
endpoint="http://127.0.0.1:8000",
|
|
42
|
+
timeout=90,
|
|
43
|
+
)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Action methods map directly to the API actions and payload fields. They submit
|
|
47
|
+
an instruction, poll until Chromium completes it, and return the action's
|
|
48
|
+
`result`.
|
|
49
|
+
|
|
50
|
+
The `tabs()` method mirrors the four tab operations:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
tabs = client.tabs(operation="list")
|
|
54
|
+
tab = client.tabs(
|
|
55
|
+
operation="navigate",
|
|
56
|
+
tid=123,
|
|
57
|
+
url="https://example.com",
|
|
58
|
+
)
|
|
59
|
+
tab = client.tabs(operation="focus", tid=123)
|
|
60
|
+
closed = client.tabs(operation="close", tid=123)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`screenshot()` returns the API's screenshot metadata unchanged. Its
|
|
64
|
+
`download_url` is single-use, so pass it directly to `download_screenshot()`
|
|
65
|
+
when ready to consume the PNG.
|
|
66
|
+
|
|
67
|
+
For lower-level queue control, use `submit()`, `wait()`, and `execute()`:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
instruction = client.submit("tabs", operation="list")
|
|
71
|
+
terminal_response = client.wait(instruction["id"])
|
|
72
|
+
|
|
73
|
+
result = client.execute("tabs", operation="list")
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`wait()` returns the complete terminal response because that response is
|
|
77
|
+
single-use. `execute()` and the action helpers raise `ACOBInstructionError`
|
|
78
|
+
when Chromium reports a failed instruction. HTTP validation errors raise
|
|
79
|
+
`ACOBHTTPError`; connection, protocol, and timeout failures derive from
|
|
80
|
+
`ACOBError`.
|
|
81
|
+
|
|
82
|
+
If an operation times out, its accepted instruction can still finish on the
|
|
83
|
+
server. `ACOBTimeoutError.instruction_id` retains its ID so it can be passed to
|
|
84
|
+
`wait()` again.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from .client import (
|
|
2
|
+
DEFAULT_ENDPOINT,
|
|
3
|
+
ACOBClient,
|
|
4
|
+
ACOBConnectionError,
|
|
5
|
+
ACOBError,
|
|
6
|
+
ACOBHTTPError,
|
|
7
|
+
ACOBInstructionError,
|
|
8
|
+
ACOBProtocolError,
|
|
9
|
+
ACOBTimeoutError,
|
|
10
|
+
ScreenshotResult,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__version__ = "0.1.0"
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"DEFAULT_ENDPOINT",
|
|
17
|
+
"ACOBClient",
|
|
18
|
+
"ACOBConnectionError",
|
|
19
|
+
"ACOBError",
|
|
20
|
+
"ACOBHTTPError",
|
|
21
|
+
"ACOBInstructionError",
|
|
22
|
+
"ACOBProtocolError",
|
|
23
|
+
"ACOBTimeoutError",
|
|
24
|
+
"ScreenshotResult",
|
|
25
|
+
"__version__",
|
|
26
|
+
]
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import math
|
|
3
|
+
import time
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from typing import TypeAlias, TypedDict, cast
|
|
6
|
+
from urllib.error import HTTPError, URLError
|
|
7
|
+
from urllib.parse import SplitResult, urljoin, urlsplit
|
|
8
|
+
from urllib.request import Request, urlopen
|
|
9
|
+
from uuid import UUID
|
|
10
|
+
|
|
11
|
+
DEFAULT_ENDPOINT = "http://127.0.0.1:58347"
|
|
12
|
+
|
|
13
|
+
JsonValue: TypeAlias = (
|
|
14
|
+
bool | int | float | str | list["JsonValue"] | dict[str, "JsonValue"] | None
|
|
15
|
+
)
|
|
16
|
+
JsonObject: TypeAlias = dict[str, JsonValue]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ScreenshotResult(TypedDict):
|
|
20
|
+
download_url: str
|
|
21
|
+
content_type: str
|
|
22
|
+
full_page: bool
|
|
23
|
+
single_use: bool
|
|
24
|
+
tid: int
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ACOBError(Exception):
|
|
28
|
+
"""Base exception for ACOB client failures."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ACOBConnectionError(ACOBError):
|
|
32
|
+
"""The ACOB server could not be reached."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ACOBProtocolError(ACOBError):
|
|
36
|
+
"""The ACOB server returned an unexpected response."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ACOBHTTPError(ACOBError):
|
|
40
|
+
"""The ACOB server rejected an HTTP request."""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
status_code: int,
|
|
45
|
+
message: str,
|
|
46
|
+
response: JsonObject | None = None,
|
|
47
|
+
) -> None:
|
|
48
|
+
super().__init__(f"{message} (HTTP {status_code})")
|
|
49
|
+
self.status_code = status_code
|
|
50
|
+
self.response = response
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ACOBInstructionError(ACOBError):
|
|
54
|
+
"""Chromium failed to execute an accepted instruction."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, instruction_id: int, response: JsonObject) -> None:
|
|
57
|
+
message = response.get("error")
|
|
58
|
+
if not isinstance(message, str) or not message:
|
|
59
|
+
message = "Browser instruction failed"
|
|
60
|
+
super().__init__(message)
|
|
61
|
+
self.instruction_id = instruction_id
|
|
62
|
+
self.response = response
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ACOBTimeoutError(ACOBError):
|
|
66
|
+
"""An instruction did not finish before the configured timeout."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, instruction_id: int, timeout: float) -> None:
|
|
69
|
+
super().__init__(
|
|
70
|
+
f"Instruction {instruction_id} did not finish within {timeout:g} seconds"
|
|
71
|
+
)
|
|
72
|
+
self.instruction_id = instruction_id
|
|
73
|
+
self.timeout = timeout
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ACOBClient:
|
|
77
|
+
"""Synchronous client for controlling one ACOB browser."""
|
|
78
|
+
|
|
79
|
+
_REQUEST_TIMEOUT = 10.0
|
|
80
|
+
|
|
81
|
+
def __init__(
|
|
82
|
+
self,
|
|
83
|
+
bid: str,
|
|
84
|
+
endpoint: str | None = None,
|
|
85
|
+
*,
|
|
86
|
+
timeout: float = 60.0,
|
|
87
|
+
poll_interval: float = 0.5,
|
|
88
|
+
) -> None:
|
|
89
|
+
self.bid = self._validate_bid(bid)
|
|
90
|
+
self.endpoint = self._validate_endpoint(
|
|
91
|
+
DEFAULT_ENDPOINT if endpoint is None else endpoint
|
|
92
|
+
)
|
|
93
|
+
self.timeout = self._positive_float(timeout, "timeout")
|
|
94
|
+
self.poll_interval = self._positive_float(
|
|
95
|
+
poll_interval,
|
|
96
|
+
"poll_interval",
|
|
97
|
+
)
|
|
98
|
+
self._instructions_url = (
|
|
99
|
+
f"{self.endpoint}/api/browsers/{self.bid}/instructions"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def submit(self, action: str, /, **payload: JsonValue) -> JsonObject:
|
|
103
|
+
"""Submit an instruction without waiting for Chromium to execute it."""
|
|
104
|
+
body = dict(payload)
|
|
105
|
+
body["action"] = action
|
|
106
|
+
return self._request_json(
|
|
107
|
+
"POST",
|
|
108
|
+
f"{self._instructions_url}/",
|
|
109
|
+
body,
|
|
110
|
+
timeout=min(self._REQUEST_TIMEOUT, self.timeout),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def wait(
|
|
114
|
+
self,
|
|
115
|
+
instruction_id: int,
|
|
116
|
+
*,
|
|
117
|
+
timeout: float | None = None,
|
|
118
|
+
) -> JsonObject:
|
|
119
|
+
"""Wait for and return an instruction's one-use terminal response."""
|
|
120
|
+
if (
|
|
121
|
+
isinstance(instruction_id, bool)
|
|
122
|
+
or not isinstance(instruction_id, int)
|
|
123
|
+
or instruction_id <= 0
|
|
124
|
+
):
|
|
125
|
+
raise ValueError("instruction_id must be a positive integer")
|
|
126
|
+
|
|
127
|
+
wait_timeout = (
|
|
128
|
+
self.timeout
|
|
129
|
+
if timeout is None
|
|
130
|
+
else self._positive_float(timeout, "timeout")
|
|
131
|
+
)
|
|
132
|
+
deadline = time.monotonic() + wait_timeout
|
|
133
|
+
instruction_url = f"{self._instructions_url}/{instruction_id}/"
|
|
134
|
+
|
|
135
|
+
while True:
|
|
136
|
+
remaining = deadline - time.monotonic()
|
|
137
|
+
if remaining <= 0:
|
|
138
|
+
raise ACOBTimeoutError(instruction_id, wait_timeout)
|
|
139
|
+
|
|
140
|
+
response = self._request_json(
|
|
141
|
+
"GET",
|
|
142
|
+
instruction_url,
|
|
143
|
+
timeout=min(self._REQUEST_TIMEOUT, remaining),
|
|
144
|
+
)
|
|
145
|
+
status = response.get("status")
|
|
146
|
+
if status in {"completed", "failed"}:
|
|
147
|
+
return response
|
|
148
|
+
if status not in {"pending", "processing"}:
|
|
149
|
+
raise ACOBProtocolError(
|
|
150
|
+
f"Instruction {instruction_id} returned invalid status: {status!r}"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
remaining = deadline - time.monotonic()
|
|
154
|
+
if remaining <= 0:
|
|
155
|
+
raise ACOBTimeoutError(instruction_id, wait_timeout)
|
|
156
|
+
time.sleep(min(self.poll_interval, remaining))
|
|
157
|
+
|
|
158
|
+
def execute(
|
|
159
|
+
self,
|
|
160
|
+
action: str,
|
|
161
|
+
/,
|
|
162
|
+
*,
|
|
163
|
+
timeout: float | None = None,
|
|
164
|
+
**payload: JsonValue,
|
|
165
|
+
) -> JsonValue:
|
|
166
|
+
"""Submit an action, wait for it, and return its browser result."""
|
|
167
|
+
instruction = self.submit(action, **payload)
|
|
168
|
+
instruction_id = instruction.get("id")
|
|
169
|
+
if (
|
|
170
|
+
isinstance(instruction_id, bool)
|
|
171
|
+
or not isinstance(instruction_id, int)
|
|
172
|
+
or instruction_id <= 0
|
|
173
|
+
):
|
|
174
|
+
raise ACOBProtocolError("Created instruction did not contain a valid id")
|
|
175
|
+
|
|
176
|
+
terminal = self.wait(instruction_id, timeout=timeout)
|
|
177
|
+
if terminal.get("status") == "failed":
|
|
178
|
+
raise ACOBInstructionError(instruction_id, terminal)
|
|
179
|
+
return terminal.get("result")
|
|
180
|
+
|
|
181
|
+
def tabs(
|
|
182
|
+
self,
|
|
183
|
+
operation: str,
|
|
184
|
+
*,
|
|
185
|
+
tid: int | None = None,
|
|
186
|
+
url: str | None = None,
|
|
187
|
+
timeout: float | None = None,
|
|
188
|
+
) -> JsonValue:
|
|
189
|
+
"""Run a list, navigate, focus, or close tab operation."""
|
|
190
|
+
payload: JsonObject = {"operation": operation}
|
|
191
|
+
if tid is not None:
|
|
192
|
+
payload["tid"] = tid
|
|
193
|
+
if url is not None:
|
|
194
|
+
payload["url"] = url
|
|
195
|
+
return self.execute("tabs", timeout=timeout, **payload)
|
|
196
|
+
|
|
197
|
+
def click(
|
|
198
|
+
self,
|
|
199
|
+
tid: int,
|
|
200
|
+
selector: str,
|
|
201
|
+
*,
|
|
202
|
+
timeout: float | None = None,
|
|
203
|
+
) -> JsonObject:
|
|
204
|
+
"""Click the center of the element matching a CSS selector."""
|
|
205
|
+
return self._expect_object(
|
|
206
|
+
self.execute(
|
|
207
|
+
"click",
|
|
208
|
+
tid=tid,
|
|
209
|
+
selector=selector,
|
|
210
|
+
timeout=timeout,
|
|
211
|
+
),
|
|
212
|
+
"click",
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
def keyboard(
|
|
216
|
+
self,
|
|
217
|
+
tid: int,
|
|
218
|
+
*,
|
|
219
|
+
text: str | None = None,
|
|
220
|
+
key: str | None = None,
|
|
221
|
+
modifiers: Sequence[str] | None = None,
|
|
222
|
+
timeout: float | None = None,
|
|
223
|
+
) -> JsonObject:
|
|
224
|
+
"""Insert text or dispatch one key to the focused page control."""
|
|
225
|
+
payload: JsonObject = {"tid": tid}
|
|
226
|
+
if text is not None:
|
|
227
|
+
payload["text"] = text
|
|
228
|
+
if key is not None:
|
|
229
|
+
payload["key"] = key
|
|
230
|
+
if modifiers is not None:
|
|
231
|
+
payload["modifiers"] = list(modifiers)
|
|
232
|
+
return self._expect_object(
|
|
233
|
+
self.execute("keyboard", timeout=timeout, **payload),
|
|
234
|
+
"keyboard",
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
def screenshot(
|
|
238
|
+
self,
|
|
239
|
+
tid: int,
|
|
240
|
+
*,
|
|
241
|
+
full_page: bool = False,
|
|
242
|
+
timeout: float | None = None,
|
|
243
|
+
) -> ScreenshotResult:
|
|
244
|
+
"""Capture a tab and return its one-use download metadata."""
|
|
245
|
+
result = self._expect_object(
|
|
246
|
+
self.execute(
|
|
247
|
+
"screenshot",
|
|
248
|
+
tid=tid,
|
|
249
|
+
full_page=full_page,
|
|
250
|
+
timeout=timeout,
|
|
251
|
+
),
|
|
252
|
+
"screenshot",
|
|
253
|
+
)
|
|
254
|
+
return cast(ScreenshotResult, result)
|
|
255
|
+
|
|
256
|
+
def download_screenshot(self, download_url: str) -> bytes:
|
|
257
|
+
"""Consume a screenshot URL returned by a low-level execute call."""
|
|
258
|
+
if not isinstance(download_url, str) or not download_url:
|
|
259
|
+
raise ValueError("download_url must be a non-empty string")
|
|
260
|
+
|
|
261
|
+
resolved_url = urljoin(f"{self.endpoint}/", download_url)
|
|
262
|
+
if self._origin(urlsplit(resolved_url)) != self._origin(
|
|
263
|
+
urlsplit(self.endpoint)
|
|
264
|
+
):
|
|
265
|
+
raise ACOBProtocolError(
|
|
266
|
+
"Screenshot download URL points to a different server"
|
|
267
|
+
)
|
|
268
|
+
return self._request_bytes(
|
|
269
|
+
"GET",
|
|
270
|
+
resolved_url,
|
|
271
|
+
timeout=min(self._REQUEST_TIMEOUT, self.timeout),
|
|
272
|
+
accept="image/png",
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
def javascript(
|
|
276
|
+
self,
|
|
277
|
+
tid: int,
|
|
278
|
+
script: str,
|
|
279
|
+
*,
|
|
280
|
+
timeout: float | None = None,
|
|
281
|
+
) -> JsonValue:
|
|
282
|
+
"""Evaluate JavaScript in a tab and return its JSON-compatible value."""
|
|
283
|
+
return self.execute(
|
|
284
|
+
"javascript",
|
|
285
|
+
tid=tid,
|
|
286
|
+
script=script,
|
|
287
|
+
timeout=timeout,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
def _request_json(
|
|
291
|
+
self,
|
|
292
|
+
method: str,
|
|
293
|
+
url: str,
|
|
294
|
+
body: JsonObject | None = None,
|
|
295
|
+
*,
|
|
296
|
+
timeout: float,
|
|
297
|
+
) -> JsonObject:
|
|
298
|
+
raw = self._request_bytes(method, url, body, timeout=timeout)
|
|
299
|
+
try:
|
|
300
|
+
parsed: object = json.loads(raw.decode("utf-8"))
|
|
301
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
302
|
+
raise ACOBProtocolError(
|
|
303
|
+
f"{method} {url} returned invalid JSON"
|
|
304
|
+
) from error
|
|
305
|
+
if not isinstance(parsed, dict):
|
|
306
|
+
raise ACOBProtocolError(
|
|
307
|
+
f"{method} {url} returned a non-object JSON response"
|
|
308
|
+
)
|
|
309
|
+
return cast(JsonObject, parsed)
|
|
310
|
+
|
|
311
|
+
def _request_bytes(
|
|
312
|
+
self,
|
|
313
|
+
method: str,
|
|
314
|
+
url: str,
|
|
315
|
+
body: JsonObject | None = None,
|
|
316
|
+
*,
|
|
317
|
+
timeout: float,
|
|
318
|
+
accept: str = "application/json",
|
|
319
|
+
) -> bytes:
|
|
320
|
+
headers = {"Accept": accept}
|
|
321
|
+
data = None
|
|
322
|
+
if body is not None:
|
|
323
|
+
headers["Content-Type"] = "application/json"
|
|
324
|
+
data = json.dumps(
|
|
325
|
+
body,
|
|
326
|
+
ensure_ascii=False,
|
|
327
|
+
separators=(",", ":"),
|
|
328
|
+
).encode("utf-8")
|
|
329
|
+
request = Request(url, data=data, headers=headers, method=method)
|
|
330
|
+
|
|
331
|
+
try:
|
|
332
|
+
with urlopen(request, timeout=timeout) as response:
|
|
333
|
+
return response.read()
|
|
334
|
+
except HTTPError as error:
|
|
335
|
+
try:
|
|
336
|
+
response_body = error.read()
|
|
337
|
+
finally:
|
|
338
|
+
error.close()
|
|
339
|
+
parsed = self._try_parse_object(response_body)
|
|
340
|
+
message = self._http_error_message(parsed)
|
|
341
|
+
raise ACOBHTTPError(error.code, message, parsed) from None
|
|
342
|
+
except (URLError, TimeoutError, OSError) as error:
|
|
343
|
+
reason = getattr(error, "reason", error)
|
|
344
|
+
raise ACOBConnectionError(
|
|
345
|
+
f"Could not connect to ACOB at {self.endpoint}: {reason}"
|
|
346
|
+
) from error
|
|
347
|
+
|
|
348
|
+
@staticmethod
|
|
349
|
+
def _expect_object(result: JsonValue, action: str) -> JsonObject:
|
|
350
|
+
if not isinstance(result, dict):
|
|
351
|
+
raise ACOBProtocolError(f"{action} returned an invalid result")
|
|
352
|
+
return result
|
|
353
|
+
|
|
354
|
+
@staticmethod
|
|
355
|
+
def _try_parse_object(body: bytes) -> JsonObject | None:
|
|
356
|
+
try:
|
|
357
|
+
parsed: object = json.loads(body.decode("utf-8"))
|
|
358
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
359
|
+
return None
|
|
360
|
+
return cast(JsonObject, parsed) if isinstance(parsed, dict) else None
|
|
361
|
+
|
|
362
|
+
@staticmethod
|
|
363
|
+
def _http_error_message(response: JsonObject | None) -> str:
|
|
364
|
+
if response is None:
|
|
365
|
+
return "ACOB request failed"
|
|
366
|
+
message = response.get("error")
|
|
367
|
+
if not isinstance(message, str) or not message:
|
|
368
|
+
return "ACOB request failed"
|
|
369
|
+
|
|
370
|
+
details = response.get("details")
|
|
371
|
+
if not isinstance(details, list):
|
|
372
|
+
return message
|
|
373
|
+
rendered_details = []
|
|
374
|
+
for detail in details:
|
|
375
|
+
if not isinstance(detail, dict):
|
|
376
|
+
continue
|
|
377
|
+
field = detail.get("field")
|
|
378
|
+
detail_message = detail.get("message")
|
|
379
|
+
if isinstance(field, str) and isinstance(detail_message, str):
|
|
380
|
+
rendered_details.append(f"{field}: {detail_message}")
|
|
381
|
+
return (
|
|
382
|
+
f"{message}: {'; '.join(rendered_details)}"
|
|
383
|
+
if rendered_details
|
|
384
|
+
else message
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
@staticmethod
|
|
388
|
+
def _validate_bid(bid: str) -> str:
|
|
389
|
+
try:
|
|
390
|
+
parsed = UUID(bid)
|
|
391
|
+
except (ValueError, TypeError, AttributeError) as error:
|
|
392
|
+
raise ValueError(
|
|
393
|
+
"bid must be a lowercase dashless UUIDv4"
|
|
394
|
+
) from error
|
|
395
|
+
if parsed.hex != bid or parsed.version != 4:
|
|
396
|
+
raise ValueError("bid must be a lowercase dashless UUIDv4")
|
|
397
|
+
return bid
|
|
398
|
+
|
|
399
|
+
@staticmethod
|
|
400
|
+
def _validate_endpoint(endpoint: str) -> str:
|
|
401
|
+
if not isinstance(endpoint, str) or not endpoint:
|
|
402
|
+
raise ValueError("endpoint must be a non-empty HTTP or HTTPS URL")
|
|
403
|
+
normalized = endpoint.rstrip("/")
|
|
404
|
+
try:
|
|
405
|
+
parsed = urlsplit(normalized)
|
|
406
|
+
_ = parsed.port
|
|
407
|
+
except ValueError as error:
|
|
408
|
+
raise ValueError("endpoint must be a valid HTTP or HTTPS URL") from error
|
|
409
|
+
if (
|
|
410
|
+
parsed.scheme not in {"http", "https"}
|
|
411
|
+
or parsed.hostname is None
|
|
412
|
+
or parsed.query
|
|
413
|
+
or parsed.fragment
|
|
414
|
+
):
|
|
415
|
+
raise ValueError("endpoint must be a valid HTTP or HTTPS URL")
|
|
416
|
+
return normalized
|
|
417
|
+
|
|
418
|
+
@staticmethod
|
|
419
|
+
def _origin(url: SplitResult) -> tuple[str, str | None, int | None]:
|
|
420
|
+
default_port = 443 if url.scheme == "https" else 80
|
|
421
|
+
return url.scheme.lower(), url.hostname, url.port or default_port
|
|
422
|
+
|
|
423
|
+
@staticmethod
|
|
424
|
+
def _positive_float(value: float, name: str) -> float:
|
|
425
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
426
|
+
raise ValueError(f"{name} must be a positive number")
|
|
427
|
+
converted = float(value)
|
|
428
|
+
if not math.isfinite(converted) or converted <= 0:
|
|
429
|
+
raise ValueError(f"{name} must be a positive number")
|
|
430
|
+
return converted
|
|
File without changes
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "acob-client"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python client for Agent Controlled Browser"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
dependencies = []
|
|
12
|
+
|
|
13
|
+
[tool.hatch.build.targets.wheel]
|
|
14
|
+
packages = ["acob"]
|
|
15
|
+
|
|
16
|
+
[tool.ruff]
|
|
17
|
+
target-version = "py310"
|
|
18
|
+
|
|
19
|
+
[tool.ruff.lint]
|
|
20
|
+
select = ["B", "E", "F", "I", "RUF", "UP"]
|
|
21
|
+
|
|
22
|
+
[tool.pyright]
|
|
23
|
+
include = ["acob", "tests"]
|
|
24
|
+
pythonVersion = "3.10"
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import unittest
|
|
3
|
+
from email.message import Message
|
|
4
|
+
from io import BytesIO
|
|
5
|
+
from unittest.mock import call, patch
|
|
6
|
+
from urllib.error import HTTPError
|
|
7
|
+
from urllib.request import Request
|
|
8
|
+
|
|
9
|
+
from acob import (
|
|
10
|
+
DEFAULT_ENDPOINT,
|
|
11
|
+
ACOBClient,
|
|
12
|
+
ACOBHTTPError,
|
|
13
|
+
ACOBInstructionError,
|
|
14
|
+
ACOBProtocolError,
|
|
15
|
+
ACOBTimeoutError,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FakeResponse:
|
|
20
|
+
def __init__(self, body):
|
|
21
|
+
self.body = (
|
|
22
|
+
body
|
|
23
|
+
if isinstance(body, bytes)
|
|
24
|
+
else json.dumps(body).encode("utf-8")
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
def __enter__(self):
|
|
28
|
+
return self
|
|
29
|
+
|
|
30
|
+
def __exit__(self, _exc_type, _exc_value, _traceback):
|
|
31
|
+
return False
|
|
32
|
+
|
|
33
|
+
def read(self):
|
|
34
|
+
return self.body
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ACOBClientTests(unittest.TestCase):
|
|
38
|
+
BID = "0123456789ab4def8123456789abcdef"
|
|
39
|
+
|
|
40
|
+
def make_client(self, endpoint="http://acob.test/"):
|
|
41
|
+
return ACOBClient(
|
|
42
|
+
self.BID,
|
|
43
|
+
endpoint=endpoint,
|
|
44
|
+
timeout=5,
|
|
45
|
+
poll_interval=0.01,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def test_initializes_with_default_endpoint_and_validates_configuration(self):
|
|
49
|
+
client = ACOBClient(self.BID)
|
|
50
|
+
|
|
51
|
+
self.assertEqual(client.bid, self.BID)
|
|
52
|
+
self.assertEqual(client.endpoint, DEFAULT_ENDPOINT)
|
|
53
|
+
|
|
54
|
+
invalid_bids = (
|
|
55
|
+
"not-a-uuid",
|
|
56
|
+
"00000000000000000000000000000000",
|
|
57
|
+
"01234567-89ab-4def-8123-456789abcdef",
|
|
58
|
+
"0123456789AB4DEF8123456789ABCDEF",
|
|
59
|
+
)
|
|
60
|
+
for bid in invalid_bids:
|
|
61
|
+
with self.subTest(bid=bid), self.assertRaises(ValueError):
|
|
62
|
+
ACOBClient(bid)
|
|
63
|
+
|
|
64
|
+
for endpoint in ("", "acob.test", "ftp://acob.test", "http://acob.test?q=1"):
|
|
65
|
+
with self.subTest(endpoint=endpoint), self.assertRaises(ValueError):
|
|
66
|
+
ACOBClient(self.BID, endpoint)
|
|
67
|
+
|
|
68
|
+
def test_tabs_submits_and_consumes_terminal_response(self):
|
|
69
|
+
tab = {
|
|
70
|
+
"tid": 12,
|
|
71
|
+
"window_id": 3,
|
|
72
|
+
"active": True,
|
|
73
|
+
"focused": True,
|
|
74
|
+
"title": "Example",
|
|
75
|
+
"url": "https://example.com/",
|
|
76
|
+
"domain": "example.com",
|
|
77
|
+
}
|
|
78
|
+
responses = [
|
|
79
|
+
FakeResponse({"id": 7, "status": "pending"}),
|
|
80
|
+
FakeResponse({"id": 7, "status": "processing"}),
|
|
81
|
+
FakeResponse({"id": 7, "status": "completed", "result": [tab]}),
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
with (
|
|
85
|
+
patch("acob.client.urlopen", side_effect=responses) as mocked_urlopen,
|
|
86
|
+
patch("acob.client.time.sleep") as mocked_sleep,
|
|
87
|
+
):
|
|
88
|
+
result = self.make_client().tabs(operation="list")
|
|
89
|
+
|
|
90
|
+
self.assertEqual(result, [tab])
|
|
91
|
+
self.assertEqual(mocked_urlopen.call_count, 3)
|
|
92
|
+
self.assertEqual(mocked_sleep.call_count, 1)
|
|
93
|
+
|
|
94
|
+
submitted_request = mocked_urlopen.call_args_list[0].args[0]
|
|
95
|
+
self.assertIsInstance(submitted_request, Request)
|
|
96
|
+
self.assertEqual(submitted_request.get_method(), "POST")
|
|
97
|
+
self.assertEqual(
|
|
98
|
+
submitted_request.full_url,
|
|
99
|
+
f"http://acob.test/api/browsers/{self.BID}/instructions/",
|
|
100
|
+
)
|
|
101
|
+
self.assertEqual(
|
|
102
|
+
json.loads(submitted_request.data),
|
|
103
|
+
{"action": "tabs", "operation": "list"},
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
terminal_request = mocked_urlopen.call_args_list[2].args[0]
|
|
107
|
+
self.assertEqual(terminal_request.get_method(), "GET")
|
|
108
|
+
self.assertEqual(
|
|
109
|
+
terminal_request.full_url,
|
|
110
|
+
f"http://acob.test/api/browsers/{self.BID}/instructions/7/",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def test_action_methods_send_the_supported_api_payloads(self):
|
|
114
|
+
client = self.make_client()
|
|
115
|
+
|
|
116
|
+
with patch.object(client, "execute", return_value={}) as execute:
|
|
117
|
+
client.tabs(operation="list")
|
|
118
|
+
client.tabs(operation="navigate", url="https://example.com")
|
|
119
|
+
client.tabs(
|
|
120
|
+
operation="navigate",
|
|
121
|
+
tid=10,
|
|
122
|
+
url="https://example.org",
|
|
123
|
+
)
|
|
124
|
+
client.tabs(operation="focus", tid=10)
|
|
125
|
+
client.tabs(operation="close", tid=10)
|
|
126
|
+
client.click(10, "button[type=submit]")
|
|
127
|
+
client.keyboard(10, text="ACOB")
|
|
128
|
+
client.keyboard(10, key="Enter", modifiers=["ctrl", "shift"])
|
|
129
|
+
client.javascript(10, "document.title")
|
|
130
|
+
|
|
131
|
+
self.assertEqual(
|
|
132
|
+
execute.call_args_list,
|
|
133
|
+
[
|
|
134
|
+
call("tabs", timeout=None, operation="list"),
|
|
135
|
+
call(
|
|
136
|
+
"tabs",
|
|
137
|
+
timeout=None,
|
|
138
|
+
operation="navigate",
|
|
139
|
+
url="https://example.com",
|
|
140
|
+
),
|
|
141
|
+
call(
|
|
142
|
+
"tabs",
|
|
143
|
+
timeout=None,
|
|
144
|
+
operation="navigate",
|
|
145
|
+
tid=10,
|
|
146
|
+
url="https://example.org",
|
|
147
|
+
),
|
|
148
|
+
call("tabs", timeout=None, operation="focus", tid=10),
|
|
149
|
+
call("tabs", timeout=None, operation="close", tid=10),
|
|
150
|
+
call(
|
|
151
|
+
"click",
|
|
152
|
+
tid=10,
|
|
153
|
+
selector="button[type=submit]",
|
|
154
|
+
timeout=None,
|
|
155
|
+
),
|
|
156
|
+
call("keyboard", timeout=None, tid=10, text="ACOB"),
|
|
157
|
+
call(
|
|
158
|
+
"keyboard",
|
|
159
|
+
timeout=None,
|
|
160
|
+
tid=10,
|
|
161
|
+
key="Enter",
|
|
162
|
+
modifiers=["ctrl", "shift"],
|
|
163
|
+
),
|
|
164
|
+
call(
|
|
165
|
+
"javascript",
|
|
166
|
+
tid=10,
|
|
167
|
+
script="document.title",
|
|
168
|
+
timeout=None,
|
|
169
|
+
),
|
|
170
|
+
],
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
def test_failed_instruction_raises_with_the_consumed_response(self):
|
|
174
|
+
terminal = {
|
|
175
|
+
"id": 4,
|
|
176
|
+
"status": "failed",
|
|
177
|
+
"result": None,
|
|
178
|
+
"error": "No element matches selector: button",
|
|
179
|
+
}
|
|
180
|
+
with patch(
|
|
181
|
+
"acob.client.urlopen",
|
|
182
|
+
side_effect=[
|
|
183
|
+
FakeResponse({"id": 4, "status": "pending"}),
|
|
184
|
+
FakeResponse(terminal),
|
|
185
|
+
],
|
|
186
|
+
):
|
|
187
|
+
with self.assertRaises(ACOBInstructionError) as raised:
|
|
188
|
+
self.make_client().click(12, "button")
|
|
189
|
+
|
|
190
|
+
self.assertEqual(raised.exception.instruction_id, 4)
|
|
191
|
+
self.assertEqual(raised.exception.response, terminal)
|
|
192
|
+
self.assertEqual(str(raised.exception), terminal["error"])
|
|
193
|
+
|
|
194
|
+
def test_http_validation_error_exposes_status_and_response(self):
|
|
195
|
+
error_body = {
|
|
196
|
+
"error": "Invalid request",
|
|
197
|
+
"details": [
|
|
198
|
+
{
|
|
199
|
+
"field": "javascript.script",
|
|
200
|
+
"message": "String should have at least 1 character",
|
|
201
|
+
"type": "string_too_short",
|
|
202
|
+
}
|
|
203
|
+
],
|
|
204
|
+
}
|
|
205
|
+
http_error = HTTPError(
|
|
206
|
+
"http://acob.test/instructions/",
|
|
207
|
+
400,
|
|
208
|
+
"Bad Request",
|
|
209
|
+
Message(),
|
|
210
|
+
BytesIO(json.dumps(error_body).encode("utf-8")),
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
with patch("acob.client.urlopen", side_effect=http_error):
|
|
214
|
+
with self.assertRaises(ACOBHTTPError) as raised:
|
|
215
|
+
self.make_client().javascript(12, "")
|
|
216
|
+
|
|
217
|
+
self.assertEqual(raised.exception.status_code, 400)
|
|
218
|
+
self.assertEqual(raised.exception.response, error_body)
|
|
219
|
+
self.assertIn("javascript.script", str(raised.exception))
|
|
220
|
+
|
|
221
|
+
def test_screenshot_returns_metadata_for_an_explicit_one_use_download(self):
|
|
222
|
+
image = b"\x89PNG\r\n\x1a\nACOB"
|
|
223
|
+
download_url = f"/api/browsers/{self.BID}/screenshots/9/"
|
|
224
|
+
responses = [
|
|
225
|
+
FakeResponse({"id": 8, "status": "pending"}),
|
|
226
|
+
FakeResponse(
|
|
227
|
+
{
|
|
228
|
+
"id": 8,
|
|
229
|
+
"status": "completed",
|
|
230
|
+
"result": {
|
|
231
|
+
"download_url": download_url,
|
|
232
|
+
"content_type": "image/png",
|
|
233
|
+
"full_page": True,
|
|
234
|
+
"single_use": True,
|
|
235
|
+
"tid": 12,
|
|
236
|
+
},
|
|
237
|
+
}
|
|
238
|
+
),
|
|
239
|
+
FakeResponse(image),
|
|
240
|
+
]
|
|
241
|
+
|
|
242
|
+
with patch(
|
|
243
|
+
"acob.client.urlopen",
|
|
244
|
+
side_effect=responses,
|
|
245
|
+
) as mocked_urlopen:
|
|
246
|
+
client = self.make_client()
|
|
247
|
+
result = client.screenshot(12, full_page=True)
|
|
248
|
+
image_result = client.download_screenshot(result["download_url"])
|
|
249
|
+
|
|
250
|
+
self.assertEqual(result["download_url"], download_url)
|
|
251
|
+
self.assertEqual(image_result, image)
|
|
252
|
+
submitted_request = mocked_urlopen.call_args_list[0].args[0]
|
|
253
|
+
self.assertEqual(
|
|
254
|
+
json.loads(submitted_request.data),
|
|
255
|
+
{"action": "screenshot", "tid": 12, "full_page": True},
|
|
256
|
+
)
|
|
257
|
+
download_request = mocked_urlopen.call_args_list[2].args[0]
|
|
258
|
+
self.assertEqual(download_request.full_url, f"http://acob.test{download_url}")
|
|
259
|
+
self.assertEqual(download_request.get_method(), "GET")
|
|
260
|
+
|
|
261
|
+
def test_screenshot_rejects_a_download_on_another_origin(self):
|
|
262
|
+
with self.assertRaises(ACOBProtocolError):
|
|
263
|
+
self.make_client().download_screenshot("https://example.com/image.png")
|
|
264
|
+
|
|
265
|
+
def test_timeout_retains_instruction_id_for_later_recovery(self):
|
|
266
|
+
with (
|
|
267
|
+
patch(
|
|
268
|
+
"acob.client.urlopen",
|
|
269
|
+
return_value=FakeResponse({"id": 21, "status": "pending"}),
|
|
270
|
+
),
|
|
271
|
+
patch("acob.client.time.monotonic", side_effect=[0, 0, 1]),
|
|
272
|
+
):
|
|
273
|
+
with self.assertRaises(ACOBTimeoutError) as raised:
|
|
274
|
+
self.make_client().wait(21, timeout=1)
|
|
275
|
+
|
|
276
|
+
self.assertEqual(raised.exception.instruction_id, 21)
|
|
277
|
+
self.assertEqual(raised.exception.timeout, 1)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
if __name__ == "__main__":
|
|
281
|
+
unittest.main()
|