pythonnative 0.8.0__tar.gz → 0.9.0__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 (95) hide show
  1. {pythonnative-0.8.0/src/pythonnative.egg-info → pythonnative-0.9.0}/PKG-INFO +1 -1
  2. {pythonnative-0.8.0 → pythonnative-0.9.0}/pyproject.toml +1 -1
  3. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/__init__.py +1 -1
  4. pythonnative-0.9.0/src/pythonnative/_ios_log.py +94 -0
  5. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/cli/pn.py +131 -11
  6. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/page.py +16 -1
  7. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/ios_template/ios_template/ViewController.swift +19 -25
  8. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/utils.py +40 -1
  9. {pythonnative-0.8.0 → pythonnative-0.9.0/src/pythonnative.egg-info}/PKG-INFO +1 -1
  10. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative.egg-info/SOURCES.txt +4 -1
  11. {pythonnative-0.8.0 → pythonnative-0.9.0}/tests/test_cli.py +28 -4
  12. pythonnative-0.9.0/tests/test_ios_log.py +147 -0
  13. pythonnative-0.9.0/tests/test_utils.py +70 -0
  14. {pythonnative-0.8.0 → pythonnative-0.9.0}/LICENSE +0 -0
  15. {pythonnative-0.8.0 → pythonnative-0.9.0}/README.md +0 -0
  16. {pythonnative-0.8.0 → pythonnative-0.9.0}/setup.cfg +0 -0
  17. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/cli/__init__.py +0 -0
  18. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/components.py +0 -0
  19. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/element.py +0 -0
  20. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/hooks.py +0 -0
  21. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/hot_reload.py +0 -0
  22. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/native_modules/__init__.py +0 -0
  23. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/native_modules/camera.py +0 -0
  24. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/native_modules/file_system.py +0 -0
  25. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/native_modules/location.py +0 -0
  26. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/native_modules/notifications.py +0 -0
  27. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/native_views/__init__.py +0 -0
  28. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/native_views/android.py +0 -0
  29. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/native_views/base.py +0 -0
  30. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/native_views/ios.py +0 -0
  31. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/navigation.py +0 -0
  32. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/reconciler.py +0 -0
  33. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/style.py +0 -0
  34. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/app/build.gradle +0 -0
  35. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/app/proguard-rules.pro +0 -0
  36. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/app/src/androidTest/java/com/pythonnative/android_template/ExampleInstrumentedTest.kt +0 -0
  37. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/app/src/main/AndroidManifest.xml +0 -0
  38. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/MainActivity.kt +0 -0
  39. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/Navigator.kt +0 -0
  40. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PageFragment.kt +0 -0
  41. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/app/src/main/res/drawable/ic_launcher_background.xml +0 -0
  42. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/app/src/main/res/drawable-v24/ic_launcher_foreground.xml +0 -0
  43. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/app/src/main/res/layout/activity_main.xml +0 -0
  44. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +0 -0
  45. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +0 -0
  46. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-hdpi/ic_launcher.webp +0 -0
  47. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp +0 -0
  48. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-mdpi/ic_launcher.webp +0 -0
  49. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp +0 -0
  50. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-xhdpi/ic_launcher.webp +0 -0
  51. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp +0 -0
  52. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp +0 -0
  53. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp +0 -0
  54. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp +0 -0
  55. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp +0 -0
  56. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/app/src/main/res/navigation/nav_graph.xml +0 -0
  57. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/app/src/main/res/values/colors.xml +0 -0
  58. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/app/src/main/res/values/strings.xml +0 -0
  59. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/app/src/main/res/values/themes.xml +0 -0
  60. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/app/src/main/res/values-night/themes.xml +0 -0
  61. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/app/src/main/res/xml/backup_rules.xml +0 -0
  62. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/app/src/main/res/xml/data_extraction_rules.xml +0 -0
  63. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/app/src/test/java/com/pythonnative/android_template/ExampleUnitTest.kt +0 -0
  64. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/build.gradle +0 -0
  65. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/gradle/wrapper/gradle-wrapper.jar +0 -0
  66. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/gradle/wrapper/gradle-wrapper.properties +0 -0
  67. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/gradle.properties +0 -0
  68. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/gradlew +0 -0
  69. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/gradlew.bat +0 -0
  70. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/settings.gradle +0 -0
  71. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/ios_template/ios_template/AppDelegate.swift +0 -0
  72. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/ios_template/ios_template/Assets.xcassets/AccentColor.colorset/Contents.json +0 -0
  73. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/ios_template/ios_template/Assets.xcassets/AppIcon.appiconset/Contents.json +0 -0
  74. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/ios_template/ios_template/Assets.xcassets/Contents.json +0 -0
  75. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/ios_template/ios_template/Base.lproj/LaunchScreen.storyboard +0 -0
  76. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/ios_template/ios_template/Base.lproj/Main.storyboard +0 -0
  77. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/ios_template/ios_template/Info.plist +0 -0
  78. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/ios_template/ios_template/SceneDelegate.swift +0 -0
  79. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/ios_template/ios_template.xcodeproj/project.pbxproj +0 -0
  80. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/ios_template/ios_template.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +0 -0
  81. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/ios_template/ios_templateTests/ios_templateTests.swift +0 -0
  82. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/ios_template/ios_templateUITests/ios_templateUITests.swift +0 -0
  83. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/ios_template/ios_templateUITests/ios_templateUITestsLaunchTests.swift +0 -0
  84. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative.egg-info/dependency_links.txt +0 -0
  85. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative.egg-info/entry_points.txt +0 -0
  86. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative.egg-info/requires.txt +0 -0
  87. {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative.egg-info/top_level.txt +0 -0
  88. {pythonnative-0.8.0 → pythonnative-0.9.0}/tests/test_components.py +0 -0
  89. {pythonnative-0.8.0 → pythonnative-0.9.0}/tests/test_element.py +0 -0
  90. {pythonnative-0.8.0 → pythonnative-0.9.0}/tests/test_hooks.py +0 -0
  91. {pythonnative-0.8.0 → pythonnative-0.9.0}/tests/test_native_views.py +0 -0
  92. {pythonnative-0.8.0 → pythonnative-0.9.0}/tests/test_navigation.py +0 -0
  93. {pythonnative-0.8.0 → pythonnative-0.9.0}/tests/test_reconciler.py +0 -0
  94. {pythonnative-0.8.0 → pythonnative-0.9.0}/tests/test_smoke.py +0 -0
  95. {pythonnative-0.8.0 → pythonnative-0.9.0}/tests/test_style.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pythonnative
3
- Version: 0.8.0
3
+ Version: 0.9.0
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.8.0"
7
+ version = "0.9.0"
8
8
  description = "Cross-platform native UI toolkit for Android and iOS"
9
9
  authors = [
10
10
  { name = "Owen Carey" }
@@ -14,7 +14,7 @@ Public API::
14
14
  )
15
15
  """
16
16
 
17
- __version__ = "0.8.0"
17
+ __version__ = "0.9.0"
18
18
 
19
19
  from .components import (
20
20
  ActivityIndicator,
@@ -0,0 +1,94 @@
1
+ """Route Python ``sys.stdout``/``sys.stderr`` through fd 2 on iOS.
2
+
3
+ Why
4
+ ---
5
+ When an app is launched via ``xcrun simctl launch --console-pty`` (what
6
+ ``pn run ios`` does), the simulator attaches the caller's terminal to
7
+ the app's stderr, which is the same channel ``NSLog`` / ``os_log``
8
+ writes to. Python ``print()`` calls, however, go to ``sys.stdout``
9
+ (fd 1), and for reasons specific to how CPython's embedded framework
10
+ is started on the iOS Simulator that descriptor does not reach the
11
+ attached console. As a result users see Swift-side ``NSLog`` output
12
+ but never their own ``print()`` output.
13
+
14
+ Redirecting ``sys.stdout`` / ``sys.stderr`` at a Python level to write
15
+ straight to fd 2 is a small, reliable fix: fd 2 *is* visible to
16
+ ``simctl`` (that's exactly how ``NSLog`` reaches the terminal), so
17
+ Python output lands next to the Swift logs with correct ordering.
18
+
19
+ This module is intentionally self-contained: no rubicon-objc or
20
+ platform-specific C bindings required, so it's safe to import early
21
+ during ``pythonnative`` package initialization.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import os
27
+ import sys
28
+ from typing import Iterable
29
+
30
+ _STDERR_FD = 2
31
+
32
+
33
+ class _StderrStream:
34
+ """Minimal text-mode file-like that writes UTF-8 bytes to fd 2.
35
+
36
+ It's write-through (no buffering) so a ``print()`` call appears in
37
+ the terminal immediately, which matches user expectations for an
38
+ interactive "run on simulator" log stream.
39
+ """
40
+
41
+ encoding = "utf-8"
42
+ errors = "replace"
43
+ mode = "w"
44
+ name = "<stderr>"
45
+
46
+ def write(self, s: str) -> int:
47
+ if not s:
48
+ return 0
49
+ data = s.encode(self.encoding, self.errors)
50
+ try:
51
+ return os.write(_STDERR_FD, data)
52
+ except OSError:
53
+ return 0
54
+
55
+ def writelines(self, lines: Iterable[str]) -> None:
56
+ for line in lines:
57
+ self.write(line)
58
+
59
+ def flush(self) -> None:
60
+ # os.write is unbuffered; nothing to flush.
61
+ return None
62
+
63
+ def isatty(self) -> bool:
64
+ try:
65
+ return os.isatty(_STDERR_FD)
66
+ except OSError:
67
+ return False
68
+
69
+ def fileno(self) -> int:
70
+ return _STDERR_FD
71
+
72
+ def close(self) -> None:
73
+ # Don't actually close fd 2.
74
+ return None
75
+
76
+ @property
77
+ def closed(self) -> bool:
78
+ return False
79
+
80
+
81
+ _installed = False
82
+
83
+
84
+ def install() -> None:
85
+ """Swap ``sys.stdout`` / ``sys.stderr`` for fd-2 writers.
86
+
87
+ Safe to call multiple times; only the first call has effect.
88
+ """
89
+ global _installed
90
+ if _installed:
91
+ return
92
+ sys.stdout = _StderrStream()
93
+ sys.stderr = _StderrStream()
94
+ _installed = True
@@ -246,6 +246,55 @@ def _read_requirements(requirements_path: str) -> list[str]:
246
246
  return result
247
247
 
248
248
 
249
+ ANDROID_LOGCAT_FILTERS: list[str] = [
250
+ "python.stdout:V",
251
+ "python.stderr:V",
252
+ "MainActivity:V",
253
+ "PageFragment:V",
254
+ "Navigator:V",
255
+ "PythonNative:V",
256
+ "AndroidRuntime:E",
257
+ "System.err:W",
258
+ "*:S",
259
+ ]
260
+
261
+ IOS_BUNDLE_ID: str = "com.pythonnative.ios-template"
262
+
263
+
264
+ def _start_android_log_stream() -> Optional[subprocess.Popen]:
265
+ """Clear logcat and stream Python-relevant log tags to the terminal.
266
+
267
+ Returns the ``adb logcat`` subprocess, or ``None`` when ``adb`` is
268
+ unavailable. Python's ``print()`` output reaches logcat via Chaquopy,
269
+ which redirects ``sys.stdout``/``sys.stderr`` to the ``python.stdout``
270
+ and ``python.stderr`` tags.
271
+ """
272
+ try:
273
+ subprocess.run(["adb", "logcat", "-c"], check=False, capture_output=True)
274
+ except FileNotFoundError:
275
+ print("Note: 'adb' not found on PATH; skipping log streaming.")
276
+ return None
277
+ try:
278
+ proc = subprocess.Popen(["adb", "logcat", *ANDROID_LOGCAT_FILTERS])
279
+ except FileNotFoundError:
280
+ return None
281
+ print("Streaming Python logs from device (Ctrl+C to stop)...")
282
+ return proc
283
+
284
+
285
+ def _terminate_subprocess(proc: Optional[subprocess.Popen]) -> None:
286
+ """Politely stop a subprocess, escalating to SIGKILL if needed."""
287
+ if proc is None:
288
+ return
289
+ if proc.poll() is not None:
290
+ return
291
+ proc.terminate()
292
+ try:
293
+ proc.wait(timeout=3)
294
+ except subprocess.TimeoutExpired:
295
+ proc.kill()
296
+
297
+
249
298
  def run_project(args: argparse.Namespace) -> None:
250
299
  """
251
300
  Run the specified project.
@@ -254,6 +303,7 @@ def run_project(args: argparse.Namespace) -> None:
254
303
  platform: str = args.platform
255
304
  prepare_only: bool = getattr(args, "prepare_only", False)
256
305
  hot_reload: bool = getattr(args, "hot_reload", False)
306
+ show_logs: bool = not getattr(args, "no_logs", False)
257
307
 
258
308
  # Read project configuration and save project root before any chdir
259
309
  project_dir: str = os.getcwd()
@@ -371,6 +421,18 @@ def run_project(args: argparse.Namespace) -> None:
371
421
  ],
372
422
  check=True,
373
423
  )
424
+
425
+ # Stream Python logs from logcat unless the user opted out or requested
426
+ # hot-reload (hot-reload handles its own log tailing below).
427
+ if show_logs and not hot_reload:
428
+ logcat_proc = _start_android_log_stream()
429
+ if logcat_proc is not None:
430
+ try:
431
+ logcat_proc.wait()
432
+ except KeyboardInterrupt:
433
+ print()
434
+ _terminate_subprocess(logcat_proc)
435
+ print("Stopped log streaming.")
374
436
  elif platform == "ios":
375
437
  # Attempt to build and run on iOS Simulator (best-effort)
376
438
  ios_project_dir: str = os.path.join(build_dir, "ios_template")
@@ -640,22 +702,64 @@ def run_project(args: argparse.Namespace) -> None:
640
702
  return
641
703
 
642
704
  udid = preferred.get("udid")
643
- # Boot (no-op if already booted)
644
- subprocess.run(["xcrun", "simctl", "boot", udid], check=False)
645
- # Install and launch
705
+ # Boot (no-op if already booted). simctl returns non-zero and
706
+ # prints to stderr when the device is already Booted; we
707
+ # don't care about that case, so swallow its output.
708
+ subprocess.run(["xcrun", "simctl", "boot", udid], check=False, capture_output=True)
709
+ # Install
646
710
  subprocess.run(["xcrun", "simctl", "install", udid, app_path], check=False)
647
- subprocess.run(["xcrun", "simctl", "launch", udid, "com.pythonnative.ios-template"], check=False)
648
- print("Launched iOS app on Simulator (best-effort).")
711
+ if show_logs and not hot_reload:
712
+ # Attach the app's stdout/stderr to this terminal so Python
713
+ # print() calls and exceptions are visible. SIMCTL_CHILD_*
714
+ # env vars are forwarded to the launched process.
715
+ sim_env = os.environ.copy()
716
+ sim_env["SIMCTL_CHILD_PYTHONUNBUFFERED"] = "1"
717
+ print("Launched iOS app on Simulator. Streaming logs (Ctrl+C to stop)...")
718
+ try:
719
+ subprocess.run(
720
+ [
721
+ "xcrun",
722
+ "simctl",
723
+ "launch",
724
+ "--console-pty",
725
+ "--terminate-running-process",
726
+ udid,
727
+ IOS_BUNDLE_ID,
728
+ ],
729
+ env=sim_env,
730
+ check=False,
731
+ )
732
+ except KeyboardInterrupt:
733
+ print()
734
+ subprocess.run(
735
+ ["xcrun", "simctl", "terminate", udid, IOS_BUNDLE_ID],
736
+ check=False,
737
+ capture_output=True,
738
+ )
739
+ print("Stopped log streaming.")
740
+ else:
741
+ subprocess.run(["xcrun", "simctl", "launch", udid, IOS_BUNDLE_ID], check=False)
742
+ print("Launched iOS app on Simulator (best-effort).")
743
+ if show_logs and hot_reload:
744
+ print(
745
+ "Note: live Python log streaming on iOS is disabled while --hot-reload is active; "
746
+ "use Console.app or Xcode to view logs."
747
+ )
649
748
  except Exception:
650
749
  print("Failed to auto-run on Simulator; open the project in Xcode to run.")
651
750
 
652
751
  # Hot-reload file watcher
653
752
  if hot_reload and not prepare_only:
654
- _run_hot_reload(platform, project_dir, build_dir)
753
+ _run_hot_reload(platform, project_dir, build_dir, show_logs=show_logs)
754
+
655
755
 
756
+ def _run_hot_reload(platform: str, project_dir: str, build_dir: str, show_logs: bool = True) -> None:
757
+ """Watch ``app/`` for changes and push updated files to the device.
656
758
 
657
- def _run_hot_reload(platform: str, project_dir: str, build_dir: str) -> None:
658
- """Watch ``app/`` for changes and push updated files to the device."""
759
+ When ``show_logs`` is true and targeting Android, ``adb logcat`` is
760
+ streamed in parallel so Python print/exception output stays visible
761
+ alongside hot-reload notifications.
762
+ """
659
763
  from .hot_reload import FileWatcher
660
764
 
661
765
  app_dir = os.path.join(project_dir, "app")
@@ -673,12 +777,23 @@ def _run_hot_reload(platform: str, project_dir: str, build_dir: str) -> None:
673
777
  print("[hot-reload] Watching app/ for changes. Press Ctrl+C to stop.")
674
778
  watcher = FileWatcher(app_dir, on_change, interval=1.0)
675
779
  watcher.start()
780
+
781
+ logcat_proc: Optional[subprocess.Popen] = None
782
+ if show_logs and platform == "android":
783
+ logcat_proc = _start_android_log_stream()
784
+
676
785
  try:
677
- import time
786
+ if logcat_proc is not None:
787
+ logcat_proc.wait()
788
+ else:
789
+ import time
678
790
 
679
- while True:
680
- time.sleep(1)
791
+ while True:
792
+ time.sleep(1)
681
793
  except KeyboardInterrupt:
794
+ pass
795
+ finally:
796
+ _terminate_subprocess(logcat_proc)
682
797
  watcher.stop()
683
798
  print("\n[hot-reload] Stopped.")
684
799
 
@@ -721,6 +836,11 @@ def main() -> None:
721
836
  action="store_true",
722
837
  help="Watch app/ for changes and push updates to the running app",
723
838
  )
839
+ parser_run.add_argument(
840
+ "--no-logs",
841
+ action="store_true",
842
+ help="Don't attach to the app's stdout/stderr after launching (default: stream logs)",
843
+ )
724
844
  parser_run.set_defaults(func=run_project)
725
845
 
726
846
  # Create a new command 'clean' that calls clean_project
@@ -27,7 +27,7 @@ import importlib
27
27
  import json
28
28
  from typing import Any, Dict, Optional
29
29
 
30
- from .utils import IS_ANDROID, set_android_context
30
+ from .utils import IS_ANDROID, IS_IOS, set_android_context
31
31
 
32
32
  _MAX_RENDER_PASSES = 25
33
33
 
@@ -250,6 +250,21 @@ else:
250
250
  except ImportError:
251
251
  pass
252
252
 
253
+ # Redirect Python's stdout/stderr through fd 2 so ``print()`` output is
254
+ # visible via ``xcrun simctl launch --console-pty``. This runs at
255
+ # ``pythonnative.page`` import time, i.e. before any user page module
256
+ # (e.g. ``app.main_page``) is imported, so their top-level ``print()``
257
+ # calls are captured too. Gated on ``IS_IOS`` rather than rubicon-objc
258
+ # being importable, so installing the ``[ios]`` extra on macOS does
259
+ # not silently swap ``sys.stdout`` on a dev machine.
260
+ if IS_IOS:
261
+ try:
262
+ from . import _ios_log
263
+
264
+ _ios_log.install()
265
+ except Exception:
266
+ pass
267
+
253
268
  _IOS_PAGE_REGISTRY: _Dict[int, Any] = {}
254
269
 
255
270
  def _ios_register_page(vc_instance: Any, host_obj: Any) -> None:
@@ -27,13 +27,18 @@ class ViewController: UIViewController {
27
27
  super.viewDidLoad()
28
28
  // Ensure a visible background when created programmatically (storyboards set this automatically)
29
29
  view.backgroundColor = .systemBackground
30
- NSLog("[PN][ViewController] viewDidLoad")
31
- if let bundleId = Bundle.main.bundleIdentifier {
32
- NSLog("[PN] Bundle Identifier: \(bundleId)")
33
- }
34
- NSLog("[PN] Bundle Path: \(Bundle.main.bundlePath)")
35
- NSLog("[PN] Resource Path: \(Bundle.main.resourcePath ?? "nil")")
36
- // Configure embedded Python if available in bundle
30
+
31
+ let firstInit = !ViewController.hasInitializedPython
32
+
33
+ // Signal to pythonnative that we're running on iOS. Read on the
34
+ // Python side (pythonnative.utils.IS_IOS) to gate iOS-only setup
35
+ // like sys.stdout redirection. Set before Python starts so it's
36
+ // visible to the very first import.
37
+ setenv("PN_PLATFORM", "ios", 1)
38
+
39
+ // Configure embedded Python if available in bundle. PYTHONHOME /
40
+ // PYTHONPATH only need to be set once per process, but setting them
41
+ // again is harmless and keeps the flow simple.
37
42
  if let resourcePath = Bundle.main.resourcePath {
38
43
  let pyStd = "\(resourcePath)/python-stdlib"
39
44
  let pyDyn = "\(resourcePath)/python-stdlib/lib-dynload"
@@ -44,8 +49,6 @@ class ViewController: UIViewController {
44
49
  }
45
50
  setenv("PYTHONHOME", pyStd, 1)
46
51
  setenv("PYTHONPATH", pyPath, 1)
47
- NSLog("[PN] Set PYTHONHOME=\(pyStd)")
48
- NSLog("[PN] Set PYTHONPATH=\(pyPath)")
49
52
  }
50
53
  #if canImport(PythonKit)
51
54
  // Ensure PythonKit knows where to load the Python library from when using an embedded framework.
@@ -53,34 +56,25 @@ class ViewController: UIViewController {
53
56
  let frameworkLib = "\(bundlePath)/Frameworks/Python.framework/Python"
54
57
  setenv("PYTHON_LIBRARY", frameworkLib, 1)
55
58
  if FileManager.default.fileExists(atPath: frameworkLib) {
56
- if !ViewController.hasInitializedPython {
57
- NSLog("[PN] Using embedded Python lib at: \(frameworkLib)")
59
+ if firstInit {
58
60
  PythonLibrary.useLibrary(at: frameworkLib)
59
61
  ViewController.hasInitializedPython = true
60
- } else {
61
- NSLog("[PN] Python library already initialized; skipping useLibrary")
62
62
  }
63
63
  pythonReady = true
64
64
  } else {
65
65
  NSLog("[PN] Embedded Python library not found at: \(frameworkLib)")
66
66
  }
67
67
  }
68
- NSLog("[PN] PythonKit available; attempting Python bootstrap")
69
68
  let sys = Python.import("sys")
70
- NSLog("[PN] Python version: \(sys.version)")
71
- NSLog("[PN] Initial sys.path: \(sys.path)")
69
+ if firstInit {
70
+ // One concise bootstrap line per process; per-page detail is left
71
+ // to Python-side print() statements streamed via pn run ios.
72
+ let shortVersion = "\(sys.version)".split(separator: "\n").first.map(String.init) ?? "\(sys.version)"
73
+ NSLog("[PN] Python \(shortVersion) initialized")
74
+ }
72
75
  if let resourcePath = Bundle.main.resourcePath {
73
76
  sys.path.append(resourcePath)
74
77
  sys.path.append("\(resourcePath)/app")
75
- NSLog("[PN] Updated sys.path: \(sys.path)")
76
- // List bundled resources to verify Python files are present
77
- let fm = FileManager.default
78
- let appDir = "\(resourcePath)/app"
79
- if let entries = try? fm.contentsOfDirectory(atPath: appDir) {
80
- NSLog("[PN] Contents of /app in bundle: \(entries)")
81
- } else {
82
- NSLog("[PN] Could not list contents of \(appDir).")
83
- }
84
78
  }
85
79
  // Determine which Python page to load
86
80
  let pagePath: String = requestedPagePath ?? "app.main_page.MainPage"
@@ -5,6 +5,7 @@ importing platform-specific packages at module level.
5
5
  """
6
6
 
7
7
  import os
8
+ import sys
8
9
  from typing import Any, Optional
9
10
 
10
11
  # ======================================================================
@@ -12,6 +13,7 @@ from typing import Any, Optional
12
13
  # ======================================================================
13
14
 
14
15
  _is_android: Optional[bool] = None
16
+ _is_ios: Optional[bool] = None
15
17
 
16
18
 
17
19
  def _detect_android() -> bool:
@@ -27,10 +29,40 @@ def _detect_android() -> bool:
27
29
  return False
28
30
 
29
31
 
32
+ def _detect_ios() -> bool:
33
+ """Detect whether we're running inside an iOS app bundle.
34
+
35
+ Signals, in priority order:
36
+
37
+ - Explicit ``PN_PLATFORM=ios`` env var (set by the iOS template's
38
+ ``ViewController.swift`` before Python starts). This is the
39
+ canonical signal and survives even on hosts where ``sys.platform``
40
+ is generic ``darwin``.
41
+ - ``sys.platform == "ios"`` (CPython 3.13+ native iOS builds).
42
+ - ``/CoreSimulator/Devices/`` in ``$HOME`` (iOS Simulator fallback
43
+ if the template signal is missing for some reason).
44
+
45
+ Crucially, having ``rubicon-objc`` importable is *not* enough:
46
+ developers frequently install it on macOS via the ``[ios]`` extra,
47
+ and treating that as iOS would cause subtle side effects
48
+ (e.g. stdout redirection) on desktop machines.
49
+ """
50
+ if os.environ.get("PN_PLATFORM") == "ios":
51
+ return True
52
+ if sys.platform == "ios":
53
+ return True
54
+ home = os.environ.get("HOME", "")
55
+ if "/CoreSimulator/Devices/" in home:
56
+ return True
57
+ return False
58
+
59
+
30
60
  def _ensure_platform_detection() -> None:
31
- global _is_android
61
+ global _is_android, _is_ios
32
62
  if _is_android is None:
33
63
  _is_android = _detect_android()
64
+ if _is_ios is None:
65
+ _is_ios = (not _is_android) and _detect_ios()
34
66
 
35
67
 
36
68
  def _get_is_android() -> bool:
@@ -39,7 +71,14 @@ def _get_is_android() -> bool:
39
71
  return _is_android
40
72
 
41
73
 
74
+ def _get_is_ios() -> bool:
75
+ _ensure_platform_detection()
76
+ assert _is_ios is not None
77
+ return _is_ios
78
+
79
+
42
80
  IS_ANDROID: bool = _get_is_android()
81
+ IS_IOS: bool = _get_is_ios()
43
82
 
44
83
  # ======================================================================
45
84
  # Android context management
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pythonnative
3
- Version: 0.8.0
3
+ Version: 0.9.0
4
4
  Summary: Cross-platform native UI toolkit for Android and iOS
5
5
  Author: Owen Carey
6
6
  License: MIT License
@@ -2,6 +2,7 @@ LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
4
  src/pythonnative/__init__.py
5
+ src/pythonnative/_ios_log.py
5
6
  src/pythonnative/components.py
6
7
  src/pythonnative/element.py
7
8
  src/pythonnative/hooks.py
@@ -83,8 +84,10 @@ tests/test_cli.py
83
84
  tests/test_components.py
84
85
  tests/test_element.py
85
86
  tests/test_hooks.py
87
+ tests/test_ios_log.py
86
88
  tests/test_native_views.py
87
89
  tests/test_navigation.py
88
90
  tests/test_reconciler.py
89
91
  tests/test_smoke.py
90
- tests/test_style.py
92
+ tests/test_style.py
93
+ tests/test_utils.py
@@ -41,6 +41,28 @@ def test_cli_init_and_clean() -> None:
41
41
  shutil.rmtree(tmpdir, ignore_errors=True)
42
42
 
43
43
 
44
+ def test_cli_run_help_lists_logging_flags() -> None:
45
+ """`pn run --help` should advertise both --no-logs and --hot-reload."""
46
+ tmpdir = tempfile.mkdtemp(prefix="pn_cli_test_")
47
+ try:
48
+ result = run_pn(["run", "--help"], tmpdir)
49
+ assert result.returncode == 0, result.stderr
50
+ assert "--no-logs" in result.stdout
51
+ assert "--hot-reload" in result.stdout
52
+ assert "--prepare-only" in result.stdout
53
+ finally:
54
+ shutil.rmtree(tmpdir, ignore_errors=True)
55
+
56
+
57
+ def test_cli_run_rejects_unknown_flag() -> None:
58
+ tmpdir = tempfile.mkdtemp(prefix="pn_cli_test_")
59
+ try:
60
+ result = run_pn(["run", "android", "--does-not-exist"], tmpdir)
61
+ assert result.returncode != 0
62
+ finally:
63
+ shutil.rmtree(tmpdir, ignore_errors=True)
64
+
65
+
44
66
  def test_cli_run_prepare_only_android_and_ios() -> None:
45
67
  tmpdir = tempfile.mkdtemp(prefix="pn_cli_test_")
46
68
  try:
@@ -48,8 +70,10 @@ def test_cli_run_prepare_only_android_and_ios() -> None:
48
70
  result = run_pn(["init", "MyApp"], tmpdir)
49
71
  assert result.returncode == 0, result.stderr
50
72
 
51
- # prepare-only android
52
- result = run_pn(["run", "android", "--prepare-only"], tmpdir)
73
+ # prepare-only android, combined with --no-logs to verify both flags
74
+ # coexist without launching any adb/simctl subprocess (prepare-only
75
+ # returns before logcat would ever be spawned).
76
+ result = run_pn(["run", "android", "--prepare-only", "--no-logs"], tmpdir)
53
77
  assert result.returncode == 0, result.stderr
54
78
  android_root = os.path.join(tmpdir, "build", "android", "android_template")
55
79
  assert os.path.isdir(android_root)
@@ -77,8 +101,8 @@ def test_cli_run_prepare_only_android_and_ios() -> None:
77
101
  )
78
102
  assert os.path.isfile(nav_graph)
79
103
 
80
- # prepare-only ios
81
- result = run_pn(["run", "ios", "--prepare-only"], tmpdir)
104
+ # prepare-only ios with --no-logs
105
+ result = run_pn(["run", "ios", "--prepare-only", "--no-logs"], tmpdir)
82
106
  assert result.returncode == 0, result.stderr
83
107
  assert os.path.isdir(os.path.join(tmpdir, "build", "ios", "ios_template"))
84
108
  finally:
@@ -0,0 +1,147 @@
1
+ """Tests for pythonnative._ios_log (iOS stdout/stderr redirection).
2
+
3
+ These tests exercise the module's behavior directly without requiring
4
+ an actual iOS runtime: ``_StderrStream`` writes via ``os.write(2, ...)``,
5
+ and ``install()`` is idempotent and swaps the ``sys`` streams in place.
6
+ """
7
+
8
+ import io
9
+ import os
10
+ import sys
11
+ from typing import Generator, List, Tuple
12
+
13
+ import pytest
14
+
15
+ from pythonnative import _ios_log
16
+ from pythonnative._ios_log import _StderrStream, install
17
+
18
+
19
+ class TestStderrStream:
20
+ def test_write_sends_utf8_bytes_to_fd_2(self, monkeypatch: pytest.MonkeyPatch) -> None:
21
+ captured: List[Tuple[int, bytes]] = []
22
+
23
+ def fake_write(fd: int, data: bytes) -> int:
24
+ captured.append((fd, data))
25
+ return len(data)
26
+
27
+ monkeypatch.setattr(os, "write", fake_write)
28
+
29
+ stream = _StderrStream()
30
+ written = stream.write("hello\n")
31
+
32
+ assert captured == [(2, b"hello\n")]
33
+ assert written == len(b"hello\n")
34
+
35
+ def test_write_handles_non_ascii(self, monkeypatch: pytest.MonkeyPatch) -> None:
36
+ captured: List[bytes] = []
37
+
38
+ def fake_write(fd: int, data: bytes) -> int:
39
+ captured.append(data)
40
+ return len(data)
41
+
42
+ monkeypatch.setattr(os, "write", fake_write)
43
+
44
+ _StderrStream().write("héllo ✓\n")
45
+
46
+ assert captured == ["héllo ✓\n".encode("utf-8")]
47
+
48
+ def test_write_empty_string_is_no_op(self, monkeypatch: pytest.MonkeyPatch) -> None:
49
+ calls: List[Tuple[int, bytes]] = []
50
+
51
+ def fake_write(fd: int, data: bytes) -> int:
52
+ calls.append((fd, data))
53
+ return len(data)
54
+
55
+ monkeypatch.setattr(os, "write", fake_write)
56
+
57
+ assert _StderrStream().write("") == 0
58
+ assert calls == []
59
+
60
+ def test_write_swallows_oserror(self, monkeypatch: pytest.MonkeyPatch) -> None:
61
+ def boom(fd: int, data: bytes) -> int:
62
+ raise OSError("nope")
63
+
64
+ monkeypatch.setattr(os, "write", boom)
65
+
66
+ assert _StderrStream().write("x") == 0
67
+
68
+ def test_writelines_iterates(self, monkeypatch: pytest.MonkeyPatch) -> None:
69
+ captured: List[bytes] = []
70
+
71
+ def fake_write(fd: int, data: bytes) -> int:
72
+ captured.append(data)
73
+ return len(data)
74
+
75
+ monkeypatch.setattr(os, "write", fake_write)
76
+
77
+ _StderrStream().writelines(["a\n", "b\n", "c\n"])
78
+
79
+ assert captured == [b"a\n", b"b\n", b"c\n"]
80
+
81
+ def test_stream_metadata(self) -> None:
82
+ stream = _StderrStream()
83
+ assert stream.encoding == "utf-8"
84
+ assert stream.errors == "replace"
85
+ assert stream.fileno() == 2
86
+ assert stream.closed is False
87
+ # flush() and close() are deliberate no-ops; just exercise them.
88
+ stream.flush()
89
+ stream.close()
90
+
91
+ def test_isatty_reflects_fd_2(self, monkeypatch: pytest.MonkeyPatch) -> None:
92
+ monkeypatch.setattr(os, "isatty", lambda fd: fd == 2)
93
+ assert _StderrStream().isatty() is True
94
+
95
+ def raiser(fd: int) -> bool:
96
+ raise OSError("bad fd")
97
+
98
+ monkeypatch.setattr(os, "isatty", raiser)
99
+ assert _StderrStream().isatty() is False
100
+
101
+
102
+ class TestInstall:
103
+ @pytest.fixture(autouse=True)
104
+ def _reset_install_flag(self) -> Generator[None, None, None]:
105
+ """Ensure each test starts with a fresh, un-installed state."""
106
+ original_stdout = sys.stdout
107
+ original_stderr = sys.stderr
108
+ original_installed = _ios_log._installed
109
+ _ios_log._installed = False
110
+ try:
111
+ yield
112
+ finally:
113
+ sys.stdout = original_stdout
114
+ sys.stderr = original_stderr
115
+ _ios_log._installed = original_installed
116
+
117
+ def test_install_replaces_streams(self) -> None:
118
+ assert not isinstance(sys.stdout, _StderrStream)
119
+ assert not isinstance(sys.stderr, _StderrStream)
120
+
121
+ install()
122
+
123
+ assert isinstance(sys.stdout, _StderrStream)
124
+ assert isinstance(sys.stderr, _StderrStream)
125
+
126
+ def test_install_is_idempotent(self) -> None:
127
+ install()
128
+ first_stdout = sys.stdout
129
+ first_stderr = sys.stderr
130
+
131
+ install()
132
+ install()
133
+
134
+ # Second+ install() calls must be no-ops — the same objects remain.
135
+ assert sys.stdout is first_stdout
136
+ assert sys.stderr is first_stderr
137
+
138
+ def test_install_does_not_raise_when_streams_missing(self, monkeypatch: pytest.MonkeyPatch) -> None:
139
+ # Replace stdout/stderr with something odd before install to confirm
140
+ # install() doesn't care about the prior stream.
141
+ monkeypatch.setattr(sys, "stdout", io.StringIO())
142
+ monkeypatch.setattr(sys, "stderr", io.StringIO())
143
+
144
+ install()
145
+
146
+ assert isinstance(sys.stdout, _StderrStream)
147
+ assert isinstance(sys.stderr, _StderrStream)
@@ -0,0 +1,70 @@
1
+ """Tests for pythonnative.utils platform detection."""
2
+
3
+ import os
4
+ import sys
5
+
6
+ import pytest
7
+
8
+ from pythonnative.utils import IS_ANDROID, IS_IOS, _detect_ios
9
+
10
+
11
+ class TestIosDetection:
12
+ """``_detect_ios()`` should key off explicit signals only, not on the
13
+ presence of optional packages like ``rubicon-objc``.
14
+ """
15
+
16
+ def test_detects_via_pn_platform_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
17
+ monkeypatch.setenv("PN_PLATFORM", "ios")
18
+ assert _detect_ios() is True
19
+
20
+ def test_other_pn_platform_values_ignored(self, monkeypatch: pytest.MonkeyPatch) -> None:
21
+ monkeypatch.setenv("PN_PLATFORM", "desktop")
22
+ monkeypatch.delenv("HOME", raising=False)
23
+ monkeypatch.setattr(sys, "platform", "darwin")
24
+ assert _detect_ios() is False
25
+
26
+ def test_detects_via_sys_platform_ios(self, monkeypatch: pytest.MonkeyPatch) -> None:
27
+ monkeypatch.delenv("PN_PLATFORM", raising=False)
28
+ monkeypatch.setattr(sys, "platform", "ios")
29
+ assert _detect_ios() is True
30
+
31
+ def test_detects_via_core_simulator_home(self, monkeypatch: pytest.MonkeyPatch) -> None:
32
+ monkeypatch.delenv("PN_PLATFORM", raising=False)
33
+ monkeypatch.setattr(sys, "platform", "darwin")
34
+ monkeypatch.setenv(
35
+ "HOME",
36
+ "/Users/x/Library/Developer/CoreSimulator/Devices/ABCD/data",
37
+ )
38
+ assert _detect_ios() is True
39
+
40
+ def test_plain_macos_is_not_ios(self, monkeypatch: pytest.MonkeyPatch) -> None:
41
+ monkeypatch.delenv("PN_PLATFORM", raising=False)
42
+ monkeypatch.setattr(sys, "platform", "darwin")
43
+ monkeypatch.setenv("HOME", "/Users/owen")
44
+ assert _detect_ios() is False
45
+
46
+ def test_plain_linux_is_not_ios(self, monkeypatch: pytest.MonkeyPatch) -> None:
47
+ monkeypatch.delenv("PN_PLATFORM", raising=False)
48
+ monkeypatch.setattr(sys, "platform", "linux")
49
+ monkeypatch.setenv("HOME", "/home/runner")
50
+ assert _detect_ios() is False
51
+
52
+
53
+ class TestPlatformFlagsConsistency:
54
+ def test_both_flags_are_bools(self) -> None:
55
+ assert isinstance(IS_ANDROID, bool)
56
+ assert isinstance(IS_IOS, bool)
57
+
58
+ def test_flags_are_mutually_exclusive(self) -> None:
59
+ # A single Python process is never simultaneously Android and iOS.
60
+ assert not (IS_ANDROID and IS_IOS)
61
+
62
+ def test_ci_environment_is_neither(self) -> None:
63
+ """The test suite runs on Linux/macOS hosts, so both flags are False."""
64
+ # This is more of a smoke check of the import-time detection: if
65
+ # this ever starts being True on CI, someone has accidentally made
66
+ # platform detection overeager on non-device hosts.
67
+ if os.environ.get("PN_PLATFORM") == "ios":
68
+ pytest.skip("Running under PN_PLATFORM=ios; flags correctly reflect that.")
69
+ assert IS_ANDROID is False
70
+ assert IS_IOS is False
File without changes
File without changes
File without changes