pythonnative 0.18.0__py3-none-any.whl → 0.20.0__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 +1 -1
- pythonnative/cli/pn.py +107 -1
- pythonnative/hooks.py +30 -6
- pythonnative/native_views/__init__.py +18 -5
- pythonnative/native_views/desktop.py +1489 -0
- pythonnative/platform.py +17 -8
- pythonnative/preview.py +471 -0
- pythonnative/reconciler.py +285 -3
- pythonnative/runtime.py +26 -1
- pythonnative/screen.py +207 -31
- pythonnative/utils.py +38 -2
- {pythonnative-0.18.0.dist-info → pythonnative-0.20.0.dist-info}/METADATA +3 -2
- {pythonnative-0.18.0.dist-info → pythonnative-0.20.0.dist-info}/RECORD +17 -15
- {pythonnative-0.18.0.dist-info → pythonnative-0.20.0.dist-info}/WHEEL +0 -0
- {pythonnative-0.18.0.dist-info → pythonnative-0.20.0.dist-info}/entry_points.txt +0 -0
- {pythonnative-0.18.0.dist-info → pythonnative-0.20.0.dist-info}/licenses/LICENSE +0 -0
- {pythonnative-0.18.0.dist-info → pythonnative-0.20.0.dist-info}/top_level.txt +0 -0
pythonnative/__init__.py
CHANGED
pythonnative/cli/pn.py
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
"""`pn` CLI: scaffold, run, and clean PythonNative projects.
|
|
2
2
|
|
|
3
3
|
The console script `pn` (declared in `pyproject.toml` under
|
|
4
|
-
`[project.scripts]`) dispatches to one of
|
|
4
|
+
`[project.scripts]`) dispatches to one of four subcommands:
|
|
5
5
|
|
|
6
6
|
- `pn init [name]`: scaffold a new project in the current directory.
|
|
7
|
+
- `pn preview [component]`: render the app in a desktop (Tkinter)
|
|
8
|
+
window with instant Fast Refresh — the fast inner dev loop, no
|
|
9
|
+
device or simulator required.
|
|
7
10
|
- `pn run android|ios`: stage code into a native template, build it,
|
|
8
11
|
install it, and stream logs back to the terminal.
|
|
9
12
|
- `pn clean`: remove the local `build/` directory.
|
|
@@ -1119,6 +1122,90 @@ def _run_hot_reload(platform: str, project_dir: str, build_dir: str, show_logs:
|
|
|
1119
1122
|
print("\n[hot-reload] Stopped.")
|
|
1120
1123
|
|
|
1121
1124
|
|
|
1125
|
+
def _entrypoint_to_module(entry_point: str) -> str:
|
|
1126
|
+
"""Convert a config ``entryPoint`` path into an importable module path.
|
|
1127
|
+
|
|
1128
|
+
``"app/main.py"`` → ``"app.main"``. Returns ``"app.main"`` for
|
|
1129
|
+
empty / unusable input so ``pn preview`` always has a sane default.
|
|
1130
|
+
"""
|
|
1131
|
+
normalized = entry_point.strip().replace("\\", "/")
|
|
1132
|
+
if normalized.endswith(".py"):
|
|
1133
|
+
normalized = normalized[:-3]
|
|
1134
|
+
normalized = normalized.strip("/").replace("/", ".")
|
|
1135
|
+
return normalized or "app.main"
|
|
1136
|
+
|
|
1137
|
+
|
|
1138
|
+
def preview_project(args: argparse.Namespace) -> None:
|
|
1139
|
+
"""Render the project in a desktop preview window (Tkinter).
|
|
1140
|
+
|
|
1141
|
+
Sets ``PN_PLATFORM=desktop`` (so PythonNative selects the Tkinter
|
|
1142
|
+
backend) and hands off to ``pythonnative.preview.run_preview``,
|
|
1143
|
+
which opens a window, mounts the app, and Fast Refreshes on every
|
|
1144
|
+
file save until the window is closed.
|
|
1145
|
+
|
|
1146
|
+
Args:
|
|
1147
|
+
args: Parsed argparse namespace. Recognized attributes:
|
|
1148
|
+
|
|
1149
|
+
- `component` (`str`, optional): Module path like
|
|
1150
|
+
``"app.main"`` (its ``App`` is used) or a dotted
|
|
1151
|
+
``module.Component`` path. Defaults to the project's
|
|
1152
|
+
configured ``entryPoint``.
|
|
1153
|
+
- `width` / `height` (`int`): Initial window size in points.
|
|
1154
|
+
- `title` (`str`): Window title.
|
|
1155
|
+
- `no_hot_reload` (`bool`): Disable file watching.
|
|
1156
|
+
"""
|
|
1157
|
+
# The desktop backend is selected at *import time* from the
|
|
1158
|
+
# ``PN_PLATFORM`` environment variable (see ``pythonnative.utils`` and
|
|
1159
|
+
# the host selection in ``pythonnative.screen``). Because the ``pn``
|
|
1160
|
+
# console entry point lives inside the ``pythonnative`` package,
|
|
1161
|
+
# importing it already loaded the package under the default,
|
|
1162
|
+
# non-desktop platform before this handler ever runs. Re-exec a fresh
|
|
1163
|
+
# interpreter with the variable set so every module binds to the
|
|
1164
|
+
# Tkinter backend; the re-execed child sees ``PN_PLATFORM=desktop`` and
|
|
1165
|
+
# skips this branch, so there is no exec loop.
|
|
1166
|
+
if os.environ.get("PN_PLATFORM") != "desktop":
|
|
1167
|
+
try:
|
|
1168
|
+
completed = subprocess.run(
|
|
1169
|
+
[sys.executable, "-m", "pythonnative.cli.pn", *sys.argv[1:]],
|
|
1170
|
+
env={**os.environ, "PN_PLATFORM": "desktop"},
|
|
1171
|
+
)
|
|
1172
|
+
except KeyboardInterrupt:
|
|
1173
|
+
sys.exit(130)
|
|
1174
|
+
sys.exit(completed.returncode)
|
|
1175
|
+
|
|
1176
|
+
project_dir = os.getcwd()
|
|
1177
|
+
component: Optional[str] = getattr(args, "component", None)
|
|
1178
|
+
if not component:
|
|
1179
|
+
config = _read_project_config()
|
|
1180
|
+
component = _entrypoint_to_module(config.get("entryPoint", "app/main.py"))
|
|
1181
|
+
|
|
1182
|
+
try:
|
|
1183
|
+
from pythonnative.preview import run_preview
|
|
1184
|
+
except Exception as exc: # pragma: no cover - environment dependent
|
|
1185
|
+
print(f"Error: could not start the desktop preview: {exc}")
|
|
1186
|
+
print(
|
|
1187
|
+
"The desktop preview needs Tkinter (Python's standard GUI toolkit).\n"
|
|
1188
|
+
"On macOS: brew install python-tk\n"
|
|
1189
|
+
"On Debian/Ubuntu: sudo apt-get install python3-tk\n"
|
|
1190
|
+
"On Windows: reinstall Python with the 'tcl/tk' option checked."
|
|
1191
|
+
)
|
|
1192
|
+
sys.exit(1)
|
|
1193
|
+
|
|
1194
|
+
print(f"Starting PythonNative preview for {component} (Ctrl+C or close the window to stop).")
|
|
1195
|
+
try:
|
|
1196
|
+
run_preview(
|
|
1197
|
+
component,
|
|
1198
|
+
project_root=project_dir,
|
|
1199
|
+
width=getattr(args, "width", 390),
|
|
1200
|
+
height=getattr(args, "height", 844),
|
|
1201
|
+
title=getattr(args, "title", "PythonNative Preview"),
|
|
1202
|
+
hot_reload=not getattr(args, "no_hot_reload", False),
|
|
1203
|
+
)
|
|
1204
|
+
except RuntimeError as exc:
|
|
1205
|
+
print(f"Error: {exc}")
|
|
1206
|
+
sys.exit(1)
|
|
1207
|
+
|
|
1208
|
+
|
|
1122
1209
|
def clean_project(args: argparse.Namespace) -> None:
|
|
1123
1210
|
"""Remove the local `build/` directory.
|
|
1124
1211
|
|
|
@@ -1152,6 +1239,25 @@ def main() -> None:
|
|
|
1152
1239
|
parser_init.add_argument("--force", action="store_true", help="Overwrite existing files if present")
|
|
1153
1240
|
parser_init.set_defaults(func=init_project)
|
|
1154
1241
|
|
|
1242
|
+
# Create a new command 'preview' that calls preview_project
|
|
1243
|
+
parser_preview = subparsers.add_parser("preview")
|
|
1244
|
+
parser_preview.add_argument(
|
|
1245
|
+
"component",
|
|
1246
|
+
nargs="?",
|
|
1247
|
+
help="Module path (e.g. app.main) or dotted component path; defaults to the project entry point",
|
|
1248
|
+
)
|
|
1249
|
+
parser_preview.add_argument("--width", type=int, default=390, help="Initial window width in points (default: 390)")
|
|
1250
|
+
parser_preview.add_argument(
|
|
1251
|
+
"--height", type=int, default=844, help="Initial window height in points (default: 844)"
|
|
1252
|
+
)
|
|
1253
|
+
parser_preview.add_argument("--title", default="PythonNative Preview", help="Preview window title")
|
|
1254
|
+
parser_preview.add_argument(
|
|
1255
|
+
"--no-hot-reload",
|
|
1256
|
+
action="store_true",
|
|
1257
|
+
help="Disable file watching / Fast Refresh",
|
|
1258
|
+
)
|
|
1259
|
+
parser_preview.set_defaults(func=preview_project)
|
|
1260
|
+
|
|
1155
1261
|
# Create a new command 'run' that calls run_project
|
|
1156
1262
|
parser_run = subparsers.add_parser("run")
|
|
1157
1263
|
parser_run.add_argument("platform", choices=["android", "ios"])
|
pythonnative/hooks.py
CHANGED
|
@@ -76,6 +76,8 @@ class HookState:
|
|
|
76
76
|
"_trigger_render",
|
|
77
77
|
"_pending_effects",
|
|
78
78
|
"_dirty",
|
|
79
|
+
"_vnode",
|
|
80
|
+
"_reconciler",
|
|
79
81
|
)
|
|
80
82
|
|
|
81
83
|
def __init__(self) -> None:
|
|
@@ -95,6 +97,13 @@ class HookState:
|
|
|
95
97
|
# knows that a memoized component still needs to re-render even
|
|
96
98
|
# when its props didn't change.
|
|
97
99
|
self._dirty: bool = False
|
|
100
|
+
# Back-references wired by the reconciler so a state setter can
|
|
101
|
+
# mark *its own* component subtree dirty for a local re-render
|
|
102
|
+
# (instead of forcing a whole-app re-render from the root). Both
|
|
103
|
+
# stay ``None`` until the component is mounted, and are cleared
|
|
104
|
+
# again when it unmounts.
|
|
105
|
+
self._vnode: Any = None
|
|
106
|
+
self._reconciler: Any = None
|
|
98
107
|
|
|
99
108
|
def reset_index(self) -> None:
|
|
100
109
|
"""Reset every per-hook cursor to ``0``.
|
|
@@ -182,6 +191,25 @@ def _schedule_trigger(trigger: Callable[[], None]) -> None:
|
|
|
182
191
|
trigger()
|
|
183
192
|
|
|
184
193
|
|
|
194
|
+
def _notify_state_changed(ctx: "HookState") -> None:
|
|
195
|
+
"""Mark ``ctx``'s component dirty and schedule a render after a state change.
|
|
196
|
+
|
|
197
|
+
Enqueuing the owning ``VNode`` in the reconciler's dirty set is what
|
|
198
|
+
makes the subsequent render *local*: the screen host's trigger calls
|
|
199
|
+
``flush_dirty``, which re-renders only the components marked here
|
|
200
|
+
rather than the whole app. The dirty mark is eager (so several
|
|
201
|
+
setters coalesce), while the render trigger respects
|
|
202
|
+
[`batch_updates`][pythonnative.batch_updates].
|
|
203
|
+
"""
|
|
204
|
+
ctx._dirty = True
|
|
205
|
+
reconciler = ctx._reconciler
|
|
206
|
+
vnode = ctx._vnode
|
|
207
|
+
if reconciler is not None and vnode is not None:
|
|
208
|
+
reconciler.mark_dirty(vnode)
|
|
209
|
+
if ctx._trigger_render:
|
|
210
|
+
_schedule_trigger(ctx._trigger_render)
|
|
211
|
+
|
|
212
|
+
|
|
185
213
|
@contextmanager
|
|
186
214
|
def batch_updates() -> Generator[None, None, None]:
|
|
187
215
|
"""Coalesce multiple state updates into a single re-render.
|
|
@@ -272,9 +300,7 @@ def use_state(initial: Any = None) -> Tuple[Any, Callable]:
|
|
|
272
300
|
new_value = new_value(ctx.states[idx])
|
|
273
301
|
if ctx.states[idx] is not new_value and ctx.states[idx] != new_value:
|
|
274
302
|
ctx.states[idx] = new_value
|
|
275
|
-
ctx
|
|
276
|
-
if ctx._trigger_render:
|
|
277
|
-
_schedule_trigger(ctx._trigger_render)
|
|
303
|
+
_notify_state_changed(ctx)
|
|
278
304
|
|
|
279
305
|
return current, setter
|
|
280
306
|
|
|
@@ -339,9 +365,7 @@ def use_reducer(reducer: Callable[[Any, Any], Any], initial_state: Any) -> Tuple
|
|
|
339
365
|
new_state = reducer(ctx.states[idx], action)
|
|
340
366
|
if ctx.states[idx] is not new_state and ctx.states[idx] != new_state:
|
|
341
367
|
ctx.states[idx] = new_state
|
|
342
|
-
ctx
|
|
343
|
-
if ctx._trigger_render:
|
|
344
|
-
_schedule_trigger(ctx._trigger_render)
|
|
368
|
+
_notify_state_changed(ctx)
|
|
345
369
|
|
|
346
370
|
return current, dispatch
|
|
347
371
|
|
|
@@ -238,18 +238,31 @@ _registry: Optional[NativeViewRegistry] = None
|
|
|
238
238
|
|
|
239
239
|
|
|
240
240
|
def _active_platform_name() -> str:
|
|
241
|
-
"""Return ``"android"`` or ``"ios"`` for the active runtime."""
|
|
242
|
-
from ..utils import IS_ANDROID
|
|
241
|
+
"""Return ``"android"``, ``"desktop"``, or ``"ios"`` for the active runtime."""
|
|
242
|
+
from ..utils import IS_ANDROID, IS_DESKTOP
|
|
243
243
|
|
|
244
|
-
|
|
244
|
+
if IS_ANDROID:
|
|
245
|
+
return "android"
|
|
246
|
+
if IS_DESKTOP:
|
|
247
|
+
return "desktop"
|
|
248
|
+
return "ios"
|
|
245
249
|
|
|
246
250
|
|
|
247
251
|
def _register_builtin_handlers(registry: NativeViewRegistry) -> None:
|
|
248
|
-
"""Register every built-in handler for the active platform.
|
|
249
|
-
|
|
252
|
+
"""Register every built-in handler for the active platform.
|
|
253
|
+
|
|
254
|
+
The desktop (Tkinter) backend is selected when ``pn preview`` sets
|
|
255
|
+
``PN_PLATFORM=desktop``; otherwise this picks Android (on device) or
|
|
256
|
+
iOS (the default off-device path, exercised by the iOS templates and
|
|
257
|
+
by tests that install the ``[ios]`` extra). Off-device unit tests
|
|
258
|
+
typically inject a mock registry via ``set_registry`` instead.
|
|
259
|
+
"""
|
|
260
|
+
from ..utils import IS_ANDROID, IS_DESKTOP
|
|
250
261
|
|
|
251
262
|
if IS_ANDROID:
|
|
252
263
|
from .android import register_handlers
|
|
264
|
+
elif IS_DESKTOP:
|
|
265
|
+
from .desktop import register_handlers
|
|
253
266
|
else:
|
|
254
267
|
from .ios import register_handlers
|
|
255
268
|
register_handlers(registry)
|