Skip to content

Commit 4ce5998

Browse files
committed
[3.12] 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 2429749 commit 4ce5998

16 files changed

+784
-59
lines changed

Doc/c-api/init_config.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1212,6 +1212,15 @@ PyConfig
12121212
12131213
Default: ``1`` in Python config and ``0`` in isolated config.
12141214
1215+
.. c:member:: int use_system_logger
1216+
1217+
If non-zero, ``stdout`` and ``stderr`` will be redirected to the system
1218+
log.
1219+
1220+
Only available on macOS 10.12 and later, and on iOS.
1221+
1222+
Default: ``0`` (don't use system log).
1223+
12151224
.. c:member:: int user_site_directory
12161225
12171226
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

Include/cpython/initconfig.h

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

184187
/* --- Path configuration inputs ------------ */
185188
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
@@ -578,6 +578,8 @@ class InitConfigTests(EmbeddingTestsMixin, unittest.TestCase):
578578
CONFIG_COMPAT.update({
579579
'legacy_windows_stdio': 0,
580580
})
581+
if support.is_apple:
582+
CONFIG_COMPAT['use_system_logger'] = False
581583

582584
CONFIG_PYTHON = dict(CONFIG_COMPAT,
583585
_config_init=API_PYTHON,

Makefile.pre.in

Lines changed: 4 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1897,7 +1897,6 @@ testuniversal: all
18971897
# This must be run *after* a `make install` has completed the build. The
18981898
# `--with-framework-name` argument *cannot* be used when configuring the build.
18991899
XCFOLDER:=iOSTestbed.$(MULTIARCH).$(shell date +%s)
1900-
XCRESULT=$(XCFOLDER)/$(MULTIARCH).xcresult
19011900
.PHONY: testios
19021901
testios:
19031902
@if test "$(MACHDEP)" != "ios"; then \
@@ -1916,29 +1915,12 @@ testios:
19161915
echo "Cannot find a finalized iOS Python.framework. Have you run 'make install' to finalize the framework build?"; \
19171916
exit 1;\
19181917
fi
1919-
# Copy the testbed project into the build folder
1920-
cp -r $(srcdir)/iOS/testbed $(XCFOLDER)
1921-
# Copy the framework from the install location to the testbed project.
1922-
cp -r $(PYTHONFRAMEWORKPREFIX)/* $(XCFOLDER)/Python.xcframework/ios-arm64_x86_64-simulator
1923-
1924-
# Run the test suite for the Xcode project, targeting the iOS simulator.
1925-
# If the suite fails, touch a file in the test folder as a marker
1926-
if ! xcodebuild test -project $(XCFOLDER)/iOSTestbed.xcodeproj -scheme "iOSTestbed" -destination "platform=iOS Simulator,name=iPhone SE (3rd Generation)" -resultBundlePath $(XCRESULT) -derivedDataPath $(XCFOLDER)/DerivedData ; then \
1927-
touch $(XCFOLDER)/failed; \
1928-
fi
19291918

1930-
# Regardless of success or failure, extract and print the test output
1931-
xcrun xcresulttool get --path $(XCRESULT) \
1932-
--id $$( \
1933-
xcrun xcresulttool get --path $(XCRESULT) --format json | \
1934-
$(PYTHON_FOR_BUILD) -c "import sys, json; result = json.load(sys.stdin); print(result['actions']['_values'][0]['actionResult']['logRef']['id']['_value'])" \
1935-
) \
1936-
--format json | \
1937-
$(PYTHON_FOR_BUILD) -c "import sys, json; result = json.load(sys.stdin); print(result['subsections']['_values'][1]['subsections']['_values'][0]['emittedOutput']['_value'])"
1919+
# Clone the testbed project into the XCFOLDER
1920+
$(PYTHON_FOR_BUILD) $(srcdir)/iOS/testbed clone --framework $(PYTHONFRAMEWORKPREFIX) "$(XCFOLDER)"
19381921

1939-
@if test -e $(XCFOLDER)/failed ; then \
1940-
exit 1; \
1941-
fi
1922+
# Run the testbed project
1923+
$(PYTHON_FOR_BUILD) "$(XCFOLDER)" run -- test -uall --single-process --rerun -W
19421924

19431925
# Like testall, but with only one pass and without multiple processes.
19441926
# 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.

0 commit comments

Comments
 (0)