Skip to content

Hot reload

Hot-reload comes in two cooperating pieces: a host-side file watcher that pushes changed .py files to the device, and a device-side module reloader that swaps the new code in and re-renders the active page. Both are wired up automatically by pn run --hot-reload.

Hot-reload support for PythonNative development.

Two cooperating pieces:

  • Host-side: FileWatcher polls the developer's app/ directory for .py changes and triggers a callback (typically adb push on Android or a simctl file copy on iOS).
  • Device-side: ModuleReloader reloads changed Python modules using importlib and asks the screen host to re-render its current tree.

Two strategies share the device-side surface:

  • Fast Refresh (default): after reloading the changed modules the reconciler tree is walked and every component function whose module was reloaded is swapped in place. Hook state, navigation state, and even scroll positions survive because the underlying VNode objects are reused — the next render simply calls the new function bodies through the old slots.
  • Full remount: when the in-place swap fails (e.g. the new module raised at import time, or a render exception bubbled out while running the new function), the host falls back to building a brand-new reconciler tree. State is lost but the app keeps running.
Example

Integrated into pn run --hot-reload:

from pythonnative.hot_reload import FileWatcher

def push(changed):
    for path in changed:
        print("changed:", path)

watcher = FileWatcher("app/", on_change=push)
watcher.start()

Classes:

Name Description
FileWatcher

Watch a directory tree for .py file changes.

ModuleReloader

Reload changed Python modules on device and trigger a re-render.

Functions:

Name Description
configure_dev_environment

Create and prioritize the writable hot-reload source overlay.

manifest_path_for

Return the reload-manifest path inside a hot-reload overlay.

Attributes:

Name Type Description
DEV_ROOT_DIR

Name of the writable on-device directory that shadows bundled app code.

RELOAD_MANIFEST

Manifest filename written by the host and polled by native templates.

DEV_ROOT_DIR module-attribute

DEV_ROOT_DIR = 'pythonnative_dev'

Name of the writable on-device directory that shadows bundled app code.

RELOAD_MANIFEST module-attribute

RELOAD_MANIFEST = 'reload.json'

Manifest filename written by the host and polled by native templates.

FileWatcher

FileWatcher(watch_dir: str, on_change: Callable[[List[str]], None], interval: float = 1.0)

Watch a directory tree for .py file changes.

Uses simple os.path.getmtime polling rather than a native inotify/FSEvents binding so the watcher works on every platform where Python runs without extra dependencies.

Parameters:

Name Type Description Default
watch_dir str

Directory to watch (recursively).

required
on_change Callable[[List[str]], None]

Called with a list of changed file paths when modifications are detected.

required
interval float

Polling interval, in seconds.

1.0

Attributes:

Name Type Description
watch_dir

Directory being watched.

on_change

Change callback.

interval

Polling interval.

Methods:

Name Description
start

Begin watching in a background daemon thread.

stop

Stop the watcher and join the background thread.

start

start() -> None

Begin watching in a background daemon thread.

Performs an initial scan to seed mtimes so the first notification reflects subsequent edits, not pre-existing files.

stop

stop() -> None

Stop the watcher and join the background thread.

ModuleReloader

Reload changed Python modules on device and trigger a re-render.

Designed to be invoked from device-side glue when a hot-reload push completes. All public methods are static; the class holds a single piece of process-wide state — the manifest version that has most recently been applied to sys.modules — so that multiple screen hosts polling the same manifest do not each re-execute the user-app modules. The first host to see a new version pays the reload_modules cost; subsequent hosts on the same version refresh only their own reconciler tree against the already-fresh modules.

Methods:

Name Description
reload_module

Reload a single module by its dotted name.

reload_modules

Reload the modules that are already imported.

reload_modules_for_version

Reload module_names for version, deduping across hosts.

expand_reload_targets

Expand a manifest of changed modules into the full reload order.

file_to_module

Convert a file path to a dotted module name.

modules_from_files

Convert Python source paths to importable module names.

reload_screen

Force a screen re-render after a module reload.

find_replacement_function

Locate a function's post-reload counterpart by qualname.

build_replacement_map

Compute {old_function: new_function} for one tree.

swap_components_in_tree

Apply a {old: new} map to every node in the reconciler tree.

refresh_in_place

Try a state-preserving Fast Refresh for one reconciler.

reload_from_manifest

Apply a reload manifest if it is newer than last_version.

reload_module staticmethod

reload_module(module_name: str) -> bool

Reload a single module by its dotted name.

Parameters:

Name Type Description Default
module_name str

Dotted module name (e.g., "app.main").

required

Returns:

Type Description
bool

True if the module imported successfully from the current

bool

sys.path; False otherwise.

reload_modules staticmethod

reload_modules(module_names: Sequence[str]) -> List[str]

Reload the modules that are already imported.

Parameters:

Name Type Description Default
module_names Sequence[str]

Dotted module names to reload.

required

Returns:

Type Description
List[str]

Names that were successfully reloaded.

reload_modules_for_version staticmethod

reload_modules_for_version(module_names: Sequence[str], version: Optional[str]) -> List[str]

Reload module_names for version, deduping across hosts.

Each native screen host on iOS / Android runs its own poll loop and would otherwise call reload_modules independently for the same manifest version. That re-executes every user-app module N times (once per host) per file change, producing N different generations of the same function objects in sys.modules and leaving each host's reconciler tree pointing at a different generation. Beyond the wasted work, the inconsistent state has been observed to crash UIKit on iOS with CALayerInvalidGeometry (NaN values fed into setFrame_: during the interleaved renders).

This helper serializes on _reload_lock and uses _last_reloaded_version to ensure only the first host to see a given version actually re-executes the modules. Subsequent hosts on the same version get back the already-fresh entries from sys.modules so their own refresh_in_place pass can still rewrite their tree against the same generation.

Parameters:

Name Type Description Default
module_names Sequence[str]

Dotted module names to reload.

required
version Optional[str]

Manifest version this reload is processing. When None (e.g. tests calling reload directly) the call falls back to the unconditional reload_modules behavior.

required

Returns:

Type Description
List[str]

The list of module names that are currently fresh in

List[str]

sys.modules — either freshly reloaded by this call, or

List[str]

already reloaded by an earlier host for the same version.

expand_reload_targets staticmethod

expand_reload_targets(changed_modules: Sequence[str], component_path: str) -> List[str]

Expand a manifest of changed modules into the full reload order.

When a user edits app/screens/home.py, only that file is in the manifest. But the entry-point module app.main has bindings like from app.screens.home import HomeScreen that need to be re-evaluated against the freshly-loaded app.screens.home; likewise other user-app modules may carry transitive bindings (e.g. through a shared app/theme.py) that go stale if only the changed file is reloaded.

This helper computes the full ordered reload list:

  1. Explicitly changed modules first (in the order given), so their fresh source replaces the cached version in sys.modules before any dependent modules re-execute.
  2. All other currently-imported modules under the entry-point's top-level package, deepest first. The depth heuristic biases toward leaves so re-executing a screen file picks up the newest shared utilities before the file that imports it does.
  3. The entry-point module itself, last, so its from ... import bindings rebind against everything that was refreshed in steps 1 and 2.

Modules outside the entry-point's top-level package (pythonnative.*, stdlib, third-party) are never included; framework code is not reloaded.

Parameters:

Name Type Description Default
changed_modules Sequence[str]

Modules reported as changed by the host file-watcher (already in dotted form).

required
component_path str

The host's entry-point identifier, either a module path ("app.main") or a dotted attribute path ("app.main.RootScreen").

required

Returns:

Type Description
List[str]

The ordered list of modules to feed to

List[str]

file_to_module staticmethod

file_to_module(file_path: str, base_dir: str = '') -> Optional[str]

Convert a file path to a dotted module name.

Parameters:

Name Type Description Default
file_path str

Path to a .py file (absolute or relative).

required
base_dir str

Base directory that names should be relative to. If empty, file_path is treated as already relative.

''

Returns:

Type Description
Optional[str]

The dotted module name (e.g., "app.screens.home"), or

Optional[str]

None for an empty path.

modules_from_files staticmethod

modules_from_files(file_paths: Sequence[str], base_dir: str = '') -> List[str]

Convert Python source paths to importable module names.

reload_screen staticmethod

reload_screen(screen_instance: Any, module_names: Optional[Sequence[str]] = None) -> None

Force a screen re-render after a module reload.

Parameters:

Name Type Description Default
screen_instance Any

A _ScreenHost instance (or duck-typed equivalent) that exposes a _reconciler attribute.

required
module_names Optional[Sequence[str]]

Optional modules that changed. Reload-aware screen hosts use this to refresh imports before re-render.

None

find_replacement_function staticmethod

find_replacement_function(old_fn: Any) -> Optional[Any]

Locate a function's post-reload counterpart by qualname.

Functions decorated with component store the user's original function on the wrapper's __wrapped__ attribute and forward __module__ / __qualname__ so that the reconciler's stored element.type (the unwrapped function) still has the information needed to re-resolve after a module reload.

Parameters:

Name Type Description Default
old_fn Any

The function captured in an Element's type slot.

required

Returns:

Type Description
Optional[Any]

The reloaded module's matching function, None if no

Optional[Any]

replacement was found, or the original function itself

Optional[Any]

when the module has not been reloaded (so callers can

Optional[Any]

skip the swap).

build_replacement_map staticmethod

build_replacement_map(reconciler: Any, reloaded_modules: Iterable[str]) -> Dict[Any, Any]

Compute {old_function: new_function} for one tree.

The reconciler's stored tree references the pre-reload component functions through VNode.element.type. This method walks the tree, collects every callable type whose __module__ was just reloaded, and asks find_replacement_function for its successor.

Parameters:

Name Type Description Default
reconciler Any

The reconciler whose _tree should be inspected.

required
reloaded_modules Iterable[str]

Set of module names that were just reloaded (only callables from these modules are considered).

required

Returns:

Type Description
Dict[Any, Any]

A mapping suitable for passing to

Dict[Any, Any]

swap_components_in_tree staticmethod

swap_components_in_tree(reconciler: Any, replacement_map: Dict[Any, Any]) -> int

Apply a {old: new} map to every node in the reconciler tree.

Mutates vnode.element.type directly so the NEXT diff sees identical types and reuses VNodes (preserving hook state). Pending Element trees stored on vnode._rendered are rewritten too because the reconciler reads from them when comparing keys across renders.

Returns:

Type Description
int

The number of element type references that were rewritten.

refresh_in_place staticmethod

refresh_in_place(reconciler: Any, reloaded_modules: Iterable[str]) -> bool

Try a state-preserving Fast Refresh for one reconciler.

Returns:

Type Description
bool

True if any component function was replaced (callers

bool

should then trigger a re-render). False means the

bool

tree already references the latest functions (or has no

bool

nodes from the reloaded modules at all).

reload_from_manifest staticmethod

reload_from_manifest(screen_instance: Any, manifest_path: str, *, last_version: Optional[str] = None) -> Optional[str]

Apply a reload manifest if it is newer than last_version.

Parameters:

Name Type Description Default
screen_instance Any

Screen host to refresh.

required
manifest_path str

JSON manifest written by the CLI.

required
last_version Optional[str]

Version already applied by this screen host.

None

Returns:

Type Description
Optional[str]

The manifest version after applying, or last_version when

Optional[str]

no new manifest is available.

configure_dev_environment

configure_dev_environment(writable_root: str) -> str

Create and prioritize the writable hot-reload source overlay.

The returned directory is inserted at the front of sys.path, so a pushed app/main.py shadows the copy bundled into the native application. Templates call this before importing user code.

Parameters:

Name Type Description Default
writable_root str

Platform data directory that the app can write to (Android filesDir, iOS Documents, or a test directory).

required

Returns:

Type Description
str

Absolute path to the hot-reload overlay root.

manifest_path_for

manifest_path_for(dev_root: str) -> str

Return the reload-manifest path inside a hot-reload overlay.

Next steps