pythonnative 0.12.0__py3-none-any.whl → 0.13.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pythonnative/__init__.py +3 -3
- pythonnative/cli/pn.py +34 -9
- pythonnative/hooks.py +22 -13
- pythonnative/hot_reload.py +340 -24
- pythonnative/native_views/__init__.py +72 -0
- pythonnative/native_views/ios.py +49 -5
- pythonnative/navigation.py +178 -35
- pythonnative/platform_metrics.py +9 -9
- pythonnative/reconciler.py +7 -7
- pythonnative/{page.py → screen.py} +294 -75
- pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/MainActivity.kt +4 -4
- pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/Navigator.kt +20 -3
- pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/{PageFragment.kt → ScreenFragment.kt} +23 -18
- pythonnative/templates/android_template/app/src/main/res/navigation/nav_graph.xml +6 -6
- pythonnative/templates/ios_template/ios_template/ViewController.swift +31 -26
- pythonnative/utils.py +5 -5
- {pythonnative-0.12.0.dist-info → pythonnative-0.13.1.dist-info}/METADATA +4 -3
- {pythonnative-0.12.0.dist-info → pythonnative-0.13.1.dist-info}/RECORD +22 -22
- {pythonnative-0.12.0.dist-info → pythonnative-0.13.1.dist-info}/WHEEL +0 -0
- {pythonnative-0.12.0.dist-info → pythonnative-0.13.1.dist-info}/entry_points.txt +0 -0
- {pythonnative-0.12.0.dist-info → pythonnative-0.13.1.dist-info}/licenses/LICENSE +0 -0
- {pythonnative-0.12.0.dist-info → pythonnative-0.13.1.dist-info}/top_level.txt +0 -0
pythonnative/__init__.py
CHANGED
|
@@ -41,7 +41,7 @@ Example:
|
|
|
41
41
|
```
|
|
42
42
|
"""
|
|
43
43
|
|
|
44
|
-
__version__ = "0.
|
|
44
|
+
__version__ = "0.13.1"
|
|
45
45
|
|
|
46
46
|
from .alerts import Alert
|
|
47
47
|
from .animated import Animated, AnimatedValue
|
|
@@ -98,8 +98,8 @@ from .navigation import (
|
|
|
98
98
|
use_focus_effect,
|
|
99
99
|
use_route,
|
|
100
100
|
)
|
|
101
|
-
from .page import create_page
|
|
102
101
|
from .platform import Platform
|
|
102
|
+
from .screen import create_screen
|
|
103
103
|
from .style import StyleSheet, ThemeContext
|
|
104
104
|
|
|
105
105
|
__all__ = [
|
|
@@ -130,7 +130,7 @@ __all__ = [
|
|
|
130
130
|
"WebView",
|
|
131
131
|
# Core
|
|
132
132
|
"Element",
|
|
133
|
-
"
|
|
133
|
+
"create_screen",
|
|
134
134
|
# Hooks
|
|
135
135
|
"batch_updates",
|
|
136
136
|
"component",
|
pythonnative/cli/pn.py
CHANGED
|
@@ -32,9 +32,9 @@ from typing import Any, Dict, List, Optional
|
|
|
32
32
|
def init_project(args: argparse.Namespace) -> None:
|
|
33
33
|
"""Scaffold a new PythonNative project in the current directory.
|
|
34
34
|
|
|
35
|
-
Creates `app/
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
Creates `app/main.py`, `pythonnative.json`, `requirements.txt`,
|
|
36
|
+
and `.gitignore`. Refuses to overwrite existing files unless
|
|
37
|
+
`--force` is passed.
|
|
38
38
|
|
|
39
39
|
Args:
|
|
40
40
|
args: The parsed argparse namespace. Recognized attributes:
|
|
@@ -68,30 +68,55 @@ def init_project(args: argparse.Namespace) -> None:
|
|
|
68
68
|
|
|
69
69
|
os.makedirs(app_dir, exist_ok=True)
|
|
70
70
|
|
|
71
|
-
|
|
72
|
-
if not os.path.exists(
|
|
73
|
-
with open(
|
|
71
|
+
main_py = os.path.join(app_dir, "main.py")
|
|
72
|
+
if not os.path.exists(main_py) or args.force:
|
|
73
|
+
with open(main_py, "w", encoding="utf-8") as f:
|
|
74
74
|
f.write("""import pythonnative as pn
|
|
75
75
|
|
|
76
|
+
Stack = pn.create_stack_navigator()
|
|
77
|
+
|
|
76
78
|
|
|
77
79
|
@pn.component
|
|
78
|
-
def
|
|
80
|
+
def HomeScreen():
|
|
79
81
|
count, set_count = pn.use_state(0)
|
|
82
|
+
nav = pn.use_navigation()
|
|
80
83
|
return pn.ScrollView(
|
|
81
84
|
pn.Column(
|
|
82
85
|
pn.Text("Hello from PythonNative!", style={"font_size": 24, "bold": True}),
|
|
83
86
|
pn.Text(f"Tapped {count} times"),
|
|
84
87
|
pn.Button("Tap me", on_click=lambda: set_count(count + 1)),
|
|
88
|
+
pn.Button("Open detail", on_click=lambda: nav.navigate("Detail", {"count": count})),
|
|
85
89
|
style={"spacing": 12, "padding": 16, "align_items": "stretch"},
|
|
86
90
|
)
|
|
87
91
|
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@pn.component
|
|
95
|
+
def DetailScreen():
|
|
96
|
+
nav = pn.use_navigation()
|
|
97
|
+
params = pn.use_route()
|
|
98
|
+
return pn.Column(
|
|
99
|
+
pn.Text(f"Detail: count was {params.get('count', 0)}", style={"font_size": 20}),
|
|
100
|
+
pn.Button("Back", on_click=nav.go_back),
|
|
101
|
+
style={"spacing": 12, "padding": 16},
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@pn.component
|
|
106
|
+
def App():
|
|
107
|
+
return pn.NavigationContainer(
|
|
108
|
+
Stack.Navigator(
|
|
109
|
+
Stack.Screen("Home", component=HomeScreen, options={"title": "Home"}),
|
|
110
|
+
Stack.Screen("Detail", component=DetailScreen, options={"title": "Detail"}),
|
|
111
|
+
)
|
|
112
|
+
)
|
|
88
113
|
""")
|
|
89
114
|
|
|
90
115
|
# Create config
|
|
91
116
|
config = {
|
|
92
117
|
"name": project_name,
|
|
93
118
|
"appId": "com.example." + project_name.replace(" ", "").lower(),
|
|
94
|
-
"entryPoint": "app/
|
|
119
|
+
"entryPoint": "app/main.py",
|
|
95
120
|
"pythonVersion": "3.11",
|
|
96
121
|
"ios": {},
|
|
97
122
|
"android": {},
|
|
@@ -319,7 +344,7 @@ ANDROID_LOGCAT_FILTERS: list[str] = [
|
|
|
319
344
|
"python.stdout:V",
|
|
320
345
|
"python.stderr:V",
|
|
321
346
|
"MainActivity:V",
|
|
322
|
-
"
|
|
347
|
+
"ScreenFragment:V",
|
|
323
348
|
"Navigator:V",
|
|
324
349
|
"PythonNative:V",
|
|
325
350
|
"AndroidRuntime:E",
|
pythonnative/hooks.py
CHANGED
|
@@ -511,13 +511,13 @@ def use_window_dimensions() -> Dict[str, float]:
|
|
|
511
511
|
"""Return the current viewport size and re-render when it changes.
|
|
512
512
|
|
|
513
513
|
Equivalent to React Native's ``useWindowDimensions``. The values
|
|
514
|
-
are pushed by the
|
|
514
|
+
are pushed by the screen host whenever the platform reports a new
|
|
515
515
|
size (initial layout, rotation, multitasking split-view).
|
|
516
516
|
|
|
517
517
|
Returns:
|
|
518
518
|
A dict with ``"width"`` and ``"height"`` floats in layout
|
|
519
519
|
units (pt on iOS, dp on Android). Both are ``0.0`` until the
|
|
520
|
-
|
|
520
|
+
screen host has run its first layout pass.
|
|
521
521
|
|
|
522
522
|
Raises:
|
|
523
523
|
RuntimeError: If called outside a `@component` function.
|
|
@@ -717,8 +717,13 @@ _NavigationContext: Context = create_context(None)
|
|
|
717
717
|
class NavigationHandle:
|
|
718
718
|
"""Handle returned by [`use_navigation`][pythonnative.use_navigation].
|
|
719
719
|
|
|
720
|
-
Wraps the host's push/pop primitives so screens can navigate
|
|
721
|
-
knowing the underlying native navigation stack.
|
|
720
|
+
Wraps the host's push/pop primitives so screens can navigate
|
|
721
|
+
without knowing the underlying native navigation stack. The
|
|
722
|
+
typical user-facing surface is the declarative handle returned by
|
|
723
|
+
a [`Stack`][pythonnative.create_stack_navigator] — this class is
|
|
724
|
+
the lower-level fallback used when no navigator is rendered (and
|
|
725
|
+
as the bridge that declarative navigators delegate to when they
|
|
726
|
+
need to push real native screens).
|
|
722
727
|
|
|
723
728
|
Example:
|
|
724
729
|
```python
|
|
@@ -729,7 +734,7 @@ class NavigationHandle:
|
|
|
729
734
|
nav = pn.use_navigation()
|
|
730
735
|
return pn.Button(
|
|
731
736
|
"Open Detail",
|
|
732
|
-
on_click=lambda: nav.navigate(
|
|
737
|
+
on_click=lambda: nav.navigate("Detail", {"id": 42}),
|
|
733
738
|
)
|
|
734
739
|
```
|
|
735
740
|
"""
|
|
@@ -737,16 +742,20 @@ class NavigationHandle:
|
|
|
737
742
|
def __init__(self, host: Any) -> None:
|
|
738
743
|
self._host = host
|
|
739
744
|
|
|
740
|
-
def navigate(self,
|
|
741
|
-
"""Push
|
|
745
|
+
def navigate(self, component: Any, params: Optional[Dict[str, Any]] = None) -> None:
|
|
746
|
+
"""Push ``component`` onto the navigation stack.
|
|
742
747
|
|
|
743
748
|
Args:
|
|
744
|
-
|
|
745
|
-
path (e.g
|
|
749
|
+
component: A ``@component`` function or a dotted Python
|
|
750
|
+
path (e.g. ``"app.detail.DetailScreen"``). When a
|
|
751
|
+
Stack navigator is the root of the app, prefer the
|
|
752
|
+
declarative ``nav.navigate("Detail", params)`` form
|
|
753
|
+
returned by ``use_navigation()`` (it pushes by route
|
|
754
|
+
name and the host re-uses its own ``App`` component).
|
|
746
755
|
params: Optional dict of arguments serialized into the
|
|
747
756
|
target screen.
|
|
748
757
|
"""
|
|
749
|
-
self._host._push(
|
|
758
|
+
self._host._push(component, params)
|
|
750
759
|
|
|
751
760
|
def go_back(self) -> None:
|
|
752
761
|
"""Pop the current screen and return to the previous one."""
|
|
@@ -771,13 +780,13 @@ def use_navigation() -> NavigationHandle:
|
|
|
771
780
|
|
|
772
781
|
Raises:
|
|
773
782
|
RuntimeError: If called outside a component rendered via
|
|
774
|
-
[`
|
|
783
|
+
[`create_screen`][pythonnative.create_screen].
|
|
775
784
|
"""
|
|
776
785
|
handle = use_context(_NavigationContext)
|
|
777
786
|
if handle is None:
|
|
778
787
|
raise RuntimeError(
|
|
779
|
-
"use_navigation() called outside a PythonNative
|
|
780
|
-
"Ensure your component is rendered via
|
|
788
|
+
"use_navigation() called outside a PythonNative screen. "
|
|
789
|
+
"Ensure your component is rendered via create_screen()."
|
|
781
790
|
)
|
|
782
791
|
return handle
|
|
783
792
|
|
pythonnative/hot_reload.py
CHANGED
|
@@ -3,16 +3,30 @@
|
|
|
3
3
|
Two cooperating pieces:
|
|
4
4
|
|
|
5
5
|
- **Host-side**: [`FileWatcher`][pythonnative.hot_reload.FileWatcher]
|
|
6
|
-
polls the developer's
|
|
7
|
-
triggers a callback (typically
|
|
8
|
-
|
|
6
|
+
polls the developer's ``app/`` directory for ``.py`` changes and
|
|
7
|
+
triggers a callback (typically ``adb push`` on Android or a
|
|
8
|
+
``simctl`` file copy on iOS).
|
|
9
9
|
- **Device-side**:
|
|
10
10
|
[`ModuleReloader`][pythonnative.hot_reload.ModuleReloader] reloads
|
|
11
|
-
changed Python modules using
|
|
12
|
-
host to re-render
|
|
11
|
+
changed Python modules using ``importlib`` and asks the screen
|
|
12
|
+
host to re-render its current tree.
|
|
13
|
+
|
|
14
|
+
Two strategies share the device-side surface:
|
|
15
|
+
|
|
16
|
+
- **Fast Refresh** (default): after reloading the changed modules
|
|
17
|
+
the reconciler tree is walked and every component function whose
|
|
18
|
+
module was reloaded is swapped in place. Hook state, navigation
|
|
19
|
+
state, and even scroll positions survive because the underlying
|
|
20
|
+
``VNode`` objects are reused — the next render simply calls the
|
|
21
|
+
new function bodies through the old slots.
|
|
22
|
+
- **Full remount**: when the in-place swap fails (e.g. the new
|
|
23
|
+
module raised at import time, or a render exception bubbled out
|
|
24
|
+
while running the new function), the host falls back to building
|
|
25
|
+
a brand-new reconciler tree. State is lost but the app keeps
|
|
26
|
+
running.
|
|
13
27
|
|
|
14
28
|
Example:
|
|
15
|
-
Integrated into
|
|
29
|
+
Integrated into ``pn run --hot-reload``:
|
|
16
30
|
|
|
17
31
|
```python
|
|
18
32
|
from pythonnative.hot_reload import FileWatcher
|
|
@@ -33,7 +47,7 @@ import os
|
|
|
33
47
|
import sys
|
|
34
48
|
import threading
|
|
35
49
|
import time
|
|
36
|
-
from typing import Any, Callable, Dict, List, Optional, Sequence
|
|
50
|
+
from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Set
|
|
37
51
|
|
|
38
52
|
DEV_ROOT_DIR = "pythonnative_dev"
|
|
39
53
|
"""Name of the writable on-device directory that shadows bundled app code."""
|
|
@@ -46,7 +60,7 @@ def configure_dev_environment(writable_root: str) -> str:
|
|
|
46
60
|
"""Create and prioritize the writable hot-reload source overlay.
|
|
47
61
|
|
|
48
62
|
The returned directory is inserted at the front of `sys.path`, so a
|
|
49
|
-
pushed `app/
|
|
63
|
+
pushed `app/main.py` shadows the copy bundled into the native
|
|
50
64
|
application. Templates call this before importing user code.
|
|
51
65
|
|
|
52
66
|
Args:
|
|
@@ -182,16 +196,25 @@ class ModuleReloader:
|
|
|
182
196
|
"""Reload changed Python modules on device and trigger a re-render.
|
|
183
197
|
|
|
184
198
|
Designed to be invoked from device-side glue when a hot-reload
|
|
185
|
-
push completes.
|
|
186
|
-
|
|
199
|
+
push completes. All public methods are static; the class holds a
|
|
200
|
+
single piece of process-wide state — the manifest version that
|
|
201
|
+
has most recently been applied to ``sys.modules`` — so that
|
|
202
|
+
multiple screen hosts polling the same manifest do not each
|
|
203
|
+
re-execute the user-app modules. The first host to see a new
|
|
204
|
+
version pays the ``reload_modules`` cost; subsequent hosts on the
|
|
205
|
+
same version refresh only their own reconciler tree against the
|
|
206
|
+
already-fresh modules.
|
|
187
207
|
"""
|
|
188
208
|
|
|
209
|
+
_last_reloaded_version: Optional[str] = None
|
|
210
|
+
_reload_lock = threading.Lock()
|
|
211
|
+
|
|
189
212
|
@staticmethod
|
|
190
213
|
def reload_module(module_name: str) -> bool:
|
|
191
214
|
"""Reload a single module by its dotted name.
|
|
192
215
|
|
|
193
216
|
Args:
|
|
194
|
-
module_name: Dotted module name (e.g., `"app.
|
|
217
|
+
module_name: Dotted module name (e.g., `"app.main"`).
|
|
195
218
|
|
|
196
219
|
Returns:
|
|
197
220
|
`True` if the module imported successfully from the current
|
|
@@ -240,6 +263,139 @@ class ModuleReloader:
|
|
|
240
263
|
reloaded.append(module_name)
|
|
241
264
|
return reloaded
|
|
242
265
|
|
|
266
|
+
@staticmethod
|
|
267
|
+
def reload_modules_for_version(
|
|
268
|
+
module_names: Sequence[str],
|
|
269
|
+
version: Optional[str],
|
|
270
|
+
) -> List[str]:
|
|
271
|
+
"""Reload ``module_names`` for ``version``, deduping across hosts.
|
|
272
|
+
|
|
273
|
+
Each native screen host on iOS / Android runs its own poll
|
|
274
|
+
loop and would otherwise call
|
|
275
|
+
[`reload_modules`][pythonnative.hot_reload.ModuleReloader.reload_modules]
|
|
276
|
+
independently for the same manifest version. That re-executes
|
|
277
|
+
every user-app module N times (once per host) per file change,
|
|
278
|
+
producing N different generations of the same function objects
|
|
279
|
+
in ``sys.modules`` and leaving each host's reconciler tree
|
|
280
|
+
pointing at a different generation. Beyond the wasted work,
|
|
281
|
+
the inconsistent state has been observed to crash UIKit on iOS
|
|
282
|
+
with ``CALayerInvalidGeometry`` (NaN values fed into ``setFrame_:``
|
|
283
|
+
during the interleaved renders).
|
|
284
|
+
|
|
285
|
+
This helper serializes on
|
|
286
|
+
[`_reload_lock`][pythonnative.hot_reload.ModuleReloader] and uses
|
|
287
|
+
[`_last_reloaded_version`][pythonnative.hot_reload.ModuleReloader]
|
|
288
|
+
to ensure only the *first* host to see a given ``version``
|
|
289
|
+
actually re-executes the modules. Subsequent hosts on the same
|
|
290
|
+
version get back the already-fresh entries from ``sys.modules``
|
|
291
|
+
so their own
|
|
292
|
+
[`refresh_in_place`][pythonnative.hot_reload.ModuleReloader.refresh_in_place]
|
|
293
|
+
pass can still rewrite their tree against the same generation.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
module_names: Dotted module names to reload.
|
|
297
|
+
version: Manifest version this reload is processing. When
|
|
298
|
+
``None`` (e.g. tests calling reload directly) the call
|
|
299
|
+
falls back to the unconditional
|
|
300
|
+
[`reload_modules`][pythonnative.hot_reload.ModuleReloader.reload_modules]
|
|
301
|
+
behavior.
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
The list of module names that are currently fresh in
|
|
305
|
+
``sys.modules`` — either freshly reloaded by this call, or
|
|
306
|
+
already reloaded by an earlier host for the same version.
|
|
307
|
+
"""
|
|
308
|
+
with ModuleReloader._reload_lock:
|
|
309
|
+
if version is not None and version == ModuleReloader._last_reloaded_version:
|
|
310
|
+
return [name for name in module_names if name in sys.modules]
|
|
311
|
+
reloaded = ModuleReloader.reload_modules(module_names)
|
|
312
|
+
if reloaded and version is not None:
|
|
313
|
+
ModuleReloader._last_reloaded_version = version
|
|
314
|
+
return reloaded
|
|
315
|
+
|
|
316
|
+
@staticmethod
|
|
317
|
+
def expand_reload_targets(changed_modules: Sequence[str], component_path: str) -> List[str]:
|
|
318
|
+
"""Expand a manifest of changed modules into the full reload order.
|
|
319
|
+
|
|
320
|
+
When a user edits ``app/screens/home.py``, only that file is in
|
|
321
|
+
the manifest. But the entry-point module ``app.main`` has
|
|
322
|
+
bindings like ``from app.screens.home import HomeScreen`` that
|
|
323
|
+
need to be re-evaluated against the freshly-loaded
|
|
324
|
+
``app.screens.home``; likewise other user-app modules may carry
|
|
325
|
+
transitive bindings (e.g. through a shared ``app/theme.py``)
|
|
326
|
+
that go stale if only the changed file is reloaded.
|
|
327
|
+
|
|
328
|
+
This helper computes the full ordered reload list:
|
|
329
|
+
|
|
330
|
+
1. Explicitly changed modules first (in the order given), so
|
|
331
|
+
their fresh source replaces the cached version in
|
|
332
|
+
``sys.modules`` before any dependent modules re-execute.
|
|
333
|
+
2. All other currently-imported modules under the entry-point's
|
|
334
|
+
top-level package, deepest first. The depth heuristic biases
|
|
335
|
+
toward leaves so re-executing a screen file picks up the
|
|
336
|
+
newest shared utilities before the file that imports it does.
|
|
337
|
+
3. The entry-point module itself, last, so its
|
|
338
|
+
``from ... import`` bindings rebind against everything that
|
|
339
|
+
was refreshed in steps 1 and 2.
|
|
340
|
+
|
|
341
|
+
Modules outside the entry-point's top-level package
|
|
342
|
+
(``pythonnative.*``, stdlib, third-party) are never included;
|
|
343
|
+
framework code is not reloaded.
|
|
344
|
+
|
|
345
|
+
Args:
|
|
346
|
+
changed_modules: Modules reported as changed by the host
|
|
347
|
+
file-watcher (already in dotted form).
|
|
348
|
+
component_path: The host's entry-point identifier, either a
|
|
349
|
+
module path (``"app.main"``) or a dotted attribute path
|
|
350
|
+
(``"app.main.RootScreen"``).
|
|
351
|
+
|
|
352
|
+
Returns:
|
|
353
|
+
The ordered list of modules to feed to
|
|
354
|
+
[`reload_modules`][pythonnative.hot_reload.ModuleReloader.reload_modules].
|
|
355
|
+
"""
|
|
356
|
+
entry_module: Optional[str] = None
|
|
357
|
+
if component_path in sys.modules:
|
|
358
|
+
entry_module = component_path
|
|
359
|
+
elif "." in component_path:
|
|
360
|
+
parent = component_path.rsplit(".", 1)[0]
|
|
361
|
+
if parent in sys.modules:
|
|
362
|
+
entry_module = parent
|
|
363
|
+
|
|
364
|
+
app_prefix: Optional[str] = None
|
|
365
|
+
if entry_module:
|
|
366
|
+
app_prefix = entry_module.split(".")[0]
|
|
367
|
+
else:
|
|
368
|
+
for m in changed_modules:
|
|
369
|
+
if m:
|
|
370
|
+
app_prefix = m.split(".")[0]
|
|
371
|
+
break
|
|
372
|
+
|
|
373
|
+
app_modules: Set[str] = set()
|
|
374
|
+
if app_prefix:
|
|
375
|
+
for name in list(sys.modules):
|
|
376
|
+
if name == app_prefix or name.startswith(app_prefix + "."):
|
|
377
|
+
app_modules.add(name)
|
|
378
|
+
|
|
379
|
+
ordered: List[str] = []
|
|
380
|
+
seen: Set[str] = set()
|
|
381
|
+
for m in changed_modules:
|
|
382
|
+
if m and m not in seen:
|
|
383
|
+
ordered.append(m)
|
|
384
|
+
seen.add(m)
|
|
385
|
+
|
|
386
|
+
others = [m for m in app_modules if m not in seen and m != entry_module]
|
|
387
|
+
others.sort(key=lambda m: (-m.count("."), m))
|
|
388
|
+
for m in others:
|
|
389
|
+
ordered.append(m)
|
|
390
|
+
seen.add(m)
|
|
391
|
+
|
|
392
|
+
if entry_module:
|
|
393
|
+
if entry_module in seen:
|
|
394
|
+
ordered.remove(entry_module)
|
|
395
|
+
ordered.append(entry_module)
|
|
396
|
+
|
|
397
|
+
return ordered
|
|
398
|
+
|
|
243
399
|
@staticmethod
|
|
244
400
|
def file_to_module(file_path: str, base_dir: str = "") -> Optional[str]:
|
|
245
401
|
"""Convert a file path to a dotted module name.
|
|
@@ -250,7 +406,7 @@ class ModuleReloader:
|
|
|
250
406
|
If empty, `file_path` is treated as already relative.
|
|
251
407
|
|
|
252
408
|
Returns:
|
|
253
|
-
The dotted module name (e.g., `"app.
|
|
409
|
+
The dotted module name (e.g., `"app.screens.home"`), or
|
|
254
410
|
`None` for an empty path.
|
|
255
411
|
"""
|
|
256
412
|
rel = os.path.relpath(file_path, base_dir) if base_dir else file_path
|
|
@@ -273,28 +429,180 @@ class ModuleReloader:
|
|
|
273
429
|
return modules
|
|
274
430
|
|
|
275
431
|
@staticmethod
|
|
276
|
-
def
|
|
277
|
-
"""Force a
|
|
432
|
+
def reload_screen(screen_instance: Any, module_names: Optional[Sequence[str]] = None) -> None:
|
|
433
|
+
"""Force a screen re-render after a module reload.
|
|
278
434
|
|
|
279
435
|
Args:
|
|
280
|
-
|
|
436
|
+
screen_instance: A `_ScreenHost` instance (or duck-typed
|
|
281
437
|
equivalent) that exposes a `_reconciler` attribute.
|
|
282
438
|
module_names: Optional modules that changed. Reload-aware
|
|
283
|
-
|
|
439
|
+
screen hosts use this to refresh imports before re-render.
|
|
284
440
|
"""
|
|
285
|
-
reload_fn = getattr(
|
|
441
|
+
reload_fn = getattr(screen_instance, "reload", None)
|
|
286
442
|
if callable(reload_fn):
|
|
287
443
|
reload_fn(list(module_names or []))
|
|
288
444
|
return
|
|
289
445
|
|
|
290
|
-
from .
|
|
446
|
+
from .screen import _request_render
|
|
291
447
|
|
|
292
|
-
if hasattr(
|
|
293
|
-
_request_render(
|
|
448
|
+
if hasattr(screen_instance, "_reconciler") and screen_instance._reconciler is not None:
|
|
449
|
+
_request_render(screen_instance)
|
|
450
|
+
|
|
451
|
+
@staticmethod
|
|
452
|
+
def find_replacement_function(old_fn: Any) -> Optional[Any]:
|
|
453
|
+
"""Locate a function's post-reload counterpart by qualname.
|
|
454
|
+
|
|
455
|
+
Functions decorated with [`component`][pythonnative.component]
|
|
456
|
+
store the user's original function on the wrapper's
|
|
457
|
+
``__wrapped__`` attribute and forward ``__module__`` /
|
|
458
|
+
``__qualname__`` so that the reconciler's stored
|
|
459
|
+
``element.type`` (the unwrapped function) still has the
|
|
460
|
+
information needed to re-resolve after a module reload.
|
|
461
|
+
|
|
462
|
+
Args:
|
|
463
|
+
old_fn: The function captured in an
|
|
464
|
+
[`Element`][pythonnative.Element]'s ``type`` slot.
|
|
465
|
+
|
|
466
|
+
Returns:
|
|
467
|
+
The reloaded module's matching function, ``None`` if no
|
|
468
|
+
replacement was found, or the original function itself
|
|
469
|
+
when the module has not been reloaded (so callers can
|
|
470
|
+
skip the swap).
|
|
471
|
+
"""
|
|
472
|
+
module_name = getattr(old_fn, "__module__", None)
|
|
473
|
+
qualname = getattr(old_fn, "__qualname__", None) or getattr(old_fn, "__name__", None)
|
|
474
|
+
if not module_name or not qualname:
|
|
475
|
+
return None
|
|
476
|
+
if "<locals>" in qualname:
|
|
477
|
+
return None # nested functions are not addressable from the module surface
|
|
478
|
+
|
|
479
|
+
module = sys.modules.get(module_name)
|
|
480
|
+
if module is None:
|
|
481
|
+
return None
|
|
482
|
+
|
|
483
|
+
obj: Any = module
|
|
484
|
+
for part in qualname.split("."):
|
|
485
|
+
obj = getattr(obj, part, None)
|
|
486
|
+
if obj is None:
|
|
487
|
+
return None
|
|
488
|
+
|
|
489
|
+
if getattr(obj, "_pn_component", False):
|
|
490
|
+
obj = getattr(obj, "__wrapped__", obj)
|
|
491
|
+
|
|
492
|
+
if obj is old_fn:
|
|
493
|
+
return None
|
|
494
|
+
return obj
|
|
495
|
+
|
|
496
|
+
@staticmethod
|
|
497
|
+
def build_replacement_map(reconciler: Any, reloaded_modules: Iterable[str]) -> Dict[Any, Any]:
|
|
498
|
+
"""Compute ``{old_function: new_function}`` for one tree.
|
|
499
|
+
|
|
500
|
+
The reconciler's stored tree references the *pre-reload*
|
|
501
|
+
component functions through ``VNode.element.type``. This
|
|
502
|
+
method walks the tree, collects every callable type whose
|
|
503
|
+
``__module__`` was just reloaded, and asks
|
|
504
|
+
[`find_replacement_function`][pythonnative.hot_reload.ModuleReloader.find_replacement_function]
|
|
505
|
+
for its successor.
|
|
506
|
+
|
|
507
|
+
Args:
|
|
508
|
+
reconciler: The reconciler whose
|
|
509
|
+
``_tree`` should be inspected.
|
|
510
|
+
reloaded_modules: Set of module names that were just
|
|
511
|
+
reloaded (only callables from these modules are
|
|
512
|
+
considered).
|
|
513
|
+
|
|
514
|
+
Returns:
|
|
515
|
+
A mapping suitable for passing to
|
|
516
|
+
[`swap_components_in_tree`][pythonnative.hot_reload.ModuleReloader.swap_components_in_tree].
|
|
517
|
+
"""
|
|
518
|
+
modules: Set[str] = {m for m in reloaded_modules if m}
|
|
519
|
+
if not modules or reconciler is None or getattr(reconciler, "_tree", None) is None:
|
|
520
|
+
return {}
|
|
521
|
+
|
|
522
|
+
seen: Set[int] = set()
|
|
523
|
+
mapping: Dict[Any, Any] = {}
|
|
524
|
+
|
|
525
|
+
def visit(vnode: Any) -> None:
|
|
526
|
+
if vnode is None:
|
|
527
|
+
return
|
|
528
|
+
elem = getattr(vnode, "element", None)
|
|
529
|
+
if elem is not None and callable(elem.type):
|
|
530
|
+
fn = elem.type
|
|
531
|
+
fn_id = id(fn)
|
|
532
|
+
if fn_id not in seen:
|
|
533
|
+
seen.add(fn_id)
|
|
534
|
+
if getattr(fn, "__module__", None) in modules:
|
|
535
|
+
replacement = ModuleReloader.find_replacement_function(fn)
|
|
536
|
+
if replacement is not None and replacement is not fn:
|
|
537
|
+
mapping[fn] = replacement
|
|
538
|
+
for child in getattr(vnode, "children", []) or []:
|
|
539
|
+
visit(child)
|
|
540
|
+
|
|
541
|
+
visit(reconciler._tree)
|
|
542
|
+
return mapping
|
|
543
|
+
|
|
544
|
+
@staticmethod
|
|
545
|
+
def swap_components_in_tree(reconciler: Any, replacement_map: Dict[Any, Any]) -> int:
|
|
546
|
+
"""Apply a ``{old: new}`` map to every node in the reconciler tree.
|
|
547
|
+
|
|
548
|
+
Mutates ``vnode.element.type`` directly so the NEXT diff sees
|
|
549
|
+
identical types and reuses VNodes (preserving hook state).
|
|
550
|
+
Pending ``Element`` trees stored on ``vnode._rendered`` are
|
|
551
|
+
rewritten too because the reconciler reads from them when
|
|
552
|
+
comparing keys across renders.
|
|
553
|
+
|
|
554
|
+
Returns:
|
|
555
|
+
The number of element type references that were rewritten.
|
|
556
|
+
"""
|
|
557
|
+
if not replacement_map or reconciler is None or getattr(reconciler, "_tree", None) is None:
|
|
558
|
+
return 0
|
|
559
|
+
|
|
560
|
+
rewrites = 0
|
|
561
|
+
|
|
562
|
+
def rewrite_element_tree(element: Any) -> None:
|
|
563
|
+
nonlocal rewrites
|
|
564
|
+
if element is None:
|
|
565
|
+
return
|
|
566
|
+
new_type = replacement_map.get(element.type)
|
|
567
|
+
if new_type is not None:
|
|
568
|
+
element.type = new_type
|
|
569
|
+
rewrites += 1
|
|
570
|
+
for child in element.children or []:
|
|
571
|
+
rewrite_element_tree(child)
|
|
572
|
+
|
|
573
|
+
def visit(vnode: Any) -> None:
|
|
574
|
+
if vnode is None:
|
|
575
|
+
return
|
|
576
|
+
if getattr(vnode, "element", None) is not None:
|
|
577
|
+
rewrite_element_tree(vnode.element)
|
|
578
|
+
rendered = getattr(vnode, "_rendered", None)
|
|
579
|
+
if rendered is not None:
|
|
580
|
+
rewrite_element_tree(rendered)
|
|
581
|
+
for child in getattr(vnode, "children", []) or []:
|
|
582
|
+
visit(child)
|
|
583
|
+
|
|
584
|
+
visit(reconciler._tree)
|
|
585
|
+
return rewrites
|
|
586
|
+
|
|
587
|
+
@staticmethod
|
|
588
|
+
def refresh_in_place(reconciler: Any, reloaded_modules: Iterable[str]) -> bool:
|
|
589
|
+
"""Try a state-preserving Fast Refresh for one reconciler.
|
|
590
|
+
|
|
591
|
+
Returns:
|
|
592
|
+
``True`` if any component function was replaced (callers
|
|
593
|
+
should then trigger a re-render). ``False`` means the
|
|
594
|
+
tree already references the latest functions (or has no
|
|
595
|
+
nodes from the reloaded modules at all).
|
|
596
|
+
"""
|
|
597
|
+
replacement_map = ModuleReloader.build_replacement_map(reconciler, reloaded_modules)
|
|
598
|
+
if not replacement_map:
|
|
599
|
+
return False
|
|
600
|
+
rewrites = ModuleReloader.swap_components_in_tree(reconciler, replacement_map)
|
|
601
|
+
return rewrites > 0
|
|
294
602
|
|
|
295
603
|
@staticmethod
|
|
296
604
|
def reload_from_manifest(
|
|
297
|
-
|
|
605
|
+
screen_instance: Any,
|
|
298
606
|
manifest_path: str,
|
|
299
607
|
*,
|
|
300
608
|
last_version: Optional[str] = None,
|
|
@@ -302,9 +610,9 @@ class ModuleReloader:
|
|
|
302
610
|
"""Apply a reload manifest if it is newer than `last_version`.
|
|
303
611
|
|
|
304
612
|
Args:
|
|
305
|
-
|
|
613
|
+
screen_instance: Screen host to refresh.
|
|
306
614
|
manifest_path: JSON manifest written by the CLI.
|
|
307
|
-
last_version: Version already applied by this
|
|
615
|
+
last_version: Version already applied by this screen host.
|
|
308
616
|
|
|
309
617
|
Returns:
|
|
310
618
|
The manifest version after applying, or `last_version` when
|
|
@@ -325,5 +633,13 @@ class ModuleReloader:
|
|
|
325
633
|
files = manifest.get("files", [])
|
|
326
634
|
modules = ModuleReloader.modules_from_files(files if isinstance(files, list) else [])
|
|
327
635
|
|
|
328
|
-
|
|
636
|
+
# Stash the version on the host so `_reload_host` can dedupe
|
|
637
|
+
# `reload_modules` across multiple hosts polling the same
|
|
638
|
+
# manifest. See `reload_modules_for_version`.
|
|
639
|
+
previous_pending = getattr(screen_instance, "_hot_reload_pending_version", None)
|
|
640
|
+
try:
|
|
641
|
+
screen_instance._hot_reload_pending_version = version
|
|
642
|
+
ModuleReloader.reload_screen(screen_instance, [str(module) for module in modules])
|
|
643
|
+
finally:
|
|
644
|
+
screen_instance._hot_reload_pending_version = previous_pending
|
|
329
645
|
return version
|