Development workflow¶
PythonNative's inner loop is modeled on Metro and Expo: one long-running dev server in your terminal, and any number of dev clients (a browser tab, simulators, emulators, physical phones) that connect to it. You edit a file, you save, and every connected client Fast Refreshes in place. Native builds happen only when something native changes.
┌────────────────────┐ ┌──────────────────────────────┐
│ pn start │ ws │ browser preview (tab) │
│ │◄──────►│ renders through the bridge │
│ watches app/ │ └──────────────────────────────┘
│ syncs sources │ ws ┌──────────────────────────────┐
│ streams logs │◄──────►│ PythonNative Go, or your │
│ serves DevTools │ │ debug build (simulator, │
│ proxies debugging │ │ emulator, phone on Wi-Fi) │
│ │ ws ┌──────────────────────────────┐
│ │◄──────►│ DevTools (browser tab) │
└────────────────────┘ └──────────────────────────────┘
One terminal¶
Run the dev server for the whole session:
pn start # dev server, QR code, and key commands
pn preview # the same, and open the browser preview
pn start prints a QR code, the preview and DevTools URLs, and the
debugger address, then reads single keys:
| Key | Action |
|---|---|
| I, A | Open the app on the iOS Simulator or an Android emulator: in PythonNative Go when the project can run there, otherwise as the project's own debug build (pn run) |
| Shift+I, Shift+A | Always build and run the project's own debug build |
| W | Open the browser preview |
| J | Open DevTools |
| R | Reload every connected app |
| M | Open the dev menu on every connected device |
| D | Show which app the debugger attaches to, and the VS Code configuration |
| C | Clear the terminal |
| ? | List the commands |
| Q | Quit |
On a phone that has PythonNative Go (or the project's development build)
installed, scanning the QR code with the camera opens the project there. Every
connected app pulls any sources newer than what it holds and from then on
Fast Refreshes on every save. Its print() output, tracebacks, and
reload notices stream back to the pn start terminal.
The same actions are commands, for scripts and second terminals: pn go
ios, pn go android, pn run ios, and pn run android find the dev
server on localhost:8765 (--port to change it), install the app, and
open it with a connect link carrying your dev token.
Rerunning pn run is cheap: when nothing native changed since the last
build (see When native rebuilds happen)
it reinstalls the previous artifact and relaunches in a few seconds.
What pn start does¶
The dev server is one Python process, standard library only, doing three jobs:
- Serves sources. It computes a content-addressed manifest of
app/(sha256per file) and hands connected clients whatever they're missing. Clients report what they already hold, so a fresh build afterpn runtransfers nothing and a stale install catches up in one round trip. - Watches for changes. Every save under
app/broadcasts anupdateto each client with the new file contents. Each client reloads the affected modules and runs Fast Refresh on its mounted screens. - Hosts the browser preview.
GET /is a preview page that renders the app in a phone frame. The page is a bridge peer exactly like the Swift and Kotlin runtimes; the reconciler for it runs insidepn startitself. See the Browser preview guide. - Serves DevTools and the debugger.
GET /devtoolsis the DevTools page, which talks to every connected app through the server, and127.0.0.1:5678accepts debugger connections and forwards them to an app.
Logs from every peer are interleaved in the terminal and prefixed with
the source: [ios iPhone 15], [android Pixel 8], [browser], and
[pn] for the server's own messages.
Endpoints, for scripting and curiosity:
| Path | Purpose | Token |
|---|---|---|
GET / |
Browser preview page | No |
GET /static/<name> |
The preview's scripts and styles | No |
GET /status |
Server, project, and connected-peer info (JSON) | Yes |
GET /manifest |
{"version", "entry", "files": {path: sha256}} |
Yes |
GET /file/<path> |
Raw bytes of one synced source file | Yes |
GET /assets/<path> |
A file under app/assets/, plus the asset manifest and font CSS |
Yes |
GET /devtools |
The DevTools page (/devtools/<name> for its assets) |
No |
GET /connect |
Connect links, server URLs, and the QR code as SVG (JSON) | Yes |
WS /ws?role=client |
Dev session protocol (version 2) | Yes |
WS /ws?role=preview |
Browser preview bridge channel | Yes |
WS /ws?role=devtools |
DevTools pages | Yes |
TCP 127.0.0.1:5678 |
Debug Adapter Protocol proxy | Via the app's session |
For example, curl -H "X-PN-Token: $(cat ~/.pythonnative/dev-token)"
localhost:8765/status prints the server's status.
Flags¶
pn start [entry] [--port 8765] [--host 0.0.0.0] [--debug-port 5678] [--no-interactive] [--open]
pn preview [entry] [--port 8765] [--host 0.0.0.0] [--debug-port 5678] [--no-interactive] [--no-open]
entry overrides the entry module from pythonnative.toml (for
example app.screens.settings to mount one screen's App). The server
binds to all interfaces by default so phones on your network can reach
it; pass --host 127.0.0.1 to keep it local. --debug-port 0 turns the
debugger proxy off, and --no-interactive stops pn start from reading
keys (it never does when stdin isn't a terminal).
The dev token¶
The dev server hands out your application's source code, so it only
answers clients that present your dev token. pn start generates
the token the first time it runs, stores it in
~/.pythonnative/dev-token (readable only by you, mode 0600), and
reuses it after every restart, so debug builds that pn run installed
keep connecting.
- The URLs
pn startprints carry it as?token=.... When you open the preview URL, the page trades the token for anHttpOnly,SameSite=Strictcookie and drops it from the address bar. Opening the barehttp://localhost:8765/in a browser that has never seen the token leaves the preview waiting; open the printed URL once instead. pn run,pn go, and the QR code pass the token to the app inside the connect link, and the app remembers it for its next launch.- Scripts can send it in an
X-PN-Tokenheader or atokenquery parameter.
The server also rejects WebSocket connections from other web pages: a
browser's upgrade request must come from the server's own origin
(its Origin host and port must match the Host header). Native dev
clients send no Origin, so they only need the token.
To issue a new token (for example, after sharing a URL you shouldn't
have), stop pn start, delete ~/.pythonnative/dev-token, and start it
again. Installed dev clients then need the new QR code (or a fresh pn
run or pn go), and the browser preview needs the newly printed URL. Two environment variables
override the file: PN_DEV_TOKEN supplies the token itself, and
PN_DEV_TOKEN_FILE points at another file. Set them the same way for
pn start and pn run.
Dev clients¶
A dev client is an app that runs your project's Python from pn
start: either PythonNative Go, the
prebuilt client that runs any project without native code, or your
project's own debug build from pn run. On launch,
pythonnative.bootstrap.start(dev=True) calls
devclient.start_if_configured,
which:
- takes the server URL, including the dev token, from the connect link
that launched the app (
pn-<app id>://connect?url=..., whichpn run,pn go, and the QR code open), or reconnects to the last server it used; - connects over WebSocket on a daemon thread and says
hello, describing its runtime (framework version, bridge protocol, plugins, and bundled packages) and a hash of every source it holds in its writable overlay. On the first launch the overlay is seeded from the sources bundled in the build, so a build made from the current tree reports everything up to date; - receives a
syncwith only the files that differ, or anincompatibleanswer naming the command that fixes a mismatch (an outdated Go, or a build that predates a plugin). Synced files go into the overlay, which sits ahead of the bundled sources onsys.path, and a Fast Refresh applies the modules that changed. Nothing changed, nothing reloads; - installs the pure-Python packages from
[requirements]that its bundle lacks, copied from the environmentpn startruns in; - mirrors
print, warnings, and tracebacks to the server, and answers DevTools and the debugger; - reports every reload (
fast_refreshorremount, and which modules).
Release builds never include any of this: pn build produces a
standalone app with your sources bundled and no dev client. Its copy of
pythonnative leaves out the development modules entirely (the dev
client, the DevTools agent, Fast Refresh, the dev server, the CLI, and
the test helpers); see
Building for release.
Simulators and emulators¶
Press I or A in the pn start terminal, or run pn go ios, pn
go android, pn run ios, or pn run android. The CLI handles the
plumbing: the iOS Simulator shares the Mac's loopback interface, so the
link uses localhost, and for Android the CLI runs adb reverse so
localhost:8765 inside the emulator (or a USB-attached phone) reaches
the server. When no Android device is running, pn boots the first
emulator from Android Studio's Device Manager.
Physical phones¶
A phone is on your Wi-Fi rather than your loopback, so it uses your
computer's LAN address. Once the phone has a dev client (see
Physical devices), scan the QR code pn
start prints with the camera; it opens the dev client with that address
and your token. pn
run ios --device <name> and pn go ios --device <name> pass the LAN
address too. Both machines must be on the same network and the port must
not be firewalled. If auto-detection picks the wrong interface, pass the
device URL that pn start printed, token included:
Fast Refresh, DevTools, and the debugger work the same over Wi-Fi.
When native rebuilds happen¶
A debug build has to go through Gradle or Xcode only when a native input changes:
pythonnative.toml(permissions, app id, requirements, versions)- the bundled native template that ships with your
pythonnativeversion - the
pythonnativepackage itself (afterpip install -U) - project-local native plugins
- the build flavor: platform, iOS SDK (device vs. simulator), release
pn run hashes the contents of all of those into one fingerprint
(see fingerprint.compute)
and writes it next to the build after a successful toolchain run. On
the next pn run, if the fingerprint matches and a dev server is up to
deliver current sources, the previous artifact is reinstalled and
launched. Edits under app/ never trigger a rebuild; they're synced.
Force the toolchain with --rebuild. Stage without building with
--prepare-only (useful for opening the project in Xcode or Android
Studio).
If no dev server is running when you pn run, the CLI says so and
builds an app that runs its bundled sources without Fast Refresh.
Start pn start and relaunch to connect it.
Which client for which job¶
| Task | Use |
|---|---|
| Layout, state, navigation, most component work | Browser preview |
| Anything touching device APIs (camera, location, haptics, biometrics) | Simulator or device |
| Text rendering, fonts, platform chrome, gesture feel | Simulator or device |
| Performance | A physical device |
| Demoing to someone at a desk | Browser preview on your own screen |
The browser preview runs your real Python in the pn start process and
shares Python reconciliation, hooks, and logical navigation with device
builds. Its renderer uses Yoga WebAssembly and DOM widgets, and
implements a subset of native modules (Alert, Clipboard, Linking,
Share, Haptics, NetInfo, AppState, Device); everything else
uses the pure-Python fallbacks in
pythonnative.native_modules.fallback,
so a Camera call returns an "unavailable" result rather than a
photo.
Logs and errors¶
Every dev client mirrors its Python output to the server, so the
pn start terminal is the one place to watch, and DevTools' Console
shows the same lines per app. Uncaught exceptions in renders, effects,
and handlers show the development error screen
on the affected screen (on device and in the preview), with the
component stack and your source, and print the traceback in the
terminal.
Errors before any screen mounts (an ImportError in app/main.py,
say) print in the terminal and pop up in the preview's console; fix
the file and save to recover, nothing needs restarting.
pn logs ios / pn logs android still attach a native log stream
(os_log, logcat) when you need lower-level output than the dev
client mirrors.
Dependencies¶
The browser preview imports your app in the pn start process, so any
package under [requirements].packages must be installed in the same
Python environment (pip install it, or run uv run --with <pkg> pn
start). pn start warns when one is missing. Device builds resolve
their own wheels from the same list; see PyPI packages.
Profiling¶
Set PN_PROFILE to a writable file path before starting the Python process:
Collection retains the latest 10,000 timing events and exports the trace at
normal process exit. The trace identifies rendered components and separates
Python preparation, acknowledgement, and native decode, queue, preflight,
mutation, and layout durations. Counters cover bridge bytes/operations, list
snapshot rows and patches, and mounted views. Profiler.summary() reports
retained-sample p50/p95/max durations, work counters, and gauges.
Setting PN_PROFILE here captures the preview's Python process; it doesn't
configure an environment variable inside a connected mobile app. To trace a
device, use Record trace in DevTools' Performance panel, which captures
the selected app and downloads the same trace format.
Run the reproducible headless work benchmark from the repository root:
It compares ordinary sequence replacement with ListData, asserts bounded
metadata and mounted-view work, and emits JSON. Timings include profiling and
aren't device frame-rate measurements or CI thresholds.
The runtime benchmark times import pythonnative in fresh interpreters,
element construction, a 300-component re-render, and commit preparation:
Compare its numbers between changes on one machine. CI doesn't gate on them;
tests/test_performance_budgets.py instead checks the deterministic causes
of slowness (source parsing or contract derivation at import, eagerly loaded
subsystems, per-field render journaling, inspect.Signature binding, and
release-mode prop validation). On a device, a debug iOS build logs
[PN] First screen attached ... ms after process start once per launch.
For an explicit capture in a headless test:
import pythonnative as pn
from pythonnative.profiling import Profiler
from pythonnative.testing import render
@pn.component
def App() -> pn.Node:
return pn.Text("Profiled render")
with Profiler() as profiler:
result = render(App())
result.unmount()
profiler.export("trace.json")
The same context manager can surround work on the application's Python thread in an embedded runtime. Choose an app-writable path when exporting on a device. Open the resulting Chrome trace in Perfetto or a compatible trace viewer. The profiler doesn't retain component trees or application snapshots. Use Instruments on iOS and Android's platform profiling tools for native frames, input, and scrolling.
Next steps¶
- Browser preview: device frames, dark mode, keyboard shortcuts, what's approximated.
- Fast Refresh: what survives a reload, what doesn't, and why.
- CLI reference for every flag.
- Dev server API if you want to script against the server or embed a client.