nomac 0.1.0__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.
nomac/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """
2
+ NoMac from Python: iPhone and iPad Simulators in the cloud for a platform's users and agents (NoMac Simulator), their
3
+ tools, live view, saved state, tokens and webhooks. NoMac for sync code, AsyncNoMac for asyncio.
4
+ """
5
+
6
+ from ._async import AsyncNoMac, AsyncPhone
7
+ from ._base import NoMacError, ToolResult, request_key, verify_webhook
8
+ from ._sync import NoMac, Phone
9
+
10
+ __all__ = ["NoMac", "AsyncNoMac", "Phone", "AsyncPhone", "NoMacError", "ToolResult", "request_key", "verify_webhook"]
11
+ __version__ = "0.1.0"
nomac/_async.py ADDED
@@ -0,0 +1,296 @@
1
+ """The async client: the same calls as NoMac, awaited, over httpx.AsyncClient."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import builtins
7
+ import os
8
+ import time
9
+ from typing import Any, AsyncIterator, Mapping
10
+ from urllib.parse import quote
11
+
12
+ import httpx
13
+
14
+ from . import _base
15
+ from ._base import ENDED, LABELS, SESSIONS, WEBHOOKS, NoMacError, ToolResult, request_key
16
+
17
+
18
+ class AsyncNoMac:
19
+ """
20
+ A client for an account (its API key, nmk_...) or for one phone (the token a platform hands its own user,
21
+ nmp_...), every call awaited. NOMAC_API_KEY from the environment by default. Close it, or use it in an async with block.
22
+ """
23
+
24
+ def __init__(self, api_key: str | None = None, *, token: str | None = None, base_url: str = _base.DEFAULT_BASE_URL,
25
+ http_client: httpx.AsyncClient | None = None, timeout: float = 90.0) -> None:
26
+ self._auth = f"Bearer {_base.credential(api_key, token)}"
27
+ self._base = base_url.rstrip("/")
28
+ self._timeout = timeout
29
+ self._http = http_client or httpx.AsyncClient(timeout=timeout)
30
+ self._own_http = http_client is None
31
+ self.phones = AsyncPhones(self)
32
+ self.labels = AsyncLabels(self)
33
+ self.webhooks = AsyncWebhooks(self)
34
+
35
+ def phone(self, id: str) -> AsyncPhone:
36
+ """A phone by its id, without reading it (a phone's token reaches its own only)."""
37
+ return AsyncPhone(self, {"id": id})
38
+
39
+ async def request(self, method: str, path: str, *, body: Any = None, idempotency_key: str | None = None, timeout: float | None = None) -> dict[str, Any]:
40
+ """
41
+ One call to the REST API: JSON in and out, NoMac's error as a NoMacError. A read, a stop, a request with a key,
42
+ and any request NoMac answered 429 are sent again up to three times when NoMac is busy or does not answer.
43
+ """
44
+ again = _base.repeatable(method, idempotency_key)
45
+ for attempt in range(1, _base.ATTEMPTS + 1):
46
+ try:
47
+ response = await self._http.request(method, f"{self._base}{path}", headers=_base.headers(self._auth, idempotency_key, body is not None),
48
+ json=body, timeout=timeout or self._timeout)
49
+ except httpx.HTTPError as error:
50
+ if again and attempt < _base.ATTEMPTS:
51
+ await asyncio.sleep(_base.pause_seconds(attempt))
52
+ continue
53
+ raise _base.unreachable(error) from None
54
+ value = _base.answer(method, path, attempt, again, response.status_code, response.text, response.headers.get("retry-after"))
55
+ if isinstance(value, dict):
56
+ return value
57
+ await asyncio.sleep(value)
58
+ raise NoMacError("unreachable", "NoMac did not answer.")
59
+
60
+ async def _upload(self, url: str, data: bytes | None, path: str | None, size: int) -> None:
61
+ """PUTs an archive to a link NoMac signed (an upload link): no NoMac credential goes with it."""
62
+ headers = {"content-length": str(size)}
63
+ if path is None:
64
+ response = await self._http.put(url, content=data, headers=headers, timeout=30 * 60)
65
+ else:
66
+ response = await self._http.put(url, content=_pieces(path), headers=headers, timeout=30 * 60)
67
+ if response.status_code >= 300:
68
+ raise NoMacError("upload_failed", f"The upload was refused ({response.status_code}); ask for a new upload link.", response.status_code)
69
+
70
+ async def close(self) -> None:
71
+ if self._own_http:
72
+ await self._http.aclose()
73
+
74
+ async def __aenter__(self) -> AsyncNoMac:
75
+ return self
76
+
77
+ async def __aexit__(self, *exc: object) -> None:
78
+ await self.close()
79
+
80
+
81
+ async def _pieces(path: str) -> AsyncIterator[bytes]:
82
+ """A file in pieces, read off the event loop."""
83
+ with open(path, "rb") as file:
84
+ while piece := await asyncio.to_thread(file.read, 1 << 20):
85
+ yield piece
86
+
87
+
88
+ class AsyncPhones:
89
+ def __init__(self, client: AsyncNoMac) -> None:
90
+ self._client = client
91
+
92
+ async def start(self, *, device: str | None = None, label: str | None = None, reuse: bool | None = None, limits: Mapping[str, Any] | None = None,
93
+ idempotency_key: str | None = None) -> AsyncPhone:
94
+ """
95
+ Starts a phone: device ("iphone", "ipad" or "iphone-pro-max"), label (your user's id), reuse, limits. With reuse
96
+ and a label, the live phone with that label is answered instead of a new one (phone.reused). A full Simulator
97
+ host queues the start, free, for up to 5 minutes: wait_until_ready() follows it. The same idempotency_key
98
+ retries a start.
99
+ """
100
+ answer = await self._client.request("POST", SESSIONS, body=_base.start_body(device, label, reuse, limits), idempotency_key=idempotency_key or request_key("start"))
101
+ return AsyncPhone(self._client, answer["session"], answer.get("links"), bool(answer.get("reused")))
102
+
103
+ async def list(self, *, label: str | None = None) -> builtins.list[AsyncPhone]:
104
+ """The account's phones, newest first; with label, the ones a platform named so."""
105
+ answer = await self._client.request("GET", f"{SESSIONS}?label={quote(label, safe='')}" if label else SESSIONS)
106
+ return [AsyncPhone(self._client, session) for session in _base.rows(answer, "sessions")]
107
+
108
+ async def get(self, id: str) -> AsyncPhone:
109
+ return await AsyncPhone(self._client, {"id": id}).refresh()
110
+
111
+
112
+ class AsyncPhone:
113
+ """A phone: its session (state, device, label, cost, deadlines) and links, and what can be done with it."""
114
+
115
+ def __init__(self, client: AsyncNoMac, session: dict[str, Any], links: dict[str, Any] | None = None, reused: bool = False) -> None:
116
+ self._client = client
117
+ self.id: str = session["id"]
118
+ self.session = session
119
+ self.links = links
120
+ self.reused = reused
121
+ # In its last 15 minutes before a limit or the credit's end: when it stops, why and what to do first.
122
+ self.stop_notice: dict[str, Any] | None = None
123
+
124
+ @property
125
+ def state(self) -> str | None:
126
+ return self.session.get("state")
127
+
128
+ def _path(self, rest: str = "") -> str:
129
+ return f"{SESSIONS}/{quote(self.id, safe='')}{rest}"
130
+
131
+ async def refresh(self) -> AsyncPhone:
132
+ answer = await self._client.request("GET", self._path())
133
+ self.session = answer["session"]
134
+ self.links = answer.get("links") or self.links
135
+ self.stop_notice = answer.get("stop_notice")
136
+ return self
137
+
138
+ async def wait_until_ready(self, *, timeout: float = 6 * 60, interval: float = 1.0) -> AsyncPhone:
139
+ """Waits until the phone is ready (a phone from the pool takes seconds; a queued one up to 5 minutes)."""
140
+ by = time.monotonic() + timeout
141
+ while True:
142
+ if self.state == "ready":
143
+ return self
144
+ if self.state in ENDED:
145
+ raise NoMacError("phone_ended", f"The phone ended before it was ready ({self.session.get('failure_code') or self.state}).", 0, self.session)
146
+ if time.monotonic() >= by:
147
+ raise NoMacError("timeout", f"The phone was not ready within {round(timeout)} s ({self.state}).", 0, self.session)
148
+ await asyncio.sleep(interval)
149
+ await self.refresh()
150
+
151
+ async def tool(self, name: str, arguments: Mapping[str, Any] | None = None, /, *, idempotency_key: str | None = None, interval: float = 1.0,
152
+ timeout: float = 60 * 60, wait_for_jobs: bool = True, **more: Any) -> ToolResult:
153
+ """
154
+ Runs one of simulator-use's tools on the phone (ui_look, ui_act, app_run, tests_run, app_control, ...), its
155
+ arguments as a dict or as keywords, and waits for its result. A long build that hands back a job is followed
156
+ with job_status until it ends, unless wait_for_jobs is False.
157
+ """
158
+ by = time.monotonic() + timeout
159
+ result = await self._call(name, _base.tool_arguments(arguments, more), idempotency_key or request_key(name), by, interval)
160
+ job = _base.job_of(result) if wait_for_jobs else None
161
+ # Each job_status waits up to 40 s for the job; one still running answers "job_status again".
162
+ for _ in range(120 if job else 0):
163
+ result = await self._call("job_status", {"job_id": job}, request_key("job_status"), by, interval)
164
+ if result.is_error or not _base.STILL_RUNNING.search(result.text):
165
+ break
166
+ if time.monotonic() >= by:
167
+ raise NoMacError("timeout", f'{name}\'s job {job} was still running; read it later with phone.tool("job_status", job_id="{job}").', 0, result.raw)
168
+ return result
169
+
170
+ async def _call(self, name: str, arguments: dict[str, Any], key: str, by: float, interval: float) -> ToolResult:
171
+ answer = await self._client.request("POST", self._path(f"/tools/{quote(name, safe='')}"), body=arguments, idempotency_key=key)
172
+ while answer.get("state") == "running" and isinstance(answer.get("id"), str):
173
+ if time.monotonic() >= by:
174
+ raise NoMacError("timeout", f'{name} was still running; cancel it with phone.cancel("{answer["id"]}") or read it later.', 0, answer)
175
+ await asyncio.sleep(interval)
176
+ answer = await self._client.request("GET", self._path(f"/calls/{answer['id']}"))
177
+ return _base.tool_result(name, answer)
178
+
179
+ async def cancel(self, call_id: str) -> dict[str, Any]:
180
+ """Stops a running tool call; a finished one is answered as it is."""
181
+ return await self._client.request("POST", self._path(f"/calls/{quote(call_id, safe='')}/cancel"))
182
+
183
+ # An operation the phone runs for a while (a fetch, a save): the same request with the same key reads it until done.
184
+ async def _operation(self, path: str, body: dict[str, Any], key: str, timeout: float | None, interval: float = 1.0) -> dict[str, Any]:
185
+ by = time.monotonic() + (30 * 60 if timeout is None else timeout)
186
+ while True:
187
+ answer = await self._client.request("POST", self._path(path), body=body, idempotency_key=key)
188
+ if _base.operation_answer(answer, key, by):
189
+ return answer
190
+ await asyncio.sleep(interval)
191
+
192
+ async def fetch_git(self, url: str, *, ref: str | None = None, token: str | None = None, secret: str | None = None,
193
+ idempotency_key: str | None = None, timeout: float | None = None) -> dict[str, Any]:
194
+ """
195
+ A git repository into the phone's workspace. A private repository's token is token itself, used for this fetch
196
+ and kept nowhere (a phone's token may send one), or secret, the name of the account's secret holding it;
197
+ github.com, gitlab.com and bitbucket.org only. Answers {"root", "commit"}: root is the path app_run and tests_run take.
198
+ """
199
+ return await self._operation("/sync/git", _base.git_body(url, ref, token, secret), idempotency_key or request_key("git"), timeout)
200
+
201
+ async def upload_archive(self, data: bytes | bytearray | memoryview | str | os.PathLike[str], *, name: str | None = None,
202
+ idempotency_key: str | None = None, timeout: float | None = None) -> dict[str, Any]:
203
+ """
204
+ An archive (a .tar.gz or .zip of a project, or of a .app built for the Simulator; bytes or a file's path) into
205
+ the phone's workspace under name, up to 2 GiB: uploaded to storage, then unpacked, only what differs written.
206
+ Answers {"root", "app"}.
207
+ """
208
+ raw, path, size, sha256 = _base.archive_bytes(data)
209
+ link = await self._client.request("POST", self._path("/sync/upload"), body={"size": size, "sha256": sha256})
210
+ await self._client._upload(link["upload_url"], raw, path, size)
211
+ return await self._operation("/sync/fetch", {"sha256": sha256, **({"name": name} if name else {})}, idempotency_key or request_key("upload"), timeout)
212
+
213
+ async def view_link(self, *, control: bool = False, minutes: int | None = None) -> dict[str, Any]:
214
+ """A link to the phone's live view for a person, no sign-in: watch only, or control=True. {"url", "expires_at"}."""
215
+ return await self._client.request("POST", self._path("/view"), body={"control": control, **({"minutes": minutes} if minutes else {})})
216
+
217
+ async def token(self, *, minutes: int | None = None) -> dict[str, Any]:
218
+ """
219
+ A token for this phone only, to hand a platform's own user or agent: AsyncNoMac(token=...) reaches its tools, files,
220
+ state and view, until it expires (2 hours by default, a day at most) or the phone ends.
221
+ """
222
+ return await self._client.request("POST", self._path("/token"), body={"minutes": minutes} if minutes else {})
223
+
224
+ async def revoke_tokens(self) -> dict[str, Any]:
225
+ """Revokes every token and link of this phone made so far, with the account's key; the phone runs on."""
226
+ return await self._client.request("DELETE", self._path("/token"))
227
+
228
+ async def replay(self) -> dict[str, Any]:
229
+ """The phone's replay: recording, saving after its stop, then ready with its video, its calls and a link."""
230
+ return await self._client.request("GET", self._path("/replay"))
231
+
232
+ async def save_state(self, name: str, *, idempotency_key: str | None = None, timeout: float | None = None) -> dict[str, Any]:
233
+ """Saves the phone's apps, their data, its keychain and permissions under name, for the phones with its label."""
234
+ return await self._operation("/state", {"action": "save", "name": name}, idempotency_key or request_key("state"), timeout)
235
+
236
+ async def restore_state(self, name: str, *, idempotency_key: str | None = None, timeout: float | None = None) -> dict[str, Any]:
237
+ """Puts a saved state back, onto a phone of the same model and iOS."""
238
+ return await self._operation("/state", {"action": "restore", "name": name}, idempotency_key or request_key("state"), timeout)
239
+
240
+ async def list_states(self) -> builtins.list[dict[str, Any]]:
241
+ return _base.rows(await self._operation("/state", {"action": "list"}, request_key("state"), None), "states")
242
+
243
+ async def delete_state(self, name: str) -> dict[str, Any]:
244
+ return await self._operation("/state", {"action": "delete", "name": name}, request_key("state"), None)
245
+
246
+ async def extend(self, *, minutes: int | None = None) -> AsyncPhone:
247
+ """
248
+ Keeps the phone running longer: its time limit goes up by minutes (60 by default, up to 8 hours in all), and its
249
+ spend cap with it. The account's key only. Not sent again by itself: a repeat adds more time.
250
+ """
251
+ answer = await self._client.request("POST", self._path("/extend"), body={"minutes": minutes} if minutes else {})
252
+ self.session = answer["session"]
253
+ self.stop_notice = answer.get("stop_notice")
254
+ return self
255
+
256
+ async def stop(self) -> AsyncPhone:
257
+ """Stops the phone: its apps and data go with it, its workspace stays for the next phone, its replay is saved."""
258
+ self.session = (await self._client.request("DELETE", self._path()))["session"]
259
+ return self
260
+
261
+
262
+ class AsyncLabels:
263
+ def __init__(self, client: AsyncNoMac) -> None:
264
+ self._client = client
265
+
266
+ async def delete(self, label: str) -> dict[str, Any]:
267
+ """
268
+ Deletes what NoMac keeps for one label, when your user deletes their account: its saved states, its phones'
269
+ replays and file copies, and its workspace. Answers {"label", "deleted": {"states", "workspaces", "replays",
270
+ "files"}}; a repeat finds nothing left. The account's key only. Refused while a phone with the label runs
271
+ (label_in_use) or for 15 minutes after the last one ends (label_busy, details["retry_after_seconds"]).
272
+ """
273
+ return await self._client.request("DELETE", f"{LABELS}/{quote(label, safe='')}")
274
+
275
+
276
+ class AsyncWebhooks:
277
+ def __init__(self, client: AsyncNoMac) -> None:
278
+ self._client = client
279
+
280
+ async def create(self, url: str, *, events: builtins.list[str] | None = None) -> dict[str, Any]:
281
+ """
282
+ A webhook: each phone's phone.ready and phone.ended POSTed to an https URL, signed. The answer carries its
283
+ secret, shown once: check each delivery with verify_webhook. Retried with backoff until answered 2xx.
284
+ """
285
+ return await self._client.request("POST", WEBHOOKS, body={"url": url, **({"events": events} if events else {})})
286
+
287
+ async def list(self) -> builtins.list[dict[str, Any]]:
288
+ return _base.rows(await self._client.request("GET", WEBHOOKS), "webhooks")
289
+
290
+ async def delete(self, id: str) -> dict[str, Any]:
291
+ """Ends a webhook: nothing more is sent to it."""
292
+ return await self._client.request("DELETE", f"{WEBHOOKS}/{quote(id, safe='')}")
293
+
294
+ async def deliveries(self, id: str, *, limit: int | None = None) -> builtins.list[dict[str, Any]]:
295
+ """A webhook's deliveries, newest first, kept 30 days: delivered, retrying or gave_up after 12 tries."""
296
+ return _base.rows(await self._client.request("GET", f"{WEBHOOKS}/{quote(id, safe='')}/deliveries{f'?limit={limit}' if limit else ''}"), "deliveries")
nomac/_base.py ADDED
@@ -0,0 +1,210 @@
1
+ """What the sync and async clients share: NoMac's answers read the same way, and webhooks checked the same way."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import hmac
7
+ import json
8
+ import os
9
+ import re
10
+ import time
11
+ import uuid
12
+ from dataclasses import dataclass, field
13
+ from typing import Any, Mapping
14
+
15
+ DEFAULT_BASE_URL = "https://nomac.app"
16
+ SESSIONS = "/api/v1/simulator/sessions"
17
+ WEBHOOKS = "/api/v1/simulator/webhooks"
18
+ LABELS = "/api/v1/simulator/labels"
19
+ # Statuses that pass: NoMac or the phone busy for a moment. A 429 was not taken, so any request may be sent again.
20
+ PASSING = {429, 502, 503, 504}
21
+ ENDED = {"stopping", "stopped", "failed"}
22
+ JOB = re.compile(r'Next: job_status \{job_id: "([^"]+)"\}')
23
+ # job_status's answer while the job still runs: read it again with the same job.
24
+ STILL_RUNNING = re.compile(r"\nNext: job_status again, or job_cancel\.")
25
+ ATTEMPTS = 4
26
+
27
+
28
+ class NoMacError(Exception):
29
+ """NoMac's refusal, in its own words: code (stable, for code to branch on), message (for a person), status."""
30
+
31
+ def __init__(self, code: str, message: str, status: int = 0, details: Any = None) -> None:
32
+ super().__init__(message)
33
+ self.code = code
34
+ self.message = message
35
+ self.status = status
36
+ self.details = details
37
+
38
+ def __repr__(self) -> str:
39
+ return f"NoMacError(code={self.code!r}, status={self.status}, message={self.message!r})"
40
+
41
+
42
+ @dataclass
43
+ class ToolResult:
44
+ """A tool's result: text says what happened and what to do next; content has its text and images."""
45
+
46
+ text: str
47
+ content: list[dict[str, Any]]
48
+ is_error: bool
49
+ notices: list[str] = field(default_factory=list)
50
+ stop_notice: dict[str, Any] | None = None
51
+ raw: dict[str, Any] = field(default_factory=dict)
52
+
53
+
54
+ def request_key(prefix: str = "sdk") -> str:
55
+ """A request key: the same key makes a retry the same request (a start, a tool call, a fetch)."""
56
+ return f"{prefix}-{uuid.uuid4()}"
57
+
58
+
59
+ def credential(api_key: str | None, token: str | None) -> str:
60
+ value = api_key or token or os.environ.get("NOMAC_API_KEY")
61
+ if not value:
62
+ raise NoMacError("credential_required", "Pass api_key (the account's nmk_ key) or token (one phone's nmp_ token), or set NOMAC_API_KEY.")
63
+ return value
64
+
65
+
66
+ def headers(auth: str, idempotency_key: str | None, has_body: bool) -> dict[str, str]:
67
+ out = {"authorization": auth}
68
+ if idempotency_key:
69
+ out["idempotency-key"] = idempotency_key
70
+ if has_body:
71
+ out["content-type"] = "application/json"
72
+ return out
73
+
74
+
75
+ def repeatable(method: str, idempotency_key: str | None) -> bool:
76
+ """A read, a stop and a request with a key may be sent again; anything else only when NoMac answered 429."""
77
+ return method in ("GET", "DELETE") or bool(idempotency_key)
78
+
79
+
80
+ def pause_seconds(attempt: int, retry_after: str | None = None) -> float:
81
+ try:
82
+ after = float(retry_after) if retry_after is not None else 0.0
83
+ except ValueError:
84
+ after = 0.0
85
+ return min(after, 30.0) if after > 0 else attempt * 2.0
86
+
87
+
88
+ def answer(method: str, path: str, attempt: int, again: bool, status: int, text: str, retry_after: str | None) -> dict[str, Any] | float:
89
+ """What to do with NoMac's answer: its payload once done, the seconds to wait before asking again, or a NoMacError."""
90
+ try:
91
+ parsed = json.loads(text) if text else {}
92
+ except ValueError:
93
+ parsed = {}
94
+ payload: dict[str, Any] = parsed if isinstance(parsed, dict) else {}
95
+ if 200 <= status < 300:
96
+ return payload
97
+ # A repeated DELETE whose first try was taken (its answer lost on the way) finds nothing left to delete.
98
+ if method == "DELETE" and path.startswith(WEBHOOKS) and attempt > 1 and status == 404:
99
+ return {"deleted": True}
100
+ if status in PASSING and (again or status == 429) and attempt < ATTEMPTS:
101
+ return pause_seconds(attempt, retry_after)
102
+ raw = payload.get("error")
103
+ error: dict[str, Any] = raw if isinstance(raw, dict) else {}
104
+ raise NoMacError(error.get("code") or f"http_{status}", error.get("message") or f"NoMac answered {status}.", status, error)
105
+
106
+
107
+ def rows(payload: dict[str, Any], key: str) -> list[dict[str, Any]]:
108
+ """The list an answer holds under key (sessions, webhooks, deliveries, states)."""
109
+ value = payload.get(key)
110
+ return value if isinstance(value, list) else []
111
+
112
+
113
+ def unreachable(error: Exception) -> NoMacError:
114
+ return NoMacError("unreachable", f"NoMac did not answer ({error}).")
115
+
116
+
117
+ def tool_result(name: str, answer: dict[str, Any]) -> ToolResult:
118
+ """A call's final answer as a ToolResult: cancelled, failed without a result, or its content."""
119
+ stop_notice = answer.get("stop_notice")
120
+ if answer.get("state") == "cancelled":
121
+ return ToolResult(f"{name} was cancelled.", [], True, [], stop_notice, answer)
122
+ result = answer.get("result")
123
+ if not isinstance(result, dict):
124
+ reason = answer.get("message") or answer.get("error") or answer.get("state")
125
+ raise NoMacError(answer.get("error") or "tool_failed", f"{name} failed on the phone: {reason}.", 0, answer)
126
+ content = result.get("content") or []
127
+ text = "\n".join(c.get("text") or "" for c in content if c.get("type") == "text")
128
+ raw = answer.get("notices")
129
+ notices = [str(n) for n in raw] if isinstance(raw, list) else []
130
+ # phone_restarted: the phone's session server came back since the last call (its apps and data are kept).
131
+ return ToolResult(text, content, bool(result.get("isError")), notices, stop_notice, answer)
132
+
133
+
134
+ def job_of(result: ToolResult) -> str | None:
135
+ found = JOB.search(result.text) if not result.is_error else None
136
+ return found.group(1) if found else None
137
+
138
+
139
+ def operation_answer(answer: dict[str, Any], key: str, by: float) -> bool:
140
+ """Whether an operation (a fetch, a save) is done; still running past by is a timeout naming its key."""
141
+ if answer.get("state") == "running":
142
+ if time.monotonic() >= by:
143
+ raise NoMacError("timeout", f"The phone was still at it; the same call with idempotency_key {key} reads it later.", 0, answer)
144
+ return False
145
+ if answer.get("state") == "failed":
146
+ raise NoMacError(answer.get("error") or "operation_failed", answer.get("message") or f"The phone could not do it ({answer.get('error') or 'failed'}).", 0, answer)
147
+ return True
148
+
149
+
150
+ def archive_bytes(data: bytes | bytearray | memoryview | str | os.PathLike[str]) -> tuple[bytes | None, str | None, int, str]:
151
+ """An archive as bytes, or a file's path read in pieces: (bytes, path, size, sha256)."""
152
+ if isinstance(data, (bytes, bytearray, memoryview)):
153
+ raw = bytes(data)
154
+ return raw, None, len(raw), hashlib.sha256(raw).hexdigest()
155
+ path = os.fspath(data)
156
+ digest = hashlib.sha256()
157
+ with open(path, "rb") as file:
158
+ for piece in iter(lambda: file.read(1 << 20), b""):
159
+ digest.update(piece)
160
+ return None, path, os.path.getsize(path), digest.hexdigest()
161
+
162
+
163
+ def verify_webhook(body: bytes | str, signature: str | None, secret: str, *, tolerance_seconds: int = 300, now: float | None = None) -> dict[str, Any]:
164
+ """
165
+ Checks a webhook delivery and answers its event: body (the raw request body, as received), signature (the
166
+ nomac-signature header), secret (the webhook's whsec_... secret). Refused when the signature does not match or is
167
+ older than tolerance_seconds (5 minutes). A delivery can come more than once: key on its id (nomac-delivery).
168
+ """
169
+ text = body.decode("utf-8") if isinstance(body, (bytes, bytearray)) else body
170
+ parts = dict(part.strip().split("=", 1) for part in (signature or "").split(",") if "=" in part)
171
+ t, v1 = parts.get("t", ""), parts.get("v1", "")
172
+ if not t.isdigit() or not re.fullmatch(r"[a-f0-9]{64}", v1):
173
+ raise NoMacError("webhook_signature_invalid", "The nomac-signature header is missing or malformed.")
174
+ if abs((time.time() if now is None else now) - int(t)) > tolerance_seconds:
175
+ raise NoMacError("webhook_signature_expired", "The delivery's signature is too old; it may be a replay.")
176
+ want = hmac.new(secret.encode(), f"{t}.{text}".encode(), hashlib.sha256).hexdigest()
177
+ if not hmac.compare_digest(want, v1):
178
+ raise NoMacError("webhook_signature_invalid", "The delivery's signature does not match this webhook's secret.")
179
+ event = json.loads(text)
180
+ if not isinstance(event, dict):
181
+ raise NoMacError("webhook_invalid", "The delivery's body is not a NoMac event.")
182
+ return event
183
+
184
+
185
+ def start_body(device: str | None, label: str | None, reuse: bool | None, limits: Mapping[str, Any] | None) -> dict[str, Any]:
186
+ body: dict[str, Any] = {}
187
+ if device is not None:
188
+ body["device"] = device
189
+ if label is not None:
190
+ body["label"] = label
191
+ if reuse is not None:
192
+ body["reuse"] = reuse
193
+ if limits is not None:
194
+ body["limits"] = dict(limits)
195
+ return body
196
+
197
+
198
+ def tool_arguments(arguments: Mapping[str, Any] | None, more: Mapping[str, Any]) -> dict[str, Any]:
199
+ return {**(dict(arguments) if arguments else {}), **more}
200
+
201
+
202
+ def git_body(url: str, ref: str | None, token: str | None, secret: str | None) -> dict[str, Any]:
203
+ body: dict[str, Any] = {"url": url}
204
+ if ref:
205
+ body["ref"] = ref
206
+ if token:
207
+ body["token"] = token
208
+ if secret:
209
+ body["secret"] = secret
210
+ return body
nomac/_sync.py ADDED
@@ -0,0 +1,289 @@
1
+ """The client: NoMac Simulator's phones for a platform's users and agents, over NoMac's REST API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import builtins
6
+ import os
7
+ import time
8
+ from typing import Any, Mapping
9
+ from urllib.parse import quote
10
+
11
+ import httpx
12
+
13
+ from . import _base
14
+ from ._base import ENDED, LABELS, SESSIONS, WEBHOOKS, NoMacError, ToolResult, request_key
15
+
16
+
17
+ class NoMac:
18
+ """
19
+ A client for an account (its API key, nmk_...) or for one phone (the token a platform hands its own user,
20
+ nmp_...). NOMAC_API_KEY from the environment by default. Close it, or use it in a with block.
21
+ """
22
+
23
+ def __init__(self, api_key: str | None = None, *, token: str | None = None, base_url: str = _base.DEFAULT_BASE_URL,
24
+ http_client: httpx.Client | None = None, timeout: float = 90.0) -> None:
25
+ self._auth = f"Bearer {_base.credential(api_key, token)}"
26
+ self._base = base_url.rstrip("/")
27
+ self._timeout = timeout
28
+ self._http = http_client or httpx.Client(timeout=timeout)
29
+ self._own_http = http_client is None
30
+ self.phones = Phones(self)
31
+ self.labels = Labels(self)
32
+ self.webhooks = Webhooks(self)
33
+
34
+ def phone(self, id: str) -> Phone:
35
+ """A phone by its id, without reading it (a phone's token reaches its own only)."""
36
+ return Phone(self, {"id": id})
37
+
38
+ def request(self, method: str, path: str, *, body: Any = None, idempotency_key: str | None = None, timeout: float | None = None) -> dict[str, Any]:
39
+ """
40
+ One call to the REST API: JSON in and out, NoMac's error as a NoMacError. A read, a stop, a request with a key,
41
+ and any request NoMac answered 429 are sent again up to three times when NoMac is busy or does not answer.
42
+ """
43
+ again = _base.repeatable(method, idempotency_key)
44
+ for attempt in range(1, _base.ATTEMPTS + 1):
45
+ try:
46
+ response = self._http.request(method, f"{self._base}{path}", headers=_base.headers(self._auth, idempotency_key, body is not None),
47
+ json=body, timeout=timeout or self._timeout)
48
+ except httpx.HTTPError as error:
49
+ if again and attempt < _base.ATTEMPTS:
50
+ time.sleep(_base.pause_seconds(attempt))
51
+ continue
52
+ raise _base.unreachable(error) from None
53
+ value = _base.answer(method, path, attempt, again, response.status_code, response.text, response.headers.get("retry-after"))
54
+ if isinstance(value, dict):
55
+ return value
56
+ time.sleep(value)
57
+ raise NoMacError("unreachable", "NoMac did not answer.")
58
+
59
+ def _upload(self, url: str, data: bytes | None, path: str | None, size: int) -> None:
60
+ """PUTs an archive to a link NoMac signed (an upload link): no NoMac credential goes with it."""
61
+ headers = {"content-length": str(size)}
62
+ if path is None:
63
+ response = self._http.put(url, content=data, headers=headers, timeout=30 * 60)
64
+ else:
65
+ with open(path, "rb") as file:
66
+ response = self._http.put(url, content=file, headers=headers, timeout=30 * 60)
67
+ if response.status_code >= 300:
68
+ raise NoMacError("upload_failed", f"The upload was refused ({response.status_code}); ask for a new upload link.", response.status_code)
69
+
70
+ def close(self) -> None:
71
+ if self._own_http:
72
+ self._http.close()
73
+
74
+ def __enter__(self) -> NoMac:
75
+ return self
76
+
77
+ def __exit__(self, *exc: object) -> None:
78
+ self.close()
79
+
80
+
81
+ class Phones:
82
+ def __init__(self, client: NoMac) -> None:
83
+ self._client = client
84
+
85
+ def start(self, *, device: str | None = None, label: str | None = None, reuse: bool | None = None, limits: Mapping[str, Any] | None = None,
86
+ idempotency_key: str | None = None) -> Phone:
87
+ """
88
+ Starts a phone: device ("iphone", "ipad" or "iphone-pro-max"), label (your user's id), reuse, limits. With reuse
89
+ and a label, the live phone with that label is answered instead of a new one (phone.reused). A full Simulator
90
+ host queues the start, free, for up to 5 minutes: wait_until_ready() follows it. The same idempotency_key
91
+ retries a start.
92
+ """
93
+ answer = self._client.request("POST", SESSIONS, body=_base.start_body(device, label, reuse, limits), idempotency_key=idempotency_key or request_key("start"))
94
+ return Phone(self._client, answer["session"], answer.get("links"), bool(answer.get("reused")))
95
+
96
+ def list(self, *, label: str | None = None) -> builtins.list[Phone]:
97
+ """The account's phones, newest first; with label, the ones a platform named so."""
98
+ answer = self._client.request("GET", f"{SESSIONS}?label={quote(label, safe='')}" if label else SESSIONS)
99
+ return [Phone(self._client, session) for session in _base.rows(answer, "sessions")]
100
+
101
+ def get(self, id: str) -> Phone:
102
+ return Phone(self._client, {"id": id}).refresh()
103
+
104
+
105
+ class Phone:
106
+ """A phone: its session (state, device, label, cost, deadlines) and links, and what can be done with it."""
107
+
108
+ def __init__(self, client: NoMac, session: dict[str, Any], links: dict[str, Any] | None = None, reused: bool = False) -> None:
109
+ self._client = client
110
+ self.id: str = session["id"]
111
+ self.session = session
112
+ self.links = links
113
+ self.reused = reused
114
+ # In its last 15 minutes before a limit or the credit's end: when it stops, why and what to do first.
115
+ self.stop_notice: dict[str, Any] | None = None
116
+
117
+ @property
118
+ def state(self) -> str | None:
119
+ return self.session.get("state")
120
+
121
+ def _path(self, rest: str = "") -> str:
122
+ return f"{SESSIONS}/{quote(self.id, safe='')}{rest}"
123
+
124
+ def refresh(self) -> Phone:
125
+ answer = self._client.request("GET", self._path())
126
+ self.session = answer["session"]
127
+ self.links = answer.get("links") or self.links
128
+ self.stop_notice = answer.get("stop_notice")
129
+ return self
130
+
131
+ def wait_until_ready(self, *, timeout: float = 6 * 60, interval: float = 1.0) -> Phone:
132
+ """Waits until the phone is ready (a phone from the pool takes seconds; a queued one up to 5 minutes)."""
133
+ by = time.monotonic() + timeout
134
+ while True:
135
+ if self.state == "ready":
136
+ return self
137
+ if self.state in ENDED:
138
+ raise NoMacError("phone_ended", f"The phone ended before it was ready ({self.session.get('failure_code') or self.state}).", 0, self.session)
139
+ if time.monotonic() >= by:
140
+ raise NoMacError("timeout", f"The phone was not ready within {round(timeout)} s ({self.state}).", 0, self.session)
141
+ time.sleep(interval)
142
+ self.refresh()
143
+
144
+ def tool(self, name: str, arguments: Mapping[str, Any] | None = None, /, *, idempotency_key: str | None = None, interval: float = 1.0,
145
+ timeout: float = 60 * 60, wait_for_jobs: bool = True, **more: Any) -> ToolResult:
146
+ """
147
+ Runs one of simulator-use's tools on the phone (ui_look, ui_act, app_run, tests_run, app_control, ...), its
148
+ arguments as a dict or as keywords, and waits for its result. A long build that hands back a job is followed
149
+ with job_status until it ends, unless wait_for_jobs is False.
150
+ """
151
+ by = time.monotonic() + timeout
152
+ result = self._call(name, _base.tool_arguments(arguments, more), idempotency_key or request_key(name), by, interval)
153
+ job = _base.job_of(result) if wait_for_jobs else None
154
+ # Each job_status waits up to 40 s for the job; one still running answers "job_status again".
155
+ for _ in range(120 if job else 0):
156
+ result = self._call("job_status", {"job_id": job}, request_key("job_status"), by, interval)
157
+ if result.is_error or not _base.STILL_RUNNING.search(result.text):
158
+ break
159
+ if time.monotonic() >= by:
160
+ raise NoMacError("timeout", f'{name}\'s job {job} was still running; read it later with phone.tool("job_status", job_id="{job}").', 0, result.raw)
161
+ return result
162
+
163
+ def _call(self, name: str, arguments: dict[str, Any], key: str, by: float, interval: float) -> ToolResult:
164
+ answer = self._client.request("POST", self._path(f"/tools/{quote(name, safe='')}"), body=arguments, idempotency_key=key)
165
+ while answer.get("state") == "running" and isinstance(answer.get("id"), str):
166
+ if time.monotonic() >= by:
167
+ raise NoMacError("timeout", f'{name} was still running; cancel it with phone.cancel("{answer["id"]}") or read it later.', 0, answer)
168
+ time.sleep(interval)
169
+ answer = self._client.request("GET", self._path(f"/calls/{answer['id']}"))
170
+ return _base.tool_result(name, answer)
171
+
172
+ def cancel(self, call_id: str) -> dict[str, Any]:
173
+ """Stops a running tool call; a finished one is answered as it is."""
174
+ return self._client.request("POST", self._path(f"/calls/{quote(call_id, safe='')}/cancel"))
175
+
176
+ # An operation the phone runs for a while (a fetch, a save): the same request with the same key reads it until done.
177
+ def _operation(self, path: str, body: dict[str, Any], key: str, timeout: float | None, interval: float = 1.0) -> dict[str, Any]:
178
+ by = time.monotonic() + (30 * 60 if timeout is None else timeout)
179
+ while True:
180
+ answer = self._client.request("POST", self._path(path), body=body, idempotency_key=key)
181
+ if _base.operation_answer(answer, key, by):
182
+ return answer
183
+ time.sleep(interval)
184
+
185
+ def fetch_git(self, url: str, *, ref: str | None = None, token: str | None = None, secret: str | None = None,
186
+ idempotency_key: str | None = None, timeout: float | None = None) -> dict[str, Any]:
187
+ """
188
+ A git repository into the phone's workspace. A private repository's token is token itself, used for this fetch
189
+ and kept nowhere (a phone's token may send one), or secret, the name of the account's secret holding it;
190
+ github.com, gitlab.com and bitbucket.org only. Answers {"root", "commit"}: root is the path app_run and tests_run take.
191
+ """
192
+ return self._operation("/sync/git", _base.git_body(url, ref, token, secret), idempotency_key or request_key("git"), timeout)
193
+
194
+ def upload_archive(self, data: bytes | bytearray | memoryview | str | os.PathLike[str], *, name: str | None = None,
195
+ idempotency_key: str | None = None, timeout: float | None = None) -> dict[str, Any]:
196
+ """
197
+ An archive (a .tar.gz or .zip of a project, or of a .app built for the Simulator; bytes or a file's path) into
198
+ the phone's workspace under name, up to 2 GiB: uploaded to storage, then unpacked, only what differs written.
199
+ Answers {"root", "app"}.
200
+ """
201
+ raw, path, size, sha256 = _base.archive_bytes(data)
202
+ link = self._client.request("POST", self._path("/sync/upload"), body={"size": size, "sha256": sha256})
203
+ self._client._upload(link["upload_url"], raw, path, size)
204
+ return self._operation("/sync/fetch", {"sha256": sha256, **({"name": name} if name else {})}, idempotency_key or request_key("upload"), timeout)
205
+
206
+ def view_link(self, *, control: bool = False, minutes: int | None = None) -> dict[str, Any]:
207
+ """A link to the phone's live view for a person, no sign-in: watch only, or control=True. {"url", "expires_at"}."""
208
+ return self._client.request("POST", self._path("/view"), body={"control": control, **({"minutes": minutes} if minutes else {})})
209
+
210
+ def token(self, *, minutes: int | None = None) -> dict[str, Any]:
211
+ """
212
+ A token for this phone only, to hand a platform's own user or agent: NoMac(token=...) reaches its tools, files,
213
+ state and view, until it expires (2 hours by default, a day at most) or the phone ends.
214
+ """
215
+ return self._client.request("POST", self._path("/token"), body={"minutes": minutes} if minutes else {})
216
+
217
+ def revoke_tokens(self) -> dict[str, Any]:
218
+ """Revokes every token and link of this phone made so far, with the account's key; the phone runs on."""
219
+ return self._client.request("DELETE", self._path("/token"))
220
+
221
+ def replay(self) -> dict[str, Any]:
222
+ """The phone's replay: recording, saving after its stop, then ready with its video, its calls and a link."""
223
+ return self._client.request("GET", self._path("/replay"))
224
+
225
+ def save_state(self, name: str, *, idempotency_key: str | None = None, timeout: float | None = None) -> dict[str, Any]:
226
+ """Saves the phone's apps, their data, its keychain and permissions under name, for the phones with its label."""
227
+ return self._operation("/state", {"action": "save", "name": name}, idempotency_key or request_key("state"), timeout)
228
+
229
+ def restore_state(self, name: str, *, idempotency_key: str | None = None, timeout: float | None = None) -> dict[str, Any]:
230
+ """Puts a saved state back, onto a phone of the same model and iOS."""
231
+ return self._operation("/state", {"action": "restore", "name": name}, idempotency_key or request_key("state"), timeout)
232
+
233
+ def list_states(self) -> builtins.list[dict[str, Any]]:
234
+ return _base.rows(self._operation("/state", {"action": "list"}, request_key("state"), None), "states")
235
+
236
+ def delete_state(self, name: str) -> dict[str, Any]:
237
+ return self._operation("/state", {"action": "delete", "name": name}, request_key("state"), None)
238
+
239
+ def extend(self, *, minutes: int | None = None) -> Phone:
240
+ """
241
+ Keeps the phone running longer: its time limit goes up by minutes (60 by default, up to 8 hours in all), and its
242
+ spend cap with it. The account's key only. Not sent again by itself: a repeat adds more time.
243
+ """
244
+ answer = self._client.request("POST", self._path("/extend"), body={"minutes": minutes} if minutes else {})
245
+ self.session = answer["session"]
246
+ self.stop_notice = answer.get("stop_notice")
247
+ return self
248
+
249
+ def stop(self) -> Phone:
250
+ """Stops the phone: its apps and data go with it, its workspace stays for the next phone, its replay is saved."""
251
+ self.session = self._client.request("DELETE", self._path())["session"]
252
+ return self
253
+
254
+
255
+ class Labels:
256
+ def __init__(self, client: NoMac) -> None:
257
+ self._client = client
258
+
259
+ def delete(self, label: str) -> dict[str, Any]:
260
+ """
261
+ Deletes what NoMac keeps for one label, when your user deletes their account: its saved states, its phones'
262
+ replays and file copies, and its workspace. Answers {"label", "deleted": {"states", "workspaces", "replays",
263
+ "files"}}; a repeat finds nothing left. The account's key only. Refused while a phone with the label runs
264
+ (label_in_use) or for 15 minutes after the last one ends (label_busy, details["retry_after_seconds"]).
265
+ """
266
+ return self._client.request("DELETE", f"{LABELS}/{quote(label, safe='')}")
267
+
268
+
269
+ class Webhooks:
270
+ def __init__(self, client: NoMac) -> None:
271
+ self._client = client
272
+
273
+ def create(self, url: str, *, events: builtins.list[str] | None = None) -> dict[str, Any]:
274
+ """
275
+ A webhook: each phone's phone.ready and phone.ended POSTed to an https URL, signed. The answer carries its
276
+ secret, shown once: check each delivery with verify_webhook. Retried with backoff until answered 2xx.
277
+ """
278
+ return self._client.request("POST", WEBHOOKS, body={"url": url, **({"events": events} if events else {})})
279
+
280
+ def list(self) -> builtins.list[dict[str, Any]]:
281
+ return _base.rows(self._client.request("GET", WEBHOOKS), "webhooks")
282
+
283
+ def delete(self, id: str) -> dict[str, Any]:
284
+ """Ends a webhook: nothing more is sent to it."""
285
+ return self._client.request("DELETE", f"{WEBHOOKS}/{quote(id, safe='')}")
286
+
287
+ def deliveries(self, id: str, *, limit: int | None = None) -> builtins.list[dict[str, Any]]:
288
+ """A webhook's deliveries, newest first, kept 30 days: delivered, retrying or gave_up after 12 tries."""
289
+ return _base.rows(self._client.request("GET", f"{WEBHOOKS}/{quote(id, safe='')}/deliveries{f'?limit={limit}' if limit else ''}"), "deliveries")
nomac/py.typed ADDED
File without changes
@@ -0,0 +1,127 @@
1
+ Metadata-Version: 2.5
2
+ Name: nomac
3
+ Version: 0.1.0
4
+ Summary: NoMac from Python: iPhone and iPad Simulators in the cloud for a platform's users and agents, their tools, live view, saved state, tokens and webhooks.
5
+ Project-URL: Homepage, https://nomac.app/platform
6
+ Project-URL: Documentation, https://nomac.app/docs/simulator
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: agents,ios,iphone,mcp,nomac,simulator,xcode
10
+ Classifier: Framework :: AsyncIO
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Typing :: Typed
14
+ Requires-Python: >=3.10
15
+ Requires-Dist: httpx>=0.27
16
+ Description-Content-Type: text/markdown
17
+
18
+ # nomac
19
+
20
+ [NoMac Simulator](https://nomac.app/platform) from Python: iPhone and iPad Simulators in the cloud, for a platform's users and agents. Start a phone, build and run a native app on it, read and tap its screen by label, show it live in your page, save its state, and hear when it is ready or ends.
21
+
22
+ ```sh
23
+ pip install nomac
24
+ ```
25
+
26
+ Python 3.10 and later, one dependency (`httpx`), typed. `NoMac` for sync code, `AsyncNoMac` for asyncio: the same calls, awaited.
27
+
28
+ ```python
29
+ from nomac import NoMac
30
+
31
+ nomac = NoMac() # NOMAC_API_KEY from the environment
32
+ phone = nomac.phones.start(label="user-42", reuse=True)
33
+ phone.wait_until_ready()
34
+ root = phone.fetch_git("https://github.com/acme/app", token=github_token)["root"]
35
+ print(phone.tool("app_run", path=root, scheme="App").text)
36
+ phone.stop()
37
+ ```
38
+
39
+ ```python
40
+ from nomac import AsyncNoMac
41
+
42
+ async with AsyncNoMac() as nomac:
43
+ phone = await nomac.phones.start(label="user-42", reuse=True)
44
+ await phone.wait_until_ready()
45
+ print((await phone.tool("ui_look")).text)
46
+ await phone.stop()
47
+ ```
48
+
49
+ A phone costs $0.80 an hour, billed per minute from the account's prepaid credit, with a one-minute minimum. Waiting in line is free.
50
+
51
+ ## Phones
52
+
53
+ `nomac.phones.start(device=, label=, reuse=, limits=)` starts a phone and answers it at once, usually `queued`; `phone.wait_until_ready()` follows it. A phone from the pool is ready in seconds. When every phone is in use, the start waits in line, free, up to 5 minutes.
54
+
55
+ - `device`: `"iphone"` (iPhone 17 Pro, the default), `"ipad"` (iPad Pro 11-inch (M5)) or `"iphone-pro-max"` (iPhone 17 Pro Max), all on iOS 27.
56
+ - `label`: your own name for the phone, such as your user's id: letters, digits and `. _ : @ -`, up to 64.
57
+ - `reuse`: with `label`, answer the live phone with that label instead of starting another (`phone.reused`).
58
+ - `limits`: `max_duration_seconds` (7,200 by default, 28,800 at most), `idle_timeout_seconds` (600 by default, 60 to 3,600) and `spend_cap_microunits` (`"5000000"`, $5, by default).
59
+
60
+ An account runs two phones at once by default; [NoMac Platform](https://nomac.app/platform) runs more. A retry of a start with the same `idempotency_key` answers the same phone, never a second one.
61
+
62
+ `nomac.phones.list(label=)` lists the account's phones, newest first, and `nomac.phones.get(id)` reads one. `phone.session` has its state, device, label, cost and deadlines; `phone.refresh()` reads it again. `phone.extend(minutes=)` keeps a running phone longer, up to 8 hours in all. `phone.stop()` ends it: its apps and data go with it, and its workspace (projects and build output) stays for the next phone with the same label, so the next build is incremental.
63
+
64
+ ## Tools
65
+
66
+ `phone.tool(name, arguments)` runs one of [simulator-use](https://simulator-use.dev)'s tools on the phone, its arguments as a dict or as keywords, and answers a `ToolResult`: `text` says what happened and what to do next, `content` has its text and images, `is_error`, and `stop_notice` in the phone's last 15 minutes. A long build is followed to its end.
67
+
68
+ | Tool | |
69
+ | --- | --- |
70
+ | `app_run` | Builds an Xcode project or workspace (`path`, `scheme`) with Xcode 27.1, installs it and launches it. |
71
+ | `tests_run` | Runs XCTest and XCUITest, each case's result as it finishes. |
72
+ | `ui_look` | The screen as text, one line per element with its label and position. |
73
+ | `ui_act` | Taps, types, swipes and keys by label: `steps=['tap "Sign In"', "fill #email a@b.co"]`. |
74
+ | `app_control` | Installs, launches and terminates apps, opens links, sets permissions, appearance, locale, time zone and location. |
75
+ | `app_logs`, `screen_record`, `devices`, `job_status`, `job_cancel` | Logs, video, the phone and Xcodes, long jobs. |
76
+
77
+ Projects reach the phone's workspace with `phone.fetch_git(url, ref=, token=)` or `phone.upload_archive(data, name=)` (bytes or a file's path: a `.tar.gz` or `.zip` of a project, or of a `.app` built for the Simulator, up to 2 GiB). Both answer `root`, the path `app_run` and `tests_run` take; only what changed is written.
78
+
79
+ A private repository on github.com, gitlab.com or bitbucket.org takes its token one of two ways, and it goes only to that host:
80
+
81
+ - `token`: the token itself, sent with this fetch and kept nowhere, not even for the phone's next fetch. Your user's token stays with you, and a short-lived one (GitHub's one-hour tokens) works. A phone's token may send one.
82
+ - `secret`: the name of one of the account's own secrets that holds it, for your own repositories (the account's key only).
83
+
84
+ Values your app needs go with the run, as `env` on `app_run` (`"API_URL=https://staging.example KEY=..."`): the phone keeps them for its relaunches until it stops, and they are erased with it.
85
+
86
+ ## Live view and your users
87
+
88
+ `phone.links["view"]` (watch) and `phone.links["control"]` (use) are links a person opens with no NoMac sign-in; `phone.view_link(control=True, minutes=120)` makes one for your user. [`@nomac/phone`](https://www.npmjs.com/package/@nomac/phone) embeds it in your page.
89
+
90
+ `phone.token(minutes=)` gives a token for that phone only, to hand your own user or agent: `NoMac(token=...)` reaches that phone's tools, files, state, view and replay until the token expires (2 hours by default, a day at most). It cannot start, list or stop phones, make another token, use the account's secrets, or reach your webhooks. `phone.revoke_tokens()` revokes every token and link of the phone made so far.
91
+
92
+ Phones are kept apart by `label`: a workspace and saved states are shared only by phones with the same label. Give each of your users their own label, and one user's phone, or its token, never reaches another's.
93
+
94
+ ## Saved state and replays
95
+
96
+ `phone.save_state("signed-in")` keeps the phone's apps, their data, its keychain and permissions for the phones with its label; `phone.restore_state("signed-in")` puts them back onto any of them of the same model and iOS. `phone.list_states()` and `phone.delete_state(name)` manage them. Every phone is recorded: `phone.replay()` answers its video and its calls (typed text left out) once it has stopped, kept 7 days.
97
+
98
+ ## Deleting a user's data
99
+
100
+ When one of your users deletes their account, `nomac.labels.delete("user-42")` deletes what NoMac keeps for that label: its saved states, its phones' replays and file copies, and its workspace (the key to its encrypted volume is destroyed at once, so it never opens again; the host deletes the volume on its own sweep). It answers `{"deleted": {"states", "workspaces", "replays", "files"}}`; a repeat finds nothing left. Stop the label's phones first (`label_in_use`); for 15 minutes after the last one ends its replay is still being saved (`label_busy`, `details["retry_after_seconds"]`). The phones' records (times and cost, under the label) stay for billing, so use your own user id as the label, not an email.
101
+
102
+ ## Webhooks
103
+
104
+ ```python
105
+ hook = nomac.webhooks.create("https://platform.example/nomac", events=["phone.ready", "phone.ended"])
106
+ # Keep hook["secret"]: it is shown once.
107
+ ```
108
+
109
+ Check every delivery before trusting it, with the raw body as received:
110
+
111
+ ```python
112
+ from nomac import verify_webhook
113
+
114
+ event = verify_webhook(request.body, request.headers["nomac-signature"], os.environ["NOMAC_WEBHOOK_SECRET"])
115
+ if event["type"] == "phone.ended":
116
+ release(event["data"]["session"]["label"], event["data"]["reason"])
117
+ ```
118
+
119
+ `phone.ended` carries the label, the time billed and the cost, and why it ended (`stopped`, `idle`, `time_limit`, `spend_cap`, `credit`, `crashed`, `no_capacity` or `start_failed`). A delivery can come more than once: key on its `id`. `nomac.webhooks.list()`, `delete(id)` and `deliveries(id)` manage them.
120
+
121
+ ## Errors
122
+
123
+ A refusal is a `NoMacError` with NoMac's `code` (for code to branch on), `message` (for a person), HTTP `status` and `details`, such as `session_limit_reached` (409) or `insufficient_credit` (402). A read, a stop and any request with a request key are sent again when NoMac is busy for a moment.
124
+
125
+ The REST API it calls is at [nomac.app/docs/simulator](https://nomac.app/docs/simulator); `nomac.request(method, path, body=, idempotency_key=)` calls it directly.
126
+
127
+ MIT licensed.
@@ -0,0 +1,9 @@
1
+ nomac/__init__.py,sha256=yiz5sOvq1XC9c1xNBR_Y11FaEiekaLT5H14k8nHywDU,511
2
+ nomac/_async.py,sha256=xpE_6LCdTNl6EXQj7_s0RiB1kXg19EfUK_yY5u2O6dQ,17172
3
+ nomac/_base.py,sha256=8OA-Tme6_Dfi0Arm7iV80T8ILGQXZ1G_jreToymjZac,9354
4
+ nomac/_sync.py,sha256=4fBKBBN-Ul-WtemRi4T5YJl-xcyEhJjvb_UsTwJe5IE,16320
5
+ nomac/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ nomac-0.1.0.dist-info/METADATA,sha256=jnItw277fKXxPuUalgW-Whq5kLk7xKkAZduKR_hmTP4,8791
7
+ nomac-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
8
+ nomac-0.1.0.dist-info/licenses/LICENSE,sha256=RcExWFfUmukhyhBnMLh2ew6vje3Yv5-LZM1bCy_mGHI,1062
9
+ nomac-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 NoMac
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.