| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
1 parent 922ec94 commit cddbcdc
12 files changed
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -64,6 +64,21 @@ pn run ios --prepare-only | |||
| 64 | 64 | ||
| 65 | 65 | This stages files under `build/` so you can open them in Android Studio or Xcode. | |
| 66 | 66 | ||
| 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 | + | ||
| 67 | 82 | ## Viewing logs | |
| 68 | 83 | ||
| 69 | 84 | After the app launches, `pn run` attaches to the app's stdout/stderr so Python | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -21,20 +21,43 @@ pn run ios --hot-reload | |||
| 21 | 21 | 2. Launch the app on a connected device or simulator. | |
| 22 | 22 | 3. Start a [`FileWatcher`][pythonnative.hot_reload.FileWatcher] over | |
| 23 | 23 | `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 | ||
| 25 | 28 | you press `Ctrl+C`. | |
| 26 | 29 | ||
| 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 | + | ||
| 27 | 50 | ## What gets reloaded | |
| 28 | 51 | ||
| 29 | 52 | PythonNative reloads any `.py` file under `app/`. The device-side | |
| 30 | 53 | [`ModuleReloader`][pythonnative.hot_reload.ModuleReloader] resolves | |
| 31 | 54 | the file to a dotted module name (e.g., `app/pages/home.py` becomes | |
| 32 | 55 | `app.pages.home`) and calls `importlib.reload` on it. | |
| 33 | 56 | ||
| 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. | ||
| 38 | 61 | ||
| 39 | 62 | ## What doesn't reload | |
| 40 | 63 | ||
@@ -55,11 +78,11 @@ stale closures from older module versions). | |||
| 55 | 78 | (counters, network calls) needs guarding. | |
| 56 | 79 | ||
| 57 | 80 | !!! 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. | ||
| 63 | 86 | ||
| 64 | 87 | !!! warning "Hook signature changes" | |
| 65 | 88 | 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: | |||
| 92 | 115 | pn run android --hot-reload --no-logs | |
| 93 | 116 | ``` | |
| 94 | 117 | ||
| 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. | ||
| 98 | 121 | ||
| 99 | 122 | ## Next steps | |
| 100 | 123 | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -14,6 +14,7 @@ | |||
| 14 | 14 | styles = pn.StyleSheet.create( | |
| 15 | 15 | title={"font_size": 24, "bold": True}, | |
| 16 | 16 | subtitle={"font_size": 16, "color": "#666666"}, | |
| 17 | + hint={"font_size": 14, "color": "#666666"}, | ||
| 17 | 18 | medal={"font_size": 32}, | |
| 18 | 19 | card={ | |
| 19 | 20 | "spacing": 12, | |
@@ -75,6 +76,11 @@ def go_to_second() -> None: | |||
| 75 | 76 | return pn.ScrollView( | |
| 76 | 77 | pn.Column( | |
| 77 | 78 | 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 | + ), | ||
| 78 | 84 | counter_badge(), | |
| 79 | 85 | pn.Button("Go to Second Page", on_click=go_to_second), | |
| 80 | 86 | style=styles["section"], | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -23,6 +23,7 @@ | |||
| 23 | 23 | import subprocess | |
| 24 | 24 | import sys | |
| 25 | 25 | import sysconfig | |
| 26 | + import time | ||
| 26 | 27 | import urllib.request | |
| 27 | 28 | from importlib import resources | |
| 28 | 29 | from typing import Any, Dict, List, Optional | |
@@ -311,6 +312,9 @@ def _read_requirements(requirements_path: str) -> list[str]: | |||
| 311 | 312 | return result | |
| 312 | 313 | ||
| 313 | 314 | ||
| 315 | + ANDROID_PACKAGE_ID: str = "com.pythonnative.android_template" | ||
| 316 | + HOT_RELOAD_DEV_ROOT: str = "pythonnative_dev" | ||
| 317 | + | ||
| 314 | 318 | ANDROID_LOGCAT_FILTERS: list[str] = [ | |
| 315 | 319 | "python.stdout:V", | |
| 316 | 320 | "python.stderr:V", | |
@@ -366,6 +370,125 @@ def _terminate_subprocess(proc: Optional[subprocess.Popen]) -> None: | |||
| 366 | 370 | proc.kill() | |
| 367 | 371 | ||
| 368 | 372 | ||
| 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 | + | ||
| 369 | 492 | def run_project(args: argparse.Namespace) -> None: | |
| 370 | 493 | """Build and run the project on the requested platform. | |
| 371 | 494 | ||
@@ -491,6 +614,8 @@ def run_project(args: argparse.Namespace) -> None: | |||
| 491 | 614 | pass | |
| 492 | 615 | subprocess.run(["./gradlew", "installDebug"], check=True, env=env) | |
| 493 | 616 | ||
| 617 | + _clear_hot_reload_overlay(platform) | ||
| 618 | + | ||
| 494 | 619 | # Run the Android app | |
| 495 | 620 | # Assumes that the package name of your app is "com.example.myapp" and the main activity is "MainActivity" | |
| 496 | 621 | # 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: | |||
| 501 | 626 | "am", | |
| 502 | 627 | "start", | |
| 503 | 628 | "-n", | |
| 504 | - "com.pythonnative.android_template/.MainActivity", | ||
| 629 | + f"{ANDROID_PACKAGE_ID}/.MainActivity", | ||
| 505 | 630 | ], | |
| 506 | 631 | check=True, | |
| 507 | 632 | ) | |
@@ -792,6 +917,7 @@ def run_project(args: argparse.Namespace) -> None: | |||
| 792 | 917 | subprocess.run(["xcrun", "simctl", "boot", udid], check=False, capture_output=True) | |
| 793 | 918 | # Install | |
| 794 | 919 | subprocess.run(["xcrun", "simctl", "install", udid, app_path], check=False) | |
| 920 | + _clear_hot_reload_overlay(platform) | ||
| 795 | 921 | if show_logs and not hot_reload: | |
| 796 | 922 | # Attach the app's stdout/stderr to this terminal so Python | |
| 797 | 923 | # 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: | |||
| 850 | 976 | build_dir: Absolute path to the staged build directory. | |
| 851 | 977 | show_logs: Whether to stream device logs in parallel. | |
| 852 | 978 | """ | |
| 853 | - from .hot_reload import FileWatcher | ||
| 979 | + from ..hot_reload import FileWatcher | ||
| 854 | 980 | ||
| 855 | 981 | app_dir = os.path.join(project_dir, "app") | |
| 856 | 982 | ||
| 857 | 983 | def on_change(changed_files: List[str]) -> None: | |
| 984 | + pushed: List[str] = [] | ||
| 858 | 985 | for fpath in changed_files: | |
| 859 | 986 | rel = os.path.relpath(fpath, project_dir) | |
| 860 | 987 | 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.") | ||
| 866 | 998 | ||
| 867 | 999 | print("[hot-reload] Watching app/ for changes. Press Ctrl+C to stop.") | |
| 868 | 1000 | watcher = FileWatcher(app_dir, on_change, interval=1.0) | |
| Back | FazBrowse Home | New Git URL |
0 commit comments