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.
@@ -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,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ cd "$(dirname "$0")/.."
5
+
6
+ python -m pip install --upgrade build twine
7
+ python -m build
8
+ twine upload dist/*
@@ -0,0 +1,10 @@
1
+ from cuakit.client import CuakitClient
2
+ from cuakit.errors import ApiError, CuakitError, QueueNotReadyError, QueueTimeoutError
3
+
4
+ __all__ = [
5
+ "ApiError",
6
+ "CuakitClient",
7
+ "CuakitError",
8
+ "QueueNotReadyError",
9
+ "QueueTimeoutError",
10
+ ]
@@ -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)