Skip to content

Commit 93b37bc

Browse files
committed
[3.10] pythongh-126925: Modify how iOS test results are gathered (pythonGH-127592) (python#127754)
Adds a `use_system_log` config item to enable stdout/stderr redirection for Apple platforms. This log streaming is then used by a new iOS test runner script, allowing the display of test suite output at runtime. The iOS test runner script can be used by any Python project, not just the CPython test suite. (cherry picked from commit 2041a95)
1 parent 34111ef commit 93b37bc

File tree

15 files changed

+759
-36
lines changed

15 files changed

+759
-36
lines changed

Doc/c-api/init_config.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1101,6 +1101,15 @@ PyConfig
11011101
11021102
Default: ``1`` in Python config and ``0`` in isolated config.
11031103
1104+
.. c:member:: int use_system_logger
1105+
1106+
If non-zero, ``stdout`` and ``stderr`` will be redirected to the system
1107+
log.
1108+
1109+
Only available on macOS 10.12 and later, and on iOS.
1110+
1111+
Default: ``0`` (don't use system log).
1112+
11041113
.. c:member:: int user_site_directory
11051114
11061115
If non-zero, add the user site directory to :data:`sys.path`.

Include/cpython/initconfig.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,9 @@ typedef struct PyConfig {
173173
int legacy_windows_stdio;
174174
#endif
175175
wchar_t *check_hash_pycs_mode;
176+
#ifdef __APPLE__
177+
int use_system_logger;
178+
#endif
176179

177180
/* --- Path configuration inputs ------------ */
178181
int pathconfig_warnings;

Lib/_apple_support.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import io
2+
import sys
3+
4+
5+
def init_streams(log_write, stdout_level, stderr_level):
6+
# Redirect stdout and stderr to the Apple system log. This method is
7+
# invoked by init_apple_streams() (initconfig.c) if config->use_system_logger
8+
# is enabled.
9+
sys.stdout = SystemLog(log_write, stdout_level, errors=sys.stderr.errors)
10+
sys.stderr = SystemLog(log_write, stderr_level, errors=sys.stderr.errors)
11+
12+
13+
class SystemLog(io.TextIOWrapper):
14+
def __init__(self, log_write, level, **kwargs):
15+
kwargs.setdefault("encoding", "UTF-8")
16+
kwargs.setdefault("line_buffering", True)
17+
super().__init__(LogStream(log_write, level), **kwargs)
18+
19+
def __repr__(self):
20+
return f"<SystemLog (level {self.buffer.level})>"
21+
22+
def write(self, s):
23+
if not isinstance(s, str):
24+
raise TypeError(
25+
f"write() argument must be str, not {type(s).__name__}")
26+
27+
# In case `s` is a str subclass that writes itself to stdout or stderr
28+
# when we call its methods, convert it to an actual str.
29+
s = str.__str__(s)
30+
31+
# We want to emit one log message per line, so split
32+
# the string before sending it to the superclass.
33+
for line in s.splitlines(keepends=True):
34+
super().write(line)
35+
36+
return len(s)
37+
38+
39+
class LogStream(io.RawIOBase):
40+
def __init__(self, log_write, level):
41+
self.log_write = log_write
42+
self.level = level
43+
44+
def __repr__(self):
45+
return f"<LogStream (level {self.level!r})>"
46+
47+
def writable(self):
48+
return True
49+
50+
def write(self, b):
51+
if type(b) is not bytes:
52+
try:
53+
b = bytes(memoryview(b))
54+
except TypeError:
55+
raise TypeError(
56+
f"write() argument must be bytes-like, not {type(b).__name__}"
57+
) from None
58+
59+
# Writing an empty string to the stream should have no effect.
60+
if b:
61+
# Encode null bytes using "modified UTF-8" to avoid truncating the
62+
# message. This should not affect the return value, as the caller
63+
# may be expecting it to match the length of the input.
64+
self.log_write(self.level, b.replace(b"\x00", b"\xc0\x80"))
65+
66+
return len(b)

Lib/test/test_apple.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import unittest
2+
from _apple_support import SystemLog
3+
from test.support import is_apple
4+
from unittest.mock import Mock, call
5+
6+
if not is_apple:
7+
raise unittest.SkipTest("Apple-specific")
8+
9+
10+
# Test redirection of stdout and stderr to the Apple system log.
11+
class TestAppleSystemLogOutput(unittest.TestCase):
12+
maxDiff = None
13+
14+
def assert_writes(self, output):
15+
self.assertEqual(
16+
self.log_write.mock_calls,
17+
[
18+
call(self.log_level, line)
19+
for line in output
20+
]
21+
)
22+
23+
self.log_write.reset_mock()
24+
25+
def setUp(self):
26+
self.log_write = Mock()
27+
self.log_level = 42
28+
self.log = SystemLog(self.log_write, self.log_level, errors="replace")
29+
30+
def test_repr(self):
31+
self.assertEqual(repr(self.log), "<SystemLog (level 42)>")
32+
self.assertEqual(repr(self.log.buffer), "<LogStream (level 42)>")
33+
34+
def test_log_config(self):
35+
self.assertIs(self.log.writable(), True)
36+
self.assertIs(self.log.readable(), False)
37+
38+
self.assertEqual("UTF-8", self.log.encoding)
39+
self.assertEqual("replace", self.log.errors)
40+
41+
self.assertIs(self.log.line_buffering, True)
42+
self.assertIs(self.log.write_through, False)
43+
44+
def test_empty_str(self):
45+
self.log.write("")
46+
self.log.flush()
47+
48+
self.assert_writes([])
49+
50+
def test_simple_str(self):
51+
self.log.write("hello world\n")
52+
53+
self.assert_writes([b"hello world\n"])
54+
55+
def test_buffered_str(self):
56+
self.log.write("h")
57+
self.log.write("ello")
58+
self.log.write(" ")
59+
self.log.write("world\n")
60+
self.log.write("goodbye.")
61+
self.log.flush()
62+
63+
self.assert_writes([b"hello world\n", b"goodbye."])
64+
65+
def test_manual_flush(self):
66+
self.log.write("Hello")
67+
68+
self.assert_writes([])
69+
70+
self.log.write(" world\nHere for a while...\nGoodbye")
71+
self.assert_writes([b"Hello world\n", b"Here for a while...\n"])
72+
73+
self.log.write(" world\nHello again")
74+
self.assert_writes([b"Goodbye world\n"])
75+
76+
self.log.flush()
77+
self.assert_writes([b"Hello again"])
78+
79+
def test_non_ascii(self):
80+
# Spanish
81+
self.log.write("ol\u00e9\n")
82+
self.assert_writes([b"ol\xc3\xa9\n"])
83+
84+
# Chinese
85+
self.log.write("\u4e2d\u6587\n")
86+
self.assert_writes([b"\xe4\xb8\xad\xe6\x96\x87\n"])
87+
88+
# Printing Non-BMP emoji
89+
self.log.write("\U0001f600\n")
90+
self.assert_writes([b"\xf0\x9f\x98\x80\n"])
91+
92+
# Non-encodable surrogates are replaced
93+
self.log.write("\ud800\udc00\n")
94+
self.assert_writes([b"??\n"])
95+
96+
def test_modified_null(self):
97+
# Null characters are logged using "modified UTF-8".
98+
self.log.write("\u0000\n")
99+
self.assert_writes([b"\xc0\x80\n"])
100+
self.log.write("a\u0000\n")
101+
self.assert_writes([b"a\xc0\x80\n"])
102+
self.log.write("\u0000b\n")
103+
self.assert_writes([b"\xc0\x80b\n"])
104+
self.log.write("a\u0000b\n")
105+
self.assert_writes([b"a\xc0\x80b\n"])
106+
107+
def test_nonstandard_str(self):
108+
# String subclasses are accepted, but they should be converted
109+
# to a standard str without calling any of their methods.
110+
class CustomStr(str):
111+
def splitlines(self, *args, **kwargs):
112+
raise AssertionError()
113+
114+
def __len__(self):
115+
raise AssertionError()
116+
117+
def __str__(self):
118+
raise AssertionError()
119+
120+
self.log.write(CustomStr("custom\n"))
121+
self.assert_writes([b"custom\n"])
122+
123+
def test_non_str(self):
124+
# Non-string classes are not accepted.
125+
for obj in [b"", b"hello", None, 42]:
126+
with self.subTest(obj=obj):
127+
with self.assertRaisesRegex(
128+
TypeError,
129+
fr"write\(\) argument must be str, not "
130+
fr"{type(obj).__name__}"
131+
):
132+
self.log.write(obj)
133+
134+
def test_byteslike_in_buffer(self):
135+
# The underlying buffer *can* accept bytes-like objects
136+
self.log.buffer.write(bytearray(b"hello"))
137+
self.log.flush()
138+
139+
self.log.buffer.write(b"")
140+
self.log.flush()
141+
142+
self.log.buffer.write(b"goodbye")
143+
self.log.flush()
144+
145+
self.assert_writes([b"hello", b"goodbye"])
146+
147+
def test_non_byteslike_in_buffer(self):
148+
for obj in ["hello", None, 42]:
149+
with self.subTest(obj=obj):
150+
with self.assertRaisesRegex(
151+
TypeError,
152+
fr"write\(\) argument must be bytes-like, not "
153+
fr"{type(obj).__name__}"
154+
):
155+
self.log.buffer.write(obj)

Lib/test/test_embed.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,8 @@ class InitConfigTests(EmbeddingTestsMixin, unittest.TestCase):
439439
CONFIG_COMPAT.update({
440440
'legacy_windows_stdio': 0,
441441
})
442+
if support.is_apple:
443+
CONFIG_COMPAT['use_system_logger'] = False
442444

443445
CONFIG_PYTHON = dict(CONFIG_COMPAT,
444446
_config_init=API_PYTHON,

Makefile.pre.in

Lines changed: 4 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1278,7 +1278,6 @@ testuniversal: @DEF_MAKE_RULE@ platform
12781278
# This must be run *after* a `make install` has completed the build. The
12791279
# `--with-framework-name` argument *cannot* be used when configuring the build.
12801280
XCFOLDER:=iOSTestbed.$(MULTIARCH).$(shell date +%s)
1281-
XCRESULT=$(XCFOLDER)/$(MULTIARCH).xcresult
12821281
.PHONY: testios
12831282
testios:
12841283
@if test "$(MACHDEP)" != "ios"; then \
@@ -1297,29 +1296,12 @@ testios:
12971296
echo "Cannot find a finalized iOS Python.framework. Have you run 'make install' to finalize the framework build?"; \
12981297
exit 1;\
12991298
fi
1300-
# Copy the testbed project into the build folder
1301-
cp -r $(srcdir)/iOS/testbed $(XCFOLDER)
1302-
# Copy the framework from the install location to the testbed project.
1303-
cp -r $(PYTHONFRAMEWORKPREFIX)/* $(XCFOLDER)/Python.xcframework/ios-arm64_x86_64-simulator
1304-
1305-
# Run the test suite for the Xcode project, targeting the iOS simulator.
1306-
# If the suite fails, touch a file in the test folder as a marker
1307-
if ! xcodebuild test -project $(XCFOLDER)/iOSTestbed.xcodeproj -scheme "iOSTestbed" -destination "platform=iOS Simulator,name=iPhone SE (3rd Generation)" -resultBundlePath $(XCRESULT) -derivedDataPath $(XCFOLDER)/DerivedData ; then \
1308-
touch $(XCFOLDER)/failed; \
1309-
fi
13101299

1311-
# Regardless of success or failure, extract and print the test output
1312-
xcrun xcresulttool get --path $(XCRESULT) \
1313-
--id $$( \
1314-
xcrun xcresulttool get --path $(XCRESULT) --format json | \
1315-
$(PYTHON_FOR_BUILD) -c "import sys, json; result = json.load(sys.stdin); print(result['actions']['_values'][0]['actionResult']['logRef']['id']['_value'])" \
1316-
) \
1317-
--format json | \
1318-
$(PYTHON_FOR_BUILD) -c "import sys, json; result = json.load(sys.stdin); print(result['subsections']['_values'][1]['subsections']['_values'][0]['emittedOutput']['_value'])"
1300+
# Clone the testbed project into the XCFOLDER
1301+
$(PYTHON_FOR_BUILD) $(srcdir)/iOS/testbed clone --framework $(PYTHONFRAMEWORKPREFIX) "$(XCFOLDER)"
13191302

1320-
@if test -e $(XCFOLDER)/failed ; then \
1321-
exit 1; \
1322-
fi
1303+
# Run the testbed project
1304+
$(PYTHON_FOR_BUILD) "$(XCFOLDER)" run -- test -uall --single-process --rerun -W
13231305

13241306
# Like testall, but with only one pass and without multiple processes.
13251307
# Run an optional script to include information about the build environment.
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
macOS and iOS apps can now choose to redirect stdout and stderr to the
2+
system log during interpreter configuration.
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
iOS test results are now streamed during test execution, and the deprecated
2+
xcresulttool is no longer used.

Python/initconfig.c

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -644,6 +644,9 @@ config_check_consistency(const PyConfig *config)
644644
assert(config->check_hash_pycs_mode != NULL);
645645
assert(config->_install_importlib >= 0);
646646
assert(config->pathconfig_warnings >= 0);
647+
#ifdef __APPLE__
648+
assert(config->use_system_logger >= 0);
649+
#endif
647650
return 1;
648651
}
649652
#endif
@@ -728,6 +731,9 @@ _PyConfig_InitCompatConfig(PyConfig *config)
728731
#ifdef MS_WINDOWS
729732
config->legacy_windows_stdio = -1;
730733
#endif
734+
#ifdef __APPLE__
735+
config->use_system_logger = 0;
736+
#endif
731737
}
732738

733739
/* Excluded from public struct PyConfig for backporting reasons. */
@@ -757,6 +763,9 @@ config_init_defaults(PyConfig *config)
757763
#ifdef MS_WINDOWS
758764
config->legacy_windows_stdio = 0;
759765
#endif
766+
#ifdef __APPLE__
767+
config->use_system_logger = 0;
768+
#endif
760769
}
761770

762771

@@ -789,6 +798,9 @@ PyConfig_InitIsolatedConfig(PyConfig *config)
789798
#ifdef MS_WINDOWS
790799
config->legacy_windows_stdio = 0;
791800
#endif
801+
#ifdef __APPLE__
802+
config->use_system_logger = 0;
803+
#endif
792804
}
793805

794806

0 commit comments

Comments
 (0)