clousd 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.
- clousd-0.1.0/PKG-INFO +72 -0
- clousd-0.1.0/README.md +57 -0
- clousd-0.1.0/clousd/__init__.py +5 -0
- clousd-0.1.0/clousd/client.py +334 -0
- clousd-0.1.0/clousd.egg-info/PKG-INFO +72 -0
- clousd-0.1.0/clousd.egg-info/SOURCES.txt +10 -0
- clousd-0.1.0/clousd.egg-info/dependency_links.txt +1 -0
- clousd-0.1.0/clousd.egg-info/requires.txt +1 -0
- clousd-0.1.0/clousd.egg-info/top_level.txt +1 -0
- clousd-0.1.0/pyproject.toml +25 -0
- clousd-0.1.0/setup.cfg +4 -0
- clousd-0.1.0/tests/test_client.py +149 -0
clousd-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: clousd
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Cloud Android phones modelled on real devices: API client for scripts and AI agents
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://clousd.com/agents/
|
|
7
|
+
Project-URL: Documentation, https://clousd.com/docs/
|
|
8
|
+
Keywords: android,cloud phone,ai agents,mobile automation,testing
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
Requires-Dist: requests>=2.28
|
|
15
|
+
|
|
16
|
+
# clousd
|
|
17
|
+
|
|
18
|
+
Python client for [CLOUSD](https://clousd.com/agents/): cloud Android phones modelled on real devices, for scripts
|
|
19
|
+
and AI agents. Look at the screen, act on it, reset the phone to a saved state between runs, clone as many copies as
|
|
20
|
+
the job needs.
|
|
21
|
+
|
|
22
|
+
Early access: ask for a key at [clousd.com/agents](https://clousd.com/agents/#access).
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install "git+https://github.com/Clousd-Android/clousd-sdk#subdirectory=python"
|
|
26
|
+
export CLOUSD_API_KEY=cl_live_...
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## One run
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from clousd import Clousd
|
|
33
|
+
|
|
34
|
+
c = Clousd() # key from CLOUSD_API_KEY
|
|
35
|
+
phone = c.device("dev_04f2")
|
|
36
|
+
|
|
37
|
+
snap = next(s for s in phone.snapshots() if s["restorable"])
|
|
38
|
+
phone.restore(snap["id"]) # reset: every run starts from the same state
|
|
39
|
+
|
|
40
|
+
png = phone.screenshot(width=540) # look (JPEG when width is given, PNG otherwise)
|
|
41
|
+
print(phone.screen_text()) # every text on the screen
|
|
42
|
+
|
|
43
|
+
phone.open_app("com.android.chrome") # act
|
|
44
|
+
phone.tap_text("Search")
|
|
45
|
+
phone.type("weather in Berlin")
|
|
46
|
+
phone.key("enter")
|
|
47
|
+
|
|
48
|
+
phone.wait_text("Berlin", timeout=30) # check
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## What a device can do
|
|
52
|
+
|
|
53
|
+
| Look | Act | State |
|
|
54
|
+
|---|---|---|
|
|
55
|
+
| `screenshot(width=None)` | `tap(x, y)`, `swipe(x, y, x2, y2, ms)`, `scroll("down")` | `snapshots()` |
|
|
56
|
+
| `screen_text()` | `type(text)`, `key("back")` | `save_snapshot()` |
|
|
57
|
+
| `find_text(text)` | `open_app(pkg)`, `close_app(pkg)`, `open_url(url)` | `restore(id)` |
|
|
58
|
+
| `size()` | `tap_text(text)`, `wait_text(text, timeout)` | `clone(id, name)` |
|
|
59
|
+
| `installed()`, `logs()` | `start()`, `stop()`, `restart()` | `c.job(id).wait()` |
|
|
60
|
+
|
|
61
|
+
Coordinates are device pixels (`size()` gives the screen). Long operations return a `Job`; they wait by default,
|
|
62
|
+
pass `wait=False` to poll yourself. Errors raise `ClousdError` with the HTTP status, a short code and a message.
|
|
63
|
+
The API allows 60 requests a minute per key; the client waits and retries on 429.
|
|
64
|
+
|
|
65
|
+
For checks from inside the phone (app databases, files, `dumpsys`) open an ADB session to the device from the
|
|
66
|
+
dashboard or `POST /v1/devices/{name}/adb`.
|
|
67
|
+
|
|
68
|
+
## Plug a phone into an AI agent
|
|
69
|
+
|
|
70
|
+
[`clousd-mcp`](../mcp) exposes the same operations as MCP tools for any MCP-capable agent.
|
|
71
|
+
|
|
72
|
+
MIT license.
|
clousd-0.1.0/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# clousd
|
|
2
|
+
|
|
3
|
+
Python client for [CLOUSD](https://clousd.com/agents/): cloud Android phones modelled on real devices, for scripts
|
|
4
|
+
and AI agents. Look at the screen, act on it, reset the phone to a saved state between runs, clone as many copies as
|
|
5
|
+
the job needs.
|
|
6
|
+
|
|
7
|
+
Early access: ask for a key at [clousd.com/agents](https://clousd.com/agents/#access).
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install "git+https://github.com/Clousd-Android/clousd-sdk#subdirectory=python"
|
|
11
|
+
export CLOUSD_API_KEY=cl_live_...
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## One run
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from clousd import Clousd
|
|
18
|
+
|
|
19
|
+
c = Clousd() # key from CLOUSD_API_KEY
|
|
20
|
+
phone = c.device("dev_04f2")
|
|
21
|
+
|
|
22
|
+
snap = next(s for s in phone.snapshots() if s["restorable"])
|
|
23
|
+
phone.restore(snap["id"]) # reset: every run starts from the same state
|
|
24
|
+
|
|
25
|
+
png = phone.screenshot(width=540) # look (JPEG when width is given, PNG otherwise)
|
|
26
|
+
print(phone.screen_text()) # every text on the screen
|
|
27
|
+
|
|
28
|
+
phone.open_app("com.android.chrome") # act
|
|
29
|
+
phone.tap_text("Search")
|
|
30
|
+
phone.type("weather in Berlin")
|
|
31
|
+
phone.key("enter")
|
|
32
|
+
|
|
33
|
+
phone.wait_text("Berlin", timeout=30) # check
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## What a device can do
|
|
37
|
+
|
|
38
|
+
| Look | Act | State |
|
|
39
|
+
|---|---|---|
|
|
40
|
+
| `screenshot(width=None)` | `tap(x, y)`, `swipe(x, y, x2, y2, ms)`, `scroll("down")` | `snapshots()` |
|
|
41
|
+
| `screen_text()` | `type(text)`, `key("back")` | `save_snapshot()` |
|
|
42
|
+
| `find_text(text)` | `open_app(pkg)`, `close_app(pkg)`, `open_url(url)` | `restore(id)` |
|
|
43
|
+
| `size()` | `tap_text(text)`, `wait_text(text, timeout)` | `clone(id, name)` |
|
|
44
|
+
| `installed()`, `logs()` | `start()`, `stop()`, `restart()` | `c.job(id).wait()` |
|
|
45
|
+
|
|
46
|
+
Coordinates are device pixels (`size()` gives the screen). Long operations return a `Job`; they wait by default,
|
|
47
|
+
pass `wait=False` to poll yourself. Errors raise `ClousdError` with the HTTP status, a short code and a message.
|
|
48
|
+
The API allows 60 requests a minute per key; the client waits and retries on 429.
|
|
49
|
+
|
|
50
|
+
For checks from inside the phone (app databases, files, `dumpsys`) open an ADB session to the device from the
|
|
51
|
+
dashboard or `POST /v1/devices/{name}/adb`.
|
|
52
|
+
|
|
53
|
+
## Plug a phone into an AI agent
|
|
54
|
+
|
|
55
|
+
[`clousd-mcp`](../mcp) exposes the same operations as MCP tools for any MCP-capable agent.
|
|
56
|
+
|
|
57
|
+
MIT license.
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"""CLOUSD API v1 client: cloud Android phones for people, scripts and AI agents.
|
|
2
|
+
|
|
3
|
+
from clousd import Clousd
|
|
4
|
+
c = Clousd() # CLOUSD_API_KEY from the environment
|
|
5
|
+
phone = c.device("dev_04f2")
|
|
6
|
+
phone.restore("v3", wait=True) # reset to a saved state
|
|
7
|
+
png = phone.screenshot() # look
|
|
8
|
+
phone.tap_text("Add to cart") # act
|
|
9
|
+
phone.wait_text("Added") # check
|
|
10
|
+
|
|
11
|
+
Every call is a plain HTTPS request to https://api.clousd.com/v1 with `Authorization: Bearer cl_live_…`.
|
|
12
|
+
Long operations (start, snapshots, restore, clone) return a Job; `wait=True` or `job.wait()` polls it.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import base64
|
|
17
|
+
import os
|
|
18
|
+
import struct
|
|
19
|
+
import time
|
|
20
|
+
from typing import Any, Dict, List, Optional
|
|
21
|
+
|
|
22
|
+
import requests
|
|
23
|
+
|
|
24
|
+
DEFAULT_URL = "https://api.clousd.com/v1"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ClousdError(Exception):
|
|
28
|
+
"""An API error: HTTP status, the short error code and the human message from the server."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, status: int, error: str, message: str = ""):
|
|
31
|
+
super().__init__(f"{status} {error}: {message}" if message else f"{status} {error}")
|
|
32
|
+
self.status, self.error, self.message = status, error, message
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Clousd:
|
|
36
|
+
"""Account-level client. `api_key` defaults to $CLOUSD_API_KEY, `base_url` to $CLOUSD_API_URL."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None, timeout: float = 60.0):
|
|
39
|
+
self.api_key = api_key or os.environ.get("CLOUSD_API_KEY", "")
|
|
40
|
+
if not self.api_key:
|
|
41
|
+
raise ValueError("no API key: pass api_key= or set CLOUSD_API_KEY (Settings → API keys in the dashboard)")
|
|
42
|
+
self.base_url = (base_url or os.environ.get("CLOUSD_API_URL") or DEFAULT_URL).rstrip("/")
|
|
43
|
+
self.timeout = timeout
|
|
44
|
+
self._http = requests.Session()
|
|
45
|
+
self._http.headers.update({"Authorization": "Bearer " + self.api_key, "User-Agent": "clousd-python/0.1"})
|
|
46
|
+
|
|
47
|
+
# -- transport ---------------------------------------------------------------------------------------------
|
|
48
|
+
def request(self, method: str, path: str, json: Any = None, params: Any = None,
|
|
49
|
+
timeout: Optional[float] = None, raw: bool = False) -> Any:
|
|
50
|
+
"""One API call. Retries politely on 429 (60 requests a minute per key) and on a dropped connection."""
|
|
51
|
+
url = self.base_url + "/" + path.lstrip("/")
|
|
52
|
+
for attempt in range(5):
|
|
53
|
+
try:
|
|
54
|
+
r = self._http.request(method, url, json=json, params=params, timeout=timeout or self.timeout)
|
|
55
|
+
except requests.ConnectionError:
|
|
56
|
+
if attempt == 4:
|
|
57
|
+
raise
|
|
58
|
+
time.sleep(1 + attempt)
|
|
59
|
+
continue
|
|
60
|
+
if r.status_code == 429 and attempt < 4:
|
|
61
|
+
ra = r.headers.get("Retry-After")
|
|
62
|
+
time.sleep(float(ra) if ra and ra.replace(".", "", 1).isdigit() else min(2 ** (attempt + 1), 30))
|
|
63
|
+
continue
|
|
64
|
+
if r.status_code >= 400:
|
|
65
|
+
try:
|
|
66
|
+
body = r.json()
|
|
67
|
+
raise ClousdError(r.status_code, str(body.get("error", "error")), str(body.get("message", "")))
|
|
68
|
+
except ValueError:
|
|
69
|
+
raise ClousdError(r.status_code, "error", r.text.strip()[:300]) from None
|
|
70
|
+
if raw:
|
|
71
|
+
return r.content
|
|
72
|
+
return r.json() if r.content else {}
|
|
73
|
+
raise ClousdError(429, "rate_limited", "too many requests")
|
|
74
|
+
|
|
75
|
+
def __repr__(self) -> str: # never shows the key
|
|
76
|
+
return f"<Clousd {self.base_url}>"
|
|
77
|
+
|
|
78
|
+
# -- account -----------------------------------------------------------------------------------------------
|
|
79
|
+
def devices(self) -> List["Device"]:
|
|
80
|
+
"""Devices this key can see."""
|
|
81
|
+
return [Device(self, d["name"], d) for d in self.request("GET", "devices").get("devices", [])]
|
|
82
|
+
|
|
83
|
+
def device(self, name: str) -> "Device":
|
|
84
|
+
"""A handle to one device (no request is made until you use it)."""
|
|
85
|
+
return Device(self, name)
|
|
86
|
+
|
|
87
|
+
def create_device(self, profile: str, network: Optional[Dict[str, Any]] = None, plan: str = "metered",
|
|
88
|
+
idempotency_key: Optional[str] = None, region: Optional[str] = None, burner: bool = False,
|
|
89
|
+
wait: bool = True, timeout: float = 600) -> "Device":
|
|
90
|
+
"""Create one device on this account (charged like the dashboard: creation fee, first month for plan monthly,
|
|
91
|
+
paid exits metered per GB). `profile` from models(); `network` like {"type": "residential", "country": "US"} or
|
|
92
|
+
{"type": "byo", "url": "socks5://…"}; `idempotency_key` - a retry with the same key within 10 minutes is not
|
|
93
|
+
charged twice (one is generated if not given). With wait=True returns once the device is running."""
|
|
94
|
+
import uuid
|
|
95
|
+
body: Dict[str, Any] = {"profile": profile, "network": network or {"type": "off"}, "plan": plan, "burner": burner,
|
|
96
|
+
"idempotency_key": idempotency_key or uuid.uuid4().hex}
|
|
97
|
+
if region:
|
|
98
|
+
body["region"] = region
|
|
99
|
+
r = self.request("POST", "devices", json=body, timeout=90)
|
|
100
|
+
d = Device(self, r["name"], {"name": r["name"], "state": r.get("state", "starting")})
|
|
101
|
+
if wait:
|
|
102
|
+
end = time.time() + timeout
|
|
103
|
+
while d.refresh().get("state") in ("starting", "") and time.time() < end:
|
|
104
|
+
time.sleep(5)
|
|
105
|
+
return d
|
|
106
|
+
|
|
107
|
+
def job(self, job_id: str) -> "Job":
|
|
108
|
+
return Job(self, self.request("GET", f"jobs/{job_id}").get("job", {"id": job_id}))
|
|
109
|
+
|
|
110
|
+
def usage(self) -> Dict[str, Any]:
|
|
111
|
+
return self.request("GET", "usage")
|
|
112
|
+
|
|
113
|
+
def network_options(self) -> Dict[str, Any]:
|
|
114
|
+
"""What can be chosen when creating a device right now: {types, countries:[{code,name}], plans}."""
|
|
115
|
+
return self.request("GET", "network/options")
|
|
116
|
+
|
|
117
|
+
def models(self) -> List[Dict[str, Any]]:
|
|
118
|
+
"""Phone models available to create devices from."""
|
|
119
|
+
return self.request("GET", "models").get("models", [])
|
|
120
|
+
|
|
121
|
+
def _job(self, resp: Dict[str, Any], wait: bool, timeout: float) -> "Job":
|
|
122
|
+
job = Job(self, resp.get("job") or {"state": "done", "output": resp.get("output", "")})
|
|
123
|
+
return job.wait(timeout) if wait else job
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class Job:
|
|
127
|
+
"""A long operation. `state` is running, done or failed."""
|
|
128
|
+
|
|
129
|
+
def __init__(self, client: Clousd, data: Dict[str, Any]):
|
|
130
|
+
self.client, self.data = client, data
|
|
131
|
+
|
|
132
|
+
id = property(lambda self: self.data.get("id", ""))
|
|
133
|
+
state = property(lambda self: self.data.get("state", ""))
|
|
134
|
+
output = property(lambda self: self.data.get("output", ""))
|
|
135
|
+
|
|
136
|
+
def refresh(self) -> "Job":
|
|
137
|
+
if self.id:
|
|
138
|
+
self.data = self.client.request("GET", f"jobs/{self.id}").get("job", self.data)
|
|
139
|
+
return self
|
|
140
|
+
|
|
141
|
+
def wait(self, timeout: float = 900, every: float = 3.0) -> "Job":
|
|
142
|
+
"""Poll until the job finishes; raises ClousdError if it failed or ran out of time."""
|
|
143
|
+
end = time.time() + timeout
|
|
144
|
+
while self.state == "running" and self.id:
|
|
145
|
+
if time.time() > end:
|
|
146
|
+
raise ClousdError(408, "timeout", f"job {self.id} still running after {int(timeout)} s")
|
|
147
|
+
time.sleep(every)
|
|
148
|
+
self.refresh()
|
|
149
|
+
if self.state == "failed":
|
|
150
|
+
raise ClousdError(500, "job_failed", self.output.strip()[-500:])
|
|
151
|
+
return self
|
|
152
|
+
|
|
153
|
+
def __repr__(self) -> str:
|
|
154
|
+
return f"<Job {self.id} {self.state}>"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class Device:
|
|
158
|
+
"""One cloud phone. Coordinates are device pixels (see `size()`)."""
|
|
159
|
+
|
|
160
|
+
def __init__(self, client: Clousd, name: str, data: Optional[Dict[str, Any]] = None):
|
|
161
|
+
self.client, self.name, self.data = client, name, data or {}
|
|
162
|
+
self._size: Optional[tuple] = None
|
|
163
|
+
|
|
164
|
+
def _p(self, sub: str = "") -> str:
|
|
165
|
+
return f"devices/{self.name}" + ("/" + sub if sub else "")
|
|
166
|
+
|
|
167
|
+
# -- state -------------------------------------------------------------------------------------------------
|
|
168
|
+
def refresh(self) -> Dict[str, Any]:
|
|
169
|
+
self.data = self.client.request("GET", self._p()).get("device", {})
|
|
170
|
+
return self.data
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def state(self) -> str:
|
|
174
|
+
return (self.data or self.refresh()).get("state", "")
|
|
175
|
+
|
|
176
|
+
def start(self, wait: bool = True, timeout: float = 900) -> Job:
|
|
177
|
+
return self.client._job(self.client.request("POST", self._p("start")), wait, timeout)
|
|
178
|
+
|
|
179
|
+
def stop(self, wait: bool = True, timeout: float = 300) -> Job:
|
|
180
|
+
return self.client._job(self.client.request("POST", self._p("stop")), wait, timeout)
|
|
181
|
+
|
|
182
|
+
def restart(self, wait: bool = True, timeout: float = 900) -> Job:
|
|
183
|
+
return self.client._job(self.client.request("POST", self._p("restart")), wait, timeout)
|
|
184
|
+
|
|
185
|
+
def logs(self) -> Any:
|
|
186
|
+
"""Recent logcat lines."""
|
|
187
|
+
return self.client.request("GET", self._p("logs"))
|
|
188
|
+
|
|
189
|
+
# -- look --------------------------------------------------------------------------------------------------
|
|
190
|
+
def screenshot(self, width: Optional[int] = None) -> bytes:
|
|
191
|
+
"""The screen as PNG (full size) or, with `width`, a smaller JPEG."""
|
|
192
|
+
return self.client.request("GET", self._p("screenshot"), params={"w": width} if width else None, raw=True)
|
|
193
|
+
|
|
194
|
+
def size(self) -> tuple:
|
|
195
|
+
"""Screen size in device pixels, read from a full-size screenshot once."""
|
|
196
|
+
if not self._size:
|
|
197
|
+
png = self.screenshot()
|
|
198
|
+
if png[:8] == b"\x89PNG\r\n\x1a\n":
|
|
199
|
+
self._size = struct.unpack(">II", png[16:24])
|
|
200
|
+
else:
|
|
201
|
+
self._size = (1080, 2400)
|
|
202
|
+
return self._size
|
|
203
|
+
|
|
204
|
+
def observe(self, width: int = 540, ui: bool = True) -> Dict[str, Any]:
|
|
205
|
+
"""One fresh look at the phone: {seq, screen:{w,h}, image:{w,h,format,data}, image_bytes, package, activity,
|
|
206
|
+
ui:[{text, desc, id, class, b:[x1,y1,x2,y2], click, scroll, focused, ...}], ui_error}.
|
|
207
|
+
Coordinates in `ui` are phone pixels. `width` is a maximum: the image is shrunk by a whole factor, its real
|
|
208
|
+
width is image.w (scale to the phone = screen.w / image.w). Pass `seq` to act() so an action on a screen that
|
|
209
|
+
has changed since is refused (409 stale) instead of landing in the wrong place."""
|
|
210
|
+
d = self.client.request("GET", self._p("observe"), params={"w": width, "ui": 1 if ui else 0}, timeout=90)
|
|
211
|
+
d["image_bytes"] = base64.b64decode(d.get("image", {}).get("data", "") or b"")
|
|
212
|
+
if d.get("screen"):
|
|
213
|
+
self._size = (d["screen"]["w"], d["screen"]["h"])
|
|
214
|
+
return d
|
|
215
|
+
|
|
216
|
+
def act(self, op: str, settle: bool = True, seq: Optional[int] = None, **kw: Any) -> Dict[str, Any]:
|
|
217
|
+
"""Do one action (same ops as `action`) and, with settle, wait until the screen stops changing (up to 5 s).
|
|
218
|
+
Returns {ok, output, seq, settled, changed, waited_ms}: changed=False means the screen did not change at all
|
|
219
|
+
(the action probably missed). With `seq` from observe() raises ClousdError 409 "stale" and does nothing if the
|
|
220
|
+
screen changed since (another observe or act happened)."""
|
|
221
|
+
body = {"op": op, "settle": settle, **kw}
|
|
222
|
+
if seq:
|
|
223
|
+
body["seq"] = seq
|
|
224
|
+
return self.client.request("POST", self._p("act"), json=body, timeout=(kw.get("ms", 0) / 1000 + 90))
|
|
225
|
+
|
|
226
|
+
def screen_text(self) -> List[str]:
|
|
227
|
+
"""All texts on the screen (buttons, labels, fields) from the UI hierarchy."""
|
|
228
|
+
return self.action("screen_text").get("texts", [])
|
|
229
|
+
|
|
230
|
+
def find_text(self, text: str) -> str:
|
|
231
|
+
"""Where `text` is on the screen ("found … at x,y"); raises if it is not there."""
|
|
232
|
+
return self.action("find_text", text=text).get("output", "")
|
|
233
|
+
|
|
234
|
+
def installed(self) -> List[str]:
|
|
235
|
+
return self.action("installed").get("packages", [])
|
|
236
|
+
|
|
237
|
+
# -- act ---------------------------------------------------------------------------------------------------
|
|
238
|
+
def action(self, op: str, timeout: Optional[float] = None, **kw: Any) -> Dict[str, Any]:
|
|
239
|
+
"""Low-level: POST /devices/{name}/action {op, …}."""
|
|
240
|
+
return self.client.request("POST", self._p("action"), json={"op": op, **kw}, timeout=timeout)
|
|
241
|
+
|
|
242
|
+
def tap(self, x: int, y: int) -> str:
|
|
243
|
+
return self.action("tap", x=int(x), y=int(y)).get("output", "")
|
|
244
|
+
|
|
245
|
+
def tap_norm(self, x: float, y: float) -> str:
|
|
246
|
+
"""Tap at a point given as fractions of the screen (0..1), independent of the screenshot size."""
|
|
247
|
+
w, h = self.size()
|
|
248
|
+
return self.tap(round(x * (w - 1)), round(y * (h - 1)))
|
|
249
|
+
|
|
250
|
+
def swipe(self, x: int, y: int, x2: int, y2: int, ms: int = 300) -> str:
|
|
251
|
+
return self.action("swipe", x=int(x), y=int(y), x2=int(x2), y2=int(y2), ms=int(ms)).get("output", "")
|
|
252
|
+
|
|
253
|
+
def scroll(self, direction: str = "down") -> str:
|
|
254
|
+
"""One human-like swipe through a feed: down or up."""
|
|
255
|
+
return self.action("scroll", text=direction).get("output", "")
|
|
256
|
+
|
|
257
|
+
def type(self, text: str) -> str:
|
|
258
|
+
"""Type into the focused field, any language: ASCII goes through the keyboard, everything else is pasted from
|
|
259
|
+
the phone's clipboard by the agent (the clipboard is cleared afterwards). One line, no control characters."""
|
|
260
|
+
if "\n" in text or "\r" in text or "\x00" in text:
|
|
261
|
+
raise ValueError("type(): one line, no control characters")
|
|
262
|
+
return self.action("text", text=text).get("output", "")
|
|
263
|
+
|
|
264
|
+
def key(self, key: str) -> str:
|
|
265
|
+
"""home, back, recents, enter, power, tab, del, menu, volume_up, volume_down, wake, sleep or a keycode."""
|
|
266
|
+
return self.action("key", key=str(key)).get("output", "")
|
|
267
|
+
|
|
268
|
+
def open_app(self, package: str) -> str:
|
|
269
|
+
return self.action("open_app", package=package).get("output", "")
|
|
270
|
+
|
|
271
|
+
def close_app(self, package: str) -> str:
|
|
272
|
+
return self.action("close_app", package=package).get("output", "")
|
|
273
|
+
|
|
274
|
+
def open_url(self, url: str) -> str:
|
|
275
|
+
return self.action("url", url=url).get("output", "")
|
|
276
|
+
|
|
277
|
+
def tap_text(self, text: str) -> str:
|
|
278
|
+
"""Tap the first element whose text contains `text` (case-insensitive)."""
|
|
279
|
+
return self.action("tap_text", text=text).get("output", "")
|
|
280
|
+
|
|
281
|
+
def wait_text(self, text: str, timeout: int = 30) -> str:
|
|
282
|
+
"""Wait until `text` appears on the screen (up to 120 s); raises if it does not."""
|
|
283
|
+
t = max(1, min(int(timeout), 120))
|
|
284
|
+
return self.action("wait_text", text=text, ms=t * 1000, timeout=t + 60).get("output", "")
|
|
285
|
+
|
|
286
|
+
# -- core primitives (same ops and result shapes as the Lusiesta core protocol) --------------------------------
|
|
287
|
+
def op(self, op: str, timeout: Optional[float] = None, **args: Any) -> Dict[str, Any]:
|
|
288
|
+
"""One primitive: shell {argv, timeout} → {rc, out_b64, err_b64}; dump → {xml_z}; launch_app {package,
|
|
289
|
+
activity}, start_activity {component}, force_stop {package} → {rc}; foreground → {package}; screenshot →
|
|
290
|
+
{png_b64}; screen → {w, h}; tap {x, y}, long_press {x, y, ms}, swipe {x1, y1, x2, y2, ms}, key {code, meta},
|
|
291
|
+
text {value}, clear_field → {}; ip_check → {ok, ip, country, …}. shell needs ADB access enabled for the device
|
|
292
|
+
(adb_enable) and your IP in its allow list - the same gate as adb shell."""
|
|
293
|
+
r = self.client.request("POST", self._p("op"), json={"op": op, "args": args}, timeout=timeout)
|
|
294
|
+
return r.get("data", {})
|
|
295
|
+
|
|
296
|
+
def shell(self, *argv: str, timeout: float = 30) -> tuple:
|
|
297
|
+
"""Run a command on the phone as the shell user (like `adb shell`): returns (rc, stdout, stderr) as str."""
|
|
298
|
+
d = self.client.request("POST", self._p("op"), json={"op": "shell", "args": {"argv": list(argv), "timeout": timeout}},
|
|
299
|
+
timeout=timeout + 30).get("data", {})
|
|
300
|
+
dec = lambda k: base64.b64decode(d.get(k, "") or b"").decode("utf-8", "replace")
|
|
301
|
+
return d.get("rc", -1), dec("out_b64"), dec("err_b64")
|
|
302
|
+
|
|
303
|
+
def push(self, data: bytes, remote: str, timeout: float = 900) -> Dict[str, Any]:
|
|
304
|
+
"""Put a file on the phone under /sdcard/ or /data/local/tmp/ (like `adb push`; same gate as shell)."""
|
|
305
|
+
url = self.client.base_url + "/" + self._p("files")
|
|
306
|
+
r = self.client._http.put(url, params={"path": remote}, data=data, timeout=timeout)
|
|
307
|
+
if r.status_code >= 400:
|
|
308
|
+
body = r.json() if r.content else {}
|
|
309
|
+
raise ClousdError(r.status_code, str(body.get("error", "error")), str(body.get("message", "")))
|
|
310
|
+
return r.json().get("data", {})
|
|
311
|
+
|
|
312
|
+
def adb_enable(self, allow_ips: List[str], ttl_hours: int = 24) -> Dict[str, Any]:
|
|
313
|
+
"""Enable ADB access to the device for these IPs/CIDRs (also unlocks shell/push); returns host, port, code."""
|
|
314
|
+
return self.client.request("POST", self._p("adb"), json={"ttl_hours": ttl_hours, "allow_ips": allow_ips})
|
|
315
|
+
|
|
316
|
+
# -- state between runs ------------------------------------------------------------------------------------
|
|
317
|
+
def snapshots(self) -> List[Dict[str, Any]]:
|
|
318
|
+
"""Saved states of this device: id, created, android, current, restorable."""
|
|
319
|
+
return self.client.request("GET", self._p("snapshots")).get("snapshots", [])
|
|
320
|
+
|
|
321
|
+
def save_snapshot(self, wait: bool = True, timeout: float = 1800) -> Job:
|
|
322
|
+
return self.client._job(self.client.request("POST", self._p("snapshots")), wait, timeout)
|
|
323
|
+
|
|
324
|
+
def restore(self, snapshot_id: str, wait: bool = True, timeout: float = 1800) -> Job:
|
|
325
|
+
"""Reset the device to a saved state."""
|
|
326
|
+
return self.client._job(self.client.request("POST", self._p(f"snapshots/{snapshot_id}/restore")), wait, timeout)
|
|
327
|
+
|
|
328
|
+
def clone(self, snapshot_id: str, name: str, wait: bool = True, timeout: float = 1800) -> "Device":
|
|
329
|
+
"""A new device (`name`: letters and digits, up to 16) from a saved state of this one."""
|
|
330
|
+
self.client._job(self.client.request("POST", self._p(f"snapshots/{snapshot_id}/clone"), json={"name": name}), wait, timeout)
|
|
331
|
+
return Device(self.client, name)
|
|
332
|
+
|
|
333
|
+
def __repr__(self) -> str:
|
|
334
|
+
return f"<Device {self.name} {self.data.get('state', '')}>".replace(" >", ">")
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: clousd
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Cloud Android phones modelled on real devices: API client for scripts and AI agents
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://clousd.com/agents/
|
|
7
|
+
Project-URL: Documentation, https://clousd.com/docs/
|
|
8
|
+
Keywords: android,cloud phone,ai agents,mobile automation,testing
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
Requires-Dist: requests>=2.28
|
|
15
|
+
|
|
16
|
+
# clousd
|
|
17
|
+
|
|
18
|
+
Python client for [CLOUSD](https://clousd.com/agents/): cloud Android phones modelled on real devices, for scripts
|
|
19
|
+
and AI agents. Look at the screen, act on it, reset the phone to a saved state between runs, clone as many copies as
|
|
20
|
+
the job needs.
|
|
21
|
+
|
|
22
|
+
Early access: ask for a key at [clousd.com/agents](https://clousd.com/agents/#access).
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install "git+https://github.com/Clousd-Android/clousd-sdk#subdirectory=python"
|
|
26
|
+
export CLOUSD_API_KEY=cl_live_...
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## One run
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from clousd import Clousd
|
|
33
|
+
|
|
34
|
+
c = Clousd() # key from CLOUSD_API_KEY
|
|
35
|
+
phone = c.device("dev_04f2")
|
|
36
|
+
|
|
37
|
+
snap = next(s for s in phone.snapshots() if s["restorable"])
|
|
38
|
+
phone.restore(snap["id"]) # reset: every run starts from the same state
|
|
39
|
+
|
|
40
|
+
png = phone.screenshot(width=540) # look (JPEG when width is given, PNG otherwise)
|
|
41
|
+
print(phone.screen_text()) # every text on the screen
|
|
42
|
+
|
|
43
|
+
phone.open_app("com.android.chrome") # act
|
|
44
|
+
phone.tap_text("Search")
|
|
45
|
+
phone.type("weather in Berlin")
|
|
46
|
+
phone.key("enter")
|
|
47
|
+
|
|
48
|
+
phone.wait_text("Berlin", timeout=30) # check
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## What a device can do
|
|
52
|
+
|
|
53
|
+
| Look | Act | State |
|
|
54
|
+
|---|---|---|
|
|
55
|
+
| `screenshot(width=None)` | `tap(x, y)`, `swipe(x, y, x2, y2, ms)`, `scroll("down")` | `snapshots()` |
|
|
56
|
+
| `screen_text()` | `type(text)`, `key("back")` | `save_snapshot()` |
|
|
57
|
+
| `find_text(text)` | `open_app(pkg)`, `close_app(pkg)`, `open_url(url)` | `restore(id)` |
|
|
58
|
+
| `size()` | `tap_text(text)`, `wait_text(text, timeout)` | `clone(id, name)` |
|
|
59
|
+
| `installed()`, `logs()` | `start()`, `stop()`, `restart()` | `c.job(id).wait()` |
|
|
60
|
+
|
|
61
|
+
Coordinates are device pixels (`size()` gives the screen). Long operations return a `Job`; they wait by default,
|
|
62
|
+
pass `wait=False` to poll yourself. Errors raise `ClousdError` with the HTTP status, a short code and a message.
|
|
63
|
+
The API allows 60 requests a minute per key; the client waits and retries on 429.
|
|
64
|
+
|
|
65
|
+
For checks from inside the phone (app databases, files, `dumpsys`) open an ADB session to the device from the
|
|
66
|
+
dashboard or `POST /v1/devices/{name}/adb`.
|
|
67
|
+
|
|
68
|
+
## Plug a phone into an AI agent
|
|
69
|
+
|
|
70
|
+
[`clousd-mcp`](../mcp) exposes the same operations as MCP tools for any MCP-capable agent.
|
|
71
|
+
|
|
72
|
+
MIT license.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
requests>=2.28
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
clousd
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "clousd"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Cloud Android phones modelled on real devices: API client for scripts and AI agents"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
dependencies = ["requests>=2.28"]
|
|
13
|
+
keywords = ["android", "cloud phone", "ai agents", "mobile automation", "testing"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Homepage = "https://clousd.com/agents/"
|
|
22
|
+
Documentation = "https://clousd.com/docs/"
|
|
23
|
+
|
|
24
|
+
[tool.setuptools]
|
|
25
|
+
packages = ["clousd"]
|
clousd-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Offline tests of the clousd client against a tiny fake /v1 server (no real phones are touched).
|
|
2
|
+
|
|
3
|
+
python -m unittest discover -s sdk/python/tests
|
|
4
|
+
"""
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import struct
|
|
8
|
+
import sys
|
|
9
|
+
import threading
|
|
10
|
+
import unittest
|
|
11
|
+
import zlib
|
|
12
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
13
|
+
|
|
14
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
15
|
+
from clousd import Clousd, ClousdError # noqa: E402
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def tiny_png(w, h):
|
|
19
|
+
raw = b"".join(b"\x00" + b"\x00\x00\x00" * w for _ in range(h))
|
|
20
|
+
chunk = lambda t, d: struct.pack(">I", len(d)) + t + d + struct.pack(">I", zlib.crc32(t + d) & 0xFFFFFFFF)
|
|
21
|
+
return b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0)) + \
|
|
22
|
+
chunk(b"IDAT", zlib.compress(raw)) + chunk(b"IEND", b"")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Fake(BaseHTTPRequestHandler):
|
|
26
|
+
calls = []
|
|
27
|
+
job_polls = 0
|
|
28
|
+
rate_limited_once = False
|
|
29
|
+
|
|
30
|
+
def log_message(self, *a):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
def _send(self, code, obj=None, raw=None, ctype="application/json", headers=None):
|
|
34
|
+
body = raw if raw is not None else json.dumps(obj).encode()
|
|
35
|
+
self.send_response(code)
|
|
36
|
+
self.send_header("Content-Type", ctype)
|
|
37
|
+
for k, v in (headers or {}).items():
|
|
38
|
+
self.send_header(k, v)
|
|
39
|
+
self.send_header("Content-Length", str(len(body)))
|
|
40
|
+
self.end_headers()
|
|
41
|
+
self.wfile.write(body)
|
|
42
|
+
|
|
43
|
+
def _record(self):
|
|
44
|
+
n = int(self.headers.get("Content-Length") or 0)
|
|
45
|
+
body = json.loads(self.rfile.read(n) or b"{}") if n else None
|
|
46
|
+
Fake.calls.append((self.command, self.path, body, self.headers.get("Authorization")))
|
|
47
|
+
return body
|
|
48
|
+
|
|
49
|
+
def do_GET(self):
|
|
50
|
+
self._record()
|
|
51
|
+
p = self.path
|
|
52
|
+
if p == "/v1/devices":
|
|
53
|
+
return self._send(200, {"devices": [{"name": "t1", "state": "running"}]})
|
|
54
|
+
if p.startswith("/v1/devices/t1/screenshot"):
|
|
55
|
+
return self._send(200, raw=tiny_png(1080, 2400), ctype="image/png")
|
|
56
|
+
if p == "/v1/devices/t1/snapshots":
|
|
57
|
+
return self._send(200, {"snapshots": [{"id": "v3", "restorable": True}]})
|
|
58
|
+
if p.startswith("/v1/devices/t1/observe"):
|
|
59
|
+
import base64
|
|
60
|
+
return self._send(200, {"seq": 7, "screen": {"w": 1080, "h": 2400},
|
|
61
|
+
"image": {"w": 540, "h": 1200, "format": "jpeg", "data": base64.b64encode(b"JPEGDATA").decode()},
|
|
62
|
+
"package": "com.android.settings", "activity": "com.android.settings.Settings",
|
|
63
|
+
"ui": [{"text": "Wi-Fi", "class": "TextView", "b": [0, 100, 540, 200], "click": True}]})
|
|
64
|
+
if p == "/v1/jobs/j1":
|
|
65
|
+
Fake.job_polls += 1
|
|
66
|
+
return self._send(200, {"job": {"id": "j1", "state": "done" if Fake.job_polls >= 2 else "running"}})
|
|
67
|
+
return self._send(404, {"error": "not_found", "message": "no such device"})
|
|
68
|
+
|
|
69
|
+
def do_POST(self):
|
|
70
|
+
body = self._record()
|
|
71
|
+
p = self.path
|
|
72
|
+
if p == "/v1/devices/t1/action":
|
|
73
|
+
if body["op"] == "screen_text" and not Fake.rate_limited_once:
|
|
74
|
+
Fake.rate_limited_once = True
|
|
75
|
+
return self._send(429, {"error": "rate_limited"}, headers={"Retry-After": "0"})
|
|
76
|
+
if body["op"] == "screen_text":
|
|
77
|
+
return self._send(200, {"ok": True, "texts": ["Settings", "Wi-Fi"]})
|
|
78
|
+
return self._send(200, {"ok": True, "output": "done " + body["op"]})
|
|
79
|
+
if p == "/v1/devices/t1/act":
|
|
80
|
+
return self._send(200, {"ok": True, "output": "", "seq": 7, "stale": body.get("seq", 7) != 7,
|
|
81
|
+
"settled": True, "waited_ms": 600})
|
|
82
|
+
if p == "/v1/devices/t1/snapshots/v3/restore":
|
|
83
|
+
return self._send(202, {"ok": True, "job": {"id": "j1", "state": "running"}})
|
|
84
|
+
return self._send(400, {"error": "bad_request", "message": "op"})
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ClientTest(unittest.TestCase):
|
|
88
|
+
@classmethod
|
|
89
|
+
def setUpClass(cls):
|
|
90
|
+
cls.srv = HTTPServer(("127.0.0.1", 0), Fake)
|
|
91
|
+
threading.Thread(target=cls.srv.serve_forever, daemon=True).start()
|
|
92
|
+
cls.c = Clousd(api_key="cl_live_test", base_url=f"http://127.0.0.1:{cls.srv.server_port}/v1")
|
|
93
|
+
|
|
94
|
+
@classmethod
|
|
95
|
+
def tearDownClass(cls):
|
|
96
|
+
cls.srv.shutdown()
|
|
97
|
+
|
|
98
|
+
def test_devices_and_auth(self):
|
|
99
|
+
ds = self.c.devices()
|
|
100
|
+
self.assertEqual([d.name for d in ds], ["t1"])
|
|
101
|
+
self.assertEqual(Fake.calls[-1][3], "Bearer cl_live_test")
|
|
102
|
+
self.assertNotIn("cl_live_test", repr(self.c))
|
|
103
|
+
|
|
104
|
+
def test_actions_shape(self):
|
|
105
|
+
d = self.c.device("t1")
|
|
106
|
+
d.tap(10, 20)
|
|
107
|
+
self.assertEqual(Fake.calls[-1][2], {"op": "tap", "x": 10, "y": 20})
|
|
108
|
+
d.wait_text("Done", timeout=500)
|
|
109
|
+
self.assertEqual(Fake.calls[-1][2], {"op": "wait_text", "text": "Done", "ms": 120000})
|
|
110
|
+
d.scroll("up")
|
|
111
|
+
self.assertEqual(Fake.calls[-1][2], {"op": "scroll", "text": "up"})
|
|
112
|
+
d.open_url("https://example.com")
|
|
113
|
+
self.assertEqual(Fake.calls[-1][2], {"op": "url", "url": "https://example.com"})
|
|
114
|
+
|
|
115
|
+
def test_screen_text_retries_429(self):
|
|
116
|
+
self.assertEqual(self.c.device("t1").screen_text(), ["Settings", "Wi-Fi"])
|
|
117
|
+
|
|
118
|
+
def test_size_from_png_and_tap_norm(self):
|
|
119
|
+
d = self.c.device("t1")
|
|
120
|
+
self.assertEqual(d.size(), (1080, 2400))
|
|
121
|
+
d.tap_norm(0.5, 0.5)
|
|
122
|
+
self.assertEqual(Fake.calls[-1][2], {"op": "tap", "x": 540, "y": 1200})
|
|
123
|
+
|
|
124
|
+
def test_restore_waits_for_job(self):
|
|
125
|
+
job = self.c.device("t1").restore("v3", timeout=30)
|
|
126
|
+
self.assertEqual(job.state, "done")
|
|
127
|
+
|
|
128
|
+
def test_observe_and_act(self):
|
|
129
|
+
d = self.c.device("t1")
|
|
130
|
+
o = d.observe()
|
|
131
|
+
self.assertEqual((o["seq"], o["image_bytes"], d.size()), (7, b"JPEGDATA", (1080, 2400)))
|
|
132
|
+
r = d.act("tap", x=270, y=150, seq=o["seq"])
|
|
133
|
+
self.assertEqual(Fake.calls[-1][2], {"op": "tap", "settle": True, "x": 270, "y": 150, "seq": 7})
|
|
134
|
+
self.assertEqual((r["settled"], r["stale"]), (True, False))
|
|
135
|
+
|
|
136
|
+
def test_type_any_language(self):
|
|
137
|
+
self.c.device("t1").type("こんにちは")
|
|
138
|
+
self.assertEqual(Fake.calls[-1][2], {"op": "text", "text": "こんにちは"})
|
|
139
|
+
with self.assertRaises(ValueError):
|
|
140
|
+
self.c.device("t1").type("two\nlines")
|
|
141
|
+
|
|
142
|
+
def test_errors_are_typed(self):
|
|
143
|
+
with self.assertRaises(ClousdError) as e:
|
|
144
|
+
self.c.device("nope").refresh()
|
|
145
|
+
self.assertEqual((e.exception.status, e.exception.error), (404, "not_found"))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
if __name__ == "__main__":
|
|
149
|
+
unittest.main()
|