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/screen.py CHANGED
@@ -50,7 +50,7 @@ import sys
50
50
  import threading
51
51
  from typing import Any, Dict, Optional, Sequence
52
52
 
53
- from .utils import IS_ANDROID, IS_IOS, set_android_context
53
+ from .utils import IS_ANDROID, IS_DESKTOP, IS_IOS, set_android_context
54
54
 
55
55
  _MAX_RENDER_PASSES = 25
56
56
  _DEBUG_ENV = "PYTHONNATIVE_DEBUG"
@@ -87,6 +87,20 @@ def _resolve_component_path(component_ref: Any) -> str:
87
87
  raise ValueError(f"Cannot resolve component path for {component_ref!r}")
88
88
 
89
89
 
90
+ def _missing_module_is_target(exc: ModuleNotFoundError, dotted: str) -> bool:
91
+ """Return ``True`` when ``exc`` means ``dotted`` itself is absent.
92
+
93
+ Distinguishes "the component module/package cannot be found" (so the
94
+ caller should fall through to the next resolution strategy) from "the
95
+ module exists but raised :class:`ModuleNotFoundError` while importing
96
+ one of *its own* dependencies". The latter must propagate so the
97
+ developer sees the real missing import (e.g. ``No module named
98
+ 'emoji'``) instead of a misleading "could not resolve component".
99
+ """
100
+ missing = exc.name or ""
101
+ return missing == dotted or dotted.startswith(missing + ".")
102
+
103
+
90
104
  def _import_component(component_path: str) -> Any:
91
105
  """Import a component by module or dotted-attribute path.
92
106
 
@@ -117,11 +131,16 @@ def _import_component(component_path: str) -> Any:
117
131
  The resolved component callable.
118
132
 
119
133
  Raises:
120
- ImportError: If neither resolution path succeeds.
134
+ ImportError: If the module or dotted path cannot be found.
135
+ Errors raised *inside* a resolvable module (such as a
136
+ missing third-party dependency it imports) propagate
137
+ unchanged so the real cause stays visible.
121
138
  """
122
139
  try:
123
140
  module = importlib.import_module(component_path)
124
- except ModuleNotFoundError:
141
+ except ModuleNotFoundError as exc:
142
+ if not _missing_module_is_target(exc, component_path):
143
+ raise
125
144
  module = None
126
145
  if module is not None:
127
146
  component = getattr(module, "App", None)
@@ -132,7 +151,9 @@ def _import_component(component_path: str) -> Any:
132
151
  module_path, attr = component_path.rsplit(".", 1)
133
152
  try:
134
153
  parent = importlib.import_module(module_path)
135
- except ModuleNotFoundError:
154
+ except ModuleNotFoundError as exc:
155
+ if not _missing_module_is_target(exc, module_path):
156
+ raise
136
157
  parent = None
137
158
  if parent is not None:
138
159
  component = getattr(parent, attr, None)
@@ -868,6 +889,165 @@ if IS_ANDROID:
868
889
  """Public hook for native code to push viewport sizes (Maestro/tests)."""
869
890
  _push_viewport_size(self, width, height)
870
891
 
892
+ elif IS_DESKTOP:
893
+ # ------------------------------------------------------------------
894
+ # Desktop preview host (Tkinter), driven by ``pn preview``.
895
+ #
896
+ # The screen host owns the reconciler + lifecycle just like the
897
+ # device hosts; placement of the root view and the navigation stack
898
+ # are delegated to the ``DesktopApp`` controller in
899
+ # ``pythonnative.preview`` (passed in as ``native_instance``). The
900
+ # controller runs the Tk event loop on the main thread and polls
901
+ # ``drain_desktop_scheduled_renders`` so renders requested from the
902
+ # asyncio worker thread are applied on the main thread.
903
+ # ------------------------------------------------------------------
904
+
905
+ _DESKTOP_SCHEDULED_RENDER_HOSTS: Dict[int, Any] = {}
906
+ _desktop_render_lock = threading.Lock()
907
+
908
+ def _schedule_render_async(host: Any) -> bool:
909
+ """Queue an off-main-thread render for the Tk poll loop to drain.
910
+
911
+ Renders requested on the Tk main thread (button handlers, etc.)
912
+ run synchronously (returns ``False``); requests from the asyncio
913
+ worker thread are queued and applied by
914
+ [`drain_desktop_scheduled_renders`][pythonnative.screen.drain_desktop_scheduled_renders].
915
+ """
916
+ if not IS_DESKTOP:
917
+ return False
918
+ if threading.current_thread() is threading.main_thread():
919
+ return False
920
+ if getattr(host, "_render_scheduled", False):
921
+ return True
922
+ host._render_scheduled = True
923
+ with _desktop_render_lock:
924
+ _DESKTOP_SCHEDULED_RENDER_HOSTS[id(host)] = host
925
+ return True
926
+
927
+ def drain_desktop_scheduled_renders() -> None:
928
+ """Apply renders queued from worker threads (called on the main thread)."""
929
+ with _desktop_render_lock:
930
+ hosts = list(_DESKTOP_SCHEDULED_RENDER_HOSTS.values())
931
+ _DESKTOP_SCHEDULED_RENDER_HOSTS.clear()
932
+ _flush_scheduled_renders(hosts)
933
+
934
+ class _ScreenHost:
935
+ """Desktop host backed by a Tk window and an in-process nav stack.
936
+
937
+ Created by ``pythonnative.preview`` for
938
+ each screen on the navigation stack. ``native_instance`` is the
939
+ ``DesktopApp`` controller, which provides the stage frame,
940
+ viewport size, and push/pop primitives.
941
+ """
942
+
943
+ def __init__(self, native_instance: Any = None, component_path: str = "", component_func: Any = None) -> None:
944
+ self.native_instance = native_instance
945
+ _init_host_common(self, component_path, component_func)
946
+
947
+ def on_create(self) -> None:
948
+ _on_create(self)
949
+
950
+ def on_start(self) -> None:
951
+ pass
952
+
953
+ def on_resume(self) -> None:
954
+ _set_host_focused(self, True)
955
+
956
+ def on_layout(self) -> None:
957
+ pass
958
+
959
+ def on_pause(self) -> None:
960
+ _set_host_focused(self, False)
961
+
962
+ def on_stop(self) -> None:
963
+ pass
964
+
965
+ def on_destroy(self) -> None:
966
+ pass
967
+
968
+ def enable_hot_reload(self, manifest_path: str, source_root: Optional[str] = None) -> None:
969
+ _enable_hot_reload(self, manifest_path)
970
+
971
+ def hot_reload_tick(self) -> bool:
972
+ return _hot_reload_tick(self)
973
+
974
+ def reload(self, changed_modules: Optional[Sequence[str]] = None) -> None:
975
+ _reload_host(self, changed_modules)
976
+
977
+ def on_restart(self) -> None:
978
+ pass
979
+
980
+ def on_save_instance_state(self) -> None:
981
+ pass
982
+
983
+ def on_restore_instance_state(self) -> None:
984
+ pass
985
+
986
+ def set_args(self, args: Any) -> None:
987
+ _set_args(self, args)
988
+
989
+ def _get_nav_args(self) -> Dict[str, Any]:
990
+ return self._args
991
+
992
+ def _push(self, component: Any, args: Optional[Dict[str, Any]] = None) -> None:
993
+ screen_path = _resolve_component_path(component)
994
+ app = self.native_instance
995
+ if app is None or not hasattr(app, "push_screen"):
996
+ raise RuntimeError("desktop navigation requires a running `pn preview` session")
997
+ app.push_screen(screen_path, args)
998
+
999
+ def _pop(self) -> None:
1000
+ app = self.native_instance
1001
+ if app is not None and hasattr(app, "pop_screen"):
1002
+ app.pop_screen()
1003
+
1004
+ def _reset_to_root(self) -> None:
1005
+ app = self.native_instance
1006
+ if app is not None and hasattr(app, "reset_to_root"):
1007
+ try:
1008
+ app.reset_to_root()
1009
+ except Exception:
1010
+ pass
1011
+
1012
+ def _set_screen_options(self, options: Dict[str, Any]) -> None:
1013
+ title = options.get("title") if isinstance(options, dict) else None
1014
+ app = self.native_instance
1015
+ if title and app is not None and hasattr(app, "set_title"):
1016
+ try:
1017
+ app.set_title(str(title))
1018
+ except Exception:
1019
+ pass
1020
+
1021
+ def _attach_root(self, native_view: Any) -> None:
1022
+ from .native_views import desktop as _desktop_backend
1023
+
1024
+ stage = _desktop_backend.get_root_container()
1025
+ if stage is not None and native_view is not None:
1026
+ try:
1027
+ native_view.place(in_=stage, x=0, y=0, relwidth=1.0, relheight=1.0)
1028
+ native_view.lift()
1029
+ except Exception:
1030
+ pass
1031
+ app = self.native_instance
1032
+ if app is not None and hasattr(app, "viewport_size"):
1033
+ try:
1034
+ width, height = app.viewport_size()
1035
+ if width > 0 and height > 0:
1036
+ _push_viewport_size(self, float(width), float(height))
1037
+ except Exception:
1038
+ pass
1039
+
1040
+ def _detach_root(self, native_view: Any) -> None:
1041
+ if native_view is not None:
1042
+ try:
1043
+ native_view.place_forget()
1044
+ except Exception:
1045
+ pass
1046
+
1047
+ def set_viewport_size(self, width: float, height: float) -> None:
1048
+ """Push a viewport-size change (called on window resize)."""
1049
+ _push_viewport_size(self, width, height)
1050
+
871
1051
  else:
872
1052
  from typing import Dict as _Dict
873
1053
 
pythonnative/utils.py CHANGED
@@ -19,6 +19,10 @@ Attributes:
19
19
  `PN_PLATFORM=ios`, `sys.platform == "ios"`, or a Simulator
20
20
  `HOME` path). Importing `rubicon-objc` alone is intentionally
21
21
  not enough to trigger this flag.
22
+ IS_DESKTOP: `True` when running the desktop preview backend
23
+ (signaled by `PN_PLATFORM=desktop`, set by ``pn preview``).
24
+ This drives the Tkinter native-view registry so a PythonNative
25
+ app can render in a real OS window for fast local iteration.
22
26
  """
23
27
 
24
28
  import os
@@ -31,6 +35,7 @@ from typing import Any, Optional
31
35
 
32
36
  _is_android: Optional[bool] = None
33
37
  _is_ios: Optional[bool] = None
38
+ _is_desktop: Optional[bool] = None
34
39
 
35
40
 
36
41
  def _detect_android() -> bool:
@@ -75,13 +80,29 @@ def _detect_ios() -> bool:
75
80
  return False
76
81
 
77
82
 
83
+ def _detect_desktop() -> bool:
84
+ """Detect whether we're running the desktop (Tkinter) preview backend.
85
+
86
+ The only signal is the explicit ``PN_PLATFORM=desktop`` env var,
87
+ set by ``pn preview`` before importing PythonNative. Desktop is a
88
+ *development* target: it renders the app in a native OS window via
89
+ the pure-Python Tkinter registry so the inner dev loop doesn't
90
+ require a device build. Off-device unit tests deliberately leave
91
+ this flag ``False`` so they keep using an injected mock registry
92
+ and ``Platform.OS == "test"``.
93
+ """
94
+ return os.environ.get("PN_PLATFORM") == "desktop"
95
+
96
+
78
97
  def _ensure_platform_detection() -> None:
79
- """Populate `_is_android` / `_is_ios` once, then reuse."""
80
- global _is_android, _is_ios
98
+ """Populate `_is_android` / `_is_ios` / `_is_desktop` once, then reuse."""
99
+ global _is_android, _is_ios, _is_desktop
81
100
  if _is_android is None:
82
101
  _is_android = _detect_android()
83
102
  if _is_ios is None:
84
103
  _is_ios = (not _is_android) and _detect_ios()
104
+ if _is_desktop is None:
105
+ _is_desktop = (not _is_android) and (not _is_ios) and _detect_desktop()
85
106
 
86
107
 
87
108
  def _get_is_android() -> bool:
@@ -98,6 +119,13 @@ def _get_is_ios() -> bool:
98
119
  return _is_ios
99
120
 
100
121
 
122
+ def _get_is_desktop() -> bool:
123
+ """Return the cached desktop-detection result."""
124
+ _ensure_platform_detection()
125
+ assert _is_desktop is not None
126
+ return _is_desktop
127
+
128
+
101
129
  IS_ANDROID: bool = _get_is_android()
102
130
  """``True`` when running inside an Android process.
103
131
 
@@ -113,6 +141,14 @@ The flag is computed once at import time, by checking
113
141
  `HOME` path.
114
142
  """
115
143
 
144
+ IS_DESKTOP: bool = _get_is_desktop()
145
+ """``True`` when running the desktop (Tkinter) preview backend.
146
+
147
+ Set by ``pn preview`` via ``PN_PLATFORM=desktop``. Mutually exclusive
148
+ with `IS_ANDROID` / `IS_IOS`. Off-device unit tests leave this
149
+ ``False`` and inject a mock registry instead.
150
+ """
151
+
116
152
  # ======================================================================
117
153
  # Android context management
118
154
  # ======================================================================
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pythonnative
3
- Version: 0.18.0
3
+ Version: 0.19.0
4
4
  Summary: Cross-platform native UI toolkit for Android and iOS
5
5
  Author: Owen Carey
6
6
  License: MIT License
@@ -106,6 +106,7 @@ PythonNative is a cross-platform toolkit for building native Android and iOS app
106
106
  - **Direct native bindings:** Python calls platform APIs directly through Chaquopy and rubicon-objc, with no JavaScript bridge.
107
107
  - **Custom-component SDK:** Wrap any platform widget as a first-class element with type-checked props via `pythonnative.sdk` (`Props`, `@native_component`, `element_factory`). Plugins distributed on PyPI auto-register through the `pythonnative.handlers` entry-point group.
108
108
  - **CLI scaffolding:** `pn init` creates a ready-to-run project; `pn run android` and `pn run ios` build and launch your app.
109
+ - **Instant desktop preview:** `pn preview` renders your app in a native desktop window via Tkinter with Fast Refresh on every save — iterate on layout, state, and navigation in milliseconds without booting a simulator or device. The reconciler, hooks, layout engine, and navigation are the same code that ships to the phone.
109
110
  - **Native-backed navigation:** Declarative `Stack`, `Tab`, and `Drawer` navigators inspired by React Navigation. The root stack drives the platform's native navigation controller (`UINavigationController` on iOS, AndroidX Navigation Component on Android), so transitions, back gestures, and the hardware back button match what users expect.
110
111
  - **Fast Refresh hot reload:** `pn run --hot-reload` watches `app/` and patches edits into the running app on save, preserving component state across most changes.
111
112
  - **Bundled templates:** Android Gradle and iOS Xcode templates are included, so scaffolding requires no network access.
@@ -1,4 +1,4 @@
1
- pythonnative/__init__.py,sha256=EI_-TSD1LQJDBMpGt94xZ6ht9htAM46FV1eOcxJK_ts,8042
1
+ pythonnative/__init__.py,sha256=DL38DFeaNGmeg_IX75zEoNoXza9oHVloG2XhPpVEe4M,8042
2
2
  pythonnative/_ios_log.py,sha256=Oi7V28VxcVoZyrpAirvLeEmUW18McqnU87V4d37Zzlw,2582
3
3
  pythonnative/alerts.py,sha256=mIANysFlaHwL5EqKnvNcyiJN9rGiZi9XDrD9Jpz1RFM,9340
4
4
  pythonnative/animated.py,sha256=bAgG_sGODAdl5eVQjX_vryaKI1hyjI92QH1PNx7Tqyg,24491
@@ -9,16 +9,17 @@ pythonnative/hot_reload.py,sha256=j7z2c7o2Hdoyd-p4nQY15LTW7CBH_1z0TSAzLCer-aA,25
9
9
  pythonnative/layout.py,sha256=siU7PeVOjL_G1f-1q31ssrKWlxz2UBmvMwNXtmqyOxw,36586
10
10
  pythonnative/navigation.py,sha256=skMZFh3AXEJgTU6qQpATFN1Lp4GB94K_ACNNXZjEOEE,35579
11
11
  pythonnative/net.py,sha256=UI-39-BmGYWLE_vMAFoAbkzWZvfhIFj9yX_gexp1loc,8091
12
- pythonnative/platform.py,sha256=jEya1KTDc3WfwpmrQkk3DIFyt7CWO4Vc3pej_wDiSR8,4629
12
+ pythonnative/platform.py,sha256=cqG37hPY1fh6QzgO_AfHObg8R1fiMZmDeMu_2TIXqO0,4997
13
13
  pythonnative/platform_metrics.py,sha256=m2u8M8x52n5THNsYdspcaI9mlWWMbfSJWai1svjD0NM,8976
14
+ pythonnative/preview.py,sha256=ZtDupXfQdW07i2JD-fnoikAd3zHKVPYoPSMob1K8KQs,16990
14
15
  pythonnative/reconciler.py,sha256=dJUSXX65Ckdj5iSmpPtXYnNk0pfg54aesmSlAV0vLbM,43996
15
- pythonnative/runtime.py,sha256=wQnMMG7ibDGR4zFtwSbh7pR6o5nRe1R_vyPYI0dVcfk,17116
16
- pythonnative/screen.py,sha256=hcOjs1ZP2jeag55NkbyzOCXiae8cUs5IUhSDwHKIFLQ,57666
16
+ pythonnative/runtime.py,sha256=8xQvhZvMQJYJ0eozWTwKvC-H1GN5vikl6OCxt8f5uI0,18267
17
+ pythonnative/screen.py,sha256=azubUCJYh9-TYC_7-cvjL2nPibimM1IkXN0GDHJCfPc,64906
17
18
  pythonnative/storage.py,sha256=hLgSI44ADq6wj29eeYbHaAUNpxYPzJ2ZLn1L7AHkPZY,12010
18
19
  pythonnative/style.py,sha256=yDJv-G6iZIgrscpc-IZS_cbEQvY2o7R02PTQZ4BV8RA,15162
19
- pythonnative/utils.py,sha256=pQSxa3QW06_Y9JzBSnK0g0eMV1VhXww8Qym6HPOGzgM,6064
20
+ pythonnative/utils.py,sha256=-hwe_YS19ebpjeygdl3dGeVsYzO4G74rYD53svSi0rI,7593
20
21
  pythonnative/cli/__init__.py,sha256=NM1psvKe8jT0vzp8Ak4MMoygZz4P_msk5g-YEsY8xLk,232
21
- pythonnative/cli/pn.py,sha256=yWhDELAN7-UOE3qiv3ir29plcbXUp_gRT23Y7sRSVZQ,49133
22
+ pythonnative/cli/pn.py,sha256=wd6mpPxO5nMzt_jJWbw_Vp4Rz-BceQZHHu2o3CaE6KE,53795
22
23
  pythonnative/native_modules/__init__.py,sha256=pgigpHuzT-rqwcjlwJvu93_4L8Nozal1HJid0S_JlmM,2636
23
24
  pythonnative/native_modules/app_state.py,sha256=EnfChi_YWEgUpZosUiBQh0CmblBZFw8ZGaW1NKIJ4WM,2666
24
25
  pythonnative/native_modules/battery.py,sha256=gOU9aN5fCmWfHgTXPD-BgvatdQnyUjfVfsJM7PQ_ARA,4317
@@ -34,9 +35,10 @@ pythonnative/native_modules/notifications.py,sha256=WVtzdimc_aGfnxU6syCFPkjHF9YR
34
35
  pythonnative/native_modules/permissions.py,sha256=adRiO_LGxZ1PSJaCoJx_3nheuh9lLWNrPIb47O_A6-Y,6710
35
36
  pythonnative/native_modules/secure_store.py,sha256=YC25T2n2Nkfgks_qrCM1CR232AeSkh1_78NOJEBQ800,5956
36
37
  pythonnative/native_modules/share.py,sha256=B9ovknmnjeQvGQ8MQ14Hmq6E1x-JA469APMburJ0KUg,4727
37
- pythonnative/native_views/__init__.py,sha256=yP0IdOmQ3Cco4kJKBcgkMBUPX30ditM_Sp6ZotKAen4,11820
38
+ pythonnative/native_views/__init__.py,sha256=PCb2twVEIwEgMS5NZxsjoe4DeZwm1LRh0Q4QKYHS--A,12347
38
39
  pythonnative/native_views/android.py,sha256=z2ypAqMcgL9WNjVcTw-oINgbcFZJR27awC2jS_cWqJ0,114036
39
40
  pythonnative/native_views/base.py,sha256=LXDQYRM8wJa3MmGPwslkVyvlu36_s1_J6aO7wwhwYpA,6173
41
+ pythonnative/native_views/desktop.py,sha256=Z4r-72B3zbuvnqbJ5um35UqPM_iTj8wrEz61qcg0SZA,54931
40
42
  pythonnative/native_views/ios.py,sha256=F-DTStyva9fsESHAR5qZkNwrQKH_q2BoSGzgKPKJCBA,142727
41
43
  pythonnative/sdk/__init__.py,sha256=btIRfW2yy2d2LzjdpFnlc6ym-G3iJj9sVUbb2IlFMOI,3384
42
44
  pythonnative/sdk/_components.py,sha256=Hw0cqiyJ1NEzUrhOIT8zsC_mnVtf0jgpPUsireQG2qM,14644
@@ -92,9 +94,9 @@ pythonnative/templates/ios_template/ios_template.xcodeproj/project.xcworkspace/x
92
94
  pythonnative/templates/ios_template/ios_templateTests/ios_templateTests.swift,sha256=YnwzZx7yXB13xKAXEGNgz17VuhWeqkHTRTtBJ2Vu3_E,1238
93
95
  pythonnative/templates/ios_template/ios_templateUITests/ios_templateUITests.swift,sha256=l2Pwa50F_rv-qPu2go6e4bQernM6PTQJeNPFl_c4ivY,1387
94
96
  pythonnative/templates/ios_template/ios_templateUITests/ios_templateUITestsLaunchTests.swift,sha256=f5JrG0uVtLMeJQy26Yyz7Om-JUkT220osqcbeIVkj2g,815
95
- pythonnative-0.18.0.dist-info/licenses/LICENSE,sha256=A69iG7TIAe6KkGQf6xoVHkc5JSZtOr5eRSvC5iuivnI,1067
96
- pythonnative-0.18.0.dist-info/METADATA,sha256=wDTaZFQc6eK0lCtvBIoBVJHIVrsx0LRm5H3saFg13UE,8644
97
- pythonnative-0.18.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
98
- pythonnative-0.18.0.dist-info/entry_points.txt,sha256=iUtDawWSAJAEyWTycpZxDuYz73ol31butpzDIEAgPO0,48
99
- pythonnative-0.18.0.dist-info/top_level.txt,sha256=kT4SEATY2ywzrZ2Pgea6_zxyym44Q_PbOsUoOYjJLFE,13
100
- pythonnative-0.18.0.dist-info/RECORD,,
97
+ pythonnative-0.19.0.dist-info/licenses/LICENSE,sha256=A69iG7TIAe6KkGQf6xoVHkc5JSZtOr5eRSvC5iuivnI,1067
98
+ pythonnative-0.19.0.dist-info/METADATA,sha256=DxxW1C3XkGCiWBiTcDUypeTu5i21hHzz85pG_MlQRuk,8972
99
+ pythonnative-0.19.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
100
+ pythonnative-0.19.0.dist-info/entry_points.txt,sha256=iUtDawWSAJAEyWTycpZxDuYz73ol31butpzDIEAgPO0,48
101
+ pythonnative-0.19.0.dist-info/top_level.txt,sha256=kT4SEATY2ywzrZ2Pgea6_zxyym44Q_PbOsUoOYjJLFE,13
102
+ pythonnative-0.19.0.dist-info/RECORD,,