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.
- {pythonnative-0.8.0/src/pythonnative.egg-info → pythonnative-0.9.0}/PKG-INFO +1 -1
- {pythonnative-0.8.0 → pythonnative-0.9.0}/pyproject.toml +1 -1
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/__init__.py +1 -1
- pythonnative-0.9.0/src/pythonnative/_ios_log.py +94 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/cli/pn.py +131 -11
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/page.py +16 -1
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/ios_template/ios_template/ViewController.swift +19 -25
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/utils.py +40 -1
- {pythonnative-0.8.0 → pythonnative-0.9.0/src/pythonnative.egg-info}/PKG-INFO +1 -1
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative.egg-info/SOURCES.txt +4 -1
- {pythonnative-0.8.0 → pythonnative-0.9.0}/tests/test_cli.py +28 -4
- pythonnative-0.9.0/tests/test_ios_log.py +147 -0
- pythonnative-0.9.0/tests/test_utils.py +70 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/LICENSE +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/README.md +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/setup.cfg +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/cli/__init__.py +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/components.py +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/element.py +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/hooks.py +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/hot_reload.py +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/native_modules/__init__.py +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/native_modules/camera.py +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/native_modules/file_system.py +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/native_modules/location.py +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/native_modules/notifications.py +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/native_views/__init__.py +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/native_views/android.py +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/native_views/base.py +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/native_views/ios.py +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/navigation.py +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/reconciler.py +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/style.py +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/app/build.gradle +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/app/proguard-rules.pro +0 -0
- {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
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/app/src/main/AndroidManifest.xml +0 -0
- {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
- {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
- {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
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/app/src/main/res/drawable/ic_launcher_background.xml +0 -0
- {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
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/app/src/main/res/layout/activity_main.xml +0 -0
- {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
- {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
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-hdpi/ic_launcher.webp +0 -0
- {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
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-mdpi/ic_launcher.webp +0 -0
- {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
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-xhdpi/ic_launcher.webp +0 -0
- {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
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp +0 -0
- {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
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp +0 -0
- {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
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/app/src/main/res/navigation/nav_graph.xml +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/app/src/main/res/values/colors.xml +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/app/src/main/res/values/strings.xml +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/app/src/main/res/values/themes.xml +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/app/src/main/res/values-night/themes.xml +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/app/src/main/res/xml/backup_rules.xml +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/app/src/main/res/xml/data_extraction_rules.xml +0 -0
- {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
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/build.gradle +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/gradle/wrapper/gradle-wrapper.jar +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/gradle/wrapper/gradle-wrapper.properties +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/gradle.properties +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/gradlew +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/gradlew.bat +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/settings.gradle +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/ios_template/ios_template/AppDelegate.swift +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/ios_template/ios_template/Assets.xcassets/AccentColor.colorset/Contents.json +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/ios_template/ios_template/Assets.xcassets/AppIcon.appiconset/Contents.json +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/ios_template/ios_template/Assets.xcassets/Contents.json +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/ios_template/ios_template/Base.lproj/LaunchScreen.storyboard +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/ios_template/ios_template/Base.lproj/Main.storyboard +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/ios_template/ios_template/Info.plist +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/ios_template/ios_template/SceneDelegate.swift +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/ios_template/ios_template.xcodeproj/project.pbxproj +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/ios_template/ios_template.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/ios_template/ios_templateTests/ios_templateTests.swift +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/ios_template/ios_templateUITests/ios_templateUITests.swift +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/ios_template/ios_templateUITests/ios_templateUITestsLaunchTests.swift +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative.egg-info/dependency_links.txt +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative.egg-info/entry_points.txt +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative.egg-info/requires.txt +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative.egg-info/top_level.txt +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/tests/test_components.py +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/tests/test_element.py +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/tests/test_hooks.py +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/tests/test_native_views.py +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/tests/test_navigation.py +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/tests/test_reconciler.py +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/tests/test_smoke.py +0 -0
- {pythonnative-0.8.0 → pythonnative-0.9.0}/tests/test_style.py +0 -0
|
@@ -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
|
-
|
|
645
|
-
#
|
|
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
|
-
|
|
648
|
-
|
|
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
|
-
|
|
658
|
-
|
|
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
|
-
|
|
786
|
+
if logcat_proc is not None:
|
|
787
|
+
logcat_proc.wait()
|
|
788
|
+
else:
|
|
789
|
+
import time
|
|
678
790
|
|
|
679
|
-
|
|
680
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
//
|
|
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
|
|
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
|
-
|
|
71
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/build.gradle
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/gradlew
RENAMED
|
File without changes
|
{pythonnative-0.8.0 → pythonnative-0.9.0}/src/pythonnative/templates/android_template/gradlew.bat
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|