nomac
NoMac Simulator 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.
pip install nomac
Python 3.10 and later, one dependency (httpx), typed. NoMac for sync code, AsyncNoMac for asyncio: the same calls, awaited.
from nomac import NoMac
nomac = NoMac() # NOMAC_API_KEY from the environment
phone = nomac.phones.start(label="user-42", reuse=True)
phone.wait_until_ready()
root = phone.fetch_git("https://github.com/acme/app", token=github_token)["root"]
print(phone.tool("app_run", path=root, scheme="App").text)
phone.stop()
from nomac import AsyncNoMac
async with AsyncNoMac() as nomac:
phone = await nomac.phones.start(label="user-42", reuse=True)
await phone.wait_until_ready()
print((await phone.tool("ui_look")).text)
await phone.stop()
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.
Phones
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.
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.label: your own name for the phone, such as your user's id: letters, digits and. _ : @ -, up to 64.reuse: withlabel, answer the live phone with that label instead of starting another (phone.reused).limits:max_duration_seconds(7,200 by default, 28,800 at most),idle_timeout_seconds(600 by default, 60 to 3,600) andspend_cap_microunits("5000000", $5, by default).
An account runs two phones at once by default; NoMac Platform runs more. A retry of a start with the same idempotency_key answers the same phone, never a second one.
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.
Tools
phone.tool(name, arguments) runs one of simulator-use'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.
| Tool | |
|---|---|
app_run |
Builds an Xcode project or workspace (path, scheme) with Xcode 27.1, installs it and launches it. |
tests_run |
Runs XCTest and XCUITest, each case's result as it finishes. |
ui_look |
The screen as text, one line per element with its label and position. |
ui_act |
Taps, types, swipes and keys by label: steps=['tap "Sign In"', "fill #email a@b.co"]. |
app_control |
Installs, launches and terminates apps, opens links, sets permissions, appearance, locale, time zone and location. |
app_logs, screen_record, devices, job_status, job_cancel |
Logs, video, the phone and Xcodes, long jobs. |
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.
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:
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.secret: the name of one of the account's own secrets that holds it, for your own repositories (the account's key only).
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.
Live view and your users
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 embeds it in your page.
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.
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.
Saved state and replays
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.
Deleting a user's data
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.
Webhooks
hook = nomac.webhooks.create("https://platform.example/nomac", events=["phone.ready", "phone.ended"])
# Keep hook["secret"]: it is shown once.
Check every delivery before trusting it, with the raw body as received:
from nomac import verify_webhook
event = verify_webhook(request.body, request.headers["nomac-signature"], os.environ["NOMAC_WEBHOOK_SECRET"])
if event["type"] == "phone.ended":
release(event["data"]["session"]["label"], event["data"]["reason"])
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.
Errors
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.
The REST API it calls is at nomac.app/docs/simulator; nomac.request(method, path, body=, idempotency_key=) calls it directly.
MIT licensed.
Metadata
Release files for nomac 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| nomac-0.1.0.tar.gz | 16.9 kB | Without provenance | Details for nomac-0.1.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| nomac-0.1.0-py3-none-any.whl | Python 3 | none | any | Details for nomac-0.1.0-py3-none-any.whl |
Total release size: 37.0 kB
Release files / nomac-0.1.0.tar.gz
| Download URL | nomac-0.1.0.tar.gz |
|---|---|
| Size | 16.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
9c1f66874dcac6579c33faaf1dc1995294f4f19582b63cc4573f26cbdd1b3f91
|
|
BLAKE2b-256 checksum How to use checksums |
3f9500f25f259091dd2f712721ca67777dc6820cce096909c7e4181ce32a3275
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.26 {"installer":{"name":"uv","version":"0.11.26","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / nomac-0.1.0-py3-none-any.whl
| Download URL | nomac-0.1.0-py3-none-any.whl |
|---|---|
| Size | 20.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
04f9803ced08c3acbf27079a059c25b0b7e2d04abaea364750d4fb7196031a61
|
|
BLAKE2b-256 checksum How to use checksums |
ac3fd3eadbfce9ee4c1b50900ca0cc921a3945b6cc5acfe5d24ee9d1bbc3442d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.26 {"installer":{"name":"uv","version":"0.11.26","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Security for nomac 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.