Skip to content

Commit

Permalink
[3.10] pythongh-126925: Modify how iOS test results are gathered (pyt…
Browse files Browse the repository at this point in the history
…honGH-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)
  • Loading branch information
freakboy3742 committed Dec 13, 2024
1 parent bb580fb commit 39fe324
Show file tree
Hide file tree
Showing 15 changed files with 759 additions and 36 deletions.
9 changes: 9 additions & 0 deletions Doc/c-api/init_config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1101,6 +1101,15 @@ PyConfig
Default: ``1`` in Python config and ``0`` in isolated config.
.. c:member:: int use_system_logger
If non-zero, ``stdout`` and ``stderr`` will be redirected to the system
log.
Only available on macOS 10.12 and later, and on iOS.
Default: ``0`` (don't use system log).
.. c:member:: int user_site_directory
If non-zero, add the user site directory to :data:`sys.path`.
Expand Down
3 changes: 3 additions & 0 deletions Include/cpython/initconfig.h
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,9 @@ typedef struct PyConfig {
int legacy_windows_stdio;
#endif
wchar_t *check_hash_pycs_mode;
#ifdef __APPLE__
int use_system_logger;
#endif

/* --- Path configuration inputs ------------ */
int pathconfig_warnings;
Expand Down
66 changes: 66 additions & 0 deletions Lib/_apple_support.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import io
import sys


def init_streams(log_write, stdout_level, stderr_level):
# Redirect stdout and stderr to the Apple system log. This method is
# invoked by init_apple_streams() (initconfig.c) if config->use_system_logger
# is enabled.
sys.stdout = SystemLog(log_write, stdout_level, errors=sys.stderr.errors)
sys.stderr = SystemLog(log_write, stderr_level, errors=sys.stderr.errors)


class SystemLog(io.TextIOWrapper):
def __init__(self, log_write, level, **kwargs):
kwargs.setdefault("encoding", "UTF-8")
kwargs.setdefault("line_buffering", True)
super().__init__(LogStream(log_write, level), **kwargs)

def __repr__(self):
return f"<SystemLog (level {self.buffer.level})>"

def write(self, s):
if not isinstance(s, str):
raise TypeError(
f"write() argument must be str, not {type(s).__name__}")

# In case `s` is a str subclass that writes itself to stdout or stderr
# when we call its methods, convert it to an actual str.
s = str.__str__(s)

# We want to emit one log message per line, so split
# the string before sending it to the superclass.
for line in s.splitlines(keepends=True):
super().write(line)

return len(s)


class LogStream(io.RawIOBase):
def __init__(self, log_write, level):
self.log_write = log_write
self.level = level

def __repr__(self):
return f"<LogStream (level {self.level!r})>"

def writable(self):
return True

def write(self, b):
if type(b) is not bytes:
try:
b = bytes(memoryview(b))
except TypeError:
raise TypeError(
f"write() argument must be bytes-like, not {type(b).__name__}"
) from None

# Writing an empty string to the stream should have no effect.
if b:
# Encode null bytes using "modified UTF-8" to avoid truncating the
# message. This should not affect the return value, as the caller
# may be expecting it to match the length of the input.
self.log_write(self.level, b.replace(b"\x00", b"\xc0\x80"))

return len(b)
155 changes: 155 additions & 0 deletions Lib/test/test_apple.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import unittest
from _apple_support import SystemLog
from test.support import is_apple
from unittest.mock import Mock, call

if not is_apple:
raise unittest.SkipTest("Apple-specific")


# Test redirection of stdout and stderr to the Apple system log.
class TestAppleSystemLogOutput(unittest.TestCase):
maxDiff = None

def assert_writes(self, output):
self.assertEqual(
self.log_write.mock_calls,
[
call(self.log_level, line)
for line in output
]
)

self.log_write.reset_mock()

def setUp(self):
self.log_write = Mock()
self.log_level = 42
self.log = SystemLog(self.log_write, self.log_level, errors="replace")

def test_repr(self):
self.assertEqual(repr(self.log), "<SystemLog (level 42)>")
self.assertEqual(repr(self.log.buffer), "<LogStream (level 42)>")

def test_log_config(self):
self.assertIs(self.log.writable(), True)
self.assertIs(self.log.readable(), False)

self.assertEqual("UTF-8", self.log.encoding)
self.assertEqual("replace", self.log.errors)

self.assertIs(self.log.line_buffering, True)
self.assertIs(self.log.write_through, False)

def test_empty_str(self):
self.log.write("")
self.log.flush()

self.assert_writes([])

def test_simple_str(self):
self.log.write("hello world\n")

self.assert_writes([b"hello world\n"])

def test_buffered_str(self):
self.log.write("h")
self.log.write("ello")
self.log.write(" ")
self.log.write("world\n")
self.log.write("goodbye.")
self.log.flush()

self.assert_writes([b"hello world\n", b"goodbye."])

def test_manual_flush(self):
self.log.write("Hello")

self.assert_writes([])

self.log.write(" world\nHere for a while...\nGoodbye")
self.assert_writes([b"Hello world\n", b"Here for a while...\n"])

self.log.write(" world\nHello again")
self.assert_writes([b"Goodbye world\n"])

self.log.flush()
self.assert_writes([b"Hello again"])

def test_non_ascii(self):
# Spanish
self.log.write("ol\u00e9\n")
self.assert_writes([b"ol\xc3\xa9\n"])

# Chinese
self.log.write("\u4e2d\u6587\n")
self.assert_writes([b"\xe4\xb8\xad\xe6\x96\x87\n"])

# Printing Non-BMP emoji
self.log.write("\U0001f600\n")
self.assert_writes([b"\xf0\x9f\x98\x80\n"])

# Non-encodable surrogates are replaced
self.log.write("\ud800\udc00\n")
self.assert_writes([b"??\n"])

def test_modified_null(self):
# Null characters are logged using "modified UTF-8".
self.log.write("\u0000\n")
self.assert_writes([b"\xc0\x80\n"])
self.log.write("a\u0000\n")
self.assert_writes([b"a\xc0\x80\n"])
self.log.write("\u0000b\n")
self.assert_writes([b"\xc0\x80b\n"])
self.log.write("a\u0000b\n")
self.assert_writes([b"a\xc0\x80b\n"])

def test_nonstandard_str(self):
# String subclasses are accepted, but they should be converted
# to a standard str without calling any of their methods.
class CustomStr(str):
def splitlines(self, *args, **kwargs):
raise AssertionError()

def __len__(self):
raise AssertionError()

def __str__(self):
raise AssertionError()

self.log.write(CustomStr("custom\n"))
self.assert_writes([b"custom\n"])

def test_non_str(self):
# Non-string classes are not accepted.
for obj in [b"", b"hello", None, 42]:
with self.subTest(obj=obj):
with self.assertRaisesRegex(
TypeError,
fr"write\(\) argument must be str, not "
fr"{type(obj).__name__}"
):
self.log.write(obj)

def test_byteslike_in_buffer(self):
# The underlying buffer *can* accept bytes-like objects
self.log.buffer.write(bytearray(b"hello"))
self.log.flush()

self.log.buffer.write(b"")
self.log.flush()

self.log.buffer.write(b"goodbye")
self.log.flush()

self.assert_writes([b"hello", b"goodbye"])

def test_non_byteslike_in_buffer(self):
for obj in ["hello", None, 42]:
with self.subTest(obj=obj):
with self.assertRaisesRegex(
TypeError,
fr"write\(\) argument must be bytes-like, not "
fr"{type(obj).__name__}"
):
self.log.buffer.write(obj)
2 changes: 2 additions & 0 deletions Lib/test/test_embed.py
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,8 @@ class InitConfigTests(EmbeddingTestsMixin, unittest.TestCase):
CONFIG_COMPAT.update({
'legacy_windows_stdio': 0,
})
if support.is_apple:
CONFIG_COMPAT['use_system_logger'] = False

CONFIG_PYTHON = dict(CONFIG_COMPAT,
_config_init=API_PYTHON,
Expand Down
26 changes: 4 additions & 22 deletions Makefile.pre.in
Original file line number Diff line number Diff line change
Expand Up @@ -1278,7 +1278,6 @@ testuniversal: @DEF_MAKE_RULE@ platform
# This must be run *after* a `make install` has completed the build. The
# `--with-framework-name` argument *cannot* be used when configuring the build.
XCFOLDER:=iOSTestbed.$(MULTIARCH).$(shell date +%s)
XCRESULT=$(XCFOLDER)/$(MULTIARCH).xcresult
.PHONY: testios
testios:
@if test "$(MACHDEP)" != "ios"; then \
Expand All @@ -1297,29 +1296,12 @@ testios:
echo "Cannot find a finalized iOS Python.framework. Have you run 'make install' to finalize the framework build?"; \
exit 1;\
fi
# Copy the testbed project into the build folder
cp -r $(srcdir)/iOS/testbed $(XCFOLDER)
# Copy the framework from the install location to the testbed project.
cp -r $(PYTHONFRAMEWORKPREFIX)/* $(XCFOLDER)/Python.xcframework/ios-arm64_x86_64-simulator

# Run the test suite for the Xcode project, targeting the iOS simulator.
# If the suite fails, touch a file in the test folder as a marker
if ! xcodebuild test -project $(XCFOLDER)/iOSTestbed.xcodeproj -scheme "iOSTestbed" -destination "platform=iOS Simulator,name=iPhone SE (3rd Generation)" -resultBundlePath $(XCRESULT) -derivedDataPath $(XCFOLDER)/DerivedData ; then \
touch $(XCFOLDER)/failed; \
fi

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

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

# Like testall, but with only one pass and without multiple processes.
# Run an optional script to include information about the build environment.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
macOS and iOS apps can now choose to redirect stdout and stderr to the
system log during interpreter configuration.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
iOS test results are now streamed during test execution, and the deprecated
xcresulttool is no longer used.
12 changes: 12 additions & 0 deletions Python/initconfig.c
Original file line number Diff line number Diff line change
Expand Up @@ -644,6 +644,9 @@ config_check_consistency(const PyConfig *config)
assert(config->check_hash_pycs_mode != NULL);
assert(config->_install_importlib >= 0);
assert(config->pathconfig_warnings >= 0);
#ifdef __APPLE__
assert(config->use_system_logger >= 0);
#endif
return 1;
}
#endif
Expand Down Expand Up @@ -728,6 +731,9 @@ _PyConfig_InitCompatConfig(PyConfig *config)
#ifdef MS_WINDOWS
config->legacy_windows_stdio = -1;
#endif
#ifdef __APPLE__
config->use_system_logger = 0;
#endif
}

/* Excluded from public struct PyConfig for backporting reasons. */
Expand Down Expand Up @@ -757,6 +763,9 @@ config_init_defaults(PyConfig *config)
#ifdef MS_WINDOWS
config->legacy_windows_stdio = 0;
#endif
#ifdef __APPLE__
config->use_system_logger = 0;
#endif
}


Expand Down Expand Up @@ -789,6 +798,9 @@ PyConfig_InitIsolatedConfig(PyConfig *config)
#ifdef MS_WINDOWS
config->legacy_windows_stdio = 0;
#endif
#ifdef __APPLE__
config->use_system_logger = 0;
#endif
}


Expand Down
Loading

0 comments on commit 39fe324

Please sign in to comment.