pythonnative 0.18.0__py3-none-any.whl → 0.19.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/platform.py CHANGED
@@ -27,7 +27,7 @@ import os
27
27
  import sys
28
28
  from typing import Any, Dict, Optional
29
29
 
30
- from .utils import IS_ANDROID, IS_IOS
30
+ from .utils import IS_ANDROID, IS_DESKTOP, IS_IOS
31
31
 
32
32
 
33
33
  def _detect_os() -> str:
@@ -35,6 +35,8 @@ def _detect_os() -> str:
35
35
  return "android"
36
36
  if IS_IOS:
37
37
  return "ios"
38
+ if IS_DESKTOP:
39
+ return "desktop"
38
40
  return "test"
39
41
 
40
42
 
@@ -72,12 +74,13 @@ class Platform:
72
74
  """Platform-aware constants and the ``select`` dispatcher.
73
75
 
74
76
  All attributes are read at import time. ``OS`` is one of
75
- ``"ios"``, ``"android"``, or ``"test"`` (the latter when running
76
- off-device, e.g., in unit tests).
77
+ ``"ios"``, ``"android"``, ``"desktop"`` (the Tkinter preview
78
+ backend), or ``"test"`` (when running off-device, e.g., in unit
79
+ tests).
77
80
  """
78
81
 
79
82
  OS: str = _detect_os()
80
- """``"ios"``, ``"android"``, or ``"test"``."""
83
+ """``"ios"``, ``"android"``, ``"desktop"``, or ``"test"``."""
81
84
 
82
85
  Version: str = _detect_version()
83
86
  """Best-effort OS version string (``"17.4"``, ``"14"``, ``"python-3.11"``)."""
@@ -88,6 +91,9 @@ class Platform:
88
91
  is_android: bool = IS_ANDROID
89
92
  """``True`` when running inside an Android process."""
90
93
 
94
+ is_desktop: bool = IS_DESKTOP
95
+ """``True`` when running the desktop (Tkinter) preview backend."""
96
+
91
97
  is_test: bool = OS == "test"
92
98
  """``True`` when running off-device (no native runtime)."""
93
99
 
@@ -96,13 +102,14 @@ class Platform:
96
102
  """Pick the value matching the current platform.
97
103
 
98
104
  Looks up ``spec[Platform.OS]``, then falls back to
99
- ``spec["native"]`` (matches both iOS and Android), then to
100
- ``spec["default"]``, then to the explicit ``default`` argument.
105
+ ``spec["native"]`` (matches iOS and Android *not* desktop,
106
+ which is a development surface), then to ``spec["default"]``,
107
+ then to the explicit ``default`` argument.
101
108
 
102
109
  Args:
103
110
  spec: Mapping from platform name to value. Recognized keys:
104
- ``"ios"``, ``"android"``, ``"test"``, ``"native"``,
105
- ``"default"``.
111
+ ``"ios"``, ``"android"``, ``"desktop"``, ``"test"``,
112
+ ``"native"``, ``"default"``.
106
113
  default: Value returned when ``spec`` has no matching key
107
114
  and no ``"default"`` entry.
108
115
 
@@ -144,9 +151,11 @@ def _set_platform_for_test(name: Optional[str]) -> None:
144
151
  Platform.OS = _detect_os()
145
152
  Platform.is_ios = IS_IOS
146
153
  Platform.is_android = IS_ANDROID
154
+ Platform.is_desktop = IS_DESKTOP
147
155
  Platform.is_test = Platform.OS == "test"
148
156
  return
149
157
  Platform.OS = name
150
158
  Platform.is_ios = name == "ios"
151
159
  Platform.is_android = name == "android"
160
+ Platform.is_desktop = name == "desktop"
152
161
  Platform.is_test = name == "test"
@@ -0,0 +1,471 @@
1
+ """Desktop preview runtime — the engine behind ``pn preview``.
2
+
3
+ ``pn preview`` renders a PythonNative app in a real OS window using the
4
+ Tkinter backend ([`pythonnative.native_views.desktop`][pythonnative.native_views.desktop]),
5
+ with **instant Fast Refresh** on every file save. It exists to make the
6
+ inner development loop fast: see your UI and iterate in seconds without
7
+ booting a simulator or deploying to a device.
8
+
9
+ Architecture
10
+ ------------
11
+ - A single Tk window holds one *stage* frame. Every screen on the
12
+ navigation stack gets its own child container inside the stage; the
13
+ desktop view handlers create widgets under the active container.
14
+ - [`DesktopApp`][pythonnative.preview.DesktopApp] owns the navigation
15
+ stack of [`screen`][pythonnative.screen] hosts and the push/pop/reset
16
+ primitives the declarative navigators call through ``host._push`` /
17
+ ``host._pop``.
18
+ - The Tk event loop runs on the main thread. A lightweight poll
19
+ (`~60 Hz`) drains (a) UI work marshaled from the asyncio runtime
20
+ thread via [`runtime.call_on_main_thread`][pythonnative.runtime.call_on_main_thread],
21
+ (b) re-renders requested off-thread, and (c) file-change reloads.
22
+ - A background [`FileWatcher`][pythonnative.hot_reload.FileWatcher]
23
+ detects ``.py`` edits and enqueues a reload onto the main thread.
24
+
25
+ This module imports ``tkinter`` and is only imported by the
26
+ ``pn preview`` command, which sets ``PN_PLATFORM=desktop`` first.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import os
32
+ import queue
33
+ import sys
34
+ import tkinter as tk
35
+ import traceback
36
+ from typing import Any, Callable, List, Optional, Tuple
37
+
38
+ # iPhone-ish logical-point window so layouts that assume a phone-sized
39
+ # viewport look right out of the box; resizable at runtime.
40
+ DEFAULT_WIDTH = 390
41
+ DEFAULT_HEIGHT = 844
42
+ _POLL_INTERVAL_MS = 16
43
+ _WATCH_INTERVAL_S = 0.4
44
+
45
+
46
+ class DesktopApp:
47
+ """Navigation-stack controller for the desktop preview window.
48
+
49
+ One instance backs a preview session. It is handed to each
50
+ [`screen`][pythonnative.screen] host as the ``native_instance`` so
51
+ hosts can drive navigation (``push_screen`` / ``pop_screen`` /
52
+ ``reset_to_root``), report the viewport size, and set the window
53
+ title — mirroring the role a ``UIViewController`` / ``Activity``
54
+ plays on device.
55
+ """
56
+
57
+ def __init__(self, root: Any, stage: Any, width: float, height: float) -> None:
58
+ self._root = root
59
+ self._stage = stage
60
+ self._width = float(width)
61
+ self._height = float(height)
62
+ self._stack: List[Any] = []
63
+ self._error_widget: Any = None
64
+ self._mount_failed = False
65
+ self._component_path = ""
66
+
67
+ # -- queried by the screen host -----------------------------------
68
+
69
+ def viewport_size(self) -> Tuple[float, float]:
70
+ """Return the current stage size in points (host viewport)."""
71
+ return (self._width, self._height)
72
+
73
+ def set_title(self, title: str) -> None:
74
+ """Set the preview window title (called from screen options)."""
75
+ try:
76
+ self._root.title(title)
77
+ except Exception:
78
+ pass
79
+
80
+ # -- container management -----------------------------------------
81
+
82
+ def _new_container(self) -> Any:
83
+ frame = tk.Frame(self._stage, highlightthickness=0, bd=0, background="#ffffff")
84
+ frame.place(x=0, y=0, relwidth=1.0, relheight=1.0)
85
+ frame.lift()
86
+ return frame
87
+
88
+ def _show_container(self, host: Any) -> None:
89
+ container = getattr(host, "_pn_container", None)
90
+ if container is not None:
91
+ try:
92
+ container.place(in_=self._stage, x=0, y=0, relwidth=1.0, relheight=1.0)
93
+ container.lift()
94
+ except Exception:
95
+ pass
96
+
97
+ def _forget_container(self, host: Any) -> None:
98
+ container = getattr(host, "_pn_container", None)
99
+ if container is not None:
100
+ try:
101
+ container.place_forget()
102
+ except Exception:
103
+ pass
104
+
105
+ def _activate(self, host: Any) -> None:
106
+ """Make ``host`` the rendering target and reflow it to the viewport."""
107
+ from .native_views import desktop as desktop_backend
108
+
109
+ desktop_backend.set_root_container(getattr(host, "_pn_container", None))
110
+ self._show_container(host)
111
+
112
+ # -- lifecycle ----------------------------------------------------
113
+
114
+ def _make_host(self, component_path: str, args: Optional[dict] = None) -> Any:
115
+ from . import screen as screen_module
116
+ from .native_views import desktop as desktop_backend
117
+
118
+ container = self._new_container()
119
+ desktop_backend.set_root_container(container)
120
+ host = screen_module.create_screen(component_path, self)
121
+ host._pn_container = container
122
+ if args:
123
+ host.set_args(args)
124
+ return host
125
+
126
+ def mount_root(self, component_path: str) -> None:
127
+ """Mount the initial screen as the base of the navigation stack.
128
+
129
+ Import-time failures (a missing dependency, a syntax error the
130
+ developer is mid-fix on) are shown as an error overlay and flagged
131
+ so the next successful reload remounts cleanly, rather than
132
+ crashing the preview process.
133
+ """
134
+ self._component_path = component_path
135
+ self._clear_error()
136
+ try:
137
+ host = self._make_host(component_path)
138
+ except Exception:
139
+ self._mount_failed = True
140
+ self._show_error(traceback.format_exc())
141
+ return
142
+ self._stack.append(host)
143
+ try:
144
+ host.on_create()
145
+ host.on_resume()
146
+ self._mount_failed = False
147
+ except Exception:
148
+ self._mount_failed = True
149
+ self._show_error(traceback.format_exc())
150
+
151
+ def push_screen(self, component_path: str, args: Optional[dict] = None) -> None:
152
+ """Push a new screen, suspending the current one (declarative nav)."""
153
+ if self._stack:
154
+ current = self._stack[-1]
155
+ try:
156
+ current.on_pause()
157
+ except Exception:
158
+ pass
159
+ self._forget_container(current)
160
+ try:
161
+ host = self._make_host(component_path, args)
162
+ except Exception:
163
+ self._show_error(traceback.format_exc())
164
+ return
165
+ self._stack.append(host)
166
+ try:
167
+ host.on_create()
168
+ host.on_resume()
169
+ except Exception:
170
+ self._show_error(traceback.format_exc())
171
+
172
+ def pop_screen(self) -> None:
173
+ """Pop the top screen and restore the one beneath it."""
174
+ if len(self._stack) <= 1:
175
+ return
176
+ top = self._stack.pop()
177
+ self._teardown(top)
178
+ restored = self._stack[-1]
179
+ self._activate(restored)
180
+ try:
181
+ restored.on_resume()
182
+ restored.set_viewport_size(self._width, self._height)
183
+ except Exception:
184
+ pass
185
+
186
+ def reset_to_root(self) -> None:
187
+ """Pop every screen above the root (declarative ``reset`` / tab root)."""
188
+ while len(self._stack) > 1:
189
+ self._teardown(self._stack.pop())
190
+ if self._stack:
191
+ root_host = self._stack[0]
192
+ self._activate(root_host)
193
+ try:
194
+ root_host.on_resume()
195
+ root_host.set_viewport_size(self._width, self._height)
196
+ except Exception:
197
+ pass
198
+
199
+ def _teardown(self, host: Any) -> None:
200
+ try:
201
+ host.on_pause()
202
+ except Exception:
203
+ pass
204
+ try:
205
+ host.on_destroy()
206
+ except Exception:
207
+ pass
208
+ reconciler = getattr(host, "_reconciler", None)
209
+ tree = getattr(reconciler, "_tree", None) if reconciler is not None else None
210
+ if reconciler is not None and tree is not None:
211
+ try:
212
+ reconciler._destroy_tree(tree)
213
+ except Exception:
214
+ pass
215
+ container = getattr(host, "_pn_container", None)
216
+ if container is not None:
217
+ try:
218
+ container.destroy()
219
+ except Exception:
220
+ pass
221
+
222
+ # -- viewport / resize --------------------------------------------
223
+
224
+ def resize(self, width: float, height: float) -> None:
225
+ """Propagate a window resize to the active screen's reconciler."""
226
+ if width <= 0 or height <= 0:
227
+ return
228
+ self._width = float(width)
229
+ self._height = float(height)
230
+ host = self.active_host()
231
+ if host is not None:
232
+ try:
233
+ host.set_viewport_size(self._width, self._height)
234
+ except Exception:
235
+ pass
236
+
237
+ def active_host(self) -> Any:
238
+ """Return the top-of-stack host, or ``None`` if nothing is mounted."""
239
+ return self._stack[-1] if self._stack else None
240
+
241
+ # -- hot reload ---------------------------------------------------
242
+
243
+ def reload(self, changed_modules: Optional[List[str]] = None) -> None:
244
+ """Apply a hot reload across every mounted screen.
245
+
246
+ If the initial mount failed (e.g. a syntax error the developer
247
+ is now fixing), this re-attempts a fresh mount so the preview
248
+ recovers without a restart. Otherwise each host on the stack
249
+ performs Fast Refresh against the reloaded modules.
250
+ """
251
+ from .native_views import desktop as desktop_backend
252
+
253
+ if self._mount_failed or not self._stack:
254
+ self._remount_root()
255
+ return
256
+
257
+ self._clear_error()
258
+ for host in self._stack:
259
+ desktop_backend.set_root_container(getattr(host, "_pn_container", None))
260
+ try:
261
+ host.reload(changed_modules)
262
+ except Exception:
263
+ self._show_error(traceback.format_exc())
264
+ active = self.active_host()
265
+ if active is not None:
266
+ desktop_backend.set_root_container(getattr(active, "_pn_container", None))
267
+
268
+ def _remount_root(self) -> None:
269
+ for host in list(self._stack):
270
+ self._teardown(host)
271
+ self._stack.clear()
272
+ self.mount_root(self._component_path)
273
+
274
+ # -- error overlay ------------------------------------------------
275
+
276
+ def _show_error(self, message: str) -> None:
277
+ self._clear_error()
278
+ widget = tk.Text(
279
+ self._stage,
280
+ wrap="word",
281
+ background="#1c1c1e",
282
+ foreground="#ff6b6b",
283
+ insertbackground="#ffffff",
284
+ borderwidth=0,
285
+ highlightthickness=0,
286
+ padx=16,
287
+ pady=16,
288
+ )
289
+ widget.insert("1.0", "PythonNative preview error\n\n" + message)
290
+ widget.configure(state="disabled")
291
+ widget.place(x=0, y=0, relwidth=1.0, relheight=1.0)
292
+ widget.lift()
293
+ self._error_widget = widget
294
+ print(message, file=sys.stderr)
295
+
296
+ def _clear_error(self) -> None:
297
+ if self._error_widget is not None:
298
+ try:
299
+ self._error_widget.destroy()
300
+ except Exception:
301
+ pass
302
+ self._error_widget = None
303
+
304
+
305
+ def _resolve_paths(component_path: str, project_root: Optional[str], watch_dir: Optional[str]) -> Tuple[str, str]:
306
+ """Return ``(project_root, watch_dir)`` with sensible defaults.
307
+
308
+ The project root (which must be importable for ``component_path`` to
309
+ resolve) is prepended to ``sys.path``; the watch dir defaults to the
310
+ top-level package directory of ``component_path`` under the root.
311
+ """
312
+ root = os.path.abspath(project_root or os.getcwd())
313
+ if root not in sys.path:
314
+ sys.path.insert(0, root)
315
+ if watch_dir is None:
316
+ top_package = component_path.split(".", 1)[0]
317
+ candidate = os.path.join(root, top_package)
318
+ watch_dir = candidate if os.path.isdir(candidate) else root
319
+ return root, os.path.abspath(watch_dir)
320
+
321
+
322
+ def run_preview(
323
+ component_path: str,
324
+ *,
325
+ project_root: Optional[str] = None,
326
+ watch_dir: Optional[str] = None,
327
+ width: int = DEFAULT_WIDTH,
328
+ height: int = DEFAULT_HEIGHT,
329
+ title: str = "PythonNative Preview",
330
+ hot_reload: bool = True,
331
+ ) -> None:
332
+ """Open the preview window for ``component_path`` and run until closed.
333
+
334
+ Args:
335
+ component_path: Module path (``"app.main"`` → its ``App``) or a
336
+ dotted ``module.Component`` path, same convention as
337
+ [`create_screen`][pythonnative.create_screen].
338
+ project_root: Directory added to ``sys.path`` so the component
339
+ imports. Defaults to the current working directory.
340
+ watch_dir: Directory watched for ``.py`` changes. Defaults to
341
+ the component's top-level package (e.g. ``app/``).
342
+ width: Initial window width in points.
343
+ height: Initial window height in points.
344
+ title: Window title.
345
+ hot_reload: Watch for file changes and Fast Refresh on save.
346
+
347
+ Raises:
348
+ RuntimeError: If ``PN_PLATFORM=desktop`` was not set before
349
+ PythonNative was imported (``pn preview`` sets it for you).
350
+ """
351
+ from . import runtime as runtime_module
352
+ from .native_views import desktop as desktop_backend
353
+ from .utils import IS_DESKTOP
354
+
355
+ if not IS_DESKTOP:
356
+ raise RuntimeError(
357
+ "run_preview() requires the desktop backend. Set PN_PLATFORM=desktop "
358
+ "before importing pythonnative (the `pn preview` command does this)."
359
+ )
360
+
361
+ root_dir, watched = _resolve_paths(component_path, project_root, watch_dir)
362
+
363
+ root = tk.Tk()
364
+ root.title(title)
365
+ root.geometry(f"{int(width)}x{int(height)}")
366
+ root.minsize(240, 320)
367
+ stage = tk.Frame(root, background="#ffffff", highlightthickness=0, bd=0)
368
+ stage.pack(fill="both", expand=True)
369
+ desktop_backend.set_root_container(stage)
370
+
371
+ app = DesktopApp(root, stage, width, height)
372
+
373
+ # Marshal asyncio-thread UI work (animations, alerts) onto the Tk
374
+ # main thread by funneling it through this queue, drained in _poll.
375
+ main_queue: "queue.Queue[Callable[[], None]]" = queue.Queue()
376
+ runtime_module.set_desktop_main_dispatch(main_queue.put)
377
+
378
+ app.mount_root(component_path)
379
+
380
+ def _on_configure(event: Any) -> None:
381
+ if event.widget is stage:
382
+ app.resize(event.width, event.height)
383
+
384
+ stage.bind("<Configure>", _on_configure)
385
+
386
+ watcher = _build_watcher(watched, root_dir, app, main_queue) if hot_reload else None
387
+ if watcher is not None:
388
+ watcher.start()
389
+ print(f"[pn preview] watching {watched} for changes", file=sys.stderr)
390
+
391
+ from . import screen as screen_module
392
+
393
+ def _poll() -> None:
394
+ for _ in range(128):
395
+ try:
396
+ job = main_queue.get_nowait()
397
+ except queue.Empty:
398
+ break
399
+ try:
400
+ job()
401
+ except Exception:
402
+ traceback.print_exc()
403
+ try:
404
+ screen_module.drain_desktop_scheduled_renders()
405
+ except Exception:
406
+ traceback.print_exc()
407
+ try:
408
+ root.after(_POLL_INTERVAL_MS, _poll)
409
+ except Exception:
410
+ pass
411
+
412
+ def _on_close() -> None:
413
+ if watcher is not None:
414
+ try:
415
+ watcher.stop()
416
+ except Exception:
417
+ pass
418
+ runtime_module.set_desktop_main_dispatch(None)
419
+ desktop_backend.clear_root_container()
420
+ try:
421
+ root.destroy()
422
+ except Exception:
423
+ pass
424
+
425
+ root.protocol("WM_DELETE_WINDOW", _on_close)
426
+ root.after(_POLL_INTERVAL_MS, _poll)
427
+ print(f"[pn preview] {component_path} — {int(width)}x{int(height)}", file=sys.stderr)
428
+ try:
429
+ root.mainloop()
430
+ except KeyboardInterrupt:
431
+ pass
432
+ finally:
433
+ if watcher is not None:
434
+ try:
435
+ watcher.stop()
436
+ except Exception:
437
+ pass
438
+ runtime_module.set_desktop_main_dispatch(None)
439
+ desktop_backend.clear_root_container()
440
+
441
+
442
+ def _build_watcher(
443
+ watch_dir: str,
444
+ base_dir: str,
445
+ app: DesktopApp,
446
+ main_queue: "queue.Queue[Callable[[], None]]",
447
+ ) -> Any:
448
+ """Create a file watcher that enqueues reloads onto the main thread.
449
+
450
+ The watcher runs on its own daemon thread; because Tkinter is not
451
+ thread-safe, the ``on_change`` callback only *enqueues* the reload
452
+ (translated from changed file paths into dotted module names), which
453
+ [`run_preview`][pythonnative.preview.run_preview]'s poll loop runs
454
+ on the Tk main thread.
455
+ """
456
+ from .hot_reload import FileWatcher, ModuleReloader
457
+
458
+ def _on_change(changed_files: List[str]) -> None:
459
+ modules: List[str] = []
460
+ for path in changed_files:
461
+ module = ModuleReloader.file_to_module(path, base_dir)
462
+ if module:
463
+ modules.append(module)
464
+
465
+ def _apply() -> None:
466
+ print(f"[pn preview] reloading: {', '.join(modules) or 'app'}", file=sys.stderr)
467
+ app.reload(modules)
468
+
469
+ main_queue.put(_apply)
470
+
471
+ return FileWatcher(watch_dir, _on_change, interval=_WATCH_INTERVAL_S)
pythonnative/runtime.py CHANGED
@@ -290,6 +290,27 @@ def create_future() -> "asyncio.Future[Any]":
290
290
  # bridge back when it needs to talk to native UI.
291
291
 
292
292
 
293
+ # Desktop (Tkinter) main-thread dispatcher, installed by
294
+ # ``pythonnative.preview`` while a ``pn preview`` session is live. Tk is
295
+ # not thread-safe, so UI work scheduled from the asyncio worker thread
296
+ # (animations, alerts) must hop onto the Tk main thread; the preview's
297
+ # poll loop drains whatever this dispatcher enqueues. When no preview is
298
+ # running (plain scripts / tests) the dispatcher stays ``None`` and work
299
+ # runs inline.
300
+ _desktop_main_dispatch: Optional[Callable[[Callable[[], None]], None]] = None
301
+
302
+
303
+ def set_desktop_main_dispatch(dispatch: Optional[Callable[[Callable[[], None]], None]]) -> None:
304
+ """Install (or clear) the desktop main-thread dispatcher.
305
+
306
+ Called by ``pythonnative.preview`` with a
307
+ function that marshals ``fn`` onto the Tk main thread, and with
308
+ ``None`` when the preview window closes.
309
+ """
310
+ global _desktop_main_dispatch
311
+ _desktop_main_dispatch = dispatch
312
+
313
+
293
314
  def call_on_main_thread(fn: Callable[[], None]) -> None:
294
315
  """Run ``fn()`` on the platform UI thread.
295
316
 
@@ -299,7 +320,9 @@ def call_on_main_thread(fn: Callable[[], None]) -> None:
299
320
  ``_ios_call_on_main`` comment block for why this matters).
300
321
  - **Android**: posts a ``Runnable`` to
301
322
  ``Handler(Looper.getMainLooper())``.
302
- - **Desktop / tests**: runs ``fn()`` inline.
323
+ - **Desktop**: enqueues ``fn`` for the ``pn preview`` poll loop to
324
+ run on the Tk main thread (or runs inline if no preview is live).
325
+ - **Tests**: runs ``fn()`` inline.
303
326
 
304
327
  Exceptions raised by ``fn`` are caught and printed; they must not
305
328
  propagate into UIKit / the Android Looper. If you need to surface
@@ -317,6 +340,8 @@ def call_on_main_thread(fn: Callable[[], None]) -> None:
317
340
  _ios_call_on_main(fn)
318
341
  elif Platform.is_android:
319
342
  _android_call_on_main(fn)
343
+ elif Platform.is_desktop and _desktop_main_dispatch is not None:
344
+ _desktop_main_dispatch(fn)
320
345
  else:
321
346
  fn()
322
347