cuakit 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.
- cuakit-0.1.0/.gitignore +29 -0
- cuakit-0.1.0/PKG-INFO +73 -0
- cuakit-0.1.0/README.md +49 -0
- cuakit-0.1.0/pyproject.toml +51 -0
- cuakit-0.1.0/scripts/publish.sh +8 -0
- cuakit-0.1.0/src/cuakit/__init__.py +10 -0
- cuakit-0.1.0/src/cuakit/client.py +176 -0
- cuakit-0.1.0/src/cuakit/errors.py +14 -0
- cuakit-0.1.0/src/cuakit/types.py +26 -0
- cuakit-0.1.0/tests/test_client.py +73 -0
- cuakit-0.1.0/uv.lock +841 -0
cuakit-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Node / web
|
|
2
|
+
node_modules/
|
|
3
|
+
.pnpm-store/
|
|
4
|
+
.next/
|
|
5
|
+
out/
|
|
6
|
+
coverage/
|
|
7
|
+
|
|
8
|
+
# Python
|
|
9
|
+
__pycache__/
|
|
10
|
+
*.py[cod]
|
|
11
|
+
*.egg-info/
|
|
12
|
+
.mypy_cache/
|
|
13
|
+
.pytest_cache/
|
|
14
|
+
.venv/
|
|
15
|
+
venv/
|
|
16
|
+
|
|
17
|
+
# Build artifacts
|
|
18
|
+
dist/
|
|
19
|
+
build/
|
|
20
|
+
|
|
21
|
+
# Env
|
|
22
|
+
.env
|
|
23
|
+
.env.*
|
|
24
|
+
!.env.example
|
|
25
|
+
|
|
26
|
+
# Editor / OS
|
|
27
|
+
.DS_Store
|
|
28
|
+
.vscode/
|
|
29
|
+
.idea/
|
cuakit-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cuakit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Remote desktop control toolkit for cuakit workers
|
|
5
|
+
Project-URL: Homepage, https://cuakit.bolte.cc
|
|
6
|
+
Project-URL: Repository, https://github.com/bolte-dev/cuakit
|
|
7
|
+
Author: cuakit maintainers
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: agents,automation,desktop,pyautogui,remote
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Requires-Python: >=3.11
|
|
15
|
+
Requires-Dist: httpx>=0.27.2
|
|
16
|
+
Requires-Dist: pydantic>=2.10.6
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: build>=1.2.2; extra == 'dev'
|
|
19
|
+
Requires-Dist: pytest>=8.3.4; extra == 'dev'
|
|
20
|
+
Requires-Dist: ruff>=0.9.6; extra == 'dev'
|
|
21
|
+
Requires-Dist: twine>=6.1.0; extra == 'dev'
|
|
22
|
+
Requires-Dist: ty>=0.0.1a14; extra == 'dev'
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# cuakit Python SDK
|
|
26
|
+
|
|
27
|
+
`cuakit` provides a pyautogui-like API for controlling remote cuakit workers.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install cuakit
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Quick start
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from cuakit import CuakitClient
|
|
39
|
+
|
|
40
|
+
client = CuakitClient(
|
|
41
|
+
base_url="https://cuakit.bolte.cc",
|
|
42
|
+
worker_id="cuaworker1",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
client.wait_for_turn(timeout_s=90)
|
|
46
|
+
client.move_to(500, 320)
|
|
47
|
+
client.click(500, 320)
|
|
48
|
+
client.typewrite("hello from cuakit")
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Queue behavior
|
|
52
|
+
|
|
53
|
+
- `wait_for_turn()` joins queue and blocks until the lease is granted.
|
|
54
|
+
- Each lease is one minute by default.
|
|
55
|
+
- SDK and web users share the same queue.
|
|
56
|
+
|
|
57
|
+
## Stream access
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
stream = client.stream_info()
|
|
61
|
+
print(stream.stream_url)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The stream URL can be opened in a browser/player to observe video and audio.
|
|
65
|
+
|
|
66
|
+
## Publish to PyPI
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
python -m build
|
|
70
|
+
twine upload dist/*
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
This repository assumes `~/.pypirc` is already configured.
|
cuakit-0.1.0/README.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# cuakit Python SDK
|
|
2
|
+
|
|
3
|
+
`cuakit` provides a pyautogui-like API for controlling remote cuakit workers.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install cuakit
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from cuakit import CuakitClient
|
|
15
|
+
|
|
16
|
+
client = CuakitClient(
|
|
17
|
+
base_url="https://cuakit.bolte.cc",
|
|
18
|
+
worker_id="cuaworker1",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
client.wait_for_turn(timeout_s=90)
|
|
22
|
+
client.move_to(500, 320)
|
|
23
|
+
client.click(500, 320)
|
|
24
|
+
client.typewrite("hello from cuakit")
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Queue behavior
|
|
28
|
+
|
|
29
|
+
- `wait_for_turn()` joins queue and blocks until the lease is granted.
|
|
30
|
+
- Each lease is one minute by default.
|
|
31
|
+
- SDK and web users share the same queue.
|
|
32
|
+
|
|
33
|
+
## Stream access
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
stream = client.stream_info()
|
|
37
|
+
print(stream.stream_url)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The stream URL can be opened in a browser/player to observe video and audio.
|
|
41
|
+
|
|
42
|
+
## Publish to PyPI
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
python -m build
|
|
46
|
+
twine upload dist/*
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
This repository assumes `~/.pypirc` is already configured.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27.0"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "cuakit"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Remote desktop control toolkit for cuakit workers"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
authors = [{ name = "cuakit maintainers" }]
|
|
12
|
+
license = { text = "MIT" }
|
|
13
|
+
keywords = ["automation", "desktop", "remote", "pyautogui", "agents"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.11",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Operating System :: OS Independent"
|
|
19
|
+
]
|
|
20
|
+
dependencies = [
|
|
21
|
+
"httpx>=0.27.2",
|
|
22
|
+
"pydantic>=2.10.6"
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
dev = [
|
|
27
|
+
"build>=1.2.2",
|
|
28
|
+
"pytest>=8.3.4",
|
|
29
|
+
"ruff>=0.9.6",
|
|
30
|
+
"ty>=0.0.1a14",
|
|
31
|
+
"twine>=6.1.0"
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://cuakit.bolte.cc"
|
|
36
|
+
Repository = "https://github.com/bolte-dev/cuakit"
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.wheel]
|
|
39
|
+
packages = ["src/cuakit"]
|
|
40
|
+
|
|
41
|
+
[tool.ruff]
|
|
42
|
+
line-length = 100
|
|
43
|
+
target-version = "py311"
|
|
44
|
+
|
|
45
|
+
[tool.ruff.lint]
|
|
46
|
+
select = ["E", "F", "I", "B", "UP"]
|
|
47
|
+
ignore = []
|
|
48
|
+
|
|
49
|
+
[tool.pytest.ini_options]
|
|
50
|
+
pythonpath = ["src"]
|
|
51
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import uuid
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from cuakit.errors import ApiError, QueueNotReadyError, QueueTimeoutError
|
|
8
|
+
from cuakit.types import CommandResult, QueueStatus, StreamInfo
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class _CommandEnvelope:
|
|
13
|
+
request_id: str
|
|
14
|
+
worker_id: str
|
|
15
|
+
command: dict[str, object]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CuakitClient:
|
|
19
|
+
"""Remote desktop client with pyautogui-like methods.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
base_url: cuakit control plane URL (for example https://cuakit.bolte.cc).
|
|
23
|
+
worker_id: Worker identifier to control.
|
|
24
|
+
client_id: Optional stable client id.
|
|
25
|
+
timeout_s: HTTP timeout per request.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
base_url: str,
|
|
31
|
+
worker_id: str = "cuaworker1",
|
|
32
|
+
client_id: str | None = None,
|
|
33
|
+
timeout_s: float = 15.0,
|
|
34
|
+
) -> None:
|
|
35
|
+
self._base_url = base_url.rstrip("/")
|
|
36
|
+
self._worker_id = worker_id
|
|
37
|
+
self._client_id = client_id or str(uuid.uuid4())
|
|
38
|
+
self._http = httpx.Client(
|
|
39
|
+
timeout=timeout_s,
|
|
40
|
+
headers={"x-cuakit-client-id": self._client_id},
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def client_id(self) -> str:
|
|
45
|
+
return self._client_id
|
|
46
|
+
|
|
47
|
+
def close(self) -> None:
|
|
48
|
+
self._http.close()
|
|
49
|
+
|
|
50
|
+
def join_queue(self) -> QueueStatus:
|
|
51
|
+
payload = self._request_json("POST", "/api/queue/join")
|
|
52
|
+
return QueueStatus.model_validate(payload["status"])
|
|
53
|
+
|
|
54
|
+
def status(self) -> QueueStatus:
|
|
55
|
+
payload = self._request_json("GET", "/api/queue/status")
|
|
56
|
+
return QueueStatus.model_validate(payload["status"])
|
|
57
|
+
|
|
58
|
+
def release(self) -> QueueStatus:
|
|
59
|
+
payload = self._request_json("POST", "/api/queue/release")
|
|
60
|
+
return QueueStatus.model_validate(payload["status"])
|
|
61
|
+
|
|
62
|
+
def wait_for_turn(self, timeout_s: float = 120.0, poll_interval_s: float = 2.0) -> QueueStatus:
|
|
63
|
+
self.join_queue()
|
|
64
|
+
deadline = time.monotonic() + timeout_s
|
|
65
|
+
|
|
66
|
+
while True:
|
|
67
|
+
status = self.status()
|
|
68
|
+
if status.isController:
|
|
69
|
+
return status
|
|
70
|
+
|
|
71
|
+
if time.monotonic() >= deadline:
|
|
72
|
+
raise QueueTimeoutError(
|
|
73
|
+
f"Timed out waiting for controller lease for worker '{self._worker_id}'"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
time.sleep(poll_interval_s)
|
|
77
|
+
|
|
78
|
+
def stream_info(self) -> StreamInfo:
|
|
79
|
+
payload = self._request_json("GET", "/api/control/stream")
|
|
80
|
+
mapped = {
|
|
81
|
+
"ok": payload["ok"],
|
|
82
|
+
"role": payload["role"],
|
|
83
|
+
"stream_url": payload["streamUrl"],
|
|
84
|
+
"status": payload["status"],
|
|
85
|
+
}
|
|
86
|
+
return StreamInfo.model_validate(mapped)
|
|
87
|
+
|
|
88
|
+
def move_to(self, x: int, y: int, duration_ms: int = 0) -> CommandResult:
|
|
89
|
+
return self._command({"type": "move_to", "x": x, "y": y, "duration_ms": duration_ms})
|
|
90
|
+
|
|
91
|
+
def moveTo(self, x: int, y: int, duration_ms: int = 0) -> CommandResult: # noqa: N802
|
|
92
|
+
return self.move_to(x, y, duration_ms=duration_ms)
|
|
93
|
+
|
|
94
|
+
def click(self, x: int, y: int, button: str = "left") -> CommandResult:
|
|
95
|
+
return self._command({"type": "click", "x": x, "y": y, "button": button})
|
|
96
|
+
|
|
97
|
+
def double_click(self, x: int, y: int, button: str = "left") -> CommandResult:
|
|
98
|
+
return self._command({"type": "double_click", "x": x, "y": y, "button": button})
|
|
99
|
+
|
|
100
|
+
def mouse_down(self, x: int, y: int, button: str = "left") -> CommandResult:
|
|
101
|
+
return self._command({"type": "mouse_down", "x": x, "y": y, "button": button})
|
|
102
|
+
|
|
103
|
+
def mouse_up(self, x: int, y: int, button: str = "left") -> CommandResult:
|
|
104
|
+
return self._command({"type": "mouse_up", "x": x, "y": y, "button": button})
|
|
105
|
+
|
|
106
|
+
def scroll(self, clicks: int, x: int | None = None, y: int | None = None) -> CommandResult:
|
|
107
|
+
command: dict[str, object] = {"type": "scroll", "clicks": clicks}
|
|
108
|
+
if x is not None:
|
|
109
|
+
command["x"] = x
|
|
110
|
+
if y is not None:
|
|
111
|
+
command["y"] = y
|
|
112
|
+
return self._command(command)
|
|
113
|
+
|
|
114
|
+
def typewrite(self, text: str, interval_ms: int = 0) -> CommandResult:
|
|
115
|
+
return self._command({"type": "typewrite", "text": text, "interval_ms": interval_ms})
|
|
116
|
+
|
|
117
|
+
def write(self, text: str, interval_ms: int = 0) -> CommandResult:
|
|
118
|
+
return self.typewrite(text, interval_ms=interval_ms)
|
|
119
|
+
|
|
120
|
+
def press(self, key: str) -> CommandResult:
|
|
121
|
+
return self._command({"type": "press", "key": key})
|
|
122
|
+
|
|
123
|
+
def hotkey(self, *keys: str) -> CommandResult:
|
|
124
|
+
if not keys:
|
|
125
|
+
raise ValueError("hotkey requires at least one key")
|
|
126
|
+
return self._command({"type": "hotkey", "keys": list(keys)})
|
|
127
|
+
|
|
128
|
+
def sleep(self, duration_ms: int) -> CommandResult:
|
|
129
|
+
return self._command({"type": "sleep", "duration_ms": duration_ms})
|
|
130
|
+
|
|
131
|
+
def _command(self, command: dict[str, object]) -> CommandResult:
|
|
132
|
+
envelope = _CommandEnvelope(
|
|
133
|
+
request_id=str(uuid.uuid4()),
|
|
134
|
+
worker_id=self._worker_id,
|
|
135
|
+
command=command,
|
|
136
|
+
)
|
|
137
|
+
payload = self._request_json(
|
|
138
|
+
"POST",
|
|
139
|
+
"/api/control/command",
|
|
140
|
+
json={
|
|
141
|
+
"request_id": envelope.request_id,
|
|
142
|
+
"worker_id": envelope.worker_id,
|
|
143
|
+
"command": envelope.command,
|
|
144
|
+
},
|
|
145
|
+
)
|
|
146
|
+
return CommandResult.model_validate(payload)
|
|
147
|
+
|
|
148
|
+
def _request_json(
|
|
149
|
+
self,
|
|
150
|
+
method: str,
|
|
151
|
+
path: str,
|
|
152
|
+
json: dict[str, object] | None = None,
|
|
153
|
+
) -> dict[str, object]:
|
|
154
|
+
response = self._http.request(method, f"{self._base_url}{path}", json=json)
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
payload = response.json()
|
|
158
|
+
except ValueError as err:
|
|
159
|
+
raise ApiError(f"Invalid JSON response from {path}") from err
|
|
160
|
+
|
|
161
|
+
if response.status_code == 423:
|
|
162
|
+
raise QueueNotReadyError(payload.get("error", "Queue lease not active"))
|
|
163
|
+
|
|
164
|
+
if response.status_code >= 400:
|
|
165
|
+
raise ApiError(f"HTTP {response.status_code} from {path}: {payload}")
|
|
166
|
+
|
|
167
|
+
if not isinstance(payload, dict):
|
|
168
|
+
raise ApiError(f"Unexpected response payload from {path}")
|
|
169
|
+
|
|
170
|
+
return payload
|
|
171
|
+
|
|
172
|
+
def __enter__(self) -> "CuakitClient":
|
|
173
|
+
return self
|
|
174
|
+
|
|
175
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
176
|
+
self.close()
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
class CuakitError(Exception):
|
|
2
|
+
"""Base SDK exception."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class QueueTimeoutError(CuakitError):
|
|
6
|
+
"""Raised when waiting for queue control exceeds timeout."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class QueueNotReadyError(CuakitError):
|
|
10
|
+
"""Raised when a control command is sent without controller lease."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ApiError(CuakitError):
|
|
14
|
+
"""Raised when the control plane returns an API error."""
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class QueueStatus(BaseModel):
|
|
7
|
+
workerId: str
|
|
8
|
+
activeControllerId: str | None
|
|
9
|
+
expiresAt: int | None
|
|
10
|
+
queueLength: int
|
|
11
|
+
position: int | None
|
|
12
|
+
isController: bool
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class StreamInfo(BaseModel):
|
|
16
|
+
ok: bool
|
|
17
|
+
role: Literal["viewer", "controller"]
|
|
18
|
+
stream_url: str
|
|
19
|
+
status: QueueStatus
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CommandResult(BaseModel):
|
|
23
|
+
ok: bool
|
|
24
|
+
request_id: str
|
|
25
|
+
worker_id: str
|
|
26
|
+
duration_ms: int
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
import pytest
|
|
3
|
+
|
|
4
|
+
from cuakit.client import CuakitClient
|
|
5
|
+
from cuakit.errors import QueueNotReadyError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _build_client(
|
|
9
|
+
payloads: list[dict[str, object]],
|
|
10
|
+
statuses: list[int] | None = None,
|
|
11
|
+
) -> CuakitClient:
|
|
12
|
+
statuses = statuses or [200] * len(payloads)
|
|
13
|
+
idx = {"value": 0}
|
|
14
|
+
|
|
15
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
16
|
+
current = idx["value"]
|
|
17
|
+
idx["value"] += 1
|
|
18
|
+
return httpx.Response(statuses[current], json=payloads[current])
|
|
19
|
+
|
|
20
|
+
transport = httpx.MockTransport(handler)
|
|
21
|
+
client = CuakitClient("https://example.com")
|
|
22
|
+
client._http = httpx.Client(transport=transport) # noqa: SLF001
|
|
23
|
+
return client
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_wait_for_turn_returns_controller() -> None:
|
|
27
|
+
client = _build_client(
|
|
28
|
+
payloads=[
|
|
29
|
+
{
|
|
30
|
+
"status": {
|
|
31
|
+
"workerId": "cuaworker1",
|
|
32
|
+
"activeControllerId": None,
|
|
33
|
+
"expiresAt": None,
|
|
34
|
+
"queueLength": 1,
|
|
35
|
+
"position": 1,
|
|
36
|
+
"isController": False,
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"status": {
|
|
41
|
+
"workerId": "cuaworker1",
|
|
42
|
+
"activeControllerId": None,
|
|
43
|
+
"expiresAt": None,
|
|
44
|
+
"queueLength": 1,
|
|
45
|
+
"position": 1,
|
|
46
|
+
"isController": False,
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"status": {
|
|
51
|
+
"workerId": "cuaworker1",
|
|
52
|
+
"activeControllerId": "abc",
|
|
53
|
+
"expiresAt": 12345,
|
|
54
|
+
"queueLength": 0,
|
|
55
|
+
"position": 0,
|
|
56
|
+
"isController": True,
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
]
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
status = client.wait_for_turn(timeout_s=0.2, poll_interval_s=0.01)
|
|
63
|
+
assert status.isController is True
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_command_raises_when_not_controller() -> None:
|
|
67
|
+
client = _build_client(
|
|
68
|
+
payloads=[{"ok": False, "error": "Client does not hold active controller lease"}],
|
|
69
|
+
statuses=[423],
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
with pytest.raises(QueueNotReadyError):
|
|
73
|
+
client.click(10, 10)
|