Skip to content

Commit cddbcdc

Browse files
committed
feat(hot_reload): reload Python app code without rebuilding
1 parent 922ec94 commit cddbcdc

12 files changed

Lines changed: 785 additions & 48 deletions

File tree

docs/getting-started.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,21 @@ pn run ios --prepare-only
6464

6565
This stages files under `build/` so you can open them in Android Studio or Xcode.
6666

67+
## Hot reload while developing
68+
69+
For day-to-day UI work, run with `--hot-reload`:
70+
71+
```bash
72+
pn run android --hot-reload
73+
pn run ios --hot-reload
74+
```
75+
76+
The first run still builds and launches the native app. After that,
77+
edits under `app/` are copied into the running app's writable source
78+
overlay and the active page is remounted without a full rebuild. This is
79+
best for Python UI changes; native template changes still require a
80+
normal rebuild.
81+
6782
## Viewing logs
6883

6984
After the app launches, `pn run` attaches to the app's stdout/stderr so Python

docs/guides/hot-reload.md

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -21,20 +21,43 @@ pn run ios --hot-reload
2121
2. Launch the app on a connected device or simulator.
2222
3. Start a [`FileWatcher`][pythonnative.hot_reload.FileWatcher] over
2323
`app/`.
24-
4. Tail logs (Android) or print hot-reload notifications (iOS) until
24+
4. Push changed Python files into a writable on-device overlay.
25+
5. Write a small reload manifest that the running app polls from the
26+
main thread.
27+
6. Tail logs (Android) or print hot-reload notifications (iOS) until
2528
you press `Ctrl+C`.
2629

30+
## How the device sees changes
31+
32+
The native templates call
33+
[`configure_dev_environment()`][pythonnative.hot_reload.configure_dev_environment]
34+
before importing your app. That creates a `pythonnative_dev/` directory
35+
in the app's writable sandbox and puts it before the bundled app code
36+
on `sys.path`.
37+
38+
When a source file changes, the CLI copies it to that overlay:
39+
40+
- Android: app-private storage via `adb` + `run-as`
41+
- iOS Simulator: the installed app's `Documents/pythonnative_dev/`
42+
directory
43+
44+
After the files are in place, the CLI writes `reload.json`. The
45+
Android and iOS templates poll that manifest on the platform main
46+
thread and call the page host's reload hook. The host re-imports the
47+
root component by dotted path, resets hook/navigation state for the
48+
page, and mounts the refreshed tree.
49+
2750
## What gets reloaded
2851

2952
PythonNative reloads any `.py` file under `app/`. The device-side
3053
[`ModuleReloader`][pythonnative.hot_reload.ModuleReloader] resolves
3154
the file to a dotted module name (e.g., `app/pages/home.py` becomes
3255
`app.pages.home`) and calls `importlib.reload` on it.
3356

34-
After reloading, the page host is notified to re-render the active
35-
page. Hook state for the affected component instances is **reset** on
36-
reload (this matches React Native's "fast refresh" model and avoids
37-
stale closures from older module versions).
57+
After reloading, the page host remounts the active page. Hook state for
58+
the affected page is **reset** on reload. This is intentionally more
59+
conservative than React Native Fast Refresh, but it avoids stale Python
60+
closures while the runtime is still young.
3861

3962
## What doesn't reload
4063

@@ -55,11 +78,11 @@ stale closures from older module versions).
5578
(counters, network calls) needs guarding.
5679

5780
!!! warning "References across modules"
58-
If module `a` does `from b import Foo` and you reload `b`, module
59-
`a` still holds the *old* `Foo`. The reconciler sidesteps this
60-
for components by re-resolving the function on every render, but
61-
long-lived references (e.g., stashed in a global) can drift. When
62-
in doubt, restart the app.
81+
If module `a` does `from b import Foo` and only `b.py` changes,
82+
module `a` may still hold the *old* `Foo`. The page host always
83+
reloads the root page module after changed modules so common
84+
component imports update, but long-lived references (e.g., stashed
85+
in a global) can drift. When in doubt, restart the app.
6386

6487
!!! warning "Hook signature changes"
6588
Adding or removing a hook in a component changes the slot layout.
@@ -92,9 +115,9 @@ reloaded modules. Pass `--no-logs` to suppress the stream:
92115
pn run android --hot-reload --no-logs
93116
```
94117

95-
On iOS, the simulator attaches the launching terminal to the app's
96-
stderr; PythonNative also rewires `sys.stdout` to that channel so
97-
`print()` calls show up alongside `NSLog` output.
118+
On iOS hot reload currently targets the Simulator flow. Use Console.app
119+
or Xcode for full live logs while the CLI keeps the watcher process in
120+
the foreground.
98121

99122
## Next steps
100123

examples/hello-world/app/main_page.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
styles = pn.StyleSheet.create(
1515
title={"font_size": 24, "bold": True},
1616
subtitle={"font_size": 16, "color": "#666666"},
17+
hint={"font_size": 14, "color": "#666666"},
1718
medal={"font_size": 32},
1819
card={
1920
"spacing": 12,
@@ -75,6 +76,11 @@ def go_to_second() -> None:
7576
return pn.ScrollView(
7677
pn.Column(
7778
pn.Text("Hello from PythonNative Demo!", style=styles["title"]),
79+
pn.Text(
80+
"Try `pn run android --hot-reload`, edit this text, and save. "
81+
"The running app should update without a rebuild.",
82+
style=styles["hint"],
83+
),
7884
counter_badge(),
7985
pn.Button("Go to Second Page", on_click=go_to_second),
8086
style=styles["section"],

src/pythonnative/cli/pn.py

Lines changed: 139 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import subprocess
2424
import sys
2525
import sysconfig
26+
import time
2627
import urllib.request
2728
from importlib import resources
2829
from typing import Any, Dict, List, Optional
@@ -311,6 +312,9 @@ def _read_requirements(requirements_path: str) -> list[str]:
311312
return result
312313

313314

315+
ANDROID_PACKAGE_ID: str = "com.pythonnative.android_template"
316+
HOT_RELOAD_DEV_ROOT: str = "pythonnative_dev"
317+
314318
ANDROID_LOGCAT_FILTERS: list[str] = [
315319
"python.stdout:V",
316320
"python.stderr:V",
@@ -366,6 +370,125 @@ def _terminate_subprocess(proc: Optional[subprocess.Popen]) -> None:
366370
proc.kill()
367371

368372

373+
def _hot_reload_manifest_payload(
374+
changed_files: List[str],
375+
project_dir: str,
376+
*,
377+
version: Optional[str] = None,
378+
) -> Dict[str, Any]:
379+
"""Build the reload manifest consumed by the running app."""
380+
from pythonnative.hot_reload import ModuleReloader
381+
382+
rel_files = sorted(os.path.relpath(path, project_dir) for path in changed_files)
383+
return {
384+
"version": version or str(time.time_ns()),
385+
"files": rel_files,
386+
"modules": ModuleReloader.modules_from_files(rel_files),
387+
}
388+
389+
390+
def _write_hot_reload_manifest(changed_files: List[str], project_dir: str, build_dir: str) -> str:
391+
"""Write a local hot-reload manifest and return its path."""
392+
manifest_dir = os.path.join(build_dir, "hot_reload")
393+
os.makedirs(manifest_dir, exist_ok=True)
394+
manifest_path = os.path.join(manifest_dir, "reload.json")
395+
with open(manifest_path, "w", encoding="utf-8") as f:
396+
json.dump(_hot_reload_manifest_payload(changed_files, project_dir), f)
397+
return manifest_path
398+
399+
400+
def _android_hot_reload_dest(rel_path: str) -> str:
401+
"""Return a `run-as` relative destination for an app source file."""
402+
return os.path.join("files", HOT_RELOAD_DEV_ROOT, rel_path)
403+
404+
405+
def _push_android_hot_reload_file(local_path: str, rel_path: str) -> bool:
406+
"""Push one file into the Android app's writable hot-reload overlay."""
407+
tmp_path = f"/data/local/tmp/pythonnative-hot-reload-{os.getpid()}-{os.path.basename(local_path)}"
408+
dest_path = _android_hot_reload_dest(rel_path)
409+
dest_dir = os.path.dirname(dest_path)
410+
push = subprocess.run(["adb", "push", local_path, tmp_path], check=False, capture_output=True)
411+
if push.returncode != 0:
412+
return False
413+
subprocess.run(
414+
["adb", "shell", "run-as", ANDROID_PACKAGE_ID, "mkdir", "-p", dest_dir],
415+
check=False,
416+
capture_output=True,
417+
)
418+
copy = subprocess.run(
419+
["adb", "shell", "run-as", ANDROID_PACKAGE_ID, "cp", tmp_path, dest_path],
420+
check=False,
421+
capture_output=True,
422+
)
423+
subprocess.run(["adb", "shell", "rm", "-f", tmp_path], check=False, capture_output=True)
424+
return copy.returncode == 0
425+
426+
427+
def _ios_data_container() -> Optional[str]:
428+
"""Return the booted simulator's app data container, if available."""
429+
try:
430+
result = subprocess.run(
431+
["xcrun", "simctl", "get_app_container", "booted", IOS_BUNDLE_ID, "data"],
432+
check=False,
433+
capture_output=True,
434+
text=True,
435+
)
436+
except FileNotFoundError:
437+
return None
438+
if result.returncode != 0:
439+
return None
440+
container = result.stdout.strip()
441+
return container or None
442+
443+
444+
def _push_ios_hot_reload_file(local_path: str, rel_path: str) -> bool:
445+
"""Copy one file into the booted iOS Simulator's hot-reload overlay."""
446+
container = _ios_data_container()
447+
if container is None:
448+
return False
449+
dest_path = os.path.join(container, "Documents", HOT_RELOAD_DEV_ROOT, rel_path)
450+
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
451+
shutil.copy2(local_path, dest_path)
452+
return True
453+
454+
455+
def _clear_android_hot_reload_overlay() -> bool:
456+
"""Remove stale Android hot-reload files before launching."""
457+
result = subprocess.run(
458+
["adb", "shell", "run-as", ANDROID_PACKAGE_ID, "rm", "-rf", f"files/{HOT_RELOAD_DEV_ROOT}"],
459+
check=False,
460+
capture_output=True,
461+
)
462+
return result.returncode == 0
463+
464+
465+
def _clear_ios_hot_reload_overlay() -> bool:
466+
"""Remove stale iOS Simulator hot-reload files before launching."""
467+
container = _ios_data_container()
468+
if container is None:
469+
return False
470+
shutil.rmtree(os.path.join(container, "Documents", HOT_RELOAD_DEV_ROOT), ignore_errors=True)
471+
return True
472+
473+
474+
def _clear_hot_reload_overlay(platform: str) -> bool:
475+
"""Remove stale hot-reload overlay files for `platform`."""
476+
if platform == "android":
477+
return _clear_android_hot_reload_overlay()
478+
if platform == "ios":
479+
return _clear_ios_hot_reload_overlay()
480+
return False
481+
482+
483+
def _push_hot_reload_file(platform: str, local_path: str, rel_path: str) -> bool:
484+
"""Push a changed source file to the running app."""
485+
if platform == "android":
486+
return _push_android_hot_reload_file(local_path, rel_path)
487+
if platform == "ios":
488+
return _push_ios_hot_reload_file(local_path, rel_path)
489+
return False
490+
491+
369492
def run_project(args: argparse.Namespace) -> None:
370493
"""Build and run the project on the requested platform.
371494
@@ -491,6 +614,8 @@ def run_project(args: argparse.Namespace) -> None:
491614
pass
492615
subprocess.run(["./gradlew", "installDebug"], check=True, env=env)
493616

617+
_clear_hot_reload_overlay(platform)
618+
494619
# Run the Android app
495620
# Assumes that the package name of your app is "com.example.myapp" and the main activity is "MainActivity"
496621
# Replace "com.example.myapp" and ".MainActivity" with your actual package name and main activity
@@ -501,7 +626,7 @@ def run_project(args: argparse.Namespace) -> None:
501626
"am",
502627
"start",
503628
"-n",
504-
"com.pythonnative.android_template/.MainActivity",
629+
f"{ANDROID_PACKAGE_ID}/.MainActivity",
505630
],
506631
check=True,
507632
)
@@ -792,6 +917,7 @@ def run_project(args: argparse.Namespace) -> None:
792917
subprocess.run(["xcrun", "simctl", "boot", udid], check=False, capture_output=True)
793918
# Install
794919
subprocess.run(["xcrun", "simctl", "install", udid, app_path], check=False)
920+
_clear_hot_reload_overlay(platform)
795921
if show_logs and not hot_reload:
796922
# Attach the app's stdout/stderr to this terminal so Python
797923
# print() calls and exceptions are visible. SIMCTL_CHILD_*
@@ -850,19 +976,25 @@ def _run_hot_reload(platform: str, project_dir: str, build_dir: str, show_logs:
850976
build_dir: Absolute path to the staged build directory.
851977
show_logs: Whether to stream device logs in parallel.
852978
"""
853-
from .hot_reload import FileWatcher
979+
from ..hot_reload import FileWatcher
854980

855981
app_dir = os.path.join(project_dir, "app")
856982

857983
def on_change(changed_files: List[str]) -> None:
984+
pushed: List[str] = []
858985
for fpath in changed_files:
859986
rel = os.path.relpath(fpath, project_dir)
860987
print(f"[hot-reload] Changed: {rel}")
861-
if platform == "android":
862-
dest = f"/data/data/com.pythonnative.android_template/files/{rel}"
863-
subprocess.run(["adb", "push", fpath, dest], check=False, capture_output=True)
864-
elif platform == "ios":
865-
pass # simctl file push would go here
988+
if _push_hot_reload_file(platform, fpath, rel):
989+
pushed.append(fpath)
990+
else:
991+
print(f"[hot-reload] Failed to push {rel}")
992+
if pushed:
993+
manifest = _write_hot_reload_manifest(pushed, project_dir, build_dir)
994+
if _push_hot_reload_file(platform, manifest, "reload.json"):
995+
print(f"[hot-reload] Signaled reload for {len(pushed)} file(s).")
996+
else:
997+
print("[hot-reload] Failed to signal reload; app will not refresh automatically.")
866998

867999
print("[hot-reload] Watching app/ for changes. Press Ctrl+C to stop.")
8681000
watcher = FileWatcher(app_dir, on_change, interval=1.0)

0 commit comments

Comments
 (0)