Skip to content

Commit 2bdc7be

Browse files
committed
Forward Python stdout/stderr to Apple unified logging
Added SeriousPythonLogForwarder to capture and forward Python stdout and stderr to Apple's unified logging system on iOS/macOS. This enables Python output to appear in flutter run and Xcode logs. The feature can be disabled by setting SERIOUS_PYTHON_FORWARD_STDIO=0 in environmentVariables. Updated README with usage details.
1 parent 68b5e84 commit 2bdc7be

File tree

2 files changed

+152
-0
lines changed

2 files changed

+152
-0
lines changed

src/serious_python/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,12 @@ Use `--verbose` flag to enabled detailed logging:
181181
dart run serious_python:main package app/src -p Darwin --verbose
182182
```
183183

184+
### iOS/macOS Python output
185+
186+
On Darwin (iOS/macOS), Python `stdout`/`stderr` is forwarded into Apple’s unified logging, so it’s visible in `flutter run` and Xcode logs.
187+
188+
To disable forwarding, set `SERIOUS_PYTHON_FORWARD_STDIO=0` in `environmentVariables`.
189+
184190
## Examples
185191

186192
[Python REPL with Flask backend](src/serious_python/example/flask_example).

src/serious_python_darwin/darwin/Classes/SeriousPythonPlugin.swift

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ import UIKit
55
import FlutterMacOS
66
#endif
77

8+
import Foundation
9+
import Dispatch
10+
import Darwin
811
import Python
12+
import os.log
913

1014
public class SeriousPythonPlugin: NSObject, FlutterPlugin {
1115

@@ -110,6 +114,7 @@ public class SeriousPythonPlugin: NSObject, FlutterPlugin {
110114
}
111115

112116
@objc func runPythonFile(appPath: String) {
117+
SeriousPythonLogForwarder.shared.beginCapturing()
113118
Py_Initialize()
114119

115120
// run app
@@ -120,9 +125,11 @@ public class SeriousPythonPlugin: NSObject, FlutterPlugin {
120125
}
121126

122127
Py_Finalize()
128+
SeriousPythonLogForwarder.shared.endCapturing()
123129
}
124130

125131
@objc func runPythonScript(script: String) {
132+
SeriousPythonLogForwarder.shared.beginCapturing()
126133
Py_Initialize()
127134

128135
// run app
@@ -132,5 +139,144 @@ public class SeriousPythonPlugin: NSObject, FlutterPlugin {
132139
}
133140

134141
Py_Finalize()
142+
SeriousPythonLogForwarder.shared.endCapturing()
143+
}
144+
}
145+
146+
private final class SeriousPythonLogForwarder {
147+
static let shared = SeriousPythonLogForwarder()
148+
149+
private let lock = NSLock()
150+
private var captureCount = 0
151+
private let enableEnvVar = "SERIOUS_PYTHON_FORWARD_STDIO"
152+
153+
private var originalStdout: Int32 = -1
154+
private var originalStderr: Int32 = -1
155+
private var pipeReadFD: Int32 = -1
156+
private var source: DispatchSourceRead?
157+
158+
private let queue = DispatchQueue(label: "serious_python.log_forwarder")
159+
private var buffer = Data()
160+
private let log = OSLog(subsystem: "dev.flet.serious_python", category: "python")
161+
162+
func beginCapturing() {
163+
lock.lock()
164+
defer { lock.unlock() }
165+
166+
if let v = ProcessInfo.processInfo.environment[enableEnvVar]?.lowercased(),
167+
v == "0" || v == "false" || v == "no" {
168+
return
169+
}
170+
171+
captureCount += 1
172+
guard captureCount == 1 else { return }
173+
174+
var fds: [Int32] = [0, 0]
175+
guard pipe(&fds) == 0 else {
176+
os_log("serious_python: failed to create pipe: errno=%d", log: log, type: .error, errno)
177+
captureCount = 0
178+
return
179+
}
180+
181+
originalStdout = dup(STDOUT_FILENO)
182+
originalStderr = dup(STDERR_FILENO)
183+
184+
fflush(stdout)
185+
fflush(stderr)
186+
setbuf(stdout, nil)
187+
setbuf(stderr, nil)
188+
189+
_ = dup2(fds[1], STDOUT_FILENO)
190+
_ = dup2(fds[1], STDERR_FILENO)
191+
close(fds[1])
192+
193+
pipeReadFD = fds[0]
194+
_ = fcntl(pipeReadFD, F_SETFL, O_NONBLOCK)
195+
196+
let src = DispatchSource.makeReadSource(fileDescriptor: pipeReadFD, queue: queue)
197+
src.setEventHandler { [weak self] in
198+
self?._drain()
199+
}
200+
src.setCancelHandler { [weak self] in
201+
guard let self else { return }
202+
if self.pipeReadFD >= 0 {
203+
close(self.pipeReadFD)
204+
self.pipeReadFD = -1
205+
}
206+
}
207+
source = src
208+
src.resume()
209+
}
210+
211+
func endCapturing() {
212+
lock.lock()
213+
defer { lock.unlock() }
214+
215+
guard captureCount > 0 else { return }
216+
captureCount -= 1
217+
guard captureCount == 0 else { return }
218+
219+
fflush(stdout)
220+
fflush(stderr)
221+
queue.sync { self._drain() }
222+
223+
if originalStdout >= 0 {
224+
_ = dup2(originalStdout, STDOUT_FILENO)
225+
close(originalStdout)
226+
originalStdout = -1
227+
}
228+
229+
if originalStderr >= 0 {
230+
_ = dup2(originalStderr, STDERR_FILENO)
231+
close(originalStderr)
232+
originalStderr = -1
233+
}
234+
235+
queue.sync { self._drain() }
236+
237+
source?.cancel()
238+
source = nil
239+
240+
if !buffer.isEmpty {
241+
_emit(String(decoding: buffer, as: UTF8.self))
242+
buffer.removeAll(keepingCapacity: false)
243+
}
244+
}
245+
246+
private func _drain() {
247+
guard pipeReadFD >= 0 else { return }
248+
249+
var chunk = [UInt8](repeating: 0, count: 4096)
250+
while true {
251+
let n = read(pipeReadFD, &chunk, chunk.count)
252+
if n > 0 {
253+
buffer.append(contentsOf: chunk[0..<n])
254+
_flushLines()
255+
if buffer.count > 32 * 1024 {
256+
_emit(String(decoding: buffer, as: UTF8.self))
257+
buffer.removeAll(keepingCapacity: true)
258+
}
259+
} else if n == 0 {
260+
break
261+
} else {
262+
if errno == EAGAIN || errno == EWOULDBLOCK { break }
263+
os_log("serious_python: read() failed: errno=%d", log: log, type: .error, errno)
264+
break
265+
}
266+
}
267+
}
268+
269+
private func _flushLines() {
270+
while let newlineIndex = buffer.firstIndex(of: 0x0A) {
271+
let lineData = buffer.subdata(in: 0..<newlineIndex)
272+
buffer.removeSubrange(0...newlineIndex)
273+
_emit(String(decoding: lineData, as: UTF8.self))
274+
}
275+
}
276+
277+
private func _emit(_ message: String) {
278+
let trimmed = message.trimmingCharacters(in: .newlines)
279+
guard !trimmed.isEmpty else { return }
280+
os_log("%{public}@", log: log, type: .default, trimmed)
135281
}
136282
}

0 commit comments

Comments
 (0)