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 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,5 @@
1
+ """CLOUSD: cloud Android phones modelled on real devices, for people, scripts and AI agents."""
2
+ from .client import Clousd, ClousdError, Device, Job
3
+
4
+ __all__ = ["Clousd", "ClousdError", "Device", "Job"]
5
+ __version__ = "0.1.0"
@@ -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,10 @@
1
+ README.md
2
+ pyproject.toml
3
+ clousd/__init__.py
4
+ clousd/client.py
5
+ clousd.egg-info/PKG-INFO
6
+ clousd.egg-info/SOURCES.txt
7
+ clousd.egg-info/dependency_links.txt
8
+ clousd.egg-info/requires.txt
9
+ clousd.egg-info/top_level.txt
10
+ tests/test_client.py
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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()