nomac 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.
- nomac-0.1.0/.gitignore +6 -0
- nomac-0.1.0/LICENSE +21 -0
- nomac-0.1.0/PKG-INFO +127 -0
- nomac-0.1.0/README.md +110 -0
- nomac-0.1.0/pyproject.toml +33 -0
- nomac-0.1.0/src/nomac/__init__.py +11 -0
- nomac-0.1.0/src/nomac/_async.py +296 -0
- nomac-0.1.0/src/nomac/_base.py +210 -0
- nomac-0.1.0/src/nomac/_sync.py +289 -0
- nomac-0.1.0/src/nomac/py.typed +0 -0
- nomac-0.1.0/tests/test_client.py +235 -0
nomac-0.1.0/.gitignore
ADDED
nomac-0.1.0/LICENSE
ADDED
|
@@ -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.
|
nomac-0.1.0/PKG-INFO
ADDED
|
@@ -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.
|
nomac-0.1.0/README.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# nomac
|
|
2
|
+
|
|
3
|
+
[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.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
pip install nomac
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Python 3.10 and later, one dependency (`httpx`), typed. `NoMac` for sync code, `AsyncNoMac` for asyncio: the same calls, awaited.
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from nomac import NoMac
|
|
13
|
+
|
|
14
|
+
nomac = NoMac() # NOMAC_API_KEY from the environment
|
|
15
|
+
phone = nomac.phones.start(label="user-42", reuse=True)
|
|
16
|
+
phone.wait_until_ready()
|
|
17
|
+
root = phone.fetch_git("https://github.com/acme/app", token=github_token)["root"]
|
|
18
|
+
print(phone.tool("app_run", path=root, scheme="App").text)
|
|
19
|
+
phone.stop()
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from nomac import AsyncNoMac
|
|
24
|
+
|
|
25
|
+
async with AsyncNoMac() as nomac:
|
|
26
|
+
phone = await nomac.phones.start(label="user-42", reuse=True)
|
|
27
|
+
await phone.wait_until_ready()
|
|
28
|
+
print((await phone.tool("ui_look")).text)
|
|
29
|
+
await phone.stop()
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
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.
|
|
33
|
+
|
|
34
|
+
## Phones
|
|
35
|
+
|
|
36
|
+
`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.
|
|
37
|
+
|
|
38
|
+
- `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.
|
|
39
|
+
- `label`: your own name for the phone, such as your user's id: letters, digits and `. _ : @ -`, up to 64.
|
|
40
|
+
- `reuse`: with `label`, answer the live phone with that label instead of starting another (`phone.reused`).
|
|
41
|
+
- `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).
|
|
42
|
+
|
|
43
|
+
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.
|
|
44
|
+
|
|
45
|
+
`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.
|
|
46
|
+
|
|
47
|
+
## Tools
|
|
48
|
+
|
|
49
|
+
`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.
|
|
50
|
+
|
|
51
|
+
| Tool | |
|
|
52
|
+
| --- | --- |
|
|
53
|
+
| `app_run` | Builds an Xcode project or workspace (`path`, `scheme`) with Xcode 27.1, installs it and launches it. |
|
|
54
|
+
| `tests_run` | Runs XCTest and XCUITest, each case's result as it finishes. |
|
|
55
|
+
| `ui_look` | The screen as text, one line per element with its label and position. |
|
|
56
|
+
| `ui_act` | Taps, types, swipes and keys by label: `steps=['tap "Sign In"', "fill #email a@b.co"]`. |
|
|
57
|
+
| `app_control` | Installs, launches and terminates apps, opens links, sets permissions, appearance, locale, time zone and location. |
|
|
58
|
+
| `app_logs`, `screen_record`, `devices`, `job_status`, `job_cancel` | Logs, video, the phone and Xcodes, long jobs. |
|
|
59
|
+
|
|
60
|
+
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.
|
|
61
|
+
|
|
62
|
+
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:
|
|
63
|
+
|
|
64
|
+
- `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.
|
|
65
|
+
- `secret`: the name of one of the account's own secrets that holds it, for your own repositories (the account's key only).
|
|
66
|
+
|
|
67
|
+
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.
|
|
68
|
+
|
|
69
|
+
## Live view and your users
|
|
70
|
+
|
|
71
|
+
`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.
|
|
72
|
+
|
|
73
|
+
`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.
|
|
74
|
+
|
|
75
|
+
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.
|
|
76
|
+
|
|
77
|
+
## Saved state and replays
|
|
78
|
+
|
|
79
|
+
`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.
|
|
80
|
+
|
|
81
|
+
## Deleting a user's data
|
|
82
|
+
|
|
83
|
+
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.
|
|
84
|
+
|
|
85
|
+
## Webhooks
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
hook = nomac.webhooks.create("https://platform.example/nomac", events=["phone.ready", "phone.ended"])
|
|
89
|
+
# Keep hook["secret"]: it is shown once.
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Check every delivery before trusting it, with the raw body as received:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from nomac import verify_webhook
|
|
96
|
+
|
|
97
|
+
event = verify_webhook(request.body, request.headers["nomac-signature"], os.environ["NOMAC_WEBHOOK_SECRET"])
|
|
98
|
+
if event["type"] == "phone.ended":
|
|
99
|
+
release(event["data"]["session"]["label"], event["data"]["reason"])
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`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.
|
|
103
|
+
|
|
104
|
+
## Errors
|
|
105
|
+
|
|
106
|
+
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.
|
|
107
|
+
|
|
108
|
+
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.
|
|
109
|
+
|
|
110
|
+
MIT licensed.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.24"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "nomac"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "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."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
dependencies = ["httpx>=0.27"]
|
|
14
|
+
keywords = ["ios", "simulator", "iphone", "xcode", "agents", "mcp", "nomac"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Typing :: Typed",
|
|
19
|
+
"Framework :: AsyncIO",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://nomac.app/platform"
|
|
24
|
+
Documentation = "https://nomac.app/docs/simulator"
|
|
25
|
+
|
|
26
|
+
[tool.hatch.build.targets.wheel]
|
|
27
|
+
packages = ["src/nomac"]
|
|
28
|
+
|
|
29
|
+
[tool.pytest.ini_options]
|
|
30
|
+
testpaths = ["tests"]
|
|
31
|
+
|
|
32
|
+
[tool.hatch.build.targets.sdist]
|
|
33
|
+
include = ["src/nomac", "tests", "README.md", "LICENSE", "pyproject.toml"]
|
|
@@ -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"
|
|
@@ -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")
|