diff --git a/.gitignore b/.gitignore index 7aa6272cf8e382..e842676d866bf8 100644 --- a/.gitignore +++ b/.gitignore @@ -80,7 +80,6 @@ iOS/testbed/Python.xcframework/ios-*/lib iOS/testbed/Python.xcframework/ios-*/Python.framework iOS/testbed/iOSTestbed.xcodeproj/project.xcworkspace iOS/testbed/iOSTestbed.xcodeproj/xcuserdata -iOS/testbed/iOSTestbed.xcodeproj/xcshareddata Mac/Makefile Mac/PythonLauncher/Info.plist Mac/PythonLauncher/Makefile diff --git a/Doc/library/curses.rst b/Doc/library/curses.rst index 0b13c559295f3c..fab54ca87efee9 100644 --- a/Doc/library/curses.rst +++ b/Doc/library/curses.rst @@ -770,7 +770,7 @@ the following methods and attributes: .. method:: window.attron(attr) - Add attribute *attr* from the "background" set applied to all writes to the + Add attribute *attr* to the "background" set applied to all writes to the current window. diff --git a/Doc/using/ios.rst b/Doc/using/ios.rst index 0fb28f8c866b02..91cfed16f0e415 100644 --- a/Doc/using/ios.rst +++ b/Doc/using/ios.rst @@ -374,6 +374,17 @@ You can also open the testbed project in Xcode by running: This will allow you to use the full Xcode suite of tools for debugging. +The arguments used to run the test suite are defined as part of the test plan. +To modify the test plan, select the test plan node of the project tree (it +should be the first child of the root node), and select the "Configurations" +tab. Modify the "Arguments Passed On Launch" value to change the testing +arguments. + +The test plan also disables parallel testing, and specifies the use of the +``iOSTestbed.lldbinit`` file for providing configuration of the debugger. The +default debugger configuration disables automatic breakpoints on the +``SIGINT``, ``SIGUSR1``, ``SIGUSR2``, and ``SIGXFSZ`` signals. + App Store Compliance ==================== diff --git a/Doc/whatsnew/3.14.rst b/Doc/whatsnew/3.14.rst index 80551048e8336b..aaa3487cb5ca8a 100644 --- a/Doc/whatsnew/3.14.rst +++ b/Doc/whatsnew/3.14.rst @@ -65,7 +65,7 @@ Summary -- release highlights .. This section singles out the most important changes in Python 3.14. Brevity is key. -Python 3.14 beta is the pre-release of the next version of the Python +Python 3.14 will be the latest stable release of the Python programming language, with a mix of changes to the language, the implementation and the standard library. @@ -635,7 +635,7 @@ Improved error messages misspellings may still result in regular syntax errors. (Contributed by Pablo Galindo in :gh:`132449`.) -* When unpacking assignment fails due to incorrect number of variables, the +* When an unpacking assignment fails due to an incorrect number of variables, the error message prints the received number of values in more cases than before. (Contributed by Tushar Sadhwani in :gh:`122239`.) @@ -763,7 +763,7 @@ ABI-compatible changes in the future. Complete the :pep:`587` :ref:`PyConfig C API ` by adding :c:func:`PyInitConfig_AddModule` which can be used to add a built-in extension -module; feature previously referred to as the “inittab”. +module; a feature previously referred to as the “inittab”. Add :c:func:`PyConfig_Get` and :c:func:`PyConfig_Set` functions to get and set the current runtime configuration. @@ -1051,7 +1051,7 @@ Concurrent safe warnings control The :class:`warnings.catch_warnings` context manager will now optionally use a context variable for warning filters. This is enabled by setting the :data:`~sys.flags.context_aware_warnings` flag, either with the ``-X`` -command-line option or an environment variable. This gives predicable +command-line option or an environment variable. This gives predictable warnings control when using :class:`~warnings.catch_warnings` combined with multiple threads or asynchronous tasks. The flag defaults to true for the free-threaded build and false for the GIL-enabled build. @@ -1152,7 +1152,7 @@ Other language changes unlike ``\Z``, which has subtly different behavior. (Contributed by Serhiy Storchaka in :gh:`133306`.) -* ``\B`` in :mod:`regular expression ` now matches empty input string. +* ``\B`` in :mod:`regular expression ` now matches the empty input string. Now it is always the opposite of ``\b``. (Contributed by Serhiy Storchaka in :gh:`124130`.) @@ -1221,7 +1221,7 @@ PEP 765: Disallow ``return``/``break``/``continue`` that exit a ``finally`` bloc --------------------------------------------------------------------------------- The compiler emits a :exc:`SyntaxWarning` when a :keyword:`return`, :keyword:`break` or -:keyword:`continue` statements appears where it exits a :keyword:`finally` block. +:keyword:`continue` statement appears where it exits a :keyword:`finally` block. This change is specified in :pep:`765`. @@ -1278,7 +1278,7 @@ ast (Contributed by Irit Katriel in :gh:`130139`.) * Add new ``--feature-version``, ``--optimize``, ``--show-empty`` options to - command-line interface. + the command-line interface. (Contributed by Semyon Moroz in :gh:`133367`.) @@ -2157,7 +2157,7 @@ unittest :meth:`~unittest.TestCase.assertNotStartsWith`, :meth:`~unittest.TestCase.assertEndsWith` and :meth:`~unittest.TestCase.assertNotEndsWith` check whether the Unicode - or byte string starts or ends with particular string(s). + or byte string starts or ends with particular strings. (Contributed by Serhiy Storchaka in :gh:`71339`.) @@ -2223,7 +2223,7 @@ webbrowser supported browsers on macOS. -zipinfo +zipfile ------- * Added :func:`ZipInfo._for_archive ` @@ -2231,7 +2231,7 @@ zipinfo as used by :func:`ZipFile.writestr `. (Contributed by Bénédikt Tran in :gh:`123424`.) -* :meth:`zipfile.ZipFile.writestr` now respect ``SOURCE_DATE_EPOCH`` that +* :meth:`zipfile.ZipFile.writestr` now respects ``SOURCE_DATE_EPOCH`` that distributions can set centrally and have build tools consume this in order to produce reproducible output. (Contributed by Jiahao Li in :gh:`91279`.) @@ -2379,7 +2379,7 @@ zlib * On Windows, `zlib-ng `__ is now used as the implementation of the :mod:`zlib` module in the default binaries. - There are no known incompatabilities between ``zlib-ng`` + There are no known incompatibilities between ``zlib-ng`` and the previously-used ``zlib`` implementation. This should result in better performance at all compression levels. @@ -2839,7 +2839,7 @@ Changes in the Python API rather than collecting generation 1. * Other calls to :func:`!gc.collect` are unchanged. -* The :func:`locale.nl_langinfo` function now sets temporarily the ``LC_CTYPE`` +* The :func:`locale.nl_langinfo` function now temporarily sets the ``LC_CTYPE`` locale in some cases. This temporary change affects other threads. (Contributed by Serhiy Storchaka in :gh:`69998`.) @@ -3019,8 +3019,8 @@ New features and get an attribute of the module. (Contributed by Victor Stinner in :gh:`128911`.) -* Add support for a new ``p`` format unit in :c:func:`Py_BuildValue` that allows to - take a C integer and produce a Python :class:`bool` object. (Contributed by +* Add support for a new ``p`` format unit in :c:func:`Py_BuildValue` that allows + taking a C integer and produces a Python :class:`bool` object. (Contributed by Pablo Galindo in :issue:`45325`.) * Add :c:func:`PyUnstable_Object_IsUniqueReferencedTemporary` to determine if an object diff --git a/Lib/json/tool.py b/Lib/json/tool.py index 1967817add8abc..050c2fe2161e3e 100644 --- a/Lib/json/tool.py +++ b/Lib/json/tool.py @@ -46,7 +46,8 @@ def main(): 'to validate and pretty-print JSON objects.') parser = argparse.ArgumentParser(description=description, color=True) parser.add_argument('infile', nargs='?', - help='a JSON file to be validated or pretty-printed', + help='a JSON file to be validated or pretty-printed; ' + 'defaults to stdin', default='-') parser.add_argument('outfile', nargs='?', help='write the output of infile to outfile', diff --git a/Misc/NEWS.d/next/Tools-Demos/2025-08-21-14-04-50.gh-issue-137873.qxffLt.rst b/Misc/NEWS.d/next/Tools-Demos/2025-08-21-14-04-50.gh-issue-137873.qxffLt.rst new file mode 100644 index 00000000000000..5b75858560ca1a --- /dev/null +++ b/Misc/NEWS.d/next/Tools-Demos/2025-08-21-14-04-50.gh-issue-137873.qxffLt.rst @@ -0,0 +1,3 @@ +The iOS test runner has been simplified, resolving some issues that have +been observed using the runner in GitHub Actions and Azure Pipelines test +environments. diff --git a/Modules/_cursesmodule.c b/Modules/_cursesmodule.c index a9c91fbcc89c18..232dbcace9ac57 100644 --- a/Modules/_cursesmodule.c +++ b/Modules/_cursesmodule.c @@ -1298,12 +1298,12 @@ _curses.window.attron attr: long / -Add attribute attr from the "background" set. +Add attribute attr to the "background" set. [clinic start generated code]*/ static PyObject * _curses_window_attron_impl(PyCursesWindowObject *self, long attr) -/*[clinic end generated code: output=7afea43b237fa870 input=5a88fba7b1524f32]*/ +/*[clinic end generated code: output=7afea43b237fa870 input=b57f824e1bf58326]*/ { int rtn = wattron(self->win, (attr_t)attr); return curses_window_check_err(self, rtn, "wattron", "attron"); diff --git a/Modules/_hashopenssl.c b/Modules/_hashopenssl.c index 9deafb5253056c..a6496d0f04f2d0 100644 --- a/Modules/_hashopenssl.c +++ b/Modules/_hashopenssl.c @@ -1939,20 +1939,30 @@ locked_HMAC_CTX_copy(HMAC_CTX *new_ctx_p, HMACobject *self) return 0; } -/* returning 0 means that an error occurred and an exception is set */ +#define BAD_DIGEST_SIZE 0 + +/* + * Return the digest size in bytes. + * + * On error, set an exception and return BAD_DIGEST_SIZE. + */ static unsigned int _hashlib_hmac_digest_size(HMACobject *self) { const EVP_MD *md = _hashlib_hmac_get_md(self); if (md == NULL) { - return 0; + return BAD_DIGEST_SIZE; } - unsigned int digest_size = EVP_MD_size(md); - assert(digest_size <= EVP_MAX_MD_SIZE); + int digest_size = EVP_MD_size(md); + /* digest_size < 0 iff EVP_MD context is NULL (which is impossible here) */ + assert(digest_size >= 0); + assert(digest_size <= (int)EVP_MAX_MD_SIZE); + /* digest_size == 0 means that the context is not entirely initialized */ if (digest_size == 0) { - notify_ssl_error_occurred("invalid digest size"); + raise_ssl_error(PyExc_ValueError, "missing digest size"); + return BAD_DIGEST_SIZE; } - return digest_size; + return (unsigned int)digest_size; } static int @@ -2053,24 +2063,38 @@ _hashlib_HMAC_update_impl(HMACobject *self, PyObject *msg) Py_RETURN_NONE; } -static int -_hmac_digest(HMACobject *self, unsigned char *buf, unsigned int len) +/* + * Extract the MAC value to 'buf' and return the digest size. + * + * The buffer 'buf' must have at least hashlib_openssl_HMAC_digest_size(self) + * bytes. Smaller buffers lead to undefined behaviors. + * + * On error, set an exception and return -1. + */ +static Py_ssize_t +_hmac_digest(HMACobject *self, unsigned char *buf) { + unsigned int digest_size = _hashlib_hmac_digest_size(self); + assert(digest_size <= EVP_MAX_MD_SIZE); + if (digest_size == BAD_DIGEST_SIZE) { + assert(PyErr_Occurred()); + return -1; + } HMAC_CTX *temp_ctx = py_openssl_wrapper_HMAC_CTX_new(); if (temp_ctx == NULL) { - return 0; + return -1; } if (locked_HMAC_CTX_copy(temp_ctx, self) < 0) { HMAC_CTX_free(temp_ctx); - return 0; + return -1; } - int r = HMAC_Final(temp_ctx, buf, &len); + int r = HMAC_Final(temp_ctx, buf, NULL); HMAC_CTX_free(temp_ctx); if (r == 0) { notify_ssl_error_occurred_in(Py_STRINGIFY(HMAC_Final)); - return 0; + return -1; } - return 1; + return digest_size; } /*[clinic input] @@ -2082,16 +2106,9 @@ static PyObject * _hashlib_HMAC_digest_impl(HMACobject *self) /*[clinic end generated code: output=1b1424355af7a41e input=bff07f74da318fb4]*/ { - unsigned char digest[EVP_MAX_MD_SIZE]; - unsigned int digest_size = _hashlib_hmac_digest_size(self); - if (digest_size == 0) { - return NULL; - } - int r = _hmac_digest(self, digest, digest_size); - if (r == 0) { - return NULL; - } - return PyBytes_FromStringAndSize((const char *)digest, digest_size); + unsigned char buf[EVP_MAX_MD_SIZE]; + Py_ssize_t n = _hmac_digest(self, buf); + return n < 0 ? NULL : PyBytes_FromStringAndSize((const char *)buf, n); } /*[clinic input] @@ -2109,24 +2126,17 @@ static PyObject * _hashlib_HMAC_hexdigest_impl(HMACobject *self) /*[clinic end generated code: output=80d825be1eaae6a7 input=5e48db83ab1a4d19]*/ { - unsigned char digest[EVP_MAX_MD_SIZE]; - unsigned int digest_size = _hashlib_hmac_digest_size(self); - if (digest_size == 0) { - return NULL; - } - int r = _hmac_digest(self, digest, digest_size); - if (r == 0) { - return NULL; - } - return _Py_strhex((const char *)digest, digest_size); + unsigned char buf[EVP_MAX_MD_SIZE]; + Py_ssize_t n = _hmac_digest(self, buf); + return n < 0 ? NULL : _Py_strhex((const char *)buf, n); } static PyObject * _hashlib_hmac_get_digest_size(PyObject *op, void *Py_UNUSED(closure)) { HMACobject *self = HMACobject_CAST(op); - unsigned int digest_size = _hashlib_hmac_digest_size(self); - return digest_size == 0 ? NULL : PyLong_FromLong(digest_size); + unsigned int size = _hashlib_hmac_digest_size(self); + return size == BAD_DIGEST_SIZE ? NULL : PyLong_FromLong(size); } static PyObject * diff --git a/Modules/clinic/_cursesmodule.c.h b/Modules/clinic/_cursesmodule.c.h index a8c32d6510604a..e6f9798cdf1249 100644 --- a/Modules/clinic/_cursesmodule.c.h +++ b/Modules/clinic/_cursesmodule.c.h @@ -301,7 +301,7 @@ PyDoc_STRVAR(_curses_window_attron__doc__, "attron($self, attr, /)\n" "--\n" "\n" -"Add attribute attr from the \"background\" set."); +"Add attribute attr to the \"background\" set."); #define _CURSES_WINDOW_ATTRON_METHODDEF \ {"attron", (PyCFunction)_curses_window_attron, METH_O, _curses_window_attron__doc__}, @@ -4450,4 +4450,4 @@ _curses_has_extended_color_support(PyObject *module, PyObject *Py_UNUSED(ignored #ifndef _CURSES_ASSUME_DEFAULT_COLORS_METHODDEF #define _CURSES_ASSUME_DEFAULT_COLORS_METHODDEF #endif /* !defined(_CURSES_ASSUME_DEFAULT_COLORS_METHODDEF) */ -/*[clinic end generated code: output=79ddaae4da3b80df input=a9049054013a1b77]*/ +/*[clinic end generated code: output=135246e29163510c input=a9049054013a1b77]*/ diff --git a/iOS/README.rst b/iOS/README.rst index f0979ba152eb20..4d38e5d7c307d1 100644 --- a/iOS/README.rst +++ b/iOS/README.rst @@ -293,7 +293,7 @@ project, and then boot and prepare the iOS simulator. Debugging test failures ----------------------- -Running ``make test`` generates a standalone version of the ``iOS/testbed`` +Running ``make testios`` generates a standalone version of the ``iOS/testbed`` project, and runs the full test suite. It does this using ``iOS/testbed`` itself - the folder is an executable module that can be used to create and run a clone of the testbed project. @@ -316,12 +316,26 @@ This is the equivalent of running ``python -m test -W test_os`` on a desktop Python build. Any arguments after the ``--`` will be passed to testbed as if they were arguments to ``python -m`` on a desktop machine. +Testing in Xcode +^^^^^^^^^^^^^^^^ + You can also open the testbed project in Xcode by running:: $ open my-testbed/iOSTestbed.xcodeproj This will allow you to use the full Xcode suite of tools for debugging. +The arguments used to run the test suite are defined as part of the test plan. +To modify the test plan, select the test plan node of the project tree (it +should be the first child of the root node), and select the "Configurations" +tab. Modify the "Arguments Passed On Launch" value to change the testing +arguments. + +The test plan also disables parallel testing, and specifies the use of the +``iOSTestbed.lldbinit`` file for providing configuration of the debugger. The +default debugger configuration disables automatic breakpoints on the +``SIGINT``, ``SIGUSR1``, ``SIGUSR2``, and ``SIGXFSZ`` signals. + Testing on an iOS device ^^^^^^^^^^^^^^^^^^^^^^^^ @@ -336,40 +350,3 @@ select the root node of the project tree (labeled "iOSTestbed"), then the (this will likely be your own name), and plug in a physical device to your macOS machine with a USB cable. You should then be able to select your physical device from the list of targets in the pulldown in the Xcode titlebar. - -Running specific tests -^^^^^^^^^^^^^^^^^^^^^^ - -As the test suite is being executed on an iOS simulator, it is not possible to -pass in command line arguments to configure test suite operation. To work -around this limitation, the arguments that would normally be passed as command -line arguments are configured as part of the ``iOSTestbed-Info.plist`` file -that is used to configure the iOS testbed app. In this file, the ``TestArgs`` -key is an array containing the arguments that would be passed to ``python -m`` -on the command line (including ``test`` in position 0, the name of the test -module to be executed). - -Disabling automated breakpoints -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -By default, Xcode will inserts an automatic breakpoint whenever a signal is -raised. The Python test suite raises many of these signals as part of normal -operation; unless you are trying to diagnose an issue with signals, the -automatic breakpoints can be inconvenient. However, they can be disabled by -creating a symbolic breakpoint that is triggered at the start of the test run. - -Select "Debug > Breakpoints > Create Symbolic Breakpoint" from the Xcode menu, and -populate the new brewpoint with the following details: - -* **Name**: IgnoreSignals -* **Symbol**: UIApplicationMain -* **Action**: Add debugger commands for: - - ``process handle SIGINT -n true -p true -s false`` - - ``process handle SIGUSR1 -n true -p true -s false`` - - ``process handle SIGUSR2 -n true -p true -s false`` - - ``process handle SIGXFSZ -n true -p true -s false`` -* Check the "Automatically continue after evaluating" box. - -All other details can be left blank. When the process executes the -``UIApplicationMain`` entry point, the breakpoint will trigger, run the debugger -commands to disable the automatic breakpoints, and automatically resume. diff --git a/iOS/testbed/__main__.py b/iOS/testbed/__main__.py index 1146bf3b988cda..6a4d9c76d162b4 100644 --- a/iOS/testbed/__main__.py +++ b/iOS/testbed/__main__.py @@ -1,134 +1,29 @@ import argparse -import asyncio -import fcntl import json -import os -import plistlib import re import shutil import subprocess import sys -import tempfile -from contextlib import asynccontextmanager -from datetime import datetime from pathlib import Path DECODE_ARGS = ("UTF-8", "backslashreplace") # The system log prefixes each line: -# 2025-01-17 16:14:29.090 Df iOSTestbed[23987:1fd393b4] (Python) ... -# 2025-01-17 16:14:29.090 E iOSTestbed[23987:1fd393b4] (Python) ... +# 2025-01-17 16:14:29.093742+0800 iOSTestbed[23987:1fd393b4] ... +# 2025-01-17 16:14:29.093742+0800 iOSTestbed[23987:1fd393b4] ... LOG_PREFIX_REGEX = re.compile( r"^\d{4}-\d{2}-\d{2}" # YYYY-MM-DD - r"\s+\d+:\d{2}:\d{2}\.\d+" # HH:MM:SS.sss - r"\s+\w+" # Df/E + r"\s+\d+:\d{2}:\d{2}\.\d+\+\d{4}" # HH:MM:SS.ssssss+ZZZZ r"\s+iOSTestbed\[\d+:\w+\]" # Process/thread ID - r"\s+\(Python\)\s" # Logger name ) -# Work around a bug involving sys.exit and TaskGroups -# (https://github.com/python/cpython/issues/101515). -def exit(*args): - raise MySystemExit(*args) - - -class MySystemExit(Exception): - pass - - -class SimulatorLock: - # An fcntl-based filesystem lock that can be used to ensure that - def __init__(self, timeout): - self.filename = Path(tempfile.gettempdir()) / "python-ios-testbed" - self.timeout = timeout - - self.fd = None - - async def acquire(self): - # Ensure the lockfile exists - self.filename.touch(exist_ok=True) - - # Try `timeout` times to acquire the lock file, with a 1 second pause - # between each attempt. Report status every 10 seconds. - for i in range(0, self.timeout): - try: - fd = os.open(self.filename, os.O_RDWR | os.O_TRUNC, 0o644) - fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) - except OSError: - os.close(fd) - if i % 10 == 0: - print("... waiting", flush=True) - await asyncio.sleep(1) - else: - self.fd = fd - return - - # If we reach the end of the loop, we've exceeded the allowed number of - # attempts. - raise ValueError("Unable to obtain lock on iOS simulator creation") - - def release(self): - # If a lock is held, release it. - if self.fd is not None: - # Release the lock. - fcntl.flock(self.fd, fcntl.LOCK_UN) - os.close(self.fd) - self.fd = None - - -# All subprocesses are executed through this context manager so that no matter -# what happens, they can always be cancelled from another task, and they will -# always be cleaned up on exit. -@asynccontextmanager -async def async_process(*args, **kwargs): - process = await asyncio.create_subprocess_exec(*args, **kwargs) - try: - yield process - finally: - if process.returncode is None: - # Allow a reasonably long time for Xcode to clean itself up, - # because we don't want stale emulators left behind. - timeout = 10 - process.terminate() - try: - await asyncio.wait_for(process.wait(), timeout) - except TimeoutError: - print( - f"Command {args} did not terminate after {timeout} seconds " - f" - sending SIGKILL" - ) - process.kill() - - # Even after killing the process we must still wait for it, - # otherwise we'll get the warning "Exception ignored in __del__". - await asyncio.wait_for(process.wait(), timeout=1) - - -async def async_check_output(*args, **kwargs): - async with async_process( - *args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kwargs - ) as process: - stdout, stderr = await process.communicate() - if process.returncode == 0: - return stdout.decode(*DECODE_ARGS) - else: - raise subprocess.CalledProcessError( - process.returncode, - args, - stdout.decode(*DECODE_ARGS), - stderr.decode(*DECODE_ARGS), - ) - - # Select a simulator device to use. -async def select_simulator_device(): +def select_simulator_device(): # List the testing simulators, in JSON format - raw_json = await async_check_output( - "xcrun", "simctl", "list", "-j" - ) + raw_json = subprocess.check_output(["xcrun", "simctl", "list", "-j"]) json_data = json.loads(raw_json) # Any device will do; we'll look for "SE" devices - but the name isn't @@ -145,7 +40,10 @@ async def select_simulator_device(): for devicetype in json_data["devicetypes"] if devicetype["productFamily"] == "iPhone" and ( - ("iPhone " in devicetype["name"] and devicetype["name"].endswith("e")) + ( + "iPhone " in devicetype["name"] + and devicetype["name"].endswith("e") + ) or "iPhone SE " in devicetype["name"] ) ) @@ -153,127 +51,42 @@ async def select_simulator_device(): return se_simulators[-1][1] -# Return a list of UDIDs associated with booted simulators -async def list_devices(): - try: - # List the testing simulators, in JSON format - raw_json = await async_check_output( - "xcrun", "simctl", "--set", "testing", "list", "-j" - ) - json_data = json.loads(raw_json) - - # Filter out the booted iOS simulators - return [ - simulator["udid"] - for runtime, simulators in json_data["devices"].items() - for simulator in simulators - if runtime.split(".")[-1].startswith("iOS") and simulator["state"] == "Booted" - ] - except subprocess.CalledProcessError as e: - # If there's no ~/Library/Developer/XCTestDevices folder (which is the - # case on fresh installs, and in some CI environments), `simctl list` - # returns error code 1, rather than an empty list. Handle that case, - # but raise all other errors. - if e.returncode == 1: - return [] - else: - raise - - -async def find_device(initial_devices, lock): - while True: - new_devices = set(await list_devices()).difference(initial_devices) - if len(new_devices) == 0: - await asyncio.sleep(1) - elif len(new_devices) == 1: - udid = new_devices.pop() - print(f"{datetime.now():%Y-%m-%d %H:%M:%S}: New test simulator detected") - print(f"UDID: {udid}", flush=True) - lock.release() - return udid - else: - exit(f"Found more than one new device: {new_devices}") - - -async def log_stream_task(initial_devices, lock): - # Wait up to 5 minutes for the build to complete and the simulator to boot. - udid = await asyncio.wait_for(find_device(initial_devices, lock), 5 * 60) - - # Stream the iOS device's logs, filtering out messages that come from the - # XCTest test suite (catching NSLog messages from the test method), or - # Python itself (catching stdout/stderr content routed to the system log - # with config->use_system_logger). +def xcode_test(location, simulator, verbose): + # Build and run the test suite on the named simulator. args = [ - "xcrun", - "simctl", - "--set", - "testing", - "spawn", - udid, - "log", - "stream", - "--style", - "compact", - "--predicate", - ( - 'senderImagePath ENDSWITH "/iOSTestbedTests.xctest/iOSTestbedTests"' - ' OR senderImagePath ENDSWITH "/Python.framework/Python"' - ), - ] - - async with async_process( - *args, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - ) as process: - suppress_dupes = False - while line := (await process.stdout.readline()).decode(*DECODE_ARGS): - # Strip the prefix from each log line - line = LOG_PREFIX_REGEX.sub("", line) - # The iOS log streamer can sometimes lag; when it does, it outputs - # a warning about messages being dropped... often multiple times. - # Only print the first of these duplicated warnings. - if line.startswith("=== Messages dropped "): - if not suppress_dupes: - suppress_dupes = True - sys.stdout.write(line) - else: - suppress_dupes = False - sys.stdout.write(line) - sys.stdout.flush() - - -async def xcode_test(location, simulator, verbose): - # Run the test suite on the named simulator - print("Starting xcodebuild...", flush=True) - args = [ - "xcodebuild", - "test", "-project", str(location / "iOSTestbed.xcodeproj"), "-scheme", "iOSTestbed", "-destination", f"platform=iOS Simulator,name={simulator}", - "-resultBundlePath", - str(location / f"{datetime.now():%Y%m%d-%H%M%S}.xcresult"), "-derivedDataPath", str(location / "DerivedData"), ] - if not verbose: - args += ["-quiet"] + verbosity_args = [] if verbose else ["-quiet"] + + print("Building test project...") + subprocess.run( + ["xcodebuild", "build-for-testing"] + args + verbosity_args, + check=True, + ) - async with async_process( - *args, + print("Running test project...") + # Test execution *can't* be run -quiet; verbose mode + # is how we see the output of the test output. + process = subprocess.Popen( + ["xcodebuild", "test-without-building"] + args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, - ) as process: - while line := (await process.stdout.readline()).decode(*DECODE_ARGS): - sys.stdout.write(line) - sys.stdout.flush() + ) + while line := (process.stdout.readline()).decode(*DECODE_ARGS): + # Strip the timestamp/process prefix from each log line + line = LOG_PREFIX_REGEX.sub("", line) + sys.stdout.write(line) + sys.stdout.flush() - status = await asyncio.wait_for(process.wait(), timeout=1) - exit(status) + status = process.wait(timeout=5) + exit(status) def clone_testbed( @@ -310,7 +123,7 @@ def clone_testbed( sys.exit(13) print("Cloning testbed project:") - print(f" Cloning {source}...", end="", flush=True) + print(f" Cloning {source}...", end="") shutil.copytree(source, target, symlinks=True) print(" done") @@ -318,7 +131,7 @@ def clone_testbed( sim_framework_path = xc_framework_path / "ios-arm64_x86_64-simulator" if framework is not None: if framework.suffix == ".xcframework": - print(" Installing XCFramework...", end="", flush=True) + print(" Installing XCFramework...", end="") if xc_framework_path.is_dir(): shutil.rmtree(xc_framework_path) else: @@ -328,7 +141,7 @@ def clone_testbed( ) print(" done") else: - print(" Installing simulator framework...", end="", flush=True) + print(" Installing simulator framework...", end="") if sim_framework_path.is_dir(): shutil.rmtree(sim_framework_path) else: @@ -344,10 +157,9 @@ def clone_testbed( ): # XCFramework is a relative symlink. Rewrite the symlink relative # to the new location. - print(" Rewriting symlink to XCframework...", end="", flush=True) + print(" Rewriting symlink to XCframework...", end="") orig_xc_framework_path = ( - source - / xc_framework_path.readlink() + source / xc_framework_path.readlink() ).resolve() xc_framework_path.unlink() xc_framework_path.symlink_to( @@ -360,13 +172,11 @@ def clone_testbed( sim_framework_path.is_symlink() and not sim_framework_path.readlink().is_absolute() ): - print(" Rewriting symlink to simulator framework...", end="", flush=True) + print(" Rewriting symlink to simulator framework...", end="") # Simulator framework is a relative symlink. Rewrite the symlink # relative to the new location. orig_sim_framework_path = ( - source - / "Python.XCframework" - / sim_framework_path.readlink() + source / "Python.XCframework" / sim_framework_path.readlink() ).resolve() sim_framework_path.unlink() sim_framework_path.symlink_to( @@ -379,7 +189,7 @@ def clone_testbed( print(" Using pre-existing iOS framework.") for app_src in apps: - print(f" Installing app {app_src.name!r}...", end="", flush=True) + print(f" Installing app {app_src.name!r}...", end="") app_target = target / f"iOSTestbed/app/{app_src.name}" if app_target.is_dir(): shutil.rmtree(app_target) @@ -389,54 +199,31 @@ def clone_testbed( print(f"Successfully cloned testbed: {target.resolve()}") -def update_plist(testbed_path, args): - # Add the test runner arguments to the testbed's Info.plist file. - info_plist = testbed_path / "iOSTestbed" / "iOSTestbed-Info.plist" - with info_plist.open("rb") as f: - info = plistlib.load(f) +def update_test_plan(testbed_path, args): + # Modify the test plan to use the requested test arguments. + test_plan_path = testbed_path / "iOSTestbed.xctestplan" + with test_plan_path.open("r", encoding="utf-8") as f: + test_plan = json.load(f) - info["TestArgs"] = args + test_plan["defaultOptions"]["commandLineArgumentEntries"] = [ + {"argument": arg} for arg in args + ] - with info_plist.open("wb") as f: - plistlib.dump(info, f) + with test_plan_path.open("w", encoding="utf-8") as f: + json.dump(test_plan, f, indent=2) -async def run_testbed(simulator: str | None, args: list[str], verbose: bool=False): +def run_testbed(simulator: str | None, args: list[str], verbose: bool = False): location = Path(__file__).parent - print("Updating plist...", end="", flush=True) - update_plist(location, args) - print(" done.", flush=True) + print("Updating test plan...", end="") + update_test_plan(location, args) + print(" done.") if simulator is None: - simulator = await select_simulator_device() - print(f"Running test on {simulator}", flush=True) - - # We need to get an exclusive lock on simulator creation, to avoid issues - # with multiple simulators starting and being unable to tell which - # simulator is due to which testbed instance. See - # https://github.com/python/cpython/issues/130294 for details. Wait up to - # 10 minutes for a simulator to boot. - print("Obtaining lock on simulator creation...", flush=True) - simulator_lock = SimulatorLock(timeout=10*60) - await simulator_lock.acquire() - print("Simulator lock acquired.", flush=True) - - # Get the list of devices that are booted at the start of the test run. - # The simulator started by the test suite will be detected as the new - # entry that appears on the device list. - initial_devices = await list_devices() + simulator = select_simulator_device() + print(f"Running test on {simulator}") - try: - async with asyncio.TaskGroup() as tg: - tg.create_task(log_stream_task(initial_devices, simulator_lock)) - tg.create_task(xcode_test(location, simulator=simulator, verbose=verbose)) - except* MySystemExit as e: - raise SystemExit(*e.exceptions[0].args) from None - except* subprocess.CalledProcessError as e: - # Extract it from the ExceptionGroup so it can be handled by `main`. - raise e.exceptions[0] - finally: - simulator_lock.release() + xcode_test(location, simulator=simulator, verbose=verbose) def main(): @@ -488,12 +275,16 @@ def main(): run.add_argument( "--simulator", help=( - "The name of the simulator to use (eg: 'iPhone 16e'). Defaults to ", - "the most recently released 'entry level' iPhone device." - ) + "The name of the simulator to use (eg: 'iPhone 16e'). Defaults to " + "the most recently released 'entry level' iPhone device. Device " + "architecture and OS version can also be specified; e.g., " + "`--simulator 'iPhone 16 Pro,arch=arm64,OS=26.0'` would run on " + "an ARM64 iPhone 16 Pro simulator running iOS 26.0." + ), ) run.add_argument( - "-v", "--verbose", + "-v", + "--verbose", action="store_true", help="Enable verbose output", ) @@ -512,13 +303,16 @@ def main(): clone_testbed( source=Path(__file__).parent.resolve(), target=Path(context.location).resolve(), - framework=Path(context.framework).resolve() if context.framework else None, + framework=Path(context.framework).resolve() + if context.framework + else None, apps=[Path(app) for app in context.apps], ) elif context.subcommand == "run": if test_args: if not ( - Path(__file__).parent / "Python.xcframework/ios-arm64_x86_64-simulator/bin" + Path(__file__).parent + / "Python.xcframework/ios-arm64_x86_64-simulator/bin" ).is_dir(): print( f"Testbed does not contain a compiled iOS framework. Use " @@ -527,15 +321,15 @@ def main(): ) sys.exit(20) - asyncio.run( - run_testbed( - simulator=context.simulator, - verbose=context.verbose, - args=test_args, - ) + run_testbed( + simulator=context.simulator, + verbose=context.verbose, + args=test_args, ) else: - print(f"Must specify test arguments (e.g., {sys.argv[0]} run -- test)") + print( + f"Must specify test arguments (e.g., {sys.argv[0]} run -- test)" + ) print() parser.print_help(sys.stderr) sys.exit(21) diff --git a/iOS/testbed/iOSTestbed.lldbinit b/iOS/testbed/iOSTestbed.lldbinit new file mode 100644 index 00000000000000..4cf00dd0f9de1d --- /dev/null +++ b/iOS/testbed/iOSTestbed.lldbinit @@ -0,0 +1,4 @@ +process handle SIGINT -n true -p true -s false +process handle SIGUSR1 -n true -p true -s false +process handle SIGUSR2 -n true -p true -s false +process handle SIGXFSZ -n true -p true -s false diff --git a/iOS/testbed/iOSTestbed.xcodeproj/project.pbxproj b/iOS/testbed/iOSTestbed.xcodeproj/project.pbxproj index c7d63909ee2453..18cdafd8127520 100644 --- a/iOS/testbed/iOSTestbed.xcodeproj/project.pbxproj +++ b/iOS/testbed/iOSTestbed.xcodeproj/project.pbxproj @@ -70,6 +70,7 @@ 607A66592B0F08600010BFC8 /* iOSTestbed-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "iOSTestbed-Info.plist"; sourceTree = ""; }; 608619532CB77BA900F46182 /* app_packages */ = {isa = PBXFileReference; lastKnownFileType = folder; path = app_packages; sourceTree = ""; }; 608619552CB7819B00F46182 /* app */ = {isa = PBXFileReference; lastKnownFileType = folder; path = app; sourceTree = ""; }; + 60FE0EFB2E56BB6D00524F87 /* iOSTestbed.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = iOSTestbed.xctestplan; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -95,6 +96,7 @@ 607A66092B0EFA380010BFC8 = { isa = PBXGroup; children = ( + 60FE0EFB2E56BB6D00524F87 /* iOSTestbed.xctestplan */, 607A664A2B0EFB310010BFC8 /* Python.xcframework */, 607A66142B0EFA380010BFC8 /* iOSTestbed */, 607A66302B0EFA3A0010BFC8 /* iOSTestbedTests */, @@ -379,7 +381,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; @@ -434,7 +436,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; @@ -460,7 +462,7 @@ INFOPLIST_KEY_UIMainStoryboardFile = Main; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -491,7 +493,7 @@ INFOPLIST_KEY_UIMainStoryboardFile = Main; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -514,7 +516,7 @@ DEVELOPMENT_TEAM = 3HEZE76D99; GENERATE_INFOPLIST_FILE = YES; HEADER_SEARCH_PATHS = "\"$(BUILT_PRODUCTS_DIR)/Python.framework/Headers\""; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.python.iOSTestbedTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -534,7 +536,7 @@ DEVELOPMENT_TEAM = 3HEZE76D99; GENERATE_INFOPLIST_FILE = YES; HEADER_SEARCH_PATHS = "\"$(BUILT_PRODUCTS_DIR)/Python.framework/Headers\""; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.python.iOSTestbedTests; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/iOS/testbed/iOSTestbed.xcodeproj/xcshareddata/xcschemes/iOSTestbed.xcscheme b/iOS/testbed/iOSTestbed.xcodeproj/xcshareddata/xcschemes/iOSTestbed.xcscheme new file mode 100644 index 00000000000000..d093a46f02e95d --- /dev/null +++ b/iOS/testbed/iOSTestbed.xcodeproj/xcshareddata/xcschemes/iOSTestbed.xcscheme @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/iOS/testbed/iOSTestbed.xctestplan b/iOS/testbed/iOSTestbed.xctestplan new file mode 100644 index 00000000000000..0c4ab9eb2bad30 --- /dev/null +++ b/iOS/testbed/iOSTestbed.xctestplan @@ -0,0 +1,46 @@ +{ + "configurations" : [ + { + "id" : "F5A95CE4-1ADE-4A6E-A0E1-CDBAE26DF0C5", + "name" : "Test Scheme Action", + "options" : { + + } + } + ], + "defaultOptions" : { + "commandLineArgumentEntries" : [ + { + "argument" : "test" + }, + { + "argument" : "-uall" + }, + { + "argument" : "--single-process" + }, + { + "argument" : "--rerun" + }, + { + "argument" : "-W" + } + ], + "targetForVariableExpansion" : { + "containerPath" : "container:iOSTestbed.xcodeproj", + "identifier" : "607A66112B0EFA380010BFC8", + "name" : "iOSTestbed" + } + }, + "testTargets" : [ + { + "parallelizable" : false, + "target" : { + "containerPath" : "container:iOSTestbed.xcodeproj", + "identifier" : "607A662C2B0EFA3A0010BFC8", + "name" : "iOSTestbedTests" + } + } + ], + "version" : 1 +} diff --git a/iOS/testbed/iOSTestbed/iOSTestbed-Info.plist b/iOS/testbed/iOSTestbed/iOSTestbed-Info.plist index a582f42a212783..fea45e1fad6f6f 100644 --- a/iOS/testbed/iOSTestbed/iOSTestbed-Info.plist +++ b/iOS/testbed/iOSTestbed/iOSTestbed-Info.plist @@ -41,18 +41,6 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - TestArgs - - test - -uall - --single-process - --rerun - -W - - UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/iOS/testbed/iOSTestbedTests/iOSTestbedTests.m b/iOS/testbed/iOSTestbedTests/iOSTestbedTests.m index b502a6eb277b0b..d3159f5c2e155c 100644 --- a/iOS/testbed/iOSTestbedTests/iOSTestbedTests.m +++ b/iOS/testbed/iOSTestbedTests/iOSTestbedTests.m @@ -38,16 +38,20 @@ - (void)testPython { // Arguments to pass into the test suite runner. // argv[0] must identify the process; any subsequent arg // will be handled as if it were an argument to `python -m test` - test_args = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"TestArgs"]; + // The processInfo arguments contain the binary that is running, + // followed by the arguments defined in the test plan. This means: + // run_module = test_args[1] + // argv = ["iOSTestbed"] + test_args[2:] + test_args = [[NSProcessInfo processInfo] arguments]; if (test_args == NULL) { NSLog(@"Unable to identify test arguments."); } - argv = malloc(sizeof(char *) * ([test_args count] + 1)); + NSLog(@"Test arguments: %@", test_args); + argv = malloc(sizeof(char *) * ([test_args count] - 1)); argv[0] = "iOSTestbed"; - for (int i = 1; i < [test_args count]; i++) { - argv[i] = [[test_args objectAtIndex:i] UTF8String]; + for (int i = 1; i < [test_args count] - 1; i++) { + argv[i] = [[test_args objectAtIndex:i+1] UTF8String]; } - NSLog(@"Test command: %@", test_args); // Generate an isolated Python configuration. NSLog(@"Configuring isolated Python..."); @@ -68,7 +72,7 @@ - (void)testPython { // Ensure that signal handlers are installed config.install_signal_handlers = 1; // Run the test module. - config.run_module = Py_DecodeLocale([[test_args objectAtIndex:0] UTF8String], NULL); + config.run_module = Py_DecodeLocale([[test_args objectAtIndex:1] UTF8String], NULL); // For debugging - enable verbose mode. // config.verbose = 1; @@ -101,7 +105,7 @@ - (void)testPython { } NSLog(@"Configure argc/argv..."); - status = PyConfig_SetBytesArgv(&config, [test_args count], (char**) argv); + status = PyConfig_SetBytesArgv(&config, [test_args count] - 1, (char**) argv); if (PyStatus_Exception(status)) { XCTFail(@"Unable to configure argc/argv: %s", status.err_msg); PyConfig_Clear(&config);