qirabot 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.
qirabot-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,24 @@
1
+ Metadata-Version: 2.4
2
+ Name: qirabot
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for Qirabot - AI-powered device automation
5
+ Author-email: Qirabot Team <support@qirabot.com>
6
+ License-Expression: MIT
7
+ Keywords: qirabot,automation,testing,ai,sdk,rpa
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+ Requires-Dist: httpx>=0.27.0
19
+ Requires-Dist: websockets>=13.0
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
22
+ Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
23
+ Requires-Dist: mypy>=1.0.0; extra == "dev"
24
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
@@ -0,0 +1,48 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "qirabot"
7
+ version = "0.1.0"
8
+ description = "Official Python SDK for Qirabot - AI-powered device automation"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [
13
+ { name = "Qirabot Team", email = "support@qirabot.com" }
14
+ ]
15
+ keywords = ["qirabot", "automation", "testing", "ai", "sdk", "rpa"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Typing :: Typed",
25
+ ]
26
+ dependencies = [
27
+ "httpx>=0.27.0",
28
+ "websockets>=13.0",
29
+ ]
30
+
31
+ [project.optional-dependencies]
32
+ dev = [
33
+ "pytest>=7.0.0",
34
+ "pytest-cov>=4.0.0",
35
+ "mypy>=1.0.0",
36
+ "ruff>=0.1.0",
37
+ ]
38
+
39
+ [tool.setuptools.packages.find]
40
+ where = ["src"]
41
+
42
+ [tool.mypy]
43
+ python_version = "3.10"
44
+ strict = true
45
+
46
+ [tool.ruff]
47
+ target-version = "py310"
48
+ line-length = 100
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,31 @@
1
+ """Qirabot - AI-powered device automation SDK."""
2
+
3
+ from qirabot.actions import Action
4
+ from qirabot.client import DeviceInfo, Qirabot, TaskResult
5
+ from qirabot.exceptions import (
6
+ ActionError,
7
+ AuthenticationError,
8
+ DeviceBusyError,
9
+ DeviceOfflineError,
10
+ LeaseExpiredError,
11
+ QirabotError,
12
+ QirabotTimeoutError,
13
+ )
14
+ from qirabot.task_context import ScreenshotEvent, StepEvent, TaskContext
15
+
16
+ __all__ = [
17
+ "Action",
18
+ "ActionError",
19
+ "AuthenticationError",
20
+ "DeviceBusyError",
21
+ "DeviceInfo",
22
+ "DeviceOfflineError",
23
+ "LeaseExpiredError",
24
+ "Qirabot",
25
+ "QirabotError",
26
+ "QirabotTimeoutError",
27
+ "ScreenshotEvent",
28
+ "StepEvent",
29
+ "TaskContext",
30
+ "TaskResult",
31
+ ]
@@ -0,0 +1,172 @@
1
+ """HTTP + WebSocket transport layer for Qirabot SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import struct
8
+ from dataclasses import dataclass, field
9
+ from typing import Any, Iterator
10
+ from urllib.parse import urlparse
11
+
12
+ import httpx
13
+ import websockets.sync.client as ws_sync
14
+
15
+ from qirabot.exceptions import raise_for_error, QirabotTimeoutError
16
+
17
+ logger = logging.getLogger("qirabot")
18
+
19
+
20
+ @dataclass
21
+ class StepMessage:
22
+ """A step event received from WebSocket."""
23
+
24
+ data: dict[str, Any]
25
+ screenshot: bytes | None = None
26
+
27
+
28
+ class Transport:
29
+ """HTTP client with WebSocket support."""
30
+
31
+ def __init__(
32
+ self,
33
+ base_url: str,
34
+ api_key: str,
35
+ timeout: float = 30.0,
36
+ verify_ssl: bool = True,
37
+ ):
38
+ self._base_url = base_url.rstrip("/")
39
+ self._api_url = f"{self._base_url}/api/v1"
40
+ self._api_key = api_key
41
+ self._headers = {"X-API-Key": api_key}
42
+ self._timeout = timeout
43
+ self._verify_ssl = verify_ssl
44
+ self._client = httpx.Client(
45
+ base_url=self._api_url,
46
+ headers=self._headers,
47
+ timeout=timeout,
48
+ verify=verify_ssl,
49
+ )
50
+
51
+ def request(self, method: str, path: str, json_data: dict[str, Any] | None = None) -> Any:
52
+ """Send an HTTP request and return parsed JSON response."""
53
+ response = self._client.request(method, path, json=json_data)
54
+ if response.status_code >= 400:
55
+ try:
56
+ data = response.json()
57
+ except Exception:
58
+ data = {"error": {"message": response.text or "Unknown error"}}
59
+ raise_for_error(response.status_code, data)
60
+ if response.status_code == 204:
61
+ return {}
62
+ try:
63
+ return response.json()
64
+ except Exception:
65
+ return {}
66
+
67
+ def post(self, path: str, json_data: dict[str, Any] | None = None) -> dict[str, Any]:
68
+ """Send a POST request."""
69
+ return self.request("POST", path, json_data)
70
+
71
+ def delete(self, path: str) -> dict[str, Any]:
72
+ """Send a DELETE request."""
73
+ return self.request("DELETE", path)
74
+
75
+ def ws_connect(self, path: str, timeout: float | None = None) -> WSConnection:
76
+ """Open a WebSocket connection.
77
+
78
+ Args:
79
+ path: API path (e.g. "/sdk/tasks/{id}/ws").
80
+ timeout: Optional timeout override.
81
+
82
+ Returns:
83
+ A WSConnection wrapper.
84
+ """
85
+ connect_timeout = timeout or self._timeout
86
+ ws_url = self._build_ws_url(path)
87
+ additional_headers = {"X-API-Key": self._api_key}
88
+ conn = ws_sync.connect(
89
+ ws_url,
90
+ additional_headers=additional_headers,
91
+ open_timeout=connect_timeout,
92
+ close_timeout=10,
93
+ ping_interval=None,
94
+ )
95
+ return WSConnection(conn)
96
+
97
+ def get_bytes(self, path: str) -> bytes:
98
+ """Send a GET request and return raw bytes (for screenshot download)."""
99
+ response = self._client.get(path)
100
+ if response.status_code >= 400:
101
+ try:
102
+ data = response.json()
103
+ except Exception:
104
+ data = {"error": {"message": response.text or "Unknown error"}}
105
+ raise_for_error(response.status_code, data)
106
+ return response.content
107
+
108
+ def close(self) -> None:
109
+ """Close the HTTP client."""
110
+ self._client.close()
111
+
112
+ def _build_ws_url(self, path: str) -> str:
113
+ """Convert HTTP base URL to WebSocket URL."""
114
+ parsed = urlparse(self._api_url)
115
+ scheme = "wss" if parsed.scheme == "https" else "ws"
116
+ return f"{scheme}://{parsed.netloc}{parsed.path}{path}"
117
+
118
+
119
+ class WSConnection:
120
+ """Wrapper around a WebSocket connection for SDK task action.
121
+
122
+ Server protocol:
123
+ - Text frame: JSON message (step event, result event, or error)
124
+ - Binary frame: composite [2-byte JSON length (big-endian)] [JSON] [PNG]
125
+ Used for step events with inline screenshots.
126
+ """
127
+
128
+ def __init__(self, conn: ws_sync.ClientConnection):
129
+ self._conn = conn
130
+
131
+ def send_action(self, action: dict[str, Any]) -> None:
132
+ """Send an execute_action request."""
133
+ msg = json.dumps({"type": "execute_action", "action": action})
134
+ self._conn.send(msg)
135
+
136
+ def receive(self, timeout: float | None = None) -> StepMessage:
137
+ """Receive the next message from the server.
138
+
139
+ Returns a StepMessage. For binary frames, the screenshot field is populated.
140
+ """
141
+ self._conn.socket.settimeout(timeout)
142
+ try:
143
+ frame = self._conn.recv()
144
+ except TimeoutError:
145
+ raise QirabotTimeoutError("WebSocket receive timed out")
146
+
147
+ if isinstance(frame, str):
148
+ # Text frame: pure JSON
149
+ data = json.loads(frame)
150
+ return StepMessage(data=data)
151
+
152
+ # Binary frame: [2-byte JSON length] [JSON] [PNG]
153
+ if len(frame) < 2:
154
+ return StepMessage(data={})
155
+ json_len = struct.unpack("!H", frame[:2])[0]
156
+ json_bytes = frame[2 : 2 + json_len]
157
+ screenshot = frame[2 + json_len :]
158
+ data = json.loads(json_bytes)
159
+ return StepMessage(data=data, screenshot=screenshot if screenshot else None)
160
+
161
+ def close(self) -> None:
162
+ """Close the WebSocket connection."""
163
+ try:
164
+ self._conn.close()
165
+ except Exception:
166
+ pass
167
+
168
+ def __enter__(self) -> WSConnection:
169
+ return self
170
+
171
+ def __exit__(self, *args: object) -> None:
172
+ self.close()
@@ -0,0 +1,156 @@
1
+ """Action definitions for Qirabot SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+
9
+ @dataclass
10
+ class Action:
11
+ """A device action to execute.
12
+
13
+ Use the class methods to create actions instead of constructing directly.
14
+ """
15
+
16
+ type: str
17
+ params: dict[str, Any] = field(default_factory=dict)
18
+
19
+ def to_dict(self) -> dict[str, Any]:
20
+ """Serialize for API request."""
21
+ return {"type": self.type, "params": self.params}
22
+
23
+ # ── Click ──
24
+
25
+ @classmethod
26
+ def click(cls, locate: str) -> Action:
27
+ return cls(type="click", params={"locate": locate})
28
+
29
+ @classmethod
30
+ def double_click(cls, locate: str) -> Action:
31
+ return cls(type="double_click", params={"locate": locate})
32
+
33
+ @classmethod
34
+ def right_click(cls, locate: str) -> Action:
35
+ return cls(type="right_click", params={"locate": locate})
36
+
37
+ @classmethod
38
+ def hover(cls, locate: str) -> Action:
39
+ return cls(type="hover", params={"locate": locate})
40
+
41
+ # ── Text Input ──
42
+
43
+ @classmethod
44
+ def type_text(cls, locate: str, content: str) -> Action:
45
+ return cls(type="type_text", params={"locate": locate, "content": content})
46
+
47
+ @classmethod
48
+ def type_direct(cls, content: str) -> Action:
49
+ return cls(type="type_text_direct", params={"content": content})
50
+
51
+ @classmethod
52
+ def clear_text(cls, locate: str) -> Action:
53
+ return cls(type="clear_text", params={"locate": locate})
54
+
55
+ @classmethod
56
+ def press_key(cls, key: str) -> Action:
57
+ return cls(type="press_key", params={"key": key})
58
+
59
+ # ── Navigation ──
60
+
61
+ @classmethod
62
+ def navigate(cls, url: str) -> Action:
63
+ return cls(type="navigate", params={"url": url})
64
+
65
+ @classmethod
66
+ def scroll(cls, direction: str = "down", distance: int | None = None) -> Action:
67
+ params: dict[str, Any] = {"direction": direction}
68
+ if distance is not None:
69
+ params["distance"] = distance
70
+ return cls(type="scroll", params=params)
71
+
72
+ @classmethod
73
+ def scroll_at(cls, locate: str, direction: str = "down", distance: int | None = None) -> Action:
74
+ params: dict[str, Any] = {"locate": locate, "direction": direction}
75
+ if distance is not None:
76
+ params["distance"] = distance
77
+ return cls(type="scroll_at", params=params)
78
+
79
+ @classmethod
80
+ def swipe(
81
+ cls,
82
+ direction: str,
83
+ locate: str | None = None,
84
+ distance: int | None = None,
85
+ duration_ms: int = 500,
86
+ ) -> Action:
87
+ params: dict[str, Any] = {"direction": direction, "durationMs": duration_ms}
88
+ if locate is not None:
89
+ params["locate"] = locate
90
+ if distance is not None:
91
+ params["distance"] = distance
92
+ return cls(type="swipe", params=params)
93
+
94
+ # ── Wait ──
95
+
96
+ @classmethod
97
+ def wait(cls, duration_ms: int) -> Action:
98
+ return cls(type="wait", params={"durationMs": duration_ms})
99
+
100
+ @classmethod
101
+ def wait_for(
102
+ cls,
103
+ assertion: str,
104
+ timeout_ms: int,
105
+ check_interval_ms: int = 1000,
106
+ ) -> Action:
107
+ return cls(
108
+ type="wait_for",
109
+ params={
110
+ "assertion": assertion,
111
+ "timeoutMs": timeout_ms,
112
+ "checkInterval": check_interval_ms,
113
+ },
114
+ )
115
+
116
+ # ── Screenshot ──
117
+
118
+ @classmethod
119
+ def take_screenshot(cls) -> Action:
120
+ return cls(type="take_screenshot")
121
+
122
+ # ── AI ──
123
+
124
+ @classmethod
125
+ def extract(cls, instruction: str, variable: str = "result", value_type: str = "string") -> Action:
126
+ return cls(
127
+ type="extract",
128
+ params={"instruction": instruction, "variable": variable, "valueType": value_type},
129
+ )
130
+
131
+ @classmethod
132
+ def verify(cls, assertion: str) -> Action:
133
+ return cls(type="assert", params={"assertion": assertion})
134
+
135
+ @classmethod
136
+ def ai(cls, instruction: str, max_steps: int = 10, model_alias: str | None = None, language: str | None = None) -> Action:
137
+ params: dict[str, Any] = {"instruction": instruction, "maxSteps": max_steps}
138
+ if model_alias is not None:
139
+ params["modelAlias"] = model_alias
140
+ if language is not None:
141
+ params["language"] = language
142
+ return cls(type="ai_decision", params=params)
143
+
144
+ @classmethod
145
+ def search(cls, locate: str, content: str) -> Action:
146
+ return cls(type="search", params={"locate": locate, "text": content})
147
+
148
+ # ── App Control ──
149
+
150
+ @classmethod
151
+ def start_app(cls, package: str) -> Action:
152
+ return cls(type="start_app", params={"package": package})
153
+
154
+ @classmethod
155
+ def stop_app(cls, package: str) -> Action:
156
+ return cls(type="stop_app", params={"package": package})
@@ -0,0 +1,231 @@
1
+ """Main client for Qirabot SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from contextlib import contextmanager
7
+ from dataclasses import dataclass, field
8
+ from typing import Any, Iterator
9
+
10
+ from qirabot._transport import Transport
11
+ from qirabot.actions import Action
12
+ from qirabot.exceptions import QirabotTimeoutError
13
+ from qirabot.task_context import TaskContext
14
+
15
+
16
+ @dataclass
17
+ class DeviceInfo:
18
+ """Device information."""
19
+
20
+ id: str
21
+ name: str
22
+ platform: str
23
+ online: bool
24
+
25
+ @classmethod
26
+ def from_dict(cls, data: dict[str, Any]) -> DeviceInfo:
27
+ return cls(
28
+ id=data.get("id", ""),
29
+ name=data.get("name", ""),
30
+ platform=data.get("platform", ""),
31
+ online=data.get("online", False),
32
+ )
33
+
34
+
35
+ @dataclass
36
+ class TaskResult:
37
+ """Result of a completed task."""
38
+
39
+ id: str
40
+ status: str
41
+ current_step: int = 0
42
+ source: str = ""
43
+ error: str = ""
44
+ steps: list[dict[str, Any]] = field(default_factory=list)
45
+
46
+ @property
47
+ def succeeded(self) -> bool:
48
+ return self.status == "succeeded"
49
+
50
+ @classmethod
51
+ def from_dict(cls, data: dict[str, Any]) -> TaskResult:
52
+ return cls(
53
+ id=data.get("id", ""),
54
+ status=data.get("status", ""),
55
+ current_step=data.get("currentStep", 0),
56
+ source=data.get("source", ""),
57
+ error=data.get("error", ""),
58
+ )
59
+
60
+
61
+ class Qirabot:
62
+ """Qirabot SDK client.
63
+
64
+ Usage::
65
+
66
+ bot = Qirabot("qk_xxx", base_url="https://app.qirabot.com")
67
+ with bot.task("device-id") as t:
68
+ t.click("Login button")
69
+ t.type("username", "admin")
70
+ """
71
+
72
+ def __init__(
73
+ self,
74
+ api_key: str,
75
+ base_url: str = "https://app.qirabot.com",
76
+ timeout: float = 30.0,
77
+ verify_ssl: bool = True,
78
+ ):
79
+ self._transport = Transport(
80
+ base_url=base_url,
81
+ api_key=api_key,
82
+ timeout=timeout,
83
+ verify_ssl=verify_ssl,
84
+ )
85
+
86
+ def devices(self) -> list[DeviceInfo]:
87
+ """List all devices for the current user."""
88
+ resp = self._transport.request("GET", "/devices")
89
+ return [DeviceInfo.from_dict(d) for d in resp] if isinstance(resp, list) else []
90
+
91
+ def active_devices(self) -> list[DeviceInfo]:
92
+ """List online devices for the current user."""
93
+ resp = self._transport.request("GET", "/devices/active")
94
+ return [DeviceInfo.from_dict(d) for d in resp] if isinstance(resp, list) else []
95
+
96
+ def submit(
97
+ self,
98
+ device_id: str = "",
99
+ *,
100
+ actions: list[Action] | None = None,
101
+ instruction: str = "",
102
+ model_alias: str = "",
103
+ language: str = "",
104
+ max_steps: int = 10,
105
+ screenshot_mode: str = "cloud",
106
+ ) -> str:
107
+ """Submit actions for autonomous execution and return the task ID immediately.
108
+
109
+ Use ``wait()`` to poll for completion.
110
+
111
+ Args:
112
+ device_id: The device to run on (optional if sandbox is configured).
113
+ actions: List of actions to execute sequentially.
114
+ instruction: Shorthand for a single AI action. Mutually exclusive with ``actions``.
115
+ model_alias: Optional AI model alias.
116
+ language: Optional language (e.g. "zh", "en").
117
+ max_steps: Max steps for AI instruction (default 10).
118
+ screenshot_mode: "cloud" (default) or "none".
119
+
120
+ Returns:
121
+ The task ID string.
122
+ """
123
+ if instruction and actions:
124
+ raise ValueError("Cannot specify both 'actions' and 'instruction'")
125
+ if not instruction and not actions:
126
+ raise ValueError("Must specify either 'actions' or 'instruction'")
127
+
128
+ if instruction:
129
+ steps = [Action.ai(instruction, max_steps=max_steps, model_alias=model_alias or None, language=language or None)]
130
+ else:
131
+ steps = actions # type: ignore[assignment]
132
+
133
+ body: dict[str, Any] = {
134
+ "actions": [a.to_dict() for a in steps],
135
+ }
136
+ if device_id:
137
+ body["deviceId"] = device_id
138
+ if model_alias:
139
+ body["modelAlias"] = model_alias
140
+ if language:
141
+ body["language"] = language
142
+ if screenshot_mode != "cloud":
143
+ body["screenshotMode"] = screenshot_mode
144
+
145
+ resp = self._transport.post("/sdk/tasks/submit", body)
146
+ return resp["taskId"]
147
+
148
+ def wait(
149
+ self,
150
+ task_id: str,
151
+ timeout: float = 120.0,
152
+ poll_interval: float = 3.0,
153
+ ) -> TaskResult:
154
+ """Poll until a submitted task reaches a terminal state.
155
+
156
+ Args:
157
+ task_id: The task ID returned by ``submit()``.
158
+ timeout: Maximum seconds to wait (default 120).
159
+ poll_interval: Seconds between polls (default 3).
160
+
161
+ Returns:
162
+ A :class:`TaskResult` with the final status and steps.
163
+
164
+ Raises:
165
+ QirabotTimeoutError: If the task does not complete within the timeout.
166
+ """
167
+ deadline = time.monotonic() + timeout
168
+ while True:
169
+ resp = self._transport.request("GET", f"/tasks/{task_id}")
170
+ status = resp.get("status", "")
171
+ if status not in ("pending", "running"):
172
+ result = TaskResult.from_dict(resp)
173
+ steps_resp = self._transport.request("GET", f"/tasks/{task_id}/steps")
174
+ if isinstance(steps_resp, list):
175
+ result.steps = steps_resp
176
+ return result
177
+ if time.monotonic() >= deadline:
178
+ raise QirabotTimeoutError(f"Task {task_id} did not complete within {timeout}s")
179
+ time.sleep(poll_interval)
180
+
181
+ @contextmanager
182
+ def task(
183
+ self,
184
+ device_id: str,
185
+ model_alias: str = "",
186
+ language: str = "",
187
+ screenshot_mode: str = "cloud",
188
+ ) -> Iterator[TaskContext]:
189
+ """Create a task on a device and return an interactive context.
190
+
191
+ Args:
192
+ device_id: The device to connect to.
193
+ model_alias: Optional AI model alias to use (default: server default).
194
+ language: Optional language for AI responses (e.g. "zh", "en", "ja").
195
+ screenshot_mode: Screenshot storage mode:
196
+ - "cloud": store to cloud, return path (default)
197
+ - "inline": no cloud storage, send binary via WebSocket
198
+ - "none": no screenshots stored or returned
199
+
200
+ Yields:
201
+ A TaskContext that auto-completes on exit.
202
+ """
203
+ body: dict[str, Any] = {"deviceId": device_id}
204
+ if model_alias:
205
+ body["modelAlias"] = model_alias
206
+ if language:
207
+ body["language"] = language
208
+ if screenshot_mode != "cloud":
209
+ body["screenshotMode"] = screenshot_mode
210
+
211
+ resp = self._transport.post("/sdk/tasks", body)
212
+ task_id = resp["taskId"]
213
+
214
+ ctx = TaskContext(
215
+ transport=self._transport,
216
+ task_id=task_id,
217
+ device_id=device_id,
218
+ screenshot_mode=screenshot_mode,
219
+ )
220
+ with ctx:
221
+ yield ctx
222
+
223
+ def close(self) -> None:
224
+ """Close the underlying HTTP client."""
225
+ self._transport.close()
226
+
227
+ def __enter__(self) -> Qirabot:
228
+ return self
229
+
230
+ def __exit__(self, *args: object) -> None:
231
+ self.close()
@@ -0,0 +1,90 @@
1
+ """Exceptions for Qirabot SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ class QirabotError(Exception):
9
+ """Base exception for all Qirabot SDK errors."""
10
+
11
+ def __init__(
12
+ self,
13
+ message: str,
14
+ code: str | None = None,
15
+ status_code: int | None = None,
16
+ ):
17
+ self.message = message
18
+ self.code = code
19
+ self.status_code = status_code
20
+ super().__init__(message)
21
+
22
+ def __str__(self) -> str:
23
+ if self.code:
24
+ return f"[{self.code}] {self.message}"
25
+ return self.message
26
+
27
+
28
+ class AuthenticationError(QirabotError):
29
+ """API key is missing or invalid (401)."""
30
+
31
+
32
+ class DeviceBusyError(QirabotError):
33
+ """Device already has a running task (409)."""
34
+
35
+
36
+ class DeviceOfflineError(QirabotError):
37
+ """Device is not connected (400)."""
38
+
39
+
40
+ class LeaseExpiredError(QirabotError):
41
+ """Task lease has expired (409)."""
42
+
43
+
44
+ class ActionError(QirabotError):
45
+ """A device action failed during task."""
46
+
47
+
48
+ class QirabotTimeoutError(QirabotError):
49
+ """Operation timed out (client-side)."""
50
+
51
+
52
+ # Error code → exception class mapping
53
+ _ERROR_CODE_MAP: dict[str, type[QirabotError]] = {
54
+ "auth.api_key_missing": AuthenticationError,
55
+ "auth.api_key_invalid": AuthenticationError,
56
+ "sdk.device_busy": DeviceBusyError,
57
+ "sdk.device_not_connected": DeviceOfflineError,
58
+ "sdk.task_not_found": QirabotError,
59
+ "sdk.task_not_active": QirabotError,
60
+ "sdk.lease_expired": LeaseExpiredError,
61
+ }
62
+
63
+ _STATUS_CODE_MAP: dict[int, type[QirabotError]] = {
64
+ 401: AuthenticationError,
65
+ 409: DeviceBusyError,
66
+ }
67
+
68
+
69
+ def raise_for_error(status_code: int, data: dict[str, Any]) -> None:
70
+ """Raise the appropriate exception for an error response.
71
+
72
+ Supports both flat format {"code": "...", "message": "..."}
73
+ and nested format {"error": {"code": "...", "message": "..."}}.
74
+ """
75
+ error = data.get("error", {})
76
+ if isinstance(error, str):
77
+ message = error or data.get("message", f"Request failed with status {status_code}")
78
+ code = data.get("code")
79
+ elif error:
80
+ message = error.get("message", f"Request failed with status {status_code}")
81
+ code = error.get("code")
82
+ else:
83
+ message = data.get("message", f"Request failed with status {status_code}")
84
+ code = data.get("code")
85
+
86
+ if code and code in _ERROR_CODE_MAP:
87
+ raise _ERROR_CODE_MAP[code](message, code=code, status_code=status_code)
88
+
89
+ exc_cls = _STATUS_CODE_MAP.get(status_code, QirabotError)
90
+ raise exc_cls(message, code=code, status_code=status_code)
@@ -0,0 +1,377 @@
1
+ """Task context for Qirabot SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import threading
7
+ from dataclasses import dataclass, field
8
+ from typing import Any, Callable, TYPE_CHECKING
9
+
10
+ from qirabot.actions import Action
11
+ from qirabot.exceptions import ActionError
12
+
13
+ if TYPE_CHECKING:
14
+ from qirabot._transport import Transport, WSConnection
15
+
16
+ logger = logging.getLogger("qirabot")
17
+
18
+ _HEARTBEAT_INTERVAL = 15.0 # seconds
19
+
20
+
21
+ @dataclass
22
+ class StepEvent:
23
+ """Emitted after each step completes."""
24
+
25
+ number: int
26
+ action: str
27
+ status: str
28
+ output: str | None = None
29
+ decision: str | None = None
30
+ error: str | None = None
31
+ action_duration_time_ms: int = 0
32
+ step_duration_ms: int = 0
33
+
34
+
35
+ @dataclass
36
+ class ScreenshotEvent:
37
+ """Emitted when a step has a screenshot available.
38
+
39
+ For inline mode, ``data`` contains the raw PNG bytes directly.
40
+ For cloud mode, use ``save()`` or ``to_bytes()`` to download from the server.
41
+ """
42
+
43
+ number: int
44
+ task_id: str
45
+ data: bytes | None = None
46
+ _transport: Transport | None = field(default=None, repr=False)
47
+
48
+ def save(self, local_path: str) -> None:
49
+ """Save the screenshot to a local file."""
50
+ img = self.to_bytes()
51
+ with open(local_path, "wb") as f:
52
+ f.write(img)
53
+
54
+ def to_bytes(self) -> bytes:
55
+ """Return raw screenshot bytes.
56
+
57
+ For inline mode, returns the data directly.
58
+ For cloud mode, downloads from the server.
59
+ """
60
+ if self.data is not None:
61
+ return self.data
62
+ if self._transport is None:
63
+ raise RuntimeError("Transport not available")
64
+ return self._transport.get_bytes(
65
+ f"/screenshots?taskId={self.task_id}&step={self.number}"
66
+ )
67
+
68
+
69
+ # Type alias for event handlers
70
+ EventHandler = Callable[..., Any]
71
+
72
+
73
+ class TaskContext:
74
+ """Interactive task context for step-by-step device automation.
75
+
76
+ Created by ``Qirabot.task()``. Use as a context manager::
77
+
78
+ with bot.task("device-id") as t:
79
+ t.click("Login button")
80
+ t.type("username", "admin")
81
+ """
82
+
83
+ def __init__(
84
+ self,
85
+ transport: Transport,
86
+ task_id: str,
87
+ device_id: str,
88
+ screenshot_mode: str = "cloud",
89
+ ):
90
+ self._transport = transport
91
+ self._task_id = task_id
92
+ self._device_id = device_id
93
+ self._screenshot_mode = screenshot_mode
94
+ self._heartbeat_stop: threading.Event | None = None
95
+ self._heartbeat_thread: threading.Thread | None = None
96
+ self._listeners: dict[str, list[EventHandler]] = {}
97
+ self._ws: WSConnection | None = None
98
+
99
+ @property
100
+ def task_id(self) -> str:
101
+ return self._task_id
102
+
103
+ @property
104
+ def device_id(self) -> str:
105
+ return self._device_id
106
+
107
+ # ── Event system ──
108
+
109
+ def on(self, event: str, handler: EventHandler) -> TaskContext:
110
+ """Register an event listener.
111
+
112
+ Supported events:
113
+ - ``"step"``: called with :class:`StepEvent` after each step completes.
114
+ - ``"screenshot"``: called with :class:`ScreenshotEvent` when a screenshot is available.
115
+
116
+ Returns self for chaining.
117
+ """
118
+ self._listeners.setdefault(event, []).append(handler)
119
+ return self
120
+
121
+ def off(self, event: str, handler: EventHandler | None = None) -> TaskContext:
122
+ """Remove event listener(s).
123
+
124
+ If handler is None, removes all listeners for the event.
125
+ """
126
+ if handler is None:
127
+ self._listeners.pop(event, None)
128
+ elif event in self._listeners:
129
+ self._listeners[event] = [h for h in self._listeners[event] if h is not handler]
130
+ return self
131
+
132
+ def _emit(self, event: str, data: Any) -> None:
133
+ for handler in self._listeners.get(event, []):
134
+ try:
135
+ handler(data)
136
+ except Exception:
137
+ logger.exception("Error in %s event handler", event)
138
+
139
+ # ── Context manager ──
140
+
141
+ def __enter__(self) -> TaskContext:
142
+ self._start_heartbeat()
143
+ self._ws = self._transport.ws_connect(
144
+ f"/sdk/tasks/{self._task_id}/ws"
145
+ )
146
+ return self
147
+
148
+ def __exit__(self, exc_type: type | None, exc_val: BaseException | None, exc_tb: Any) -> None:
149
+ self._stop_heartbeat()
150
+ try:
151
+ if exc_type is None:
152
+ self.complete()
153
+ elif issubclass(exc_type, KeyboardInterrupt):
154
+ self.cancel()
155
+ else:
156
+ self.complete(status="failed", error_message=str(exc_val) if exc_val else "")
157
+ except Exception:
158
+ logger.exception("Failed to complete task during cleanup")
159
+ if self._ws is not None:
160
+ try:
161
+ self._ws.close()
162
+ except Exception:
163
+ pass
164
+ self._ws = None
165
+
166
+ # ── Device actions ──
167
+
168
+ def click(self, locate: str, **kw: Any) -> None:
169
+ self._act(Action.click(locate), **kw)
170
+
171
+ def double_click(self, locate: str, **kw: Any) -> None:
172
+ self._act(Action.double_click(locate), **kw)
173
+
174
+ def right_click(self, locate: str, **kw: Any) -> None:
175
+ self._act(Action.right_click(locate), **kw)
176
+
177
+ def hover(self, locate: str, **kw: Any) -> None:
178
+ self._act(Action.hover(locate), **kw)
179
+
180
+ def type(self, locate: str, content: str, **kw: Any) -> None:
181
+ self._act(Action.type_text(locate, content), **kw)
182
+
183
+ def type_direct(self, content: str, **kw: Any) -> None:
184
+ self._act(Action.type_direct(content), **kw)
185
+
186
+ def clear_text(self, locate: str, **kw: Any) -> None:
187
+ self._act(Action.clear_text(locate), **kw)
188
+
189
+ def press_key(self, key: str, **kw: Any) -> None:
190
+ self._act(Action.press_key(key), **kw)
191
+
192
+ def navigate(self, url: str, **kw: Any) -> None:
193
+ self._act(Action.navigate(url), **kw)
194
+
195
+ def scroll(self, direction: str = "down", distance: int | None = None, **kw: Any) -> None:
196
+ self._act(Action.scroll(direction, distance), **kw)
197
+
198
+ def scroll_at(self, locate: str, direction: str = "down", distance: int | None = None, **kw: Any) -> None:
199
+ self._act(Action.scroll_at(locate, direction, distance), **kw)
200
+
201
+ def swipe(self, direction: str, locate: str | None = None, distance: int | None = None, duration_ms: int = 500, **kw: Any) -> None:
202
+ self._act(Action.swipe(direction, locate, distance, duration_ms), **kw)
203
+
204
+ def wait(self, duration_ms: int, **kw: Any) -> None:
205
+ self._act(Action.wait(duration_ms), **kw)
206
+
207
+ def wait_for(self, assertion: str, timeout_ms: int, check_interval_ms: int = 1000, **kw: Any) -> None:
208
+ self._act(Action.wait_for(assertion, timeout_ms, check_interval_ms), **kw)
209
+
210
+ def screenshot(self, path: str | None = None, **kw: Any) -> bytes | None:
211
+ """Take a screenshot.
212
+
213
+ Args:
214
+ path: If provided, save the screenshot to this local file path.
215
+
216
+ Returns:
217
+ Screenshot bytes if path is None, otherwise None.
218
+ """
219
+ captured: dict[str, Any] = {}
220
+
221
+ def _capture_screenshot(event: ScreenshotEvent) -> None:
222
+ captured["event"] = event
223
+
224
+ self.on("screenshot", _capture_screenshot)
225
+ try:
226
+ self._act(Action.take_screenshot(), **kw)
227
+ finally:
228
+ self.off("screenshot", _capture_screenshot)
229
+
230
+ event = captured.get("event")
231
+ if event is not None:
232
+ img = event.to_bytes()
233
+ if path:
234
+ with open(path, "wb") as f:
235
+ f.write(img)
236
+ return None
237
+ return img
238
+ return None
239
+
240
+ def extract(self, instruction: str, variable: str = "result", value_type: str = "string", **kw: Any) -> str:
241
+ """Extract data from the screen using AI.
242
+
243
+ Returns:
244
+ The extracted value as a string.
245
+ """
246
+ result = self._act(Action.extract(instruction, variable, value_type), **kw)
247
+ return result.get("output", "") if result else ""
248
+
249
+ def verify(self, assertion: str, **kw: Any) -> bool:
250
+ """Verify a condition on the screen using AI.
251
+
252
+ Returns:
253
+ True if the assertion passes, False otherwise.
254
+ """
255
+ result = self._act(Action.verify(assertion), **kw)
256
+ output = result.get("output", "") if result else ""
257
+ return output.lower() in ("true", "pass", "yes", "1") if output else False
258
+
259
+ def ai(self, instruction: str, max_steps: int = 10, model_alias: str | None = None, language: str | None = None, **kw: Any) -> str:
260
+ """Let AI autonomously complete a task.
261
+
262
+ Args:
263
+ instruction: Natural language description of the goal.
264
+ max_steps: Maximum number of steps AI can take.
265
+ model_alias: Optional model alias to override the session default.
266
+ language: Optional language to override the session default (e.g. "zh", "en").
267
+
268
+ Returns:
269
+ The output string from the AI task, or empty string if none.
270
+ """
271
+ result = self._act(Action.ai(instruction, max_steps, model_alias=model_alias, language=language), **kw)
272
+ return result.get("output", "") if result else ""
273
+
274
+ def search(self, locate: str, content: str, **kw: Any) -> None:
275
+ self._act(Action.search(locate, content), **kw)
276
+
277
+ def start_app(self, package: str, **kw: Any) -> None:
278
+ self._act(Action.start_app(package), **kw)
279
+
280
+ def stop_app(self, package: str, **kw: Any) -> None:
281
+ self._act(Action.stop_app(package), **kw)
282
+
283
+ # ── Execution lifecycle ──
284
+
285
+ def complete(self, status: str = "succeeded", error_message: str = "") -> None:
286
+ """Complete the task and release the device."""
287
+ body: dict[str, Any] = {"status": status}
288
+ if error_message:
289
+ body["errorMessage"] = error_message
290
+ try:
291
+ self._transport.post(f"/sdk/tasks/{self._task_id}/complete", body)
292
+ except Exception:
293
+ logger.exception("Failed to complete task %s", self._task_id)
294
+
295
+ def cancel(self) -> None:
296
+ """Cancel the task and release the device."""
297
+ try:
298
+ self._transport.delete(f"/sdk/tasks/{self._task_id}")
299
+ except Exception:
300
+ logger.exception("Failed to cancel task %s", self._task_id)
301
+
302
+ # ── Internal ──
303
+
304
+ def _act(self, action: Action, timeout: float = 300.0, on_step: EventHandler | None = None) -> dict[str, Any] | None:
305
+ """Execute a single action via WebSocket and return the final result data."""
306
+ if self._ws is None:
307
+ raise RuntimeError("Task not started. Use as a context manager.")
308
+
309
+ self._ws.send_action(action.to_dict())
310
+
311
+ last_result: dict[str, Any] = {}
312
+
313
+ while True:
314
+ msg = self._ws.receive(timeout=timeout)
315
+ msg_type = msg.data.get("type", "")
316
+
317
+ if msg_type == "step":
318
+ step_event = StepEvent(
319
+ number=msg.data.get("stepNumber", 0),
320
+ action=msg.data.get("actionType", ""),
321
+ status=msg.data.get("status", ""),
322
+ output=msg.data.get("output"),
323
+ decision=msg.data.get("decision"),
324
+ error=msg.data.get("error"),
325
+ action_duration_time_ms=msg.data.get("actionDurationTimeMs", 0),
326
+ step_duration_ms=msg.data.get("stepDurationMs", 0),
327
+ )
328
+ self._emit("step", step_event)
329
+ if on_step:
330
+ on_step(step_event)
331
+
332
+ # Emit screenshot event if available (inline binary or cloud path)
333
+ has_screenshot = msg.screenshot is not None
334
+ screenshot_path = msg.data.get("screenshotPath")
335
+ if has_screenshot or screenshot_path:
336
+ screenshot_event = ScreenshotEvent(
337
+ number=step_event.number,
338
+ task_id=self._task_id,
339
+ data=msg.screenshot,
340
+ _transport=self._transport if screenshot_path else None,
341
+ )
342
+ self._emit("screenshot", screenshot_event)
343
+
344
+ elif msg_type == "result":
345
+ last_result = msg.data
346
+ if not msg.data.get("success", False) and not msg.data.get("finished", False):
347
+ error_msg = msg.data.get("error", "Action failed")
348
+ raise ActionError(error_msg, code="action.failed")
349
+ break
350
+
351
+ elif msg_type == "error":
352
+ error_msg = msg.data.get("error", "Unknown error")
353
+ raise ActionError(error_msg, code="ws.error")
354
+
355
+ return last_result
356
+
357
+ def _start_heartbeat(self) -> None:
358
+ self._heartbeat_stop = threading.Event()
359
+ self._heartbeat_thread = threading.Thread(
360
+ target=self._heartbeat_loop,
361
+ daemon=True,
362
+ )
363
+ self._heartbeat_thread.start()
364
+
365
+ def _stop_heartbeat(self) -> None:
366
+ if self._heartbeat_stop:
367
+ self._heartbeat_stop.set()
368
+ if self._heartbeat_thread:
369
+ self._heartbeat_thread.join(timeout=5.0)
370
+
371
+ def _heartbeat_loop(self) -> None:
372
+ assert self._heartbeat_stop is not None
373
+ while not self._heartbeat_stop.wait(_HEARTBEAT_INTERVAL):
374
+ try:
375
+ self._transport.post(f"/sdk/tasks/{self._task_id}/heartbeat")
376
+ except Exception:
377
+ logger.warning("Heartbeat failed for task %s", self._task_id)
@@ -0,0 +1,24 @@
1
+ Metadata-Version: 2.4
2
+ Name: qirabot
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for Qirabot - AI-powered device automation
5
+ Author-email: Qirabot Team <support@qirabot.com>
6
+ License-Expression: MIT
7
+ Keywords: qirabot,automation,testing,ai,sdk,rpa
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+ Requires-Dist: httpx>=0.27.0
19
+ Requires-Dist: websockets>=13.0
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
22
+ Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
23
+ Requires-Dist: mypy>=1.0.0; extra == "dev"
24
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
@@ -0,0 +1,12 @@
1
+ pyproject.toml
2
+ src/qirabot/__init__.py
3
+ src/qirabot/_transport.py
4
+ src/qirabot/actions.py
5
+ src/qirabot/client.py
6
+ src/qirabot/exceptions.py
7
+ src/qirabot/task_context.py
8
+ src/qirabot.egg-info/PKG-INFO
9
+ src/qirabot.egg-info/SOURCES.txt
10
+ src/qirabot.egg-info/dependency_links.txt
11
+ src/qirabot.egg-info/requires.txt
12
+ src/qirabot.egg-info/top_level.txt
@@ -0,0 +1,8 @@
1
+ httpx>=0.27.0
2
+ websockets>=13.0
3
+
4
+ [dev]
5
+ pytest>=7.0.0
6
+ pytest-cov>=4.0.0
7
+ mypy>=1.0.0
8
+ ruff>=0.1.0
@@ -0,0 +1 @@
1
+ qirabot