Skip to content

Commit 57179b9

Browse files
committed
feat(cli,page,templates): stream Python logs from pn run android/ios
1 parent 1a3bf47 commit 57179b9

15 files changed

Lines changed: 675 additions & 59 deletions

File tree

.github/workflows/e2e.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ jobs:
4242
arch: x86_64
4343
script: >-
4444
cd examples/hello-world &&
45-
pn run android &&
45+
pn run android --no-logs &&
4646
sleep 5 &&
4747
cd ../.. &&
4848
maestro test tests/e2e/android.yaml
@@ -71,7 +71,7 @@ jobs:
7171
7272
- name: Build and launch iOS app
7373
working-directory: examples/hello-world
74-
run: pn run ios
74+
run: pn run ios --no-logs
7575
env:
7676
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
7777

docs/getting-started.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,39 @@ pn run ios --prepare-only
6464

6565
This stages files under `build/` so you can open them in Android Studio or Xcode.
6666

67+
## Viewing logs
68+
69+
After the app launches, `pn run` attaches to the app's stdout/stderr so Python
70+
`print()` output and tracebacks stream back into your terminal until you press
71+
Ctrl+C:
72+
73+
```python
74+
import pythonnative as pn
75+
76+
77+
@pn.component
78+
def MainPage():
79+
count, set_count = pn.use_state(0)
80+
print(f"[MainPage] render count={count}")
81+
return pn.Column(
82+
pn.Text(f"Count: {count}"),
83+
pn.Button("Tap me", on_click=lambda: set_count(count + 1)),
84+
)
85+
```
86+
87+
- On Android, logs are streamed via `adb logcat` filtered to the
88+
`python.stdout` / `python.stderr` tags (that Chaquopy redirects `print()` to)
89+
plus the template's Kotlin tags.
90+
- On iOS Simulator, the app is launched via `xcrun simctl launch --console-pty`,
91+
which forwards the Python process's standard streams to your terminal.
92+
93+
Pass `--no-logs` if you'd rather run fire-and-forget:
94+
95+
```bash
96+
pn run android --no-logs
97+
pn run ios --no-logs
98+
```
99+
67100
## Clean
68101

69102
Remove the build artifacts safely:

docs/guides/android.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,24 @@ pn run android --prepare-only
2626

2727
This will stage files under `build/android/android_template` so you can open it in Android Studio if you prefer.
2828

29+
## Viewing logs
30+
31+
After the app is installed and launched, `pn run android` tails `adb logcat` and
32+
streams it back to your terminal. The filter is scoped to the tags Chaquopy and
33+
the template use, so you get Python output without the usual logcat noise:
34+
35+
| Tag | Source |
36+
|-----------------|--------------------------------------------------|
37+
| `python.stdout` | `print()` / anything written to `sys.stdout` |
38+
| `python.stderr` | tracebacks / anything written to `sys.stderr` |
39+
| `MainActivity`, `PageFragment`, `Navigator` | Kotlin template lifecycle |
40+
| `AndroidRuntime:E` | Fatal Java/Kotlin exceptions |
41+
42+
Press Ctrl+C to stop streaming. Pass `--no-logs` to skip log streaming
43+
entirely (useful in CI or when you'd rather watch logs from Android Studio).
44+
45+
If you need unfiltered output, run `adb logcat` yourself in another terminal.
46+
2947
## Clean
3048

3149
Remove the build directory safely:

docs/guides/ios.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,29 @@ pn run ios --prepare-only
2626

2727
You can then open `build/ios/ios_template/ios_template.xcodeproj` in Xcode.
2828

29+
## Viewing logs
30+
31+
After building and installing into the Simulator, `pn run ios` launches the app
32+
with `xcrun simctl launch --console-pty`, which attaches your terminal to the
33+
app's stdout/stderr. Python `print()` calls and exception tracebacks appear
34+
inline until you press Ctrl+C, at which point the app is terminated cleanly.
35+
36+
`SIMCTL_CHILD_PYTHONUNBUFFERED=1` is forwarded to the launched process so
37+
output is line-buffered and doesn't get stuck behind Python's stream buffers.
38+
39+
Pass `--no-logs` to skip the console attach and use the legacy fire-and-exit
40+
launch instead — useful when you want to continue interacting with the app
41+
from Xcode or Console.app, or when running in a non-interactive context like
42+
CI (see the `e2e.yml` workflow for an example).
43+
44+
### Requirements
45+
46+
- **Xcode 14 or newer.** `simctl launch --console-pty` was added in Xcode 14;
47+
on older toolchains either upgrade Xcode or pass `--no-logs`.
48+
- Python `print()` output on the Simulator is routed through the app's stderr
49+
by the `pythonnative._ios_log` module — this runs automatically when the
50+
embedded Python bootstraps on iOS, so you don't need to configure it.
51+
2952
## Clean
3053

3154
Remove the build directory safely:

examples/hello-world/app/main_page.py

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1+
from typing import Callable
2+
13
import emoji
24

35
import pythonnative as pn
46
from pythonnative.navigation import NavigationContainer, create_tab_navigator
57

8+
print("[hello-world] main_page module imported")
9+
610
MEDALS = [":1st_place_medal:", ":2nd_place_medal:", ":3rd_place_medal:"]
711

812
Tab = create_tab_navigator()
@@ -28,12 +32,22 @@ def counter_badge(initial: int = 0) -> pn.Element:
2832
count, set_count = pn.use_state(initial)
2933
medal = emoji.emojize(MEDALS[count] if count < len(MEDALS) else ":star:")
3034

35+
print(f"[counter_badge] render count={count}")
36+
37+
def handle_tap() -> None:
38+
print(f"[counter_badge] Tap me clicked; {count} -> {count + 1}")
39+
set_count(count + 1)
40+
41+
def handle_reset() -> None:
42+
print(f"[counter_badge] Reset clicked from count={count}")
43+
set_count(0)
44+
3145
return pn.View(
3246
pn.Text(f"Tapped {count} times", style=styles["subtitle"]),
3347
pn.Text(medal, style=styles["medal"]),
3448
pn.Row(
35-
pn.Button("Tap me", on_click=lambda: set_count(count + 1)),
36-
pn.Button("Reset", on_click=lambda: set_count(0)),
49+
pn.Button("Tap me", on_click=handle_tap),
50+
pn.Button("Reset", on_click=handle_reset),
3751
style=styles["button_row"],
3852
),
3953
style=styles["card"],
@@ -44,17 +58,25 @@ def counter_badge(initial: int = 0) -> pn.Element:
4458
def HomeTab() -> pn.Element:
4559
"""Home tab — counter demo and push-navigation to other pages."""
4660
nav = pn.use_navigation()
61+
62+
def _on_mount() -> Callable[[], None]:
63+
print("[HomeTab] mounted")
64+
return lambda: print("[HomeTab] unmounted")
65+
66+
pn.use_effect(_on_mount, [])
67+
68+
def go_to_second() -> None:
69+
print("[HomeTab] navigating to SecondPage")
70+
nav.navigate(
71+
"app.second_page.SecondPage",
72+
params={"message": "Greetings from MainPage"},
73+
)
74+
4775
return pn.ScrollView(
4876
pn.Column(
4977
pn.Text("Hello from PythonNative Demo!", style=styles["title"]),
5078
counter_badge(),
51-
pn.Button(
52-
"Go to Second Page",
53-
on_click=lambda: nav.navigate(
54-
"app.second_page.SecondPage",
55-
params={"message": "Greetings from MainPage"},
56-
),
57-
),
79+
pn.Button("Go to Second Page", on_click=go_to_second),
5880
style=styles["section"],
5981
)
6082
)
Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,27 @@
11
import pythonnative as pn
22

3+
print("[hello-world] second_page module imported")
4+
35

46
@pn.component
57
def SecondPage() -> pn.Element:
68
nav = pn.use_navigation()
79
message = nav.get_params().get("message", "Second Page")
10+
print(f"[SecondPage] render message={message!r}")
11+
12+
def go_to_third() -> None:
13+
print("[SecondPage] navigating to ThirdPage")
14+
nav.navigate("app.third_page.ThirdPage")
15+
16+
def go_back() -> None:
17+
print("[SecondPage] going back")
18+
nav.go_back()
19+
820
return pn.ScrollView(
921
pn.Column(
1022
pn.Text(message, style={"font_size": 24, "bold": True}),
11-
pn.Button(
12-
"Go to Third Page",
13-
on_click=lambda: nav.navigate("app.third_page.ThirdPage"),
14-
),
15-
pn.Button("Back", on_click=nav.go_back),
23+
pn.Button("Go to Third Page", on_click=go_to_third),
24+
pn.Button("Back", on_click=go_back),
1625
style={"spacing": 16, "padding": 24, "align_items": "stretch"},
1726
)
1827
)
Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,22 @@
11
import pythonnative as pn
22

3+
print("[hello-world] third_page module imported")
4+
35

46
@pn.component
57
def ThirdPage() -> pn.Element:
68
nav = pn.use_navigation()
9+
print("[ThirdPage] render")
10+
11+
def go_back() -> None:
12+
print("[ThirdPage] going back")
13+
nav.go_back()
14+
715
return pn.ScrollView(
816
pn.Column(
917
pn.Text("Third Page", style={"font_size": 24, "bold": True}),
1018
pn.Text("You navigated two levels deep."),
11-
pn.Button("Back to Second", on_click=nav.go_back),
19+
pn.Button("Back to Second", on_click=go_back),
1220
style={"spacing": 16, "padding": 24, "align_items": "stretch"},
1321
)
1422
)

src/pythonnative/_ios_log.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

0 commit comments

Comments
 (0)