Skip to content

Commit 2041a95

Browse files
authored
gh-126925: Modify how iOS test results are gathered (#127592)
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.
1 parent d8d12b3 commit 2041a95

18 files changed

+792
-58
lines changed

Doc/c-api/init_config.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1281,6 +1281,15 @@ PyConfig
12811281
12821282
Default: ``1`` in Python config and ``0`` in isolated config.
12831283
1284+
.. c:member:: int use_system_logger
1285+
1286+
If non-zero, ``stdout`` and ``stderr`` will be redirected to the system
1287+
log.
1288+
1289+
Only available on macOS 10.12 and later, and on iOS.
1290+
1291+
Default: ``0`` (don't use system log).
1292+
12841293
.. c:member:: int user_site_directory
12851294
12861295
If non-zero, add the user site directory to :data:`sys.path`.

Doc/using/ios.rst

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -292,10 +292,12 @@ To add Python to an iOS Xcode project:
292292
10. Add Objective C code to initialize and use a Python interpreter in embedded
293293
mode. You should ensure that:
294294

295-
* :c:member:`UTF-8 mode <PyPreConfig.utf8_mode>` is *enabled*;
296-
* :c:member:`Buffered stdio <PyConfig.buffered_stdio>` is *disabled*;
297-
* :c:member:`Writing bytecode <PyConfig.write_bytecode>` is *disabled*;
298-
* :c:member:`Signal handlers <PyConfig.install_signal_handlers>` are *enabled*;
295+
* UTF-8 mode (:c:member:`PyPreConfig.utf8_mode`) is *enabled*;
296+
* Buffered stdio (:c:member:`PyConfig.buffered_stdio`) is *disabled*;
297+
* Writing bytecode (:c:member:`PyConfig.write_bytecode`) is *disabled*;
298+
* Signal handlers (:c:member:`PyConfig.install_signal_handlers`) are *enabled*;
299+
* System logging (:c:member:`PyConfig.use_system_logger`) is *enabled*
300+
(optional, but strongly recommended);
299301
* ``PYTHONHOME`` for the interpreter is configured to point at the
300302
``python`` subfolder of your app's bundle; and
301303
* The ``PYTHONPATH`` for the interpreter includes:
@@ -324,6 +326,49 @@ modules in your app, some additional steps will be required:
324326
* If you're using a separate folder for third-party packages, ensure that folder
325327
is included as part of the ``PYTHONPATH`` configuration in step 10.
326328

329+
Testing a Python package
330+
------------------------
331+
332+
The CPython source tree contains :source:`a testbed project <iOS/testbed>` that
333+
is used to run the CPython test suite on the iOS simulator. This testbed can also
334+
be used as a testbed project for running your Python library's test suite on iOS.
335+
336+
After building or obtaining an iOS XCFramework (See :source:`iOS/README.rst`
337+
for details), create a clone of the Python iOS testbed project by running:
338+
339+
.. code-block:: bash
340+
341+
$ python iOS/testbed clone --framework <path/to/Python.xcframework> --app <path/to/module1> --app <path/to/module2> app-testbed
342+
343+
You will need to modify the ``iOS/testbed`` reference to point to that
344+
directory in the CPython source tree; any folders specified with the ``--app``
345+
flag will be copied into the cloned testbed project. The resulting testbed will
346+
be created in the ``app-testbed`` folder. In this example, the ``module1`` and
347+
``module2`` would be importable modules at runtime. If your project has
348+
additional dependencies, they can be installed into the
349+
``app-testbed/iOSTestbed/app_packages`` folder (using ``pip install --target
350+
app-testbed/iOSTestbed/app_packages`` or similar).
351+
352+
You can then use the ``app-testbed`` folder to run the test suite for your app,
353+
For example, if ``module1.tests`` was the entry point to your test suite, you
354+
could run:
355+
356+
.. code-block:: bash
357+
358+
$ python app-testbed run -- module1.tests
359+
360+
This is the equivalent of running ``python -m module1.tests`` on a desktop
361+
Python build. Any arguments after the ``--`` will be passed to the testbed as
362+
if they were arguments to ``python -m`` on a desktop machine.
363+
364+
You can also open the testbed project in Xcode by running:
365+
366+
.. code-block:: bash
367+
368+
$ open app-testbed/iOSTestbed.xcodeproj
369+
370+
This will allow you to use the full Xcode suite of tools for debugging.
371+
327372
App Store Compliance
328373
====================
329374

Doc/whatsnew/3.14.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,13 @@ Other language changes
245245
making it a :term:`generic type`.
246246
(Contributed by Brian Schubert in :gh:`126012`.)
247247

248+
* iOS and macOS apps can now be configured to redirect ``stdout`` and
249+
``stderr`` content to the system log. (Contributed by Russell Keith-Magee in
250+
:gh:`127592`.)
251+
252+
* The iOS testbed is now able to stream test output while the test is running.
253+
The testbed can also be used to run the test suite of projects other than
254+
CPython itself. (Contributed by Russell Keith-Magee in :gh:`127592`.)
248255

249256
New modules
250257
===========

Include/cpython/initconfig.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,9 @@ typedef struct PyConfig {
179179
int use_frozen_modules;
180180
int safe_path;
181181
int int_max_str_digits;
182+
#ifdef __APPLE__
183+
int use_system_logger;
184+
#endif
182185

183186
int cpu_count;
184187
#ifdef Py_GIL_DISABLED

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_capi/test_config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,10 @@ def test_config_get(self):
110110
options.extend((
111111
("_pystats", bool, None),
112112
))
113+
if support.is_apple:
114+
options.extend((
115+
("use_system_logger", bool, None),
116+
))
113117

114118
for name, option_type, sys_attr in options:
115119
with self.subTest(name=name, option_type=option_type,

Lib/test/test_embed.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -649,6 +649,8 @@ class InitConfigTests(EmbeddingTestsMixin, unittest.TestCase):
649649
CONFIG_COMPAT.update({
650650
'legacy_windows_stdio': False,
651651
})
652+
if support.is_apple:
653+
CONFIG_COMPAT['use_system_logger'] = False
652654

653655
CONFIG_PYTHON = dict(CONFIG_COMPAT,
654656
_config_init=API_PYTHON,

Makefile.pre.in

Lines changed: 4 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2146,7 +2146,6 @@ testuniversal: all
21462146
# This must be run *after* a `make install` has completed the build. The
21472147
# `--with-framework-name` argument *cannot* be used when configuring the build.
21482148
XCFOLDER:=iOSTestbed.$(MULTIARCH).$(shell date +%s)
2149-
XCRESULT=$(XCFOLDER)/$(MULTIARCH).xcresult
21502149
.PHONY: testios
21512150
testios:
21522151
@if test "$(MACHDEP)" != "ios"; then \
@@ -2165,29 +2164,12 @@ testios:
21652164
echo "Cannot find a finalized iOS Python.framework. Have you run 'make install' to finalize the framework build?"; \
21662165
exit 1;\
21672166
fi
2168-
# Copy the testbed project into the build folder
2169-
cp -r $(srcdir)/iOS/testbed $(XCFOLDER)
2170-
# Copy the framework from the install location to the testbed project.
2171-
cp -r $(PYTHONFRAMEWORKPREFIX)/* $(XCFOLDER)/Python.xcframework/ios-arm64_x86_64-simulator
2172-
2173-
# Run the test suite for the Xcode project, targeting the iOS simulator.
2174-
# If the suite fails, touch a file in the test folder as a marker
2175-
if ! xcodebuild test -project $(XCFOLDER)/iOSTestbed.xcodeproj -scheme "iOSTestbed" -destination "platform=iOS Simulator,name=iPhone SE (3rd Generation)" -resultBundlePath $(XCRESULT) -derivedDataPath $(XCFOLDER)/DerivedData ; then \
2176-
touch $(XCFOLDER)/failed; \
2177-
fi
21782167

2179-
# Regardless of success or failure, extract and print the test output
2180-
xcrun xcresulttool get --path $(XCRESULT) \
2181-
--id $$( \
2182-
xcrun xcresulttool get --path $(XCRESULT) --format json | \
2183-
$(PYTHON_FOR_BUILD) -c "import sys, json; result = json.load(sys.stdin); print(result['actions']['_values'][0]['actionResult']['logRef']['id']['_value'])" \
2184-
) \
2185-
--format json | \
2186-
$(PYTHON_FOR_BUILD) -c "import sys, json; result = json.load(sys.stdin); print(result['subsections']['_values'][1]['subsections']['_values'][0]['emittedOutput']['_value'])"
2168+
# Clone the testbed project into the XCFOLDER
2169+
$(PYTHON_FOR_BUILD) $(srcdir)/iOS/testbed clone --framework $(PYTHONFRAMEWORKPREFIX) "$(XCFOLDER)"
21872170

2188-
@if test -e $(XCFOLDER)/failed ; then \
2189-
exit 1; \
2190-
fi
2171+
# Run the testbed project
2172+
$(PYTHON_FOR_BUILD) "$(XCFOLDER)" run -- test -uall --single-process --rerun -W
21912173

21922174
# Like test, but using --slow-ci which enables all test resources and use
21932175
# longer timeout. Run an optional pybuildbot.identify script to include
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.

0 commit comments

Comments
 (0)