pythonnative 0.13.0__tar.gz → 0.13.1__tar.gz

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.
Files changed (111) hide show
  1. {pythonnative-0.13.0/src/pythonnative.egg-info → pythonnative-0.13.1}/PKG-INFO +1 -1
  2. {pythonnative-0.13.0 → pythonnative-0.13.1}/pyproject.toml +2 -1
  3. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/__init__.py +1 -1
  4. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/hot_reload.py +153 -3
  5. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/native_views/__init__.py +72 -0
  6. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/native_views/ios.py +49 -5
  7. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/screen.py +47 -12
  8. {pythonnative-0.13.0 → pythonnative-0.13.1/src/pythonnative.egg-info}/PKG-INFO +1 -1
  9. {pythonnative-0.13.0 → pythonnative-0.13.1}/tests/test_hot_reload.py +239 -6
  10. {pythonnative-0.13.0 → pythonnative-0.13.1}/tests/test_native_views.py +95 -0
  11. {pythonnative-0.13.0 → pythonnative-0.13.1}/LICENSE +0 -0
  12. {pythonnative-0.13.0 → pythonnative-0.13.1}/README.md +0 -0
  13. {pythonnative-0.13.0 → pythonnative-0.13.1}/setup.cfg +0 -0
  14. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/_ios_log.py +0 -0
  15. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/alerts.py +0 -0
  16. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/animated.py +0 -0
  17. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/cli/__init__.py +0 -0
  18. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/cli/pn.py +0 -0
  19. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/components.py +0 -0
  20. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/element.py +0 -0
  21. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/hooks.py +0 -0
  22. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/layout.py +0 -0
  23. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/native_modules/__init__.py +0 -0
  24. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/native_modules/camera.py +0 -0
  25. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/native_modules/file_system.py +0 -0
  26. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/native_modules/location.py +0 -0
  27. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/native_modules/notifications.py +0 -0
  28. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/native_views/android.py +0 -0
  29. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/native_views/base.py +0 -0
  30. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/navigation.py +0 -0
  31. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/platform.py +0 -0
  32. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/platform_metrics.py +0 -0
  33. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/reconciler.py +0 -0
  34. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/style.py +0 -0
  35. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/templates/android_template/app/build.gradle +0 -0
  36. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/templates/android_template/app/proguard-rules.pro +0 -0
  37. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/templates/android_template/app/src/androidTest/java/com/pythonnative/android_template/ExampleInstrumentedTest.kt +0 -0
  38. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/templates/android_template/app/src/main/AndroidManifest.xml +0 -0
  39. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/MainActivity.kt +0 -0
  40. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/Navigator.kt +0 -0
  41. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNVirtualListView.java +0 -0
  42. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/ScreenFragment.kt +0 -0
  43. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/templates/android_template/app/src/main/res/drawable/ic_launcher_background.xml +0 -0
  44. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/templates/android_template/app/src/main/res/drawable-v24/ic_launcher_foreground.xml +0 -0
  45. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/templates/android_template/app/src/main/res/layout/activity_main.xml +0 -0
  46. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +0 -0
  47. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +0 -0
  48. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-hdpi/ic_launcher.webp +0 -0
  49. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp +0 -0
  50. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-mdpi/ic_launcher.webp +0 -0
  51. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp +0 -0
  52. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-xhdpi/ic_launcher.webp +0 -0
  53. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp +0 -0
  54. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp +0 -0
  55. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp +0 -0
  56. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp +0 -0
  57. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp +0 -0
  58. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/templates/android_template/app/src/main/res/navigation/nav_graph.xml +0 -0
  59. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/templates/android_template/app/src/main/res/values/colors.xml +0 -0
  60. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/templates/android_template/app/src/main/res/values/strings.xml +0 -0
  61. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/templates/android_template/app/src/main/res/values/themes.xml +0 -0
  62. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/templates/android_template/app/src/main/res/values-night/themes.xml +0 -0
  63. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/templates/android_template/app/src/main/res/xml/backup_rules.xml +0 -0
  64. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/templates/android_template/app/src/main/res/xml/data_extraction_rules.xml +0 -0
  65. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/templates/android_template/app/src/test/java/com/pythonnative/android_template/ExampleUnitTest.kt +0 -0
  66. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/templates/android_template/build.gradle +0 -0
  67. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/templates/android_template/gradle/wrapper/gradle-wrapper.jar +0 -0
  68. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/templates/android_template/gradle/wrapper/gradle-wrapper.properties +0 -0
  69. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/templates/android_template/gradle.properties +0 -0
  70. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/templates/android_template/gradlew +0 -0
  71. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/templates/android_template/gradlew.bat +0 -0
  72. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/templates/android_template/settings.gradle +0 -0
  73. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/templates/ios_template/ios_template/AppDelegate.swift +0 -0
  74. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/templates/ios_template/ios_template/Assets.xcassets/AccentColor.colorset/Contents.json +0 -0
  75. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/templates/ios_template/ios_template/Assets.xcassets/AppIcon.appiconset/Contents.json +0 -0
  76. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/templates/ios_template/ios_template/Assets.xcassets/Contents.json +0 -0
  77. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/templates/ios_template/ios_template/Base.lproj/LaunchScreen.storyboard +0 -0
  78. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/templates/ios_template/ios_template/Base.lproj/Main.storyboard +0 -0
  79. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/templates/ios_template/ios_template/Info.plist +0 -0
  80. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/templates/ios_template/ios_template/SceneDelegate.swift +0 -0
  81. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/templates/ios_template/ios_template/ViewController.swift +0 -0
  82. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/templates/ios_template/ios_template.xcodeproj/project.pbxproj +0 -0
  83. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/templates/ios_template/ios_template.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +0 -0
  84. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/templates/ios_template/ios_templateTests/ios_templateTests.swift +0 -0
  85. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/templates/ios_template/ios_templateUITests/ios_templateUITests.swift +0 -0
  86. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/templates/ios_template/ios_templateUITests/ios_templateUITestsLaunchTests.swift +0 -0
  87. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative/utils.py +0 -0
  88. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative.egg-info/SOURCES.txt +0 -0
  89. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative.egg-info/dependency_links.txt +0 -0
  90. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative.egg-info/entry_points.txt +0 -0
  91. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative.egg-info/requires.txt +0 -0
  92. {pythonnative-0.13.0 → pythonnative-0.13.1}/src/pythonnative.egg-info/top_level.txt +0 -0
  93. {pythonnative-0.13.0 → pythonnative-0.13.1}/tests/test_alert.py +0 -0
  94. {pythonnative-0.13.0 → pythonnative-0.13.1}/tests/test_animated.py +0 -0
  95. {pythonnative-0.13.0 → pythonnative-0.13.1}/tests/test_cli.py +0 -0
  96. {pythonnative-0.13.0 → pythonnative-0.13.1}/tests/test_components.py +0 -0
  97. {pythonnative-0.13.0 → pythonnative-0.13.1}/tests/test_element.py +0 -0
  98. {pythonnative-0.13.0 → pythonnative-0.13.1}/tests/test_hooks.py +0 -0
  99. {pythonnative-0.13.0 → pythonnative-0.13.1}/tests/test_ios_log.py +0 -0
  100. {pythonnative-0.13.0 → pythonnative-0.13.1}/tests/test_layout.py +0 -0
  101. {pythonnative-0.13.0 → pythonnative-0.13.1}/tests/test_metric_hooks.py +0 -0
  102. {pythonnative-0.13.0 → pythonnative-0.13.1}/tests/test_navigation.py +0 -0
  103. {pythonnative-0.13.0 → pythonnative-0.13.1}/tests/test_new_components.py +0 -0
  104. {pythonnative-0.13.0 → pythonnative-0.13.1}/tests/test_platform.py +0 -0
  105. {pythonnative-0.13.0 → pythonnative-0.13.1}/tests/test_platform_metrics.py +0 -0
  106. {pythonnative-0.13.0 → pythonnative-0.13.1}/tests/test_reconciler.py +0 -0
  107. {pythonnative-0.13.0 → pythonnative-0.13.1}/tests/test_ref.py +0 -0
  108. {pythonnative-0.13.0 → pythonnative-0.13.1}/tests/test_screen.py +0 -0
  109. {pythonnative-0.13.0 → pythonnative-0.13.1}/tests/test_smoke.py +0 -0
  110. {pythonnative-0.13.0 → pythonnative-0.13.1}/tests/test_style.py +0 -0
  111. {pythonnative-0.13.0 → pythonnative-0.13.1}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pythonnative
3
- Version: 0.13.0
3
+ Version: 0.13.1
4
4
  Summary: Cross-platform native UI toolkit for Android and iOS
5
5
  Author: Owen Carey
6
6
  License: MIT License
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pythonnative"
7
- version = "0.13.0"
7
+ version = "0.13.1"
8
8
  description = "Cross-platform native UI toolkit for Android and iOS"
9
9
  authors = [
10
10
  { name = "Owen Carey" }
@@ -62,6 +62,7 @@ Documentation = "https://docs.pythonnative.com/"
62
62
 
63
63
 
64
64
 
65
+
65
66
  [tool.setuptools.packages.find]
66
67
  where = ["src"]
67
68
 
@@ -41,7 +41,7 @@ Example:
41
41
  ```
42
42
  """
43
43
 
44
- __version__ = "0.13.0"
44
+ __version__ = "0.13.1"
45
45
 
46
46
  from .alerts import Alert
47
47
  from .animated import Animated, AnimatedValue
@@ -196,10 +196,19 @@ class ModuleReloader:
196
196
  """Reload changed Python modules on device and trigger a re-render.
197
197
 
198
198
  Designed to be invoked from device-side glue when a hot-reload
199
- push completes. The class itself holds no state; all methods are
200
- static.
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.
201
207
  """
202
208
 
209
+ _last_reloaded_version: Optional[str] = None
210
+ _reload_lock = threading.Lock()
211
+
203
212
  @staticmethod
204
213
  def reload_module(module_name: str) -> bool:
205
214
  """Reload a single module by its dotted name.
@@ -254,6 +263,139 @@ class ModuleReloader:
254
263
  reloaded.append(module_name)
255
264
  return reloaded
256
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
+
257
399
  @staticmethod
258
400
  def file_to_module(file_path: str, base_dir: str = "") -> Optional[str]:
259
401
  """Convert a file path to a dotted module name.
@@ -491,5 +633,13 @@ class ModuleReloader:
491
633
  files = manifest.get("files", [])
492
634
  modules = ModuleReloader.modules_from_files(files if isinstance(files, list) else [])
493
635
 
494
- ModuleReloader.reload_screen(screen_instance, [str(module) for module in modules])
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
495
645
  return version
@@ -23,10 +23,67 @@ A mock registry can be installed via
23
23
  reconciler with no real native views.
24
24
  """
25
25
 
26
+ import math
27
+ import sys
28
+ import threading
29
+ import time
26
30
  from typing import Any, Dict, Optional, Tuple
27
31
 
28
32
  from .base import ViewHandler
29
33
 
34
+ # ======================================================================
35
+ # Tripwire log rate limiter
36
+ # ======================================================================
37
+ #
38
+ # Defensive NaN/Inf guards in ``set_frame`` and ``_apply_transform`` log
39
+ # a single line per occurrence. That's fine for one-off events, but
40
+ # ``Animated.View`` drives transforms at ~60 Hz; once an
41
+ # ``Animated.Value`` enters a stuck NaN state (e.g., a spring tick
42
+ # corrupted across a Fast Refresh), the tripwire would otherwise emit
43
+ # thousands of identical lines per second and drown the dev console.
44
+ #
45
+ # We instead log the first occurrence immediately, then suppress
46
+ # further messages with the same ``label`` for
47
+ # ``_TRIPWIRE_RATE_LIMIT_S`` seconds, and append a
48
+ # ``(+N similar in last Xs)`` suffix to the next message that escapes
49
+ # the window. The first sample plus a count is enough to diagnose; the
50
+ # bounded log keeps the dev console usable.
51
+
52
+ _TRIPWIRE_RATE_LIMIT_S: float = 1.0
53
+ _TRIPWIRE_LOG_LOCK = threading.Lock()
54
+ _TRIPWIRE_LAST_LOG_TIME: Dict[str, float] = {}
55
+ _TRIPWIRE_SUPPRESSED_COUNT: Dict[str, int] = {}
56
+
57
+
58
+ def _tripwire_log(label: str, message: str) -> None:
59
+ """Emit ``message`` to stderr, rate-limited per ``label``.
60
+
61
+ The first call for a given ``label`` always emits. Calls within
62
+ ``_TRIPWIRE_RATE_LIMIT_S`` seconds are silently counted. The next
63
+ call after the window appends ``(+N similar in last Xs)`` and
64
+ resets the counter.
65
+ """
66
+ now = time.monotonic()
67
+ write = False
68
+ suppressed = 0
69
+ with _TRIPWIRE_LOG_LOCK:
70
+ last = _TRIPWIRE_LAST_LOG_TIME.get(label)
71
+ if last is None or now - last >= _TRIPWIRE_RATE_LIMIT_S:
72
+ write = True
73
+ suppressed = _TRIPWIRE_SUPPRESSED_COUNT.get(label, 0)
74
+ _TRIPWIRE_SUPPRESSED_COUNT[label] = 0
75
+ _TRIPWIRE_LAST_LOG_TIME[label] = now
76
+ else:
77
+ _TRIPWIRE_SUPPRESSED_COUNT[label] = _TRIPWIRE_SUPPRESSED_COUNT.get(label, 0) + 1
78
+ if not write:
79
+ return
80
+ if suppressed > 0:
81
+ message = f"{message} (+{suppressed} similar in last {_TRIPWIRE_RATE_LIMIT_S:g}s)"
82
+ try:
83
+ print(message, file=sys.stderr, flush=True)
84
+ except Exception:
85
+ pass
86
+
30
87
 
31
88
  class NativeViewRegistry:
32
89
  """Map element type names to platform-specific view handlers.
@@ -136,6 +193,21 @@ class NativeViewRegistry:
136
193
  coordinates computed by ``pythonnative.layout`` in points
137
194
  relative to the parent's content origin.
138
195
  """
196
+ # Tripwire: log non-finite layout values so we can diagnose
197
+ # crashes like iOS `CALayerInvalidGeometry` without losing the
198
+ # repro. Handlers are responsible for clamping before applying.
199
+ # Rate-limited via ``_tripwire_log`` to avoid 60 Hz floods when
200
+ # an animated value is stuck at NaN.
201
+ try:
202
+ finite = math.isfinite(x) and math.isfinite(y) and math.isfinite(width) and math.isfinite(height)
203
+ except (TypeError, ValueError):
204
+ finite = False
205
+ if not finite:
206
+ _tripwire_log(
207
+ "set_frame:nan",
208
+ f"[set_frame:nan] type={type_name!r} " f"x={x!r} y={y!r} w={width!r} h={height!r}",
209
+ )
210
+
139
211
  handler = self._handlers.get(type_name)
140
212
  if handler is not None:
141
213
  handler.set_frame(native_view, x, y, width, height)
@@ -28,8 +28,28 @@ from typing import Any, Callable, Dict, List, Optional, Tuple
28
28
 
29
29
  from rubicon.objc import SEL, ObjCClass, objc_method
30
30
 
31
+ from . import _tripwire_log
31
32
  from .base import ViewHandler, _safe_max, parse_color_int
32
33
 
34
+
35
+ def _safe_finite(value: Any, default: float = 0.0) -> float:
36
+ """Coerce ``value`` to a finite float, falling back to ``default``.
37
+
38
+ Used as a defensive guard around every call into UIKit that takes a
39
+ geometry value. Without this, a single NaN or inf produced upstream
40
+ (layout edge case, stale prop during a reload, etc.) crashes the
41
+ process via `CALayerInvalidGeometry`. Clamping to ``default``
42
+ converts that into a recoverable visual glitch and lets the
43
+ `[set_frame:nan]` / `[set_transform:nan]` tripwire logs surface
44
+ where the bad value came from.
45
+ """
46
+ try:
47
+ f = float(value)
48
+ except (TypeError, ValueError):
49
+ return default
50
+ return f if math.isfinite(f) else default
51
+
52
+
33
53
  NSObject = ObjCClass("NSObject")
34
54
  UIColor = ObjCClass("UIColor")
35
55
  UIFont = ObjCClass("UIFont")
@@ -350,8 +370,32 @@ def _apply_transform(view: Any, props: Dict[str, Any]) -> None:
350
370
  return
351
371
  try:
352
372
  transform = _make_transform(spec)
373
+ a = float(transform.a)
374
+ b = float(transform.b)
375
+ c = float(transform.c)
376
+ d = float(transform.d)
377
+ tx = float(transform.tx)
378
+ ty = float(transform.ty)
379
+ if not (
380
+ math.isfinite(a)
381
+ and math.isfinite(b)
382
+ and math.isfinite(c)
383
+ and math.isfinite(d)
384
+ and math.isfinite(tx)
385
+ and math.isfinite(ty)
386
+ ):
387
+ # Tripwire: a NaN/inf transform crashes UIKit. Log
388
+ # (rate-limited to avoid 60 Hz spam from stuck Animated
389
+ # values) and fall back to identity so the app keeps
390
+ # running.
391
+ _tripwire_log(
392
+ "set_transform:nan",
393
+ f"[set_transform:nan] spec={spec!r} -> " f"(a={a!r}, b={b!r}, c={c!r}, d={d!r}, tx={tx!r}, ty={ty!r})",
394
+ )
395
+ view.setTransform_((1.0, 0.0, 0.0, 1.0, 0.0, 0.0))
396
+ return
353
397
  # rubicon-objc accepts the C struct as a tuple of its fields.
354
- view.setTransform_((transform.a, transform.b, transform.c, transform.d, transform.tx, transform.ty))
398
+ view.setTransform_((a, b, c, d, tx, ty))
355
399
  except Exception:
356
400
  pass
357
401
 
@@ -450,10 +494,10 @@ class IOSViewHandler(ViewHandler):
450
494
  if native_view is None:
451
495
  return
452
496
  try:
453
- frame_x = float(x)
454
- frame_y = float(y)
455
- frame_w = float(max(0.0, width))
456
- frame_h = float(max(0.0, height))
497
+ frame_x = _safe_finite(x, 0.0)
498
+ frame_y = _safe_finite(y, 0.0)
499
+ frame_w = max(0.0, _safe_finite(width, 0.0))
500
+ frame_h = max(0.0, _safe_finite(height, 0.0))
457
501
  native_view.setTranslatesAutoresizingMaskIntoConstraints_(True)
458
502
  native_view.setFrame_(((frame_x, frame_y), (frame_w, frame_h)))
459
503
  _clamp_view_corner_radius(native_view, frame_w, frame_h)
@@ -341,6 +341,25 @@ def _hot_reload_tick(host: Any) -> bool:
341
341
  if not manifest_exists and last is None:
342
342
  return False
343
343
 
344
+ # The iOS template polls every 0.5s per UIViewController, so this
345
+ # tick fires several times per second per host. The per-tick log is
346
+ # gated behind ``PYTHONNATIVE_DEBUG`` to keep normal output quiet
347
+ # while preserving the breadcrumb when investigating reload races.
348
+ if _debug_enabled():
349
+ manifest_version: Optional[str] = None
350
+ if manifest_exists:
351
+ try:
352
+ with open(manifest_path, encoding="utf-8") as f:
353
+ raw_version = json.load(f).get("version", "")
354
+ manifest_version = str(raw_version) if raw_version else None
355
+ except Exception:
356
+ manifest_version = None
357
+ action = "reload" if (manifest_version is not None and manifest_version != last) else "skip"
358
+ _log_pn(
359
+ f"_hot_reload_tick: host=0x{id(host):x} component={host._component_path} "
360
+ f"last={last!r} manifest={manifest_version!r} action={action}"
361
+ )
362
+
344
363
  next_version = ModuleReloader.reload_from_manifest(
345
364
  host,
346
365
  manifest_path,
@@ -348,10 +367,6 @@ def _hot_reload_tick(host: Any) -> bool:
348
367
  )
349
368
  if next_version == last:
350
369
  return False
351
- _log_pn(
352
- f"_hot_reload_tick: triggered reload "
353
- f"(manifest_exists={manifest_exists}, last={last!r}, next={next_version!r})"
354
- )
355
370
  host._hot_reload_last_version = next_version
356
371
  return True
357
372
 
@@ -365,6 +380,19 @@ def _reload_host(host: Any, changed_modules: Optional[Sequence[str]] = None) ->
365
380
  next render then runs the new bodies through the existing hook
366
381
  slots, so component state survives.
367
382
 
383
+ The reload set is **expanded** to include every currently-imported
384
+ module under the entry-point's top-level package (see
385
+ [`expand_reload_targets`][pythonnative.hot_reload.ModuleReloader.expand_reload_targets]).
386
+ This catches transitive ``from ... import`` bindings that would
387
+ otherwise remain stale: if ``app/main.py`` does
388
+ ``from app.screens.home import HomeScreen`` and the user edits
389
+ ``home.py``, reloading just ``app.screens.home`` leaves
390
+ ``app.main.HomeScreen`` pointing at the pre-edit function, so the
391
+ new render emits stale element types and the reconciler is forced
392
+ to unmount and remount the screen (losing state and showing old
393
+ code). Reloading every user-app module in dependency-friendly
394
+ order, with the entry-point last, keeps every binding fresh.
395
+
368
396
  If Fast Refresh fails (the new module raised at import time, no
369
397
  replacements could be located, or the next render itself
370
398
  threw), the host falls back to a full remount: a brand-new
@@ -374,14 +402,20 @@ def _reload_host(host: Any, changed_modules: Optional[Sequence[str]] = None) ->
374
402
  """
375
403
  from .hot_reload import ModuleReloader
376
404
 
377
- modules = list(changed_modules or [])
378
- root_module = host._component_path.rsplit(".", 1)[0] if "." in host._component_path else host._component_path
379
- if root_module not in modules:
380
- modules.append(root_module)
405
+ requested = list(changed_modules or [])
406
+ targets = ModuleReloader.expand_reload_targets(requested, host._component_path)
407
+
408
+ pending_version = getattr(host, "_hot_reload_pending_version", None)
409
+ already_loaded = pending_version is not None and pending_version == ModuleReloader._last_reloaded_version
410
+ _log_pn(
411
+ f"_reload_host: host=0x{id(host):x} component={host._component_path} "
412
+ f"requested={requested!r} targets={len(targets)} version={pending_version!r} "
413
+ f"action={'reuse_modules' if already_loaded else 'reload_modules'}"
414
+ )
381
415
 
382
- reloaded = ModuleReloader.reload_modules(modules)
416
+ reloaded = ModuleReloader.reload_modules_for_version(targets, pending_version)
383
417
  if not reloaded:
384
- _log_pn(f"_reload_host: no modules could be reloaded from {modules!r}; aborting")
418
+ _log_pn(f"_reload_host: no modules could be reloaded from {targets!r}; aborting")
385
419
  return
386
420
 
387
421
  try:
@@ -392,10 +426,11 @@ def _reload_host(host: Any, changed_modules: Optional[Sequence[str]] = None) ->
392
426
  host._component = new_component
393
427
 
394
428
  if host._reconciler is None:
429
+ _log_pn(f"_reload_host: host=0x{id(host):x} reconciler=None; skipping refresh")
395
430
  return
396
431
 
397
432
  if _try_fast_refresh(host, reloaded):
398
- print(f"[hot-reload] Fast Refresh: {', '.join(reloaded)}", file=sys.stderr)
433
+ print(f"[hot-reload] Fast Refresh: {', '.join(requested) or ', '.join(reloaded)}", file=sys.stderr)
399
434
  return
400
435
 
401
436
  _full_remount(host, reloaded)
@@ -1080,7 +1115,7 @@ else:
1080
1115
  nav = getattr(self.native_instance, "navigationController", None)
1081
1116
  if nav is None:
1082
1117
  raise RuntimeError(
1083
- "No UINavigationController available; " "ensure template embeds root in navigation controller"
1118
+ "No UINavigationController available; ensure template embeds root in navigation controller"
1084
1119
  )
1085
1120
  nav.pushViewController_animated_(next_vc, True)
1086
1121
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pythonnative
3
- Version: 0.13.0
3
+ Version: 0.13.1
4
4
  Summary: Cross-platform native UI toolkit for Android and iOS
5
5
  Author: Owen Carey
6
6
  License: MIT License