use-computer 0.0.1__py3-none-any.whl
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.
- mmini/__init__.py +48 -0
- mmini/ax_transpile.py +504 -0
- mmini/client.py +362 -0
- mmini/display.py +39 -0
- mmini/errors.py +68 -0
- mmini/ios/__init__.py +14 -0
- mmini/ios/apps.py +57 -0
- mmini/ios/environment.py +49 -0
- mmini/ios/input.py +118 -0
- mmini/macos/__init__.py +4 -0
- mmini/macos/keyboard.py +59 -0
- mmini/macos/mouse.py +105 -0
- mmini/models.py +79 -0
- mmini/parsers.py +308 -0
- mmini/py.typed +1 -0
- mmini/recording.py +88 -0
- mmini/retry.py +135 -0
- mmini/sandbox.py +419 -0
- mmini/screenshot.py +63 -0
- mmini/tasks/__init__.py +307 -0
- mmini/tasks/templates/pre_command.sh +2 -0
- mmini/tasks/templates/task.toml +18 -0
- mmini/tasks/templates/test_ios.sh +20 -0
- mmini/tasks/templates/test_ios_nograder.sh +4 -0
- mmini/tasks/templates/test_macos.sh +10 -0
- mmini/tasks/templates/test_macos_check.sh +10 -0
- mmini/tasks/templates/test_macos_nograder.sh +7 -0
- use_computer/__init__.py +15 -0
- use_computer/py.typed +1 -0
- use_computer-0.0.1.dist-info/METADATA +133 -0
- use_computer-0.0.1.dist-info/RECORD +32 -0
- use_computer-0.0.1.dist-info/WHEEL +4 -0
mmini/client.py
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Literal, overload
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from mmini.errors import _araise_if_unsupported, _raise_if_unsupported
|
|
12
|
+
from mmini.retry import AsyncRetryTransport, RetryTransport
|
|
13
|
+
from mmini.sandbox import (
|
|
14
|
+
AsyncIOSSandbox,
|
|
15
|
+
AsyncMacOSSandbox,
|
|
16
|
+
AsyncSandbox,
|
|
17
|
+
IOSSandbox,
|
|
18
|
+
MacOSSandbox,
|
|
19
|
+
Sandbox,
|
|
20
|
+
SandboxType,
|
|
21
|
+
)
|
|
22
|
+
from mmini.tasks import TasksClient
|
|
23
|
+
|
|
24
|
+
DEFAULT_BASE_URL = "https://api.use.computer"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _resolve_api_key(api_key: str | None) -> str | None:
|
|
28
|
+
if api_key is not None:
|
|
29
|
+
return api_key
|
|
30
|
+
return os.getenv("USE_COMPUTER_API_KEY") or os.getenv("MMINI_API_KEY")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _resolve_base_url(base_url: str | None) -> str:
|
|
34
|
+
return (base_url or os.getenv("USE_COMPUTER_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class RunStatus:
|
|
39
|
+
"""Status of an ad-hoc or model run."""
|
|
40
|
+
|
|
41
|
+
id: str
|
|
42
|
+
status: str # "starting" | "running" | "completed" | "failed"
|
|
43
|
+
model: str = ""
|
|
44
|
+
task_id: str = ""
|
|
45
|
+
sandbox_id: str = ""
|
|
46
|
+
mini_ip: str = ""
|
|
47
|
+
mini_hostname: str = ""
|
|
48
|
+
max_steps: int = 0
|
|
49
|
+
reward: float | None = None
|
|
50
|
+
error: str = ""
|
|
51
|
+
n_steps: int | None = None
|
|
52
|
+
n_actions: int | None = None
|
|
53
|
+
started_at: str = ""
|
|
54
|
+
finished_at: str | None = None
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def done(self) -> bool:
|
|
58
|
+
return self.status in ("completed", "failed")
|
|
59
|
+
|
|
60
|
+
@staticmethod
|
|
61
|
+
def _from_dict(d: dict[str, Any]) -> RunStatus:
|
|
62
|
+
return RunStatus(
|
|
63
|
+
id=d.get("id", ""),
|
|
64
|
+
status=d.get("status", ""),
|
|
65
|
+
model=d.get("model", ""),
|
|
66
|
+
task_id=d.get("task_id", ""),
|
|
67
|
+
sandbox_id=d.get("sandbox_id", ""),
|
|
68
|
+
mini_ip=d.get("mini_ip", ""),
|
|
69
|
+
mini_hostname=d.get("mini_hostname", ""),
|
|
70
|
+
max_steps=d.get("max_steps", 0),
|
|
71
|
+
reward=d.get("reward"),
|
|
72
|
+
error=d.get("error", ""),
|
|
73
|
+
n_steps=d.get("n_steps"),
|
|
74
|
+
n_actions=d.get("n_actions"),
|
|
75
|
+
started_at=d.get("started_at", ""),
|
|
76
|
+
finished_at=d.get("finished_at"),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Mmini:
|
|
81
|
+
"""mmini client. Creates and manages macOS and iOS sandboxes."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, api_key: str | None = None, base_url: str | None = None):
|
|
84
|
+
self._base_url = _resolve_base_url(base_url)
|
|
85
|
+
self._api_key = _resolve_api_key(api_key)
|
|
86
|
+
headers: dict[str, str] = {}
|
|
87
|
+
if self._api_key:
|
|
88
|
+
headers["Authorization"] = f"Bearer {self._api_key}"
|
|
89
|
+
self._http = httpx.Client(
|
|
90
|
+
base_url=self._base_url,
|
|
91
|
+
headers=headers,
|
|
92
|
+
timeout=60.0,
|
|
93
|
+
transport=RetryTransport(httpx.HTTPTransport()),
|
|
94
|
+
event_hooks={"response": [_raise_if_unsupported]},
|
|
95
|
+
)
|
|
96
|
+
self.tasks = TasksClient(self._http)
|
|
97
|
+
|
|
98
|
+
def platforms(self) -> dict:
|
|
99
|
+
"""Discover available platforms, images, runtimes, and device types."""
|
|
100
|
+
resp = self._http.get("/v1/platforms")
|
|
101
|
+
resp.raise_for_status()
|
|
102
|
+
return resp.json()
|
|
103
|
+
|
|
104
|
+
@overload
|
|
105
|
+
def create(self, *, type: Literal["macos"] = ..., host: str = ...) -> MacOSSandbox: ...
|
|
106
|
+
@overload
|
|
107
|
+
def create(
|
|
108
|
+
self, *, type: Literal["ios"], device_type: str = ..., runtime: str = ...
|
|
109
|
+
) -> IOSSandbox: ...
|
|
110
|
+
|
|
111
|
+
def create(
|
|
112
|
+
self,
|
|
113
|
+
*,
|
|
114
|
+
type: str = "macos",
|
|
115
|
+
host: str = "",
|
|
116
|
+
device_type: str = "",
|
|
117
|
+
runtime: str = "",
|
|
118
|
+
) -> MacOSSandbox | IOSSandbox:
|
|
119
|
+
"""Create a new sandbox.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
type: "macos" (default) or "ios".
|
|
123
|
+
host: Pin sandbox to a specific host machine (e.g. "mm001").
|
|
124
|
+
device_type: iOS only — simulator device type identifier.
|
|
125
|
+
runtime: iOS only — simulator runtime identifier.
|
|
126
|
+
"""
|
|
127
|
+
body: dict = {"type": type}
|
|
128
|
+
if host:
|
|
129
|
+
body["host"] = host
|
|
130
|
+
if type == "ios":
|
|
131
|
+
if device_type:
|
|
132
|
+
body["device_type"] = device_type
|
|
133
|
+
if runtime:
|
|
134
|
+
body["runtime"] = runtime
|
|
135
|
+
|
|
136
|
+
resp = self._http.post("/v1/sandboxes", json=body, timeout=180.0)
|
|
137
|
+
resp.raise_for_status()
|
|
138
|
+
data = resp.json()
|
|
139
|
+
sid = data["sandbox_id"]
|
|
140
|
+
|
|
141
|
+
if type == "ios":
|
|
142
|
+
return IOSSandbox(sandbox_id=sid, http=self._http)
|
|
143
|
+
return MacOSSandbox(
|
|
144
|
+
sandbox_id=sid,
|
|
145
|
+
http=self._http,
|
|
146
|
+
vnc_url=f"{self._base_url}{data.get('vnc_url', '')}",
|
|
147
|
+
vm_ip=data.get("vm_ip", ""),
|
|
148
|
+
host=data.get("host", ""),
|
|
149
|
+
ssh_url=f"{self._base_url}{data.get('ssh_url', '')}",
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
def get(self, sandbox_id: str) -> Sandbox:
|
|
153
|
+
"""Get an existing sandbox by ID. Returns base Sandbox (use create() for typed access)."""
|
|
154
|
+
resp = self._http.get(f"/v1/sandboxes/{sandbox_id}")
|
|
155
|
+
resp.raise_for_status()
|
|
156
|
+
data = resp.json()
|
|
157
|
+
sb_type = SandboxType(data.get("type", "macos"))
|
|
158
|
+
if sb_type == SandboxType.IOS:
|
|
159
|
+
return IOSSandbox(sandbox_id=data["sandbox_id"], http=self._http)
|
|
160
|
+
return MacOSSandbox(
|
|
161
|
+
sandbox_id=data["sandbox_id"],
|
|
162
|
+
http=self._http,
|
|
163
|
+
vnc_url=f"{self._base_url}{data.get('vnc_url', '')}",
|
|
164
|
+
ssh_url=f"{self._base_url}{data.get('ssh_url', '')}",
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def adhoc_run(
|
|
168
|
+
self,
|
|
169
|
+
instruction: str,
|
|
170
|
+
*,
|
|
171
|
+
platform: str = "macos",
|
|
172
|
+
model: str = "claude-sonnet-4-6",
|
|
173
|
+
max_steps: int = 50,
|
|
174
|
+
files: list[dict[str, str]] | None = None,
|
|
175
|
+
poll: bool = True,
|
|
176
|
+
poll_interval: float = 3.0,
|
|
177
|
+
) -> RunStatus:
|
|
178
|
+
"""Run an ad-hoc task: spin up a sandbox and drive a model against a free-form instruction.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
instruction: What the agent should do.
|
|
182
|
+
platform: "macos" or "ios".
|
|
183
|
+
model: Model to use.
|
|
184
|
+
max_steps: Max agent steps.
|
|
185
|
+
files: Optional list of {remote_path, content_b64} to pre-upload.
|
|
186
|
+
poll: If True, block until the run finishes.
|
|
187
|
+
poll_interval: Seconds between status polls.
|
|
188
|
+
"""
|
|
189
|
+
body: dict[str, Any] = {
|
|
190
|
+
"instruction": instruction,
|
|
191
|
+
"platform": platform,
|
|
192
|
+
"model": model,
|
|
193
|
+
"max_steps": max_steps,
|
|
194
|
+
}
|
|
195
|
+
if files:
|
|
196
|
+
body["files"] = files
|
|
197
|
+
resp = self._http.post("/admin/adhoc-runs/", json=body, timeout=30.0)
|
|
198
|
+
resp.raise_for_status()
|
|
199
|
+
status = RunStatus._from_dict(resp.json())
|
|
200
|
+
if not poll:
|
|
201
|
+
return status
|
|
202
|
+
return self._poll_run(status.id, poll_interval)
|
|
203
|
+
|
|
204
|
+
def get_run(self, run_id: str) -> RunStatus:
|
|
205
|
+
"""Get current status of a model run."""
|
|
206
|
+
resp = self._http.get(f"/admin/tasks/model-runs/{run_id}")
|
|
207
|
+
resp.raise_for_status()
|
|
208
|
+
return RunStatus._from_dict(resp.json())
|
|
209
|
+
|
|
210
|
+
def _poll_run(self, run_id: str, interval: float = 3.0) -> RunStatus:
|
|
211
|
+
while True:
|
|
212
|
+
status = self.get_run(run_id)
|
|
213
|
+
if status.done:
|
|
214
|
+
return status
|
|
215
|
+
time.sleep(interval)
|
|
216
|
+
|
|
217
|
+
def close(self):
|
|
218
|
+
self._http.close()
|
|
219
|
+
|
|
220
|
+
def __enter__(self):
|
|
221
|
+
return self
|
|
222
|
+
|
|
223
|
+
def __exit__(self, *_):
|
|
224
|
+
self.close()
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class AsyncMmini:
|
|
228
|
+
"""Async mmini client."""
|
|
229
|
+
|
|
230
|
+
def __init__(self, api_key: str | None = None, base_url: str | None = None):
|
|
231
|
+
self._base_url = _resolve_base_url(base_url)
|
|
232
|
+
self._api_key = _resolve_api_key(api_key)
|
|
233
|
+
headers: dict[str, str] = {}
|
|
234
|
+
if self._api_key:
|
|
235
|
+
headers["Authorization"] = f"Bearer {self._api_key}"
|
|
236
|
+
self._http = httpx.AsyncClient(
|
|
237
|
+
base_url=self._base_url,
|
|
238
|
+
headers=headers,
|
|
239
|
+
timeout=60.0,
|
|
240
|
+
transport=AsyncRetryTransport(httpx.AsyncHTTPTransport()),
|
|
241
|
+
event_hooks={"response": [_araise_if_unsupported]},
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
async def platforms(self) -> dict:
|
|
245
|
+
"""Discover available platforms, images, runtimes, and device types."""
|
|
246
|
+
resp = await self._http.get("/v1/platforms")
|
|
247
|
+
resp.raise_for_status()
|
|
248
|
+
return resp.json()
|
|
249
|
+
|
|
250
|
+
@overload
|
|
251
|
+
async def create(
|
|
252
|
+
self, *, type: Literal["macos"] = ..., host: str = ...
|
|
253
|
+
) -> AsyncMacOSSandbox: ...
|
|
254
|
+
@overload
|
|
255
|
+
async def create(
|
|
256
|
+
self, *, type: Literal["ios"], device_type: str = ..., runtime: str = ...
|
|
257
|
+
) -> AsyncIOSSandbox: ...
|
|
258
|
+
|
|
259
|
+
async def create(
|
|
260
|
+
self,
|
|
261
|
+
*,
|
|
262
|
+
type: str = "macos",
|
|
263
|
+
host: str = "",
|
|
264
|
+
device_type: str = "",
|
|
265
|
+
runtime: str = "",
|
|
266
|
+
) -> AsyncMacOSSandbox | AsyncIOSSandbox:
|
|
267
|
+
"""Create a new sandbox.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
type: "macos" (default) or "ios".
|
|
271
|
+
host: Pin sandbox to a specific host machine (e.g. "mm001").
|
|
272
|
+
device_type: iOS only — simulator device type identifier.
|
|
273
|
+
runtime: iOS only — simulator runtime identifier.
|
|
274
|
+
"""
|
|
275
|
+
body: dict = {"type": type}
|
|
276
|
+
if host:
|
|
277
|
+
body["host"] = host
|
|
278
|
+
if type == "ios":
|
|
279
|
+
if device_type:
|
|
280
|
+
body["device_type"] = device_type
|
|
281
|
+
if runtime:
|
|
282
|
+
body["runtime"] = runtime
|
|
283
|
+
|
|
284
|
+
resp = await self._http.post("/v1/sandboxes", json=body, timeout=180.0)
|
|
285
|
+
resp.raise_for_status()
|
|
286
|
+
data = resp.json()
|
|
287
|
+
sid = data["sandbox_id"]
|
|
288
|
+
|
|
289
|
+
if type == "ios":
|
|
290
|
+
return AsyncIOSSandbox(sandbox_id=sid, http=self._http)
|
|
291
|
+
return AsyncMacOSSandbox(
|
|
292
|
+
sandbox_id=sid,
|
|
293
|
+
http=self._http,
|
|
294
|
+
vnc_url=f"{self._base_url}{data.get('vnc_url', '')}",
|
|
295
|
+
ssh_url=f"{self._base_url}{data.get('ssh_url', '')}",
|
|
296
|
+
vm_ip=data.get("vm_ip", ""),
|
|
297
|
+
host=data.get("host", ""),
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
async def get(self, sandbox_id: str) -> AsyncSandbox:
|
|
301
|
+
"""Get an existing sandbox by ID."""
|
|
302
|
+
resp = await self._http.get(f"/v1/sandboxes/{sandbox_id}")
|
|
303
|
+
resp.raise_for_status()
|
|
304
|
+
data = resp.json()
|
|
305
|
+
sb_type = SandboxType(data.get("type", "macos"))
|
|
306
|
+
if sb_type == SandboxType.IOS:
|
|
307
|
+
return AsyncIOSSandbox(sandbox_id=data["sandbox_id"], http=self._http)
|
|
308
|
+
return AsyncMacOSSandbox(
|
|
309
|
+
sandbox_id=data["sandbox_id"],
|
|
310
|
+
http=self._http,
|
|
311
|
+
vnc_url=f"{self._base_url}{data.get('vnc_url', '')}",
|
|
312
|
+
ssh_url=f"{self._base_url}{data.get('ssh_url', '')}",
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
async def adhoc_run(
|
|
316
|
+
self,
|
|
317
|
+
instruction: str,
|
|
318
|
+
*,
|
|
319
|
+
platform: str = "macos",
|
|
320
|
+
model: str = "claude-sonnet-4-6",
|
|
321
|
+
max_steps: int = 50,
|
|
322
|
+
files: list[dict[str, str]] | None = None,
|
|
323
|
+
poll: bool = True,
|
|
324
|
+
poll_interval: float = 3.0,
|
|
325
|
+
) -> RunStatus:
|
|
326
|
+
"""Run an ad-hoc task and drive a model against a free-form instruction."""
|
|
327
|
+
body: dict[str, Any] = {
|
|
328
|
+
"instruction": instruction,
|
|
329
|
+
"platform": platform,
|
|
330
|
+
"model": model,
|
|
331
|
+
"max_steps": max_steps,
|
|
332
|
+
}
|
|
333
|
+
if files:
|
|
334
|
+
body["files"] = files
|
|
335
|
+
resp = await self._http.post("/admin/adhoc-runs/", json=body, timeout=30.0)
|
|
336
|
+
resp.raise_for_status()
|
|
337
|
+
status = RunStatus._from_dict(resp.json())
|
|
338
|
+
if not poll:
|
|
339
|
+
return status
|
|
340
|
+
return await self._poll_run(status.id, poll_interval)
|
|
341
|
+
|
|
342
|
+
async def get_run(self, run_id: str) -> RunStatus:
|
|
343
|
+
"""Get current status of a model run."""
|
|
344
|
+
resp = await self._http.get(f"/admin/tasks/model-runs/{run_id}")
|
|
345
|
+
resp.raise_for_status()
|
|
346
|
+
return RunStatus._from_dict(resp.json())
|
|
347
|
+
|
|
348
|
+
async def _poll_run(self, run_id: str, interval: float = 3.0) -> RunStatus:
|
|
349
|
+
while True:
|
|
350
|
+
status = await self.get_run(run_id)
|
|
351
|
+
if status.done:
|
|
352
|
+
return status
|
|
353
|
+
await asyncio.sleep(interval)
|
|
354
|
+
|
|
355
|
+
async def close(self):
|
|
356
|
+
await self._http.aclose()
|
|
357
|
+
|
|
358
|
+
async def __aenter__(self):
|
|
359
|
+
return self
|
|
360
|
+
|
|
361
|
+
async def __aexit__(self, *_):
|
|
362
|
+
await self.close()
|
mmini/display.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from mmini.models import DisplayInfo
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Display:
|
|
11
|
+
def __init__(self, http: httpx.Client, prefix: str):
|
|
12
|
+
self._http = http
|
|
13
|
+
self._prefix = prefix
|
|
14
|
+
|
|
15
|
+
def get_info(self) -> DisplayInfo:
|
|
16
|
+
resp = self._http.get(f"{self._prefix}/display/info")
|
|
17
|
+
resp.raise_for_status()
|
|
18
|
+
return DisplayInfo.from_dict(resp.json())
|
|
19
|
+
|
|
20
|
+
def get_windows(self) -> list[dict[str, Any]]:
|
|
21
|
+
resp = self._http.get(f"{self._prefix}/display/windows")
|
|
22
|
+
resp.raise_for_status()
|
|
23
|
+
return resp.json()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AsyncDisplay:
|
|
27
|
+
def __init__(self, http: httpx.AsyncClient, prefix: str):
|
|
28
|
+
self._http = http
|
|
29
|
+
self._prefix = prefix
|
|
30
|
+
|
|
31
|
+
async def get_info(self) -> DisplayInfo:
|
|
32
|
+
resp = await self._http.get(f"{self._prefix}/display/info")
|
|
33
|
+
resp.raise_for_status()
|
|
34
|
+
return DisplayInfo.from_dict(resp.json())
|
|
35
|
+
|
|
36
|
+
async def get_windows(self) -> list[dict[str, Any]]:
|
|
37
|
+
resp = await self._http.get(f"{self._prefix}/display/windows")
|
|
38
|
+
resp.raise_for_status()
|
|
39
|
+
return resp.json()
|
mmini/errors.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Typed exceptions for mmini SDK errors that callers should catch by class
|
|
2
|
+
rather than by parsing HTTP status codes or response bodies."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MminiError(Exception):
|
|
10
|
+
"""Base class for typed mmini SDK errors."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PlatformNotSupportedError(MminiError):
|
|
14
|
+
"""Raised when an action is not supported on the sandbox's platform.
|
|
15
|
+
|
|
16
|
+
The gateway returns 501 with a JSON body of the shape:
|
|
17
|
+
{"error": "...", "sandbox_type": "ios", "action": "POST /mouse/click",
|
|
18
|
+
"hint": "use POST /tap"}
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, action: str, sandbox_type: str, hint: str, response: httpx.Response):
|
|
22
|
+
self.action = action
|
|
23
|
+
self.sandbox_type = sandbox_type
|
|
24
|
+
self.hint = hint
|
|
25
|
+
self.response = response
|
|
26
|
+
super().__init__(f"{action} not supported on {sandbox_type} sandbox: {hint}")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _raise_if_unsupported(resp: httpx.Response) -> None:
|
|
30
|
+
"""httpx event hook: convert 501-with-platform-shape into a typed error.
|
|
31
|
+
|
|
32
|
+
httpx hooks run before the body is read on streaming responses, so we
|
|
33
|
+
only touch the body when the status is 501 — anything else is left
|
|
34
|
+
untouched for raise_for_status() to handle normally.
|
|
35
|
+
"""
|
|
36
|
+
if resp.status_code != 501:
|
|
37
|
+
return
|
|
38
|
+
try:
|
|
39
|
+
resp.read()
|
|
40
|
+
data = resp.json()
|
|
41
|
+
except Exception:
|
|
42
|
+
return
|
|
43
|
+
if not isinstance(data, dict) or data.get("error") != "not supported on iOS sandbox":
|
|
44
|
+
return
|
|
45
|
+
raise PlatformNotSupportedError(
|
|
46
|
+
action=data.get("action", ""),
|
|
47
|
+
sandbox_type=data.get("sandbox_type", ""),
|
|
48
|
+
hint=data.get("hint", ""),
|
|
49
|
+
response=resp,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def _araise_if_unsupported(resp: httpx.Response) -> None:
|
|
54
|
+
if resp.status_code != 501:
|
|
55
|
+
return
|
|
56
|
+
try:
|
|
57
|
+
await resp.aread()
|
|
58
|
+
data = resp.json()
|
|
59
|
+
except Exception:
|
|
60
|
+
return
|
|
61
|
+
if not isinstance(data, dict) or data.get("error") != "not supported on iOS sandbox":
|
|
62
|
+
return
|
|
63
|
+
raise PlatformNotSupportedError(
|
|
64
|
+
action=data.get("action", ""),
|
|
65
|
+
sandbox_type=data.get("sandbox_type", ""),
|
|
66
|
+
hint=data.get("hint", ""),
|
|
67
|
+
response=resp,
|
|
68
|
+
)
|
mmini/ios/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from mmini.ios.apps import Apps, AsyncApps
|
|
2
|
+
from mmini.ios.environment import AsyncEnvironment, Environment
|
|
3
|
+
from mmini.ios.input import AsyncInput, Button, Input, Key
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"Apps",
|
|
7
|
+
"AsyncApps",
|
|
8
|
+
"AsyncEnvironment",
|
|
9
|
+
"AsyncInput",
|
|
10
|
+
"Button",
|
|
11
|
+
"Environment",
|
|
12
|
+
"Input",
|
|
13
|
+
"Key",
|
|
14
|
+
]
|
mmini/ios/apps.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from mmini.models import ActionResult
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Apps:
|
|
9
|
+
def __init__(self, http: httpx.Client, prefix: str):
|
|
10
|
+
self._http = http
|
|
11
|
+
self._prefix = prefix
|
|
12
|
+
|
|
13
|
+
def open_url(self, url: str) -> ActionResult:
|
|
14
|
+
resp = self._http.post(f"{self._prefix}/openurl", json={"url": url})
|
|
15
|
+
resp.raise_for_status()
|
|
16
|
+
return ActionResult.from_dict(resp.json())
|
|
17
|
+
|
|
18
|
+
def install(self, app_path: str) -> ActionResult:
|
|
19
|
+
resp = self._http.post(f"{self._prefix}/install", json={"appPath": app_path})
|
|
20
|
+
resp.raise_for_status()
|
|
21
|
+
return ActionResult.from_dict(resp.json())
|
|
22
|
+
|
|
23
|
+
def launch(self, bundle_id: str) -> ActionResult:
|
|
24
|
+
resp = self._http.post(f"{self._prefix}/launch", json={"bundleId": bundle_id})
|
|
25
|
+
resp.raise_for_status()
|
|
26
|
+
return ActionResult.from_dict(resp.json())
|
|
27
|
+
|
|
28
|
+
def terminate(self, bundle_id: str) -> ActionResult:
|
|
29
|
+
resp = self._http.post(f"{self._prefix}/terminate", json={"bundleId": bundle_id})
|
|
30
|
+
resp.raise_for_status()
|
|
31
|
+
return ActionResult.from_dict(resp.json())
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AsyncApps:
|
|
35
|
+
def __init__(self, http: httpx.AsyncClient, prefix: str):
|
|
36
|
+
self._http = http
|
|
37
|
+
self._prefix = prefix
|
|
38
|
+
|
|
39
|
+
async def open_url(self, url: str) -> ActionResult:
|
|
40
|
+
resp = await self._http.post(f"{self._prefix}/openurl", json={"url": url})
|
|
41
|
+
resp.raise_for_status()
|
|
42
|
+
return ActionResult.from_dict(resp.json())
|
|
43
|
+
|
|
44
|
+
async def install(self, app_path: str) -> ActionResult:
|
|
45
|
+
resp = await self._http.post(f"{self._prefix}/install", json={"appPath": app_path})
|
|
46
|
+
resp.raise_for_status()
|
|
47
|
+
return ActionResult.from_dict(resp.json())
|
|
48
|
+
|
|
49
|
+
async def launch(self, bundle_id: str) -> ActionResult:
|
|
50
|
+
resp = await self._http.post(f"{self._prefix}/launch", json={"bundleId": bundle_id})
|
|
51
|
+
resp.raise_for_status()
|
|
52
|
+
return ActionResult.from_dict(resp.json())
|
|
53
|
+
|
|
54
|
+
async def terminate(self, bundle_id: str) -> ActionResult:
|
|
55
|
+
resp = await self._http.post(f"{self._prefix}/terminate", json={"bundleId": bundle_id})
|
|
56
|
+
resp.raise_for_status()
|
|
57
|
+
return ActionResult.from_dict(resp.json())
|
mmini/ios/environment.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from mmini.models import ActionResult
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Environment:
|
|
9
|
+
def __init__(self, http: httpx.Client, prefix: str):
|
|
10
|
+
self._http = http
|
|
11
|
+
self._prefix = prefix
|
|
12
|
+
|
|
13
|
+
def set_location(self, lat: float, lon: float) -> ActionResult:
|
|
14
|
+
resp = self._http.post(f"{self._prefix}/location", json={"lat": lat, "lon": lon})
|
|
15
|
+
resp.raise_for_status()
|
|
16
|
+
return ActionResult.from_dict(resp.json())
|
|
17
|
+
|
|
18
|
+
def clear_location(self) -> ActionResult:
|
|
19
|
+
resp = self._http.post(f"{self._prefix}/location", json={"action": "clear"})
|
|
20
|
+
resp.raise_for_status()
|
|
21
|
+
return ActionResult.from_dict(resp.json())
|
|
22
|
+
|
|
23
|
+
def set_appearance(self, mode: str) -> ActionResult:
|
|
24
|
+
"""Set dark or light mode."""
|
|
25
|
+
resp = self._http.post(f"{self._prefix}/appearance", json={"mode": mode})
|
|
26
|
+
resp.raise_for_status()
|
|
27
|
+
return ActionResult.from_dict(resp.json())
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AsyncEnvironment:
|
|
31
|
+
def __init__(self, http: httpx.AsyncClient, prefix: str):
|
|
32
|
+
self._http = http
|
|
33
|
+
self._prefix = prefix
|
|
34
|
+
|
|
35
|
+
async def set_location(self, lat: float, lon: float) -> ActionResult:
|
|
36
|
+
resp = await self._http.post(f"{self._prefix}/location", json={"lat": lat, "lon": lon})
|
|
37
|
+
resp.raise_for_status()
|
|
38
|
+
return ActionResult.from_dict(resp.json())
|
|
39
|
+
|
|
40
|
+
async def clear_location(self) -> ActionResult:
|
|
41
|
+
resp = await self._http.post(f"{self._prefix}/location", json={"action": "clear"})
|
|
42
|
+
resp.raise_for_status()
|
|
43
|
+
return ActionResult.from_dict(resp.json())
|
|
44
|
+
|
|
45
|
+
async def set_appearance(self, mode: str) -> ActionResult:
|
|
46
|
+
"""Set dark or light mode."""
|
|
47
|
+
resp = await self._http.post(f"{self._prefix}/appearance", json={"mode": mode})
|
|
48
|
+
resp.raise_for_status()
|
|
49
|
+
return ActionResult.from_dict(resp.json())
|