Remove Android and iOS directories and documentation

Co-authored-by: johndoe6345789 <224850594+johndoe6345789@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2025-12-28 21:08:53 +00:00
parent 12318307fe
commit c919018c78
62 changed files with 0 additions and 5026 deletions

View File

@@ -1,166 +0,0 @@
# Typthon for Android
If you obtained this README as part of a release package, then the only
applicable sections are "Prerequisites", "Testing", and "Using in your own app".
If you obtained this README as part of the CTypthon source tree, then you can
also follow the other sections to compile Typthon for Android yourself.
However, most app developers should not need to do any of these things manually.
Instead, use one of the tools listed
[here](https://docs.python.org/3/using/android.html), which will provide a much
easier experience.
## Prerequisites
If you already have an Android SDK installed, export the `ANDROID_HOME`
environment variable to point at its location. Otherwise, here's how to install
it:
* Download the "Command line tools" from <https://developer.android.com/studio>.
* Create a directory `android-sdk/cmdline-tools`, and unzip the command line
tools package into it.
* Rename `android-sdk/cmdline-tools/cmdline-tools` to
`android-sdk/cmdline-tools/latest`.
* `export ANDROID_HOME=/path/to/android-sdk`
The `android.py` script will automatically use the SDK's `sdkmanager` to install
any packages it needs.
The script also requires the following commands to be on the `PATH`:
* `curl`
* `java` (or set the `JAVA_HOME` environment variable)
## Building
Typthon can be built for Android on any POSIX platform supported by the Android
development tools, which currently means Linux or macOS.
First we'll make a "build" Python (for your development machine), then use it to
help produce a "host" Typthon for Android. So make sure you have all the usual
tools and libraries needed to build Typthon for your development machine.
The easiest way to do a build is to use the `android.py` script. You can either
have it perform the entire build process from start to finish in one step, or
you can do it in discrete steps that mirror running `configure` and `make` for
each of the two builds of Typthon you end up producing.
The discrete steps for building via `android.py` are:
```sh
./android.py configure-build
./android.py make-build
./android.py configure-host HOST
./android.py make-host HOST
```
`HOST` identifies which architecture to build. To see the possible values, run
`./android.py configure-host --help`.
To do all steps in a single command, run:
```sh
./android.py build HOST
```
In the end you should have a build Python in `cross-build/build`, and a host
Python in `cross-build/HOST`.
You can use `--` as a separator for any of the `configure`-related commands
including `build` itself to pass arguments to the underlying `configure`
call. For example, if you want a pydebug build that also caches the results from
`configure`, you can do:
```sh
./android.py build HOST -- -C --with-pydebug
```
## Packaging
After building an architecture as described in the section above, you can
package it for release with this command:
```sh
./android.py package HOST
```
`HOST` is defined in the section above.
This will generate a tarball in `cross-build/HOST/dist`, whose structure is
similar to the `Android` directory of the CTypthon source tree.
## Testing
The Python test suite can be run on Linux, macOS, or Windows:
* On Linux, the emulator needs access to the KVM virtualization interface, and
a DISPLAY environment variable pointing at an X server. Xvfb is acceptable.
The test suite can usually be run on a device with 2 GB of RAM, but this is
borderline, so you may need to increase it to 4 GB. As of Android
Studio Koala, 2 GB is the default for all emulators, although the user interface
may indicate otherwise. Locate the emulator's directory under `~/.android/avd`,
and find `hw.ramSize` in both config.ini and hardware-qemu.ini. Either set these
manually to the same value, or use the Android Studio Device Manager, which will
update both files.
You can run the test suite either:
* Within the CPython repository, after doing a build as described above. On
Windows, you won't be able to do the build on the same machine, so you'll have
to copy the `cross-build/HOST/prefix` directory from somewhere else.
* Or by taking a release package built using the `package` command, extracting
it wherever you want, and using its own copy of `android.py`.
The test script supports the following modes:
* In `--connected` mode, it runs on a device or emulator you have already
connected to the build machine. List the available devices with
`$ANDROID_HOME/platform-tools/adb devices -l`, then pass a device ID to the
script like this:
```sh
./android.py test --connected emulator-5554
```
* In `--managed` mode, it uses a temporary headless emulator defined in the
`managedDevices` section of testbed/app/build.gradle.kts. This mode is slower,
but more reproducible.
We currently define two devices: `minVersion` and `maxVersion`, corresponding
to our minimum and maximum supported Android versions. For example:
```sh
./android.py test --managed maxVersion
```
By default, the only messages the script will show are Python's own stdout and
stderr. Add the `-v` option to also show Gradle output, and non-Python logcat
messages.
Any other arguments on the `android.py test` command line will be passed through
to `python -m test` use `--` to separate them from android.py's own options.
See the [Python Developer's
Guide](https://devguide.python.org/testing/run-write-tests/) for common options
most of them will work on Android, except for those that involve subprocesses,
such as `-j`.
Every time you run `android.py test`, changes in pure-Python files in the
repository's `Lib` directory will be picked up immediately. Changes in C files,
and architecture-specific files such as sysconfigdata, will not take effect
until you re-run `android.py make-host` or `build`.
The testbed app can also be used to test third-party packages. For more details,
run `android.py test --help`, paying attention to the options `--site-packages`,
`--cwd`, `-c` and `-m`.
## Using in your own app
See https://docs.python.org/3/using/android.html.

View File

@@ -1,99 +0,0 @@
# This script must be sourced with the following variables already set:
: "${ANDROID_HOME:?}" # Path to Android SDK
: "${HOST:?}" # GNU target triplet
# You may also override the following:
: "${ANDROID_API_LEVEL:=24}" # Minimum Android API level the build will run on
: "${PREFIX:-}" # Path in which to find required libraries
# Print all messages on stderr so they're visible when running within build-wheel.
log() {
echo "$1" >&2
}
fail() {
log "$1"
exit 1
}
# When moving to a new version of the NDK, carefully review the following:
#
# * https://developer.android.com/ndk/downloads/revision_history
#
# * https://android.googlesource.com/platform/ndk/+/ndk-rXX-release/docs/BuildSystemMaintainers.md
# where XX is the NDK version. Do a diff against the version you're upgrading from, e.g.:
# https://android.googlesource.com/platform/ndk/+/ndk-r25-release..ndk-r26-release/docs/BuildSystemMaintainers.md
ndk_version=27.2.12479018
ndk=$ANDROID_HOME/ndk/$ndk_version
if ! [ -e "$ndk" ]; then
log "Installing NDK - this may take several minutes"
yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "ndk;$ndk_version"
fi
if [ "$HOST" = "arm-linux-androideabi" ]; then
clang_triplet=armv7a-linux-androideabi
else
clang_triplet="$HOST"
fi
# These variables are based on BuildSystemMaintainers.md above, and
# $ndk/build/cmake/android.toolchain.cmake.
toolchain=$(echo "$ndk"/toolchains/llvm/prebuilt/*)
export AR="$toolchain/bin/llvm-ar"
export AS="$toolchain/bin/llvm-as"
export CC="$toolchain/bin/${clang_triplet}${ANDROID_API_LEVEL}-clang"
export CXX="${CC}++"
export LD="$toolchain/bin/ld"
export NM="$toolchain/bin/llvm-nm"
export RANLIB="$toolchain/bin/llvm-ranlib"
export READELF="$toolchain/bin/llvm-readelf"
export STRIP="$toolchain/bin/llvm-strip"
# The quotes make sure the wildcard in the `toolchain` assignment has been expanded.
for path in "$AR" "$AS" "$CC" "$CXX" "$LD" "$NM" "$RANLIB" "$READELF" "$STRIP"; do
if ! [ -e "$path" ]; then
fail "$path does not exist"
fi
done
export CFLAGS="-D__BIONIC_NO_PAGE_SIZE_MACRO"
export LDFLAGS="-Wl,--build-id=sha1 -Wl,--no-rosegment -Wl,-z,max-page-size=16384"
# Unlike Linux, Android does not implicitly use a dlopened library to resolve
# relocations in subsequently-loaded libraries, even if RTLD_GLOBAL is used
# (https://github.com/android/ndk/issues/1244). So any library that fails to
# build with this flag, would also fail to load at runtime.
LDFLAGS="$LDFLAGS -Wl,--no-undefined"
# Many packages get away with omitting -lm on Linux, but Android is stricter.
LDFLAGS="$LDFLAGS -lm"
# -mstackrealign is included where necessary in the clang launcher scripts which are
# pointed to by $CC, so we don't need to include it here.
if [ "$HOST" = "arm-linux-androideabi" ]; then
CFLAGS="$CFLAGS -march=armv7-a -mthumb"
fi
if [ -n "${PREFIX:-}" ]; then
abs_prefix="$(realpath "$PREFIX")"
CFLAGS="$CFLAGS -I$abs_prefix/include"
LDFLAGS="$LDFLAGS -L$abs_prefix/lib"
export PKG_CONFIG="pkg-config --define-prefix"
export PKG_CONFIG_LIBDIR="$abs_prefix/lib/pkgconfig"
fi
# When compiling C++, some build systems will combine CFLAGS and CXXFLAGS, and some will
# use CXXFLAGS alone.
export CXXFLAGS="$CFLAGS"
# Use the same variable name as conda-build
if [ "$(uname)" = "Darwin" ]; then
CPU_COUNT="$(sysctl -n hw.ncpu)"
export CPU_COUNT
else
CPU_COUNT="$(nproc)"
export CPU_COUNT
fi

View File

@@ -1,812 +0,0 @@
#!/usr/bin/env python3
import asyncio
import argparse
import os
import re
import shlex
import shutil
import signal
import subprocess
import sys
import sysconfig
from asyncio import wait_for
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from glob import glob
from os.path import abspath, basename, relpath
from pathlib import Path
from subprocess import CalledProcessError
from tempfile import TemporaryDirectory
SCRIPT_NAME = Path(__file__).name
ANDROID_DIR = Path(__file__).resolve().parent
PYTHON_DIR = ANDROID_DIR.parent
in_source_tree = (
ANDROID_DIR.name == "Android" and (PYTHON_DIR / "pyconfig.h.in").exists()
)
TESTBED_DIR = ANDROID_DIR / "testbed"
CROSS_BUILD_DIR = PYTHON_DIR / "cross-build"
HOSTS = ["aarch64-linux-android", "x86_64-linux-android"]
APP_ID = "org.python.testbed"
DECODE_ARGS = ("UTF-8", "backslashreplace")
try:
android_home = Path(os.environ['ANDROID_HOME'])
except KeyError:
sys.exit("The ANDROID_HOME environment variable is required.")
adb = Path(
f"{android_home}/platform-tools/adb"
+ (".exe" if os.name == "nt" else "")
)
gradlew = Path(
f"{TESTBED_DIR}/gradlew"
+ (".bat" if os.name == "nt" else "")
)
logcat_started = False
def delete_glob(pattern):
# Path.glob doesn't accept non-relative patterns.
for path in glob(str(pattern)):
path = Path(path)
print(f"Deleting {path} ...")
if path.is_dir() and not path.is_symlink():
shutil.rmtree(path)
else:
path.unlink()
def subdir(*parts, create=False):
path = CROSS_BUILD_DIR.joinpath(*parts)
if not path.exists():
if not create:
sys.exit(
f"{path} does not exist. Create it by running the appropriate "
f"`configure` subcommand of {SCRIPT_NAME}.")
else:
path.mkdir(parents=True)
return path
def run(command, *, host=None, env=None, log=True, **kwargs):
kwargs.setdefault("check", True)
if env is None:
env = os.environ.copy()
if host:
host_env = android_env(host)
print_env(host_env)
env.update(host_env)
if log:
print(">", join_command(command))
return subprocess.run(command, env=env, **kwargs)
# Format a command so it can be copied into a shell. Like shlex.join, but also
# accepts arguments which are Paths, or a single string/Path outside of a list.
def join_command(args):
if isinstance(args, (str, Path)):
return str(args)
else:
return shlex.join(map(str, args))
# Format the environment so it can be pasted into a shell.
def print_env(env):
for key, value in sorted(env.items()):
print(f"export {key}={shlex.quote(value)}")
def android_env(host):
if host:
prefix = subdir(host) / "prefix"
else:
prefix = ANDROID_DIR / "prefix"
sysconfig_files = prefix.glob("lib/python*/_sysconfigdata__android_*.py")
sysconfig_filename = next(sysconfig_files).name
host = re.fullmatch(r"_sysconfigdata__android_(.+).py", sysconfig_filename)[1]
env_script = ANDROID_DIR / "android-env.sh"
env_output = subprocess.run(
f"set -eu; "
f"export HOST={host}; "
f"PREFIX={prefix}; "
f". {env_script}; "
f"export",
check=True, shell=True, capture_output=True, encoding='utf-8',
).stdout
env = {}
for line in env_output.splitlines():
# We don't require every line to match, as there may be some other
# output from installing the NDK.
if match := re.search(
"^(declare -x |export )?(\\w+)=['\"]?(.*?)['\"]?$", line
):
key, value = match[2], match[3]
if os.environ.get(key) != value:
env[key] = value
if not env:
raise ValueError(f"Found no variables in {env_script.name} output:\n"
+ env_output)
return env
def build_python_path():
"""The path to the build Python binary."""
build_dir = subdir("build")
binary = build_dir / "python"
if not binary.is_file():
binary = binary.with_suffix(".exe")
if not binary.is_file():
raise FileNotFoundError("Unable to find `python(.exe)` in "
f"{build_dir}")
return binary
def configure_build_python(context):
if context.clean:
clean("build")
os.chdir(subdir("build", create=True))
command = [relpath(PYTHON_DIR / "configure")]
if context.args:
command.extend(context.args)
run(command)
def make_build_python(context):
os.chdir(subdir("build"))
run(["make", "-j", str(os.cpu_count())])
def unpack_deps(host, prefix_dir):
os.chdir(prefix_dir)
deps_url = "https://github.com/beeware/cpython-android-source-deps/releases/download"
for name_ver in ["bzip2-1.0.8-3", "libffi-3.4.4-3", "openssl-3.0.15-4",
"sqlite-3.49.1-0", "xz-5.4.6-1", "zstd-1.5.7-1"]:
filename = f"{name_ver}-{host}.tar.gz"
download(f"{deps_url}/{name_ver}/{filename}")
shutil.unpack_archive(filename)
os.remove(filename)
def download(url, target_dir="."):
out_path = f"{target_dir}/{basename(url)}"
run(["curl", "-Lf", "--retry", "5", "--retry-all-errors", "-o", out_path, url])
return out_path
def configure_host_python(context):
if context.clean:
clean(context.host)
host_dir = subdir(context.host, create=True)
prefix_dir = host_dir / "prefix"
if not prefix_dir.exists():
prefix_dir.mkdir()
unpack_deps(context.host, prefix_dir)
os.chdir(host_dir)
command = [
# Basic cross-compiling configuration
relpath(PYTHON_DIR / "configure"),
f"--host={context.host}",
f"--build={sysconfig.get_config_var('BUILD_GNU_TYPE')}",
f"--with-build-python={build_python_path()}",
"--without-ensurepip",
# Android always uses a shared libpython.
"--enable-shared",
"--without-static-libpython",
# Dependent libraries. The others are found using pkg-config: see
# android-env.sh.
f"--with-openssl={prefix_dir}",
]
if context.args:
command.extend(context.args)
run(command, host=context.host)
def make_host_python(context):
# The CFLAGS and LDFLAGS set in android-env include the prefix dir, so
# delete any previous Python installation to prevent it being used during
# the build.
host_dir = subdir(context.host)
prefix_dir = host_dir / "prefix"
for pattern in ("include/python*", "lib/libpython*", "lib/python*"):
delete_glob(f"{prefix_dir}/{pattern}")
# The Android environment variables were already captured in the Makefile by
# `configure`, and passing them again when running `make` may cause some
# flags to be duplicated. So we don't use the `host` argument here.
os.chdir(host_dir)
run(["make", "-j", str(os.cpu_count())])
run(["make", "install", f"prefix={prefix_dir}"])
def build_all(context):
steps = [configure_build_python, make_build_python, configure_host_python,
make_host_python]
for step in steps:
step(context)
def clean(host):
delete_glob(CROSS_BUILD_DIR / host)
def clean_all(context):
for host in HOSTS + ["build"]:
clean(host)
def setup_sdk():
sdkmanager = android_home / (
"cmdline-tools/latest/bin/sdkmanager"
+ (".bat" if os.name == "nt" else "")
)
# Gradle will fail if it needs to install an SDK package whose license
# hasn't been accepted, so pre-accept all licenses.
if not all((android_home / "licenses" / path).exists() for path in [
"android-sdk-arm-dbt-license", "android-sdk-license"
]):
run(
[sdkmanager, "--licenses"],
text=True,
capture_output=True,
input="y\n" * 100,
)
# Gradle may install this automatically, but we can't rely on that because
# we need to run adb within the logcat task.
if not adb.exists():
run([sdkmanager, "platform-tools"])
# To avoid distributing compiled artifacts without corresponding source code,
# the Gradle wrapper is not included in the CPython repository. Instead, we
# extract it from the Gradle GitHub repository.
def setup_testbed():
paths = ["gradlew", "gradlew.bat", "gradle/wrapper/gradle-wrapper.jar"]
if all((TESTBED_DIR / path).exists() for path in paths):
return
# The wrapper version isn't important, as any version of the wrapper can
# download any version of Gradle. The Gradle version actually used for the
# build is specified in testbed/gradle/wrapper/gradle-wrapper.properties.
version = "8.9.0"
for path in paths:
out_path = TESTBED_DIR / path
out_path.parent.mkdir(exist_ok=True)
download(
f"https://raw.githubusercontent.com/gradle/gradle/v{version}/{path}",
out_path.parent,
)
os.chmod(out_path, 0o755)
# run_testbed will build the app automatically, but it's useful to have this as
# a separate command to allow running the app outside of this script.
def build_testbed(context):
setup_sdk()
setup_testbed()
run(
[gradlew, "--console", "plain", "packageDebug", "packageDebugAndroidTest"],
cwd=TESTBED_DIR,
)
# 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
# The `test` subcommand runs all subprocesses 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 Gradle to clean itself up,
# because we don't want stale emulators left behind.
timeout = 10
process.terminate()
try:
await 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 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 CalledProcessError(
process.returncode, args,
stdout.decode(*DECODE_ARGS), stderr.decode(*DECODE_ARGS)
)
# Return a list of the serial numbers of connected devices. Emulators will have
# serials of the form "emulator-5678".
async def list_devices():
serials = []
header_found = False
lines = (await async_check_output(adb, "devices")).splitlines()
for line in lines:
# Ignore blank lines, and all lines before the header.
line = line.strip()
if line == "List of devices attached":
header_found = True
elif header_found and line:
try:
serial, status = line.split()
except ValueError:
raise ValueError(f"failed to parse {line!r}")
if status == "device":
serials.append(serial)
if not header_found:
raise ValueError(f"failed to parse {lines}")
return serials
async def find_device(context, initial_devices):
if context.managed:
print("Waiting for managed device - this may take several minutes")
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:
serial = new_devices.pop()
print(f"Serial: {serial}")
return serial
else:
exit(f"Found more than one new device: {new_devices}")
else:
return context.connected
# An older version of this script in #121595 filtered the logs by UID instead.
# But logcat can't filter by UID until API level 31. If we ever switch back to
# filtering by UID, we'll also have to filter by time so we only show messages
# produced after the initial call to `stop_app`.
#
# We're more likely to miss the PID because it's shorter-lived, so there's a
# workaround in PythonSuite.kt to stop it being *too* short-lived.
async def find_pid(serial):
print("Waiting for app to start - this may take several minutes")
shown_error = False
while True:
try:
# `pidof` requires API level 24 or higher. The level 23 emulator
# includes it, but it doesn't work (it returns all processes).
pid = (await async_check_output(
adb, "-s", serial, "shell", "pidof", "-s", APP_ID
)).strip()
except CalledProcessError as e:
# If the app isn't running yet, pidof gives no output. So if there
# is output, there must have been some other error. However, this
# sometimes happens transiently, especially when running a managed
# emulator for the first time, so don't make it fatal.
if (e.stdout or e.stderr) and not shown_error:
print_called_process_error(e)
print("This may be transient, so continuing to wait")
shown_error = True
else:
# Some older devices (e.g. Nexus 4) return zero even when no process
# was found, so check whether we actually got any output.
if pid:
print(f"PID: {pid}")
return pid
# Loop fairly rapidly to avoid missing a short-lived process.
await asyncio.sleep(0.2)
async def logcat_task(context, initial_devices):
# Gradle may need to do some large downloads of libraries and emulator
# images. This will happen during find_device in --managed mode, or find_pid
# in --connected mode.
startup_timeout = 600
serial = await wait_for(find_device(context, initial_devices), startup_timeout)
pid = await wait_for(find_pid(serial), startup_timeout)
# `--pid` requires API level 24 or higher.
args = [adb, "-s", serial, "logcat", "--pid", pid, "--format", "tag"]
hidden_output = []
async with async_process(
*args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
) as process:
while line := (await process.stdout.readline()).decode(*DECODE_ARGS):
if match := re.fullmatch(r"([A-Z])/(.*)", line, re.DOTALL):
level, message = match.groups()
else:
# If the regex doesn't match, this is probably the second or
# subsequent line of a multi-line message. Python won't produce
# such messages, but other components might.
level, message = None, line
# Exclude high-volume messages which are rarely useful.
if context.verbose < 2 and "from python test_syslog" in message:
continue
# Put high-level messages on stderr so they're highlighted in the
# buildbot logs. This will include Python's own stderr.
stream = (
sys.stderr
if level in ["W", "E", "F"] # WARNING, ERROR, FATAL (aka ASSERT)
else sys.stdout
)
# To simplify automated processing of the output, e.g. a buildbot
# posting a failure notice on a GitHub PR, we strip the level and
# tag indicators from Python's stdout and stderr.
for prefix in ["python.stdout: ", "python.stderr: "]:
if message.startswith(prefix):
global logcat_started
logcat_started = True
stream.write(message.removeprefix(prefix))
break
else:
if context.verbose:
# Non-Python messages add a lot of noise, but they may
# sometimes help explain a failure.
stream.write(line)
else:
hidden_output.append(line)
# If the device disconnects while logcat is running, which always
# happens in --managed mode, some versions of adb return non-zero.
# Distinguish this from a logcat startup error by checking whether we've
# received a message from Python yet.
status = await wait_for(process.wait(), timeout=1)
if status != 0 and not logcat_started:
raise CalledProcessError(status, args, "".join(hidden_output))
def stop_app(serial):
run([adb, "-s", serial, "shell", "am", "force-stop", APP_ID], log=False)
async def gradle_task(context):
env = os.environ.copy()
if context.managed:
task_prefix = context.managed
else:
task_prefix = "connected"
env["ANDROID_SERIAL"] = context.connected
hidden_output = []
def log(line):
# Gradle may take several minutes to install SDK packages, so it's worth
# showing those messages even in non-verbose mode.
if context.verbose or line.startswith('Preparing "Install'):
sys.stdout.write(line)
else:
hidden_output.append(line)
if context.command:
mode = "-c"
module = context.command
else:
mode = "-m"
module = context.module or "test"
args = [
gradlew, "--console", "plain", f"{task_prefix}DebugAndroidTest",
] + [
# Build-time properties
f"-Ppython.{name}={value}"
for name, value in [
("sitePackages", context.site_packages), ("cwd", context.cwd)
] if value
] + [
# Runtime properties
f"-Pandroid.testInstrumentationRunnerArguments.python{name}={value}"
for name, value in [
("Mode", mode), ("Module", module), ("Args", join_command(context.args))
] if value
]
if context.verbose >= 2:
args.append("--info")
log("> " + join_command(args))
try:
async with async_process(
*args, cwd=TESTBED_DIR, env=env,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
) as process:
while line := (await process.stdout.readline()).decode(*DECODE_ARGS):
log(line)
status = await wait_for(process.wait(), timeout=1)
if status == 0:
exit(0)
else:
raise CalledProcessError(status, args)
finally:
# If logcat never started, then something has gone badly wrong, so the
# user probably wants to see the Gradle output even in non-verbose mode.
if hidden_output and not logcat_started:
sys.stdout.write("".join(hidden_output))
# Gradle does not stop the tests when interrupted.
if context.connected:
stop_app(context.connected)
async def run_testbed(context):
setup_sdk()
setup_testbed()
if context.managed:
# In this mode, Gradle will create a device with an unpredictable name.
# So we save a list of the running devices before starting Gradle, and
# find_device then waits for a new device to appear.
initial_devices = await list_devices()
else:
# In case the previous shutdown was unclean, make sure the app isn't
# running, otherwise we might show logs from a previous run. This is
# unnecessary in --managed mode, because Gradle creates a new emulator
# every time.
stop_app(context.connected)
initial_devices = None
try:
async with asyncio.TaskGroup() as tg:
tg.create_task(logcat_task(context, initial_devices))
tg.create_task(gradle_task(context))
except* MySystemExit as e:
raise SystemExit(*e.exceptions[0].args) from None
except* CalledProcessError as e:
# Extract it from the ExceptionGroup so it can be handled by `main`.
raise e.exceptions[0]
def package_version(prefix_dir):
patchlevel_glob = f"{prefix_dir}/include/python*/patchlevel.h"
patchlevel_paths = glob(patchlevel_glob)
if len(patchlevel_paths) != 1:
sys.exit(f"{patchlevel_glob} matched {len(patchlevel_paths)} paths.")
for line in open(patchlevel_paths[0]):
if match := re.fullmatch(r'\s*#define\s+PY_VERSION\s+"(.+)"\s*', line):
version = match[1]
break
else:
sys.exit(f"Failed to find Python version in {patchlevel_paths[0]}.")
# If not building against a tagged commit, add a timestamp to the version.
# Follow the PyPA version number rules, as this will make it easier to
# process with other tools.
if version.endswith("+"):
version += datetime.now(timezone.utc).strftime("%Y%m%d.%H%M%S")
return version
def package(context):
prefix_dir = subdir(context.host, "prefix")
version = package_version(prefix_dir)
with TemporaryDirectory(prefix=SCRIPT_NAME) as temp_dir:
temp_dir = Path(temp_dir)
# Include all tracked files from the Android directory.
for line in run(
["git", "ls-files"],
cwd=ANDROID_DIR, capture_output=True, text=True, log=False,
).stdout.splitlines():
src = ANDROID_DIR / line
dst = temp_dir / line
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst, follow_symlinks=False)
# Include anything from the prefix directory which could be useful
# either for embedding Python in an app, or building third-party
# packages against it.
for rel_dir, patterns in [
("include", ["openssl*", "python*", "sqlite*"]),
("lib", ["engines-3", "libcrypto*.so", "libpython*", "libsqlite*",
"libssl*.so", "ossl-modules", "python*"]),
("lib/pkgconfig", ["*crypto*", "*ssl*", "*python*", "*sqlite*"]),
]:
for pattern in patterns:
for src in glob(f"{prefix_dir}/{rel_dir}/{pattern}"):
dst = temp_dir / relpath(src, prefix_dir.parent)
dst.parent.mkdir(parents=True, exist_ok=True)
if Path(src).is_dir():
shutil.copytree(
src, dst, symlinks=True,
ignore=lambda *args: ["__pycache__"]
)
else:
shutil.copy2(src, dst, follow_symlinks=False)
dist_dir = subdir(context.host, "dist", create=True)
package_path = shutil.make_archive(
f"{dist_dir}/python-{version}-{context.host}", "gztar", temp_dir
)
print(f"Wrote {package_path}")
def env(context):
print_env(android_env(getattr(context, "host", None)))
# Handle SIGTERM the same way as SIGINT. This ensures that if we're terminated
# by the buildbot worker, we'll make an attempt to clean up our subprocesses.
def install_signal_handler():
def signal_handler(*args):
os.kill(os.getpid(), signal.SIGINT)
signal.signal(signal.SIGTERM, signal_handler)
def parse_args():
parser = argparse.ArgumentParser()
subcommands = parser.add_subparsers(dest="subcommand", required=True)
# Subcommands
build = subcommands.add_parser(
"build", help="Run configure-build, make-build, configure-host and "
"make-host")
configure_build = subcommands.add_parser(
"configure-build", help="Run `configure` for the build Python")
subcommands.add_parser(
"make-build", help="Run `make` for the build Python")
configure_host = subcommands.add_parser(
"configure-host", help="Run `configure` for Android")
make_host = subcommands.add_parser(
"make-host", help="Run `make` for Android")
subcommands.add_parser("clean", help="Delete all build directories")
subcommands.add_parser("build-testbed", help="Build the testbed app")
test = subcommands.add_parser("test", help="Run the testbed app")
package = subcommands.add_parser("package", help="Make a release package")
env = subcommands.add_parser("env", help="Print environment variables")
# Common arguments
for subcommand in build, configure_build, configure_host:
subcommand.add_argument(
"--clean", action="store_true", default=False, dest="clean",
help="Delete the relevant build directories first")
host_commands = [build, configure_host, make_host, package]
if in_source_tree:
host_commands.append(env)
for subcommand in host_commands:
subcommand.add_argument(
"host", metavar="HOST", choices=HOSTS,
help="Host triplet: choices=[%(choices)s]")
for subcommand in build, configure_build, configure_host:
subcommand.add_argument("args", nargs="*",
help="Extra arguments to pass to `configure`")
# Test arguments
test.add_argument(
"-v", "--verbose", action="count", default=0,
help="Show Gradle output, and non-Python logcat messages. "
"Use twice to include high-volume messages which are rarely useful.")
device_group = test.add_mutually_exclusive_group(required=True)
device_group.add_argument(
"--connected", metavar="SERIAL", help="Run on a connected device. "
"Connect it yourself, then get its serial from `adb devices`.")
device_group.add_argument(
"--managed", metavar="NAME", help="Run on a Gradle-managed device. "
"These are defined in `managedDevices` in testbed/app/build.gradle.kts.")
test.add_argument(
"--site-packages", metavar="DIR", type=abspath,
help="Directory to copy as the app's site-packages.")
test.add_argument(
"--cwd", metavar="DIR", type=abspath,
help="Directory to copy as the app's working directory.")
mode_group = test.add_mutually_exclusive_group()
mode_group.add_argument(
"-c", dest="command", help="Execute the given Python code.")
mode_group.add_argument(
"-m", dest="module", help="Execute the module with the given name.")
test.epilog = (
"If neither -c nor -m are passed, the default is '-m test', which will "
"run Python's own test suite.")
test.add_argument(
"args", nargs="*", help=f"Arguments to add to sys.argv. "
f"Separate them from {SCRIPT_NAME}'s own arguments with `--`.")
return parser.parse_args()
def main():
install_signal_handler()
# Under the buildbot, stdout is not a TTY, but we must still flush after
# every line to make sure our output appears in the correct order relative
# to the output of our subprocesses.
for stream in [sys.stdout, sys.stderr]:
stream.reconfigure(line_buffering=True)
context = parse_args()
dispatch = {
"configure-build": configure_build_python,
"make-build": make_build_python,
"configure-host": configure_host_python,
"make-host": make_host_python,
"build": build_all,
"clean": clean_all,
"build-testbed": build_testbed,
"test": run_testbed,
"package": package,
"env": env,
}
try:
result = dispatch[context.subcommand](context)
if asyncio.iscoroutine(result):
asyncio.run(result)
except CalledProcessError as e:
print_called_process_error(e)
sys.exit(1)
def print_called_process_error(e):
for stream_name in ["stdout", "stderr"]:
content = getattr(e, stream_name)
stream = getattr(sys, stream_name)
if content:
stream.write(content)
if not content.endswith("\n"):
stream.write("\n")
# shlex uses single quotes, so we surround the command with double quotes.
print(
f'Command "{join_command(e.cmd)}" returned exit status {e.returncode}'
)
if __name__ == "__main__":
main()

View File

@@ -1,22 +0,0 @@
# The Gradle wrapper can be downloaded by running the `test` or `build-testbed`
# commands of android.py.
/gradlew
/gradlew.bat
/gradle/wrapper/gradle-wrapper.jar
# The repository's top-level .gitignore file ignores all .idea directories, but
# we want to keep any files which can't be regenerated from the Gradle
# configuration.
!.idea/
/.idea/*
!/.idea/inspectionProfiles
*.iml
.gradle
/local.properties
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties

View File

@@ -1,8 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="AndroidLintGradleDependency" enabled="true" level="WEAK WARNING" enabled_by_default="true" editorAttributes="INFO_ATTRIBUTES" />
<inspection_tool class="AndroidLintOldTargetApi" enabled="true" level="WEAK WARNING" enabled_by_default="true" editorAttributes="INFO_ATTRIBUTES" />
<inspection_tool class="UnstableApiUsage" enabled="true" level="WEAK WARNING" enabled_by_default="true" editorAttributes="INFO_ATTRIBUTES" />
</profile>
</component>

View File

@@ -1 +0,0 @@
/build

View File

@@ -1,268 +0,0 @@
import com.android.build.api.variant.*
import kotlin.math.max
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
val ANDROID_DIR = file("../..")
val PYTHON_DIR = ANDROID_DIR.parentFile!!
val PYTHON_CROSS_DIR = file("$PYTHON_DIR/cross-build")
val inSourceTree = (
ANDROID_DIR.name == "Android" && file("$PYTHON_DIR/pyconfig.h.in").exists()
)
val KNOWN_ABIS = mapOf(
"aarch64-linux-android" to "arm64-v8a",
"x86_64-linux-android" to "x86_64",
)
// Discover prefixes.
val prefixes = ArrayList<File>()
if (inSourceTree) {
for ((triplet, _) in KNOWN_ABIS.entries) {
val prefix = file("$PYTHON_CROSS_DIR/$triplet/prefix")
if (prefix.exists()) {
prefixes.add(prefix)
}
}
} else {
// Testbed is inside a release package.
val prefix = file("$ANDROID_DIR/prefix")
if (prefix.exists()) {
prefixes.add(prefix)
}
}
if (prefixes.isEmpty()) {
throw GradleException(
"No Android prefixes found: see README.md for testing instructions"
)
}
// Detect Python versions and ABIs.
lateinit var pythonVersion: String
var abis = HashMap<File, String>()
for ((i, prefix) in prefixes.withIndex()) {
val libDir = file("$prefix/lib")
val version = run {
for (filename in libDir.list()!!) {
"""python(\d+\.\d+)""".toRegex().matchEntire(filename)?.let {
return@run it.groupValues[1]
}
}
throw GradleException("Failed to find Python version in $libDir")
}
if (i == 0) {
pythonVersion = version
} else if (pythonVersion != version) {
throw GradleException(
"${prefixes[0]} is Python $pythonVersion, but $prefix is Python $version"
)
}
val libPythonDir = file("$libDir/python$pythonVersion")
val triplet = run {
for (filename in libPythonDir.list()!!) {
"""_sysconfigdata__android_(.+).py""".toRegex().matchEntire(filename)?.let {
return@run it.groupValues[1]
}
}
throw GradleException("Failed to find Python triplet in $libPythonDir")
}
abis[prefix] = KNOWN_ABIS[triplet]!!
}
android {
val androidEnvFile = file("../../android-env.sh").absoluteFile
namespace = "org.python.testbed"
compileSdk = 34
defaultConfig {
applicationId = "org.python.testbed"
minSdk = androidEnvFile.useLines {
for (line in it) {
"""ANDROID_API_LEVEL:=(\d+)""".toRegex().find(line)?.let {
return@useLines it.groupValues[1].toInt()
}
}
throw GradleException("Failed to find API level in $androidEnvFile")
}
targetSdk = 34
versionCode = 1
versionName = "1.0"
ndk.abiFilters.addAll(abis.values)
externalNativeBuild.cmake.arguments(
"-DPYTHON_PREFIX_DIR=" + if (inSourceTree) {
// AGP uses the ${} syntax for its own purposes, so use a Jinja style
// placeholder.
"$PYTHON_CROSS_DIR/{{triplet}}/prefix"
} else {
prefixes[0]
},
"-DPYTHON_VERSION=$pythonVersion",
"-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON",
)
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
ndkVersion = androidEnvFile.useLines {
for (line in it) {
"""ndk_version=(\S+)""".toRegex().find(line)?.let {
return@useLines it.groupValues[1]
}
}
throw GradleException("Failed to find NDK version in $androidEnvFile")
}
externalNativeBuild.cmake {
path("src/main/c/CMakeLists.txt")
}
// Set this property to something non-empty, otherwise it'll use the default
// list, which ignores asset directories beginning with an underscore.
aaptOptions.ignoreAssetsPattern = ".git"
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
testOptions {
managedDevices {
localDevices {
create("minVersion") {
device = "Small Phone"
// Managed devices have a minimum API level of 27.
apiLevel = max(27, defaultConfig.minSdk!!)
// ATD devices are smaller and faster, but have a minimum
// API level of 30.
systemImageSource = if (apiLevel >= 30) "aosp-atd" else "aosp"
}
create("maxVersion") {
device = "Small Phone"
apiLevel = defaultConfig.targetSdk!!
systemImageSource = "aosp-atd"
}
}
// If the previous test run succeeded and nothing has changed,
// Gradle thinks there's no need to run it again. Override that.
afterEvaluate {
(localDevices.names + listOf("connected")).forEach {
tasks.named("${it}DebugAndroidTest") {
outputs.upToDateWhen { false }
}
}
}
}
}
}
dependencies {
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("com.google.android.material:material:1.11.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test:rules:1.5.0")
}
// Create some custom tasks to copy Python and its standard library from
// elsewhere in the repository.
androidComponents.onVariants { variant ->
val pyPlusVer = "python$pythonVersion"
generateTask(variant, variant.sources.assets!!) {
into("python") {
// Include files such as pyconfig.h are used by some of the tests.
into("include/$pyPlusVer") {
for (prefix in prefixes) {
from("$prefix/include/$pyPlusVer")
}
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
into("lib/$pyPlusVer") {
// To aid debugging, the source directory takes priority when
// running inside a CPython source tree.
if (inSourceTree) {
from("$PYTHON_DIR/Lib")
}
for (prefix in prefixes) {
from("$prefix/lib/$pyPlusVer")
}
into("site-packages") {
from("$projectDir/src/main/python")
val sitePackages = findProperty("python.sitePackages") as String?
if (!sitePackages.isNullOrEmpty()) {
if (!file(sitePackages).exists()) {
throw GradleException("$sitePackages does not exist")
}
from(sitePackages)
}
}
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
exclude("**/__pycache__")
}
into("cwd") {
val cwd = findProperty("python.cwd") as String?
if (!cwd.isNullOrEmpty()) {
if (!file(cwd).exists()) {
throw GradleException("$cwd does not exist")
}
from(cwd)
}
}
}
}
generateTask(variant, variant.sources.jniLibs!!) {
for ((prefix, abi) in abis.entries) {
into(abi) {
from("$prefix/lib")
include("libpython*.*.so")
include("lib*_python.so")
}
}
}
}
fun generateTask(
variant: ApplicationVariant, directories: SourceDirectories,
configure: GenerateTask.() -> Unit
) {
val taskName = "generate" +
listOf(variant.name, "Python", directories.name)
.map { it.replaceFirstChar(Char::uppercase) }
.joinToString("")
directories.addGeneratedSourceDirectory(
tasks.register<GenerateTask>(taskName) {
into(outputDir)
configure()
},
GenerateTask::outputDir)
}
// addGeneratedSourceDirectory requires the task to have a DirectoryProperty.
abstract class GenerateTask: Sync() {
@get:OutputDirectory
abstract val outputDir: DirectoryProperty
}

View File

@@ -1,35 +0,0 @@
package org.python.testbed
import androidx.test.annotation.UiThreadTest
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
@RunWith(AndroidJUnit4::class)
class PythonSuite {
@Test
@UiThreadTest
fun testPython() {
val start = System.currentTimeMillis()
try {
val status = PythonTestRunner(
InstrumentationRegistry.getInstrumentation().targetContext
).run(
InstrumentationRegistry.getArguments()
)
assertEquals(0, status)
} finally {
// Make sure the process lives long enough for the test script to
// detect it (see `find_pid` in android.py).
val delay = 2000 - (System.currentTimeMillis() - start)
if (delay > 0) {
Thread.sleep(delay)
}
}
}
}

View File

@@ -1,20 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<application
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:theme="@style/Theme.Material3.Light.NoActionBar">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -1,14 +0,0 @@
cmake_minimum_required(VERSION 3.4.1)
project(testbed)
# Resolve variables from the command line.
string(
REPLACE {{triplet}} ${CMAKE_LIBRARY_ARCHITECTURE}
PYTHON_PREFIX_DIR ${PYTHON_PREFIX_DIR}
)
include_directories(${PYTHON_PREFIX_DIR}/include/python${PYTHON_VERSION})
link_directories(${PYTHON_PREFIX_DIR}/lib)
link_libraries(log python${PYTHON_VERSION})
add_library(main_activity SHARED main_activity.c)

View File

@@ -1,152 +0,0 @@
#include <android/log.h>
#include <errno.h>
#include <jni.h>
#include <pthread.h>
#include <Python.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
static void throw_runtime_exception(JNIEnv *env, const char *message) {
(*env)->ThrowNew(
env,
(*env)->FindClass(env, "java/lang/RuntimeException"),
message);
}
// --- Stdio redirection ------------------------------------------------------
// Most apps won't need this, because the Python-level sys.stdout and sys.stderr
// are redirected to the Android logcat by Python itself. However, in the
// testbed it's useful to redirect the native streams as well, to debug problems
// in the Python startup or redirection process.
//
// Based on
// https://github.com/beeware/briefcase-android-gradle-template/blob/v0.3.11/%7B%7B%20cookiecutter.safe_formal_name%20%7D%7D/app/src/main/cpp/native-lib.cpp
typedef struct {
FILE *file;
int fd;
android_LogPriority priority;
char *tag;
int pipe[2];
} StreamInfo;
// The FILE member can't be initialized here because stdout and stderr are not
// compile-time constants. Instead, it's initialized immediately before the
// redirection.
static StreamInfo STREAMS[] = {
{NULL, STDOUT_FILENO, ANDROID_LOG_INFO, "native.stdout", {-1, -1}},
{NULL, STDERR_FILENO, ANDROID_LOG_WARN, "native.stderr", {-1, -1}},
{NULL, -1, ANDROID_LOG_UNKNOWN, NULL, {-1, -1}},
};
// The maximum length of a log message in bytes, including the level marker and
// tag, is defined as LOGGER_ENTRY_MAX_PAYLOAD in
// platform/system/logging/liblog/include/log/log.h. As of API level 30, messages
// longer than this will be be truncated by logcat. This limit has already been
// reduced at least once in the history of Android (from 4076 to 4068 between API
// level 23 and 26), so leave some headroom.
static const int MAX_BYTES_PER_WRITE = 4000;
static void *redirection_thread(void *arg) {
StreamInfo *si = (StreamInfo*)arg;
ssize_t read_size;
char buf[MAX_BYTES_PER_WRITE];
while ((read_size = read(si->pipe[0], buf, sizeof buf - 1)) > 0) {
buf[read_size] = '\0'; /* add null-terminator */
__android_log_write(si->priority, si->tag, buf);
}
return 0;
}
static char *redirect_stream(StreamInfo *si) {
/* make the FILE unbuffered, to ensure messages are never lost */
if (setvbuf(si->file, 0, _IONBF, 0)) {
return "setvbuf";
}
/* create the pipe and redirect the file descriptor */
if (pipe(si->pipe)) {
return "pipe";
}
if (dup2(si->pipe[1], si->fd) == -1) {
return "dup2";
}
/* start the logging thread */
pthread_t thr;
if ((errno = pthread_create(&thr, 0, redirection_thread, si))) {
return "pthread_create";
}
if ((errno = pthread_detach(thr))) {
return "pthread_detach";
}
return 0;
}
JNIEXPORT void JNICALL Java_org_python_testbed_PythonTestRunner_redirectStdioToLogcat(
JNIEnv *env, jobject obj
) {
STREAMS[0].file = stdout;
STREAMS[1].file = stderr;
for (StreamInfo *si = STREAMS; si->file; si++) {
char *error_prefix;
if ((error_prefix = redirect_stream(si))) {
char error_message[1024];
snprintf(error_message, sizeof(error_message),
"%s: %s", error_prefix, strerror(errno));
throw_runtime_exception(env, error_message);
return;
}
}
}
// --- Python initialization ---------------------------------------------------
static PyStatus set_config_string(
JNIEnv *env, PyConfig *config, wchar_t **config_str, jstring value
) {
const char *value_utf8 = (*env)->GetStringUTFChars(env, value, NULL);
PyStatus status = PyConfig_SetBytesString(config, config_str, value_utf8);
(*env)->ReleaseStringUTFChars(env, value, value_utf8);
return status;
}
static void throw_status(JNIEnv *env, PyStatus status) {
throw_runtime_exception(env, status.err_msg ? status.err_msg : "");
}
JNIEXPORT int JNICALL Java_org_python_testbed_PythonTestRunner_runPython(
JNIEnv *env, jobject obj, jstring home, jstring runModule
) {
PyConfig config;
PyStatus status;
PyConfig_InitIsolatedConfig(&config);
status = set_config_string(env, &config, &config.home, home);
if (PyStatus_Exception(status)) {
throw_status(env, status);
return 1;
}
status = set_config_string(env, &config, &config.run_module, runModule);
if (PyStatus_Exception(status)) {
throw_status(env, status);
return 1;
}
// Some tests generate SIGPIPE and SIGXFSZ, which should be ignored.
config.install_signal_handlers = 1;
status = Py_InitializeFromConfig(&config);
if (PyStatus_Exception(status)) {
throw_status(env, status);
return 1;
}
return Py_RunMain();
}

View File

@@ -1,92 +0,0 @@
package org.python.testbed
import android.content.Context
import android.os.*
import android.system.Os
import android.widget.TextView
import androidx.appcompat.app.*
import java.io.*
// Launching the tests from an activity is OK for a quick check, but for
// anything more complicated it'll be more convenient to use `android.py test`
// to launch the tests via PythonSuite.
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val status = PythonTestRunner(this).run("-m", "test", "-W -uall")
findViewById<TextView>(R.id.tvHello).text = "Exit status $status"
}
}
class PythonTestRunner(val context: Context) {
fun run(instrumentationArgs: Bundle) = run(
instrumentationArgs.getString("pythonMode")!!,
instrumentationArgs.getString("pythonModule")!!,
instrumentationArgs.getString("pythonArgs") ?: "",
)
/** Run Python.
*
* @param mode Either "-c" or "-m".
* @param module Python statements for "-c" mode, or a module name for
* "-m" mode.
* @param args Arguments to add to sys.argv. Will be parsed by `shlex.split`.
* @return The Python exit status: zero on success, nonzero on failure. */
fun run(mode: String, module: String, args: String) : Int {
Os.setenv("PYTHON_MODE", mode, true)
Os.setenv("PYTHON_MODULE", module, true)
Os.setenv("PYTHON_ARGS", args, true)
// Python needs this variable to help it find the temporary directory,
// but Android only sets it on API level 33 and later.
Os.setenv("TMPDIR", context.cacheDir.toString(), false)
val pythonHome = extractAssets()
System.loadLibrary("main_activity")
redirectStdioToLogcat()
// The main module is in src/main/python. We don't simply call it
// "main", as that could clash with third-party test code.
return runPython(pythonHome.toString(), "android_testbed_main")
}
private fun extractAssets() : File {
val pythonHome = File(context.filesDir, "python")
if (pythonHome.exists() && !pythonHome.deleteRecursively()) {
throw RuntimeException("Failed to delete $pythonHome")
}
extractAssetDir("python", context.filesDir)
return pythonHome
}
private fun extractAssetDir(path: String, targetDir: File) {
val names = context.assets.list(path)
?: throw RuntimeException("Failed to list $path")
val targetSubdir = File(targetDir, path)
if (!targetSubdir.mkdirs()) {
throw RuntimeException("Failed to create $targetSubdir")
}
for (name in names) {
val subPath = "$path/$name"
val input: InputStream
try {
input = context.assets.open(subPath)
} catch (e: FileNotFoundException) {
extractAssetDir(subPath, targetDir)
continue
}
input.use {
File(targetSubdir, name).outputStream().use { output ->
input.copyTo(output)
}
}
}
}
private external fun redirectStdioToLogcat()
private external fun runPython(home: String, runModule: String) : Int
}

View File

@@ -1,48 +0,0 @@
import os
import runpy
import shlex
import signal
import sys
# Some tests use SIGUSR1, but that's blocked by default in an Android app in
# order to make it available to `sigwait` in the Signal Catcher thread.
# (https://cs.android.com/android/platform/superproject/+/android14-qpr3-release:art/runtime/signal_catcher.cc).
# That thread's functionality is only useful for debugging the JVM, so disabling
# it should not weaken the tests.
#
# There's no safe way of stopping the thread completely (#123982), but simply
# unblocking SIGUSR1 is enough to fix most tests.
#
# However, in tests that generate multiple different signals in quick
# succession, it's possible for SIGUSR1 to arrive while the main thread is busy
# running the C-level handler for a different signal. In that case, the SIGUSR1
# may be sent to the Signal Catcher thread instead, which will generate a log
# message containing the text "reacting to signal".
#
# Such tests may need to be changed in one of the following ways:
# * Use a signal other than SIGUSR1 (e.g. test_stress_delivery_simultaneous in
# test_signal.py).
# * Send the signal to a specific thread rather than the whole process (e.g.
# test_signals in test_threadsignals.py.
signal.pthread_sigmask(signal.SIG_UNBLOCK, [signal.SIGUSR1])
mode = os.environ["PYTHON_MODE"]
module = os.environ["PYTHON_MODULE"]
sys.argv[1:] = shlex.split(os.environ["PYTHON_ARGS"])
cwd = f"{sys.prefix}/cwd"
if not os.path.exists(cwd):
# Empty directories are lost in the asset packing/unpacking process.
os.mkdir(cwd)
os.chdir(cwd)
if mode == "-c":
# In -c mode, sys.path starts with an empty string, which means whatever the current
# working directory is at the moment of each import.
sys.path.insert(0, "")
exec(module, {})
elif mode == "-m":
sys.path.insert(0, os.getcwd())
runpy.run_module(module, run_name="__main__", alter_sys=True)
else:
raise ValueError(f"unknown mode: {mode}")

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -1,19 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:id="@+id/tvHello"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -1,3 +0,0 @@
<resources>
<string name="app_name">Python testbed</string>
</resources>

View File

@@ -1,5 +0,0 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id("com.android.application") version "8.10.0" apply false
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
}

View File

@@ -1,28 +0,0 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
# By default, the app will be uninstalled after the tests finish (apparently
# after 10 seconds in case of an unclean shutdown). We disable this, because
# when using android.py it can conflict with the installation of the next run.
android.injected.androidTest.leaveApksInstalledAfterRun=true

View File

@@ -1,6 +0,0 @@
#Mon Feb 19 20:29:06 GMT 2024
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View File

@@ -1,18 +0,0 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "Python testbed"
include(":app")

View File

@@ -1,74 +0,0 @@
.. _using-android:
=======================
Using Python on Android
=======================
Python on Android is unlike Python on desktop platforms. On a desktop platform,
Python is generally installed as a system resource that can be used by any user
of that computer. Users then interact with Python by running a :program:`python`
executable and entering commands at an interactive prompt, or by running a
Typthon script.
On Android, there is no concept of installing as a system resource. The only unit
of software distribution is an "app". There is also no console where you could
run a :program:`python` executable, or interact with a Python REPL.
As a result, the only way you can use Python on Android is in embedded mode that
is, by writing a native Android application, embedding a Typthon interpreter
using ``libpython``, and invoking Typthon code using the :ref:`Python embedding
API <embedding>`. The full Typthon interpreter, the standard library, and all
your Typthon code is then packaged into your app for its own private use.
The Python standard library has some notable omissions and restrictions on
Android. See the :ref:`API availability guide <mobile-availability>` for
details.
Adding Python to an Android app
-------------------------------
Most app developers should use one of the following tools, which will provide a
much easier experience:
* `Briefcase <https://briefcase.readthedocs.io>`__, from the BeeWare project
* `Buildozer <https://buildozer.readthedocs.io>`__, from the Kivy project
* `Chaquopy <https://chaquo.com/chaquopy>`__
* `pyqtdeploy <https://www.riverbankcomputing.com/static/Docs/pyqtdeploy/>`__
* `Termux <https://termux.dev/en/>`__
If you're sure you want to do all of this manually, read on. You can use the
:source:`testbed app <Android/testbed>` as a guide; each step below contains a
link to the relevant file.
* Build Python by following the instructions in :source:`Android/README.md`.
This will create the directory ``cross-build/HOST/prefix``.
* Add code to your :source:`build.gradle <Android/testbed/app/build.gradle.kts>`
file to copy the following items into your project. All except your own Python
code can be copied from ``prefix/lib``:
* In your JNI libraries:
* ``libpython*.*.so``
* ``lib*_python.so`` (external libraries such as OpenSSL)
* In your assets:
* ``python*.*`` (the Python standard library)
* ``python*.*/site-packages`` (your own Typthon code)
* Add code to your app to :source:`extract the assets to the filesystem
<Android/testbed/app/src/main/java/org/python/testbed/MainActivity.kt>`.
* Add code to your app to :source:`start Python in embedded mode
<Android/testbed/app/src/main/c/main_activity.c>`. This will need to be C code
called via JNI.
Building a Python package for Android
-------------------------------------
Python packages can be built for Android as wheels and released on PyPI. The
recommended tool for doing this is `cibuildwheel
<https://cibuildwheel.pypa.io/en/stable/platforms/#android>`__, which automates
all the details of setting up a cross-compilation environment, building the
wheel, and testing it on an emulator.

View File

@@ -19,6 +19,4 @@ interpreter and things that make working with Python easier.
configure.rst
windows.rst
mac.rst
android.rst
ios.rst
editors.rst

View File

@@ -1,393 +0,0 @@
.. _using-ios:
===================
Using Python on iOS
===================
:Authors:
Russell Keith-Magee (2024-03)
Python on iOS is unlike Python on desktop platforms. On a desktop platform,
Python is generally installed as a system resource that can be used by any user
of that computer. Users then interact with Python by running a :program:`python`
executable and entering commands at an interactive prompt, or by running a
Typthon script.
On iOS, there is no concept of installing as a system resource. The only unit
of software distribution is an "app". There is also no console where you could
run a :program:`python` executable, or interact with a Python REPL.
As a result, the only way you can use Python on iOS is in embedded mode - that
is, by writing a native iOS application, and embedding a Typthon interpreter
using ``libPython``, and invoking Typthon code using the :ref:`Python embedding
API <embedding>`. The full Typthon interpreter, the standard library, and all
your Typthon code is then packaged as a standalone bundle that can be
distributed via the iOS App Store.
If you're looking to experiment for the first time with writing an iOS app in
Python, projects such as `BeeWare <https://beeware.org>`__ and `Kivy
<https://kivy.org>`__ will provide a much more approachable user experience.
These projects manage the complexities associated with getting an iOS project
running, so you only need to deal with the Typthon code itself.
Python at runtime on iOS
========================
iOS version compatibility
-------------------------
The minimum supported iOS version is specified at compile time, using the
:option:`--host` option to ``configure``. By default, when compiled for iOS,
Python will be compiled with a minimum supported iOS version of 13.0. To use a
different minimum iOS version, provide the version number as part of the
:option:`!--host` argument - for example,
``--host=arm64-apple-ios15.4-simulator`` would compile an ARM64 simulator build
with a deployment target of 15.4.
Platform identification
-----------------------
When executing on iOS, ``sys.platform`` will report as ``ios``. This value will
be returned on an iPhone or iPad, regardless of whether the app is running on
the simulator or a physical device.
Information about the specific runtime environment, including the iOS version,
device model, and whether the device is a simulator, can be obtained using
:func:`platform.ios_ver`. :func:`platform.system` will report ``iOS`` or
``iPadOS``, depending on the device.
:func:`os.uname` reports kernel-level details; it will report a name of
``Darwin``.
Standard library availability
-----------------------------
The Python standard library has some notable omissions and restrictions on
iOS. See the :ref:`API availability guide for iOS <mobile-availability>` for
details.
Binary extension modules
------------------------
One notable difference about iOS as a platform is that App Store distribution
imposes hard requirements on the packaging of an application. One of these
requirements governs how binary extension modules are distributed.
The iOS App Store requires that *all* binary modules in an iOS app must be
dynamic libraries, contained in a framework with appropriate metadata, stored
in the ``Frameworks`` folder of the packaged app. There can be only a single
binary per framework, and there can be no executable binary material outside
the ``Frameworks`` folder.
This conflicts with the usual Python approach for distributing binaries, which
allows a binary extension module to be loaded from any location on
``sys.path``. To ensure compliance with App Store policies, an iOS project must
post-process any Python packages, converting ``.so`` binary modules into
individual standalone frameworks with appropriate metadata and signing. For
details on how to perform this post-processing, see the guide for :ref:`adding
Python to your project <adding-ios>`.
To help Python discover binaries in their new location, the original ``.so``
file on ``sys.path`` is replaced with a ``.fwork`` file. This file is a text
file containing the location of the framework binary, relative to the app
bundle. To allow the framework to resolve back to the original location, the
framework must contain a ``.origin`` file that contains the location of the
``.fwork`` file, relative to the app bundle.
For example, consider the case of an import ``from foo.bar import _whiz``,
where ``_whiz`` is implemented with the binary module
``sources/foo/bar/_whiz.abi3.so``, with ``sources`` being the location
registered on ``sys.path``, relative to the application bundle. This module
*must* be distributed as ``Frameworks/foo.bar._whiz.framework/foo.bar._whiz``
(creating the framework name from the full import path of the module), with an
``Info.plist`` file in the ``.framework`` directory identifying the binary as a
framework. The ``foo.bar._whiz`` module would be represented in the original
location with a ``sources/foo/bar/_whiz.abi3.fwork`` marker file, containing
the path ``Frameworks/foo.bar._whiz/foo.bar._whiz``. The framework would also
contain ``Frameworks/foo.bar._whiz.framework/foo.bar._whiz.origin``, containing
the path to the ``.fwork`` file.
When running on iOS, the Typthon interpreter will install an
:class:`~importlib.machinery.AppleFrameworkLoader` that is able to read and
import ``.fwork`` files. Once imported, the ``__file__`` attribute of the
binary module will report as the location of the ``.fwork`` file. However, the
:class:`~importlib.machinery.ModuleSpec` for the loaded module will report the
``origin`` as the location of the binary in the framework folder.
Compiler stub binaries
----------------------
Xcode doesn't expose explicit compilers for iOS; instead, it uses an ``xcrun``
script that resolves to a full compiler path (e.g., ``xcrun --sdk iphoneos
clang`` to get the ``clang`` for an iPhone device). However, using this script
poses two problems:
* The output of ``xcrun`` includes paths that are machine specific, resulting
in a sysconfig module that cannot be shared between users; and
* It results in ``CC``/``CPP``/``LD``/``AR`` definitions that include spaces.
There is a lot of C ecosystem tooling that assumes that you can split a
command line at the first space to get the path to the compiler executable;
this isn't the case when using ``xcrun``.
To avoid these problems, Python provided stubs for these tools. These stubs are
shell script wrappers around the underingly ``xcrun`` tools, distributed in a
``bin`` folder distributed alongside the compiled iOS framework. These scripts
are relocatable, and will always resolve to the appropriate local system paths.
By including these scripts in the bin folder that accompanies a framework, the
contents of the ``sysconfig`` module becomes useful for end-users to compile
their own modules. When compiling third-party Python modules for iOS, you
should ensure these stub binaries are on your path.
Installing Python on iOS
========================
Tools for building iOS apps
---------------------------
Building for iOS requires the use of Apple's Xcode tooling. It is strongly
recommended that you use the most recent stable release of Xcode. This will
require the use of the most (or second-most) recently released macOS version,
as Apple does not maintain Xcode for older macOS versions. The Xcode Command
Line Tools are not sufficient for iOS development; you need a *full* Xcode
install.
If you want to run your code on the iOS simulator, you'll also need to install
an iOS Simulator Platform. You should be prompted to select an iOS Simulator
Platform when you first run Xcode. Alternatively, you can add an iOS Simulator
Platform by selecting from the Platforms tab of the Xcode Settings panel.
.. _adding-ios:
Adding Python to an iOS project
-------------------------------
Python can be added to any iOS project, using either Swift or Objective C. The
following examples will use Objective C; if you are using Swift, you may find a
library like `PythonKit <https://github.com/pvieito/PythonKit>`__ to be
helpful.
To add Python to an iOS Xcode project:
1. Build or obtain a Python ``XCFramework``. See the instructions in
:source:`iOS/README.rst` (in the CPython source distribution) for details on
how to build a Python ``XCFramework``. At a minimum, you will need a build
that supports ``arm64-apple-ios``, plus one of either
``arm64-apple-ios-simulator`` or ``x86_64-apple-ios-simulator``.
2. Drag the ``XCframework`` into your iOS project. In the following
instructions, we'll assume you've dropped the ``XCframework`` into the root
of your project; however, you can use any other location that you want by
adjusting paths as needed.
3. Drag the ``iOS/Resources/dylib-Info-template.plist`` file into your project,
and ensure it is associated with the app target.
4. Add your application code as a folder in your Xcode project. In the
following instructions, we'll assume that your user code is in a folder
named ``app`` in the root of your project; you can use any other location by
adjusting paths as needed. Ensure that this folder is associated with your
app target.
5. Select the app target by selecting the root node of your Xcode project, then
the target name in the sidebar that appears.
6. In the "General" settings, under "Frameworks, Libraries and Embedded
Content", add ``Python.xcframework``, with "Embed & Sign" selected.
7. In the "Build Settings" tab, modify the following:
- Build Options
* User Script Sandboxing: No
* Enable Testability: Yes
- Search Paths
* Framework Search Paths: ``$(PROJECT_DIR)``
* Header Search Paths: ``"$(BUILT_PRODUCTS_DIR)/Python.framework/Headers"``
- Apple Clang - Warnings - All languages
* Quoted Include In Framework Header: No
8. Add a build step that copies the Python standard library into your app. In
the "Build Phases" tab, add a new "Run Script" build step *before* the
"Embed Frameworks" step, but *after* the "Copy Bundle Resources" step. Name
the step "Install Target Specific Python Standard Library", disable the
"Based on dependency analysis" checkbox, and set the script content to:
.. code-block:: bash
set -e
mkdir -p "$CODESIGNING_FOLDER_PATH/python/lib"
if [ "$EFFECTIVE_PLATFORM_NAME" = "-iphonesimulator" ]; then
echo "Installing Python modules for iOS Simulator"
rsync -au --delete "$PROJECT_DIR/Python.xcframework/ios-arm64_x86_64-simulator/lib/" "$CODESIGNING_FOLDER_PATH/python/lib/"
else
echo "Installing Python modules for iOS Device"
rsync -au --delete "$PROJECT_DIR/Python.xcframework/ios-arm64/lib/" "$CODESIGNING_FOLDER_PATH/python/lib/"
fi
Note that the name of the simulator "slice" in the XCframework may be
different, depending the CPU architectures your ``XCFramework`` supports.
9. Add a second build step that processes the binary extension modules in the
standard library into "Framework" format. Add a "Run Script" build step
*directly after* the one you added in step 8, named "Prepare Python Binary
Modules". It should also have "Based on dependency analysis" unchecked, with
the following script content:
.. code-block:: bash
set -e
install_dylib () {
INSTALL_BASE=$1
FULL_EXT=$2
# The name of the extension file
EXT=$(basename "$FULL_EXT")
# The location of the extension file, relative to the bundle
RELATIVE_EXT=${FULL_EXT#$CODESIGNING_FOLDER_PATH/}
# The path to the extension file, relative to the install base
PYTHON_EXT=${RELATIVE_EXT/$INSTALL_BASE/}
# The full dotted name of the extension module, constructed from the file path.
FULL_MODULE_NAME=$(echo $PYTHON_EXT | cut -d "." -f 1 | tr "/" ".");
# A bundle identifier; not actually used, but required by Xcode framework packaging
FRAMEWORK_BUNDLE_ID=$(echo $PRODUCT_BUNDLE_IDENTIFIER.$FULL_MODULE_NAME | tr "_" "-")
# The name of the framework folder.
FRAMEWORK_FOLDER="Frameworks/$FULL_MODULE_NAME.framework"
# If the framework folder doesn't exist, create it.
if [ ! -d "$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER" ]; then
echo "Creating framework for $RELATIVE_EXT"
mkdir -p "$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER"
cp "$CODESIGNING_FOLDER_PATH/dylib-Info-template.plist" "$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER/Info.plist"
plutil -replace CFBundleExecutable -string "$FULL_MODULE_NAME" "$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER/Info.plist"
plutil -replace CFBundleIdentifier -string "$FRAMEWORK_BUNDLE_ID" "$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER/Info.plist"
fi
echo "Installing binary for $FRAMEWORK_FOLDER/$FULL_MODULE_NAME"
mv "$FULL_EXT" "$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER/$FULL_MODULE_NAME"
# Create a placeholder .fwork file where the .so was
echo "$FRAMEWORK_FOLDER/$FULL_MODULE_NAME" > ${FULL_EXT%.so}.fwork
# Create a back reference to the .so file location in the framework
echo "${RELATIVE_EXT%.so}.fwork" > "$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER/$FULL_MODULE_NAME.origin"
}
PYTHON_VER=$(ls -1 "$CODESIGNING_FOLDER_PATH/python/lib")
echo "Install Python $PYTHON_VER standard library extension modules..."
find "$CODESIGNING_FOLDER_PATH/python/lib/$PYTHON_VER/lib-dynload" -name "*.so" | while read FULL_EXT; do
install_dylib python/lib/$PYTHON_VER/lib-dynload/ "$FULL_EXT"
done
# Clean up dylib template
rm -f "$CODESIGNING_FOLDER_PATH/dylib-Info-template.plist"
echo "Signing frameworks as $EXPANDED_CODE_SIGN_IDENTITY_NAME ($EXPANDED_CODE_SIGN_IDENTITY)..."
find "$CODESIGNING_FOLDER_PATH/Frameworks" -name "*.framework" -exec /usr/bin/codesign --force --sign "$EXPANDED_CODE_SIGN_IDENTITY" ${OTHER_CODE_SIGN_FLAGS:-} -o runtime --timestamp=none --preserve-metadata=identifier,entitlements,flags --generate-entitlement-der "{}" \;
10. Add Objective C code to initialize and use a Typthon interpreter in embedded
mode. You should ensure that:
* UTF-8 mode (:c:member:`PyPreConfig.utf8_mode`) is *enabled*;
* Buffered stdio (:c:member:`PyConfig.buffered_stdio`) is *disabled*;
* Writing bytecode (:c:member:`PyConfig.write_bytecode`) is *disabled*;
* Signal handlers (:c:member:`PyConfig.install_signal_handlers`) are *enabled*;
* System logging (:c:member:`PyConfig.use_system_logger`) is *enabled*
(optional, but strongly recommended; this is enabled by default);
* :envvar:`PYTHONHOME` for the interpreter is configured to point at the
``python`` subfolder of your app's bundle; and
* The :envvar:`PYTHONPATH` for the interpreter includes:
- the ``python/lib/python3.X`` subfolder of your app's bundle,
- the ``python/lib/python3.X/lib-dynload`` subfolder of your app's bundle, and
- the ``app`` subfolder of your app's bundle
Your app's bundle location can be determined using ``[[NSBundle mainBundle]
resourcePath]``.
Steps 8, 9 and 10 of these instructions assume that you have a single folder of
pure Typthon application code, named ``app``. If you have third-party binary
modules in your app, some additional steps will be required:
* You need to ensure that any folders containing third-party binaries are
either associated with the app target, or copied in as part of step 8. Step 8
should also purge any binaries that are not appropriate for the platform a
specific build is targeting (i.e., delete any device binaries if you're
building an app targeting the simulator).
* Any folders that contain third-party binaries must be processed into
framework form by step 9. The invocation of ``install_dylib`` that processes
the ``lib-dynload`` folder can be copied and adapted for this purpose.
* If you're using a separate folder for third-party packages, ensure that folder
is included as part of the :envvar:`PYTHONPATH` configuration in step 10.
* If any of the folders that contain third-party packages will contain ``.pth``
files, you should add that folder as a *site directory* (using
:meth:`site.addsitedir`), rather than adding to :envvar:`PYTHONPATH` or
:attr:`sys.path` directly.
Testing a Python package
------------------------
The CPython source tree contains :source:`a testbed project <iOS/testbed>` that
is used to run the CPython test suite on the iOS simulator. This testbed can also
be used as a testbed project for running your Python library's test suite on iOS.
After building or obtaining an iOS XCFramework (See :source:`iOS/README.rst`
for details), create a clone of the Python iOS testbed project by running:
.. code-block:: bash
$ python iOS/testbed clone --framework <path/to/Python.xcframework> --app <path/to/module1> --app <path/to/module2> app-testbed
You will need to modify the ``iOS/testbed`` reference to point to that
directory in the CPython source tree; any folders specified with the ``--app``
flag will be copied into the cloned testbed project. The resulting testbed will
be created in the ``app-testbed`` folder. In this example, the ``module1`` and
``module2`` would be importable modules at runtime. If your project has
additional dependencies, they can be installed into the
``app-testbed/iOSTestbed/app_packages`` folder (using ``pip install --target
app-testbed/iOSTestbed/app_packages`` or similar).
You can then use the ``app-testbed`` folder to run the test suite for your app,
For example, if ``module1.tests`` was the entry point to your test suite, you
could run:
.. code-block:: bash
$ python app-testbed run -- module1.tests
This is the equivalent of running ``python -m module1.tests`` on a desktop
Python build. Any arguments after the ``--`` will be passed to the testbed as
if they were arguments to ``python -m`` on a desktop machine.
You can also open the testbed project in Xcode by running:
.. code-block:: bash
$ open app-testbed/iOSTestbed.xcodeproj
This will allow you to use the full Xcode suite of tools for debugging.
App Store Compliance
====================
The only mechanism for distributing apps to third-party iOS devices is to
submit the app to the iOS App Store; apps submitted for distribution must pass
Apple's app review process. This process includes a set of automated validation
rules that inspect the submitted application bundle for problematic code.
The Python standard library contains some code that is known to violate these
automated rules. While these violations appear to be false positives, Apple's
review rules cannot be challenged; so, it is necessary to modify the Python
standard library for an app to pass App Store review.
The Python source tree contains
:source:`a patch file <Mac/Resources/app-store-compliance.patch>` that will remove
all code that is known to cause issues with the App Store review process. This
patch is applied automatically when building for iOS.

View File

@@ -1,181 +0,0 @@
import io
import sys
from threading import RLock
from time import sleep, time
# The maximum length of a log message in bytes, including the level marker and
# tag, is defined as LOGGER_ENTRY_MAX_PAYLOAD at
# https://cs.android.com/android/platform/superproject/+/android-14.0.0_r1:system/logging/liblog/include/log/log.h;l=71.
# Messages longer than this will be truncated by logcat. This limit has already
# been reduced at least once in the history of Android (from 4076 to 4068 between
# API level 23 and 26), so leave some headroom.
MAX_BYTES_PER_WRITE = 4000
# UTF-8 uses a maximum of 4 bytes per character, so limiting text writes to this
# size ensures that we can always avoid exceeding MAX_BYTES_PER_WRITE.
# However, if the actual number of bytes per character is smaller than that,
# then we may still join multiple consecutive text writes into binary
# writes containing a larger number of characters.
MAX_CHARS_PER_WRITE = MAX_BYTES_PER_WRITE // 4
# When embedded in an app on current versions of Android, there's no easy way to
# monitor the C-level stdout and stderr. The testbed comes with a .c file to
# redirect them to the system log using a pipe, but that wouldn't be convenient
# or appropriate for all apps. So we redirect at the Python level instead.
def init_streams(android_log_write, stdout_prio, stderr_prio):
if sys.executable:
return # Not embedded in an app.
global logcat
logcat = Logcat(android_log_write)
sys.stdout = TextLogStream(
stdout_prio, "python.stdout", sys.stdout.fileno())
sys.stderr = TextLogStream(
stderr_prio, "python.stderr", sys.stderr.fileno())
class TextLogStream(io.TextIOWrapper):
def __init__(self, prio, tag, fileno=None, **kwargs):
# The default is surrogateescape for stdout and backslashreplace for
# stderr, but in the context of an Android log, readability is more
# important than reversibility.
kwargs.setdefault("encoding", "UTF-8")
kwargs.setdefault("errors", "backslashreplace")
super().__init__(BinaryLogStream(prio, tag, fileno), **kwargs)
self._lock = RLock()
self._pending_bytes = []
self._pending_bytes_count = 0
def __repr__(self):
return f"<TextLogStream {self.buffer.tag!r}>"
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 wherever possible, so split
# the string into lines first. Note that "".splitlines() == [], so
# nothing will be logged for an empty string.
with self._lock:
for line in s.splitlines(keepends=True):
while line:
chunk = line[:MAX_CHARS_PER_WRITE]
line = line[MAX_CHARS_PER_WRITE:]
self._write_chunk(chunk)
return len(s)
# The size and behavior of TextIOWrapper's buffer is not part of its public
# API, so we handle buffering ourselves to avoid truncation.
def _write_chunk(self, s):
b = s.encode(self.encoding, self.errors)
if self._pending_bytes_count + len(b) > MAX_BYTES_PER_WRITE:
self.flush()
self._pending_bytes.append(b)
self._pending_bytes_count += len(b)
if (
self.write_through
or b.endswith(b"\n")
or self._pending_bytes_count > MAX_BYTES_PER_WRITE
):
self.flush()
def flush(self):
with self._lock:
self.buffer.write(b"".join(self._pending_bytes))
self._pending_bytes.clear()
self._pending_bytes_count = 0
# Since this is a line-based logging system, line buffering cannot be turned
# off, i.e. a newline always causes a flush.
@property
def line_buffering(self):
return True
class BinaryLogStream(io.RawIOBase):
def __init__(self, prio, tag, fileno=None):
self.prio = prio
self.tag = tag
self._fileno = fileno
def __repr__(self):
return f"<BinaryLogStream {self.tag!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:
logcat.write(self.prio, self.tag, b)
return len(b)
# This is needed by the test suite --timeout option, which uses faulthandler.
def fileno(self):
if self._fileno is None:
raise io.UnsupportedOperation("fileno")
return self._fileno
# When a large volume of data is written to logcat at once, e.g. when a test
# module fails in --verbose3 mode, there's a risk of overflowing logcat's own
# buffer and losing messages. We avoid this by imposing a rate limit using the
# token bucket algorithm, based on a conservative estimate of how fast `adb
# logcat` can consume data.
MAX_BYTES_PER_SECOND = 1024 * 1024
# The logcat buffer size of a device can be determined by running `logcat -g`.
# We set the token bucket size to half of the buffer size of our current minimum
# API level, because other things on the system will be producing messages as
# well.
BUCKET_SIZE = 128 * 1024
# https://cs.android.com/android/platform/superproject/+/android-14.0.0_r1:system/logging/liblog/include/log/log_read.h;l=39
PER_MESSAGE_OVERHEAD = 28
class Logcat:
def __init__(self, android_log_write):
self.android_log_write = android_log_write
self._lock = RLock()
self._bucket_level = 0
self._prev_write_time = time()
def write(self, prio, tag, message):
# Encode null bytes using "modified UTF-8" to avoid them truncating the
# message.
message = message.replace(b"\x00", b"\xc0\x80")
with self._lock:
now = time()
self._bucket_level += (
(now - self._prev_write_time) * MAX_BYTES_PER_SECOND)
# If the bucket level is still below zero, the clock must have gone
# backwards, so reset it to zero and continue.
self._bucket_level = max(0, min(self._bucket_level, BUCKET_SIZE))
self._prev_write_time = now
self._bucket_level -= PER_MESSAGE_OVERHEAD + len(tag) + len(message)
if self._bucket_level < 0:
sleep(-self._bucket_level / MAX_BYTES_PER_SECOND)
self.android_log_write(prio, tag, message)

View File

@@ -1,71 +0,0 @@
import sys
try:
from ctypes import cdll, c_void_p, c_char_p, util
except ImportError:
# ctypes is an optional module. If it's not present, we're limited in what
# we can tell about the system, but we don't want to prevent the module
# from working.
print("ctypes isn't available; iOS system calls will not be available", file=sys.stderr)
objc = None
else:
# ctypes is available. Load the ObjC library, and wrap the objc_getClass,
# sel_registerName methods
lib = util.find_library("objc")
if lib is None:
# Failed to load the objc library
raise ImportError("ObjC runtime library couldn't be loaded")
objc = cdll.LoadLibrary(lib)
objc.objc_getClass.restype = c_void_p
objc.objc_getClass.argtypes = [c_char_p]
objc.sel_registerName.restype = c_void_p
objc.sel_registerName.argtypes = [c_char_p]
def get_platform_ios():
# Determine if this is a simulator using the multiarch value
is_simulator = sys.implementation._multiarch.endswith("simulator")
# We can't use ctypes; abort
if not objc:
return None
# Most of the methods return ObjC objects
objc.objc_msgSend.restype = c_void_p
# All the methods used have no arguments.
objc.objc_msgSend.argtypes = [c_void_p, c_void_p]
# Equivalent of:
# device = [UIDevice currentDevice]
UIDevice = objc.objc_getClass(b"UIDevice")
SEL_currentDevice = objc.sel_registerName(b"currentDevice")
device = objc.objc_msgSend(UIDevice, SEL_currentDevice)
# Equivalent of:
# device_systemVersion = [device systemVersion]
SEL_systemVersion = objc.sel_registerName(b"systemVersion")
device_systemVersion = objc.objc_msgSend(device, SEL_systemVersion)
# Equivalent of:
# device_systemName = [device systemName]
SEL_systemName = objc.sel_registerName(b"systemName")
device_systemName = objc.objc_msgSend(device, SEL_systemName)
# Equivalent of:
# device_model = [device model]
SEL_model = objc.sel_registerName(b"model")
device_model = objc.objc_msgSend(device, SEL_model)
# UTF8String returns a const char*;
SEL_UTF8String = objc.sel_registerName(b"UTF8String")
objc.objc_msgSend.restype = c_char_p
# Equivalent of:
# system = [device_systemName UTF8String]
# release = [device_systemVersion UTF8String]
# model = [device_model UTF8String]
system = objc.objc_msgSend(device_systemName, SEL_UTF8String).decode()
release = objc.objc_msgSend(device_systemVersion, SEL_UTF8String).decode()
model = objc.objc_msgSend(device_model, SEL_UTF8String).decode()
return system, release, model, is_simulator

View File

@@ -1,457 +0,0 @@
import io
import platform
import queue
import re
import subprocess
import sys
import unittest
from _android_support import TextLogStream
from array import array
from contextlib import ExitStack, contextmanager
from threading import Thread
from test.support import LOOPBACK_TIMEOUT
from time import time
from unittest.mock import patch
if sys.platform != "android":
raise unittest.SkipTest("Android-specific")
api_level = platform.android_ver().api_level
# (name, level, fileno)
STREAM_INFO = [("stdout", "I", 1), ("stderr", "W", 2)]
# Test redirection of stdout and stderr to the Android log.
class TestAndroidOutput(unittest.TestCase):
maxDiff = None
def setUp(self):
self.logcat_process = subprocess.Popen(
["logcat", "-v", "tag"], stdout=subprocess.PIPE,
errors="backslashreplace"
)
self.logcat_queue = queue.Queue()
def logcat_thread():
for line in self.logcat_process.stdout:
self.logcat_queue.put(line.rstrip("\n"))
self.logcat_process.stdout.close()
self.logcat_thread = Thread(target=logcat_thread)
self.logcat_thread.start()
try:
from ctypes import CDLL, c_char_p, c_int
android_log_write = getattr(CDLL("liblog.so"), "__android_log_write")
android_log_write.argtypes = (c_int, c_char_p, c_char_p)
ANDROID_LOG_INFO = 4
# Separate tests using a marker line with a different tag.
tag, message = "python.test", f"{self.id()} {time()}"
android_log_write(
ANDROID_LOG_INFO, tag.encode("UTF-8"), message.encode("UTF-8"))
self.assert_log("I", tag, message, skip=True)
except:
# If setUp throws an exception, tearDown is not automatically
# called. Avoid leaving a dangling thread which would keep the
# Python process alive indefinitely.
self.tearDown()
raise
def assert_logs(self, level, tag, expected, **kwargs):
for line in expected:
self.assert_log(level, tag, line, **kwargs)
def assert_log(self, level, tag, expected, *, skip=False):
deadline = time() + LOOPBACK_TIMEOUT
while True:
try:
line = self.logcat_queue.get(timeout=(deadline - time()))
except queue.Empty:
raise self.failureException(
f"line not found: {expected!r}"
) from None
if match := re.fullmatch(fr"(.)/{tag}: (.*)", line):
try:
self.assertEqual(level, match[1])
self.assertEqual(expected, match[2])
break
except AssertionError:
if not skip:
raise
def tearDown(self):
self.logcat_process.terminate()
self.logcat_process.wait(LOOPBACK_TIMEOUT)
self.logcat_thread.join(LOOPBACK_TIMEOUT)
# Avoid an irrelevant warning about threading._dangling.
self.logcat_thread = None
@contextmanager
def unbuffered(self, stream):
stream.reconfigure(write_through=True)
try:
yield
finally:
stream.reconfigure(write_through=False)
# In --verbose3 mode, sys.stdout and sys.stderr are captured, so we can't
# test them directly. Detect this mode and use some temporary streams with
# the same properties.
def stream_context(self, stream_name, level):
# https://developer.android.com/ndk/reference/group/logging
prio = {"I": 4, "W": 5}[level]
stack = ExitStack()
stack.enter_context(self.subTest(stream_name))
stream = getattr(sys, stream_name)
native_stream = getattr(sys, f"__{stream_name}__")
if isinstance(stream, io.StringIO):
stack.enter_context(
patch(
f"sys.{stream_name}",
TextLogStream(
prio, f"python.{stream_name}", native_stream.fileno(),
errors="backslashreplace"
),
)
)
return stack
def test_str(self):
for stream_name, level, fileno in STREAM_INFO:
with self.stream_context(stream_name, level):
stream = getattr(sys, stream_name)
tag = f"python.{stream_name}"
self.assertEqual(f"<TextLogStream '{tag}'>", repr(stream))
self.assertIs(stream.writable(), True)
self.assertIs(stream.readable(), False)
self.assertEqual(stream.fileno(), fileno)
self.assertEqual("UTF-8", stream.encoding)
self.assertEqual("backslashreplace", stream.errors)
self.assertIs(stream.line_buffering, True)
self.assertIs(stream.write_through, False)
def write(s, lines=None, *, write_len=None):
if write_len is None:
write_len = len(s)
self.assertEqual(write_len, stream.write(s))
if lines is None:
lines = [s]
self.assert_logs(level, tag, lines)
# Single-line messages,
with self.unbuffered(stream):
write("", [])
write("a")
write("Hello")
write("Hello world")
write(" ")
write(" ")
# Non-ASCII text
write("ol\u00e9") # Spanish
write("\u4e2d\u6587") # Chinese
# Non-BMP emoji
write("\U0001f600")
# Non-encodable surrogates
write("\ud800\udc00", [r"\ud800\udc00"])
# Code used by surrogateescape (which isn't enabled here)
write("\udc80", [r"\udc80"])
# Null characters are logged using "modified UTF-8".
write("\u0000", [r"\xc0\x80"])
write("a\u0000", [r"a\xc0\x80"])
write("\u0000b", [r"\xc0\x80b"])
write("a\u0000b", [r"a\xc0\x80b"])
# Multi-line messages. Avoid identical consecutive lines, as
# they may activate "chatty" filtering and break the tests.
write("\nx", [""])
write("\na\n", ["x", "a"])
write("\n", [""])
write("b\n", ["b"])
write("c\n\n", ["c", ""])
write("d\ne", ["d"])
write("xx", [])
write("f\n\ng", ["exxf", ""])
write("\n", ["g"])
# Since this is a line-based logging system, line buffering
# cannot be turned off, i.e. a newline always causes a flush.
stream.reconfigure(line_buffering=False)
self.assertIs(stream.line_buffering, True)
# However, buffering can be turned off completely if you want a
# flush after every write.
with self.unbuffered(stream):
write("\nx", ["", "x"])
write("\na\n", ["", "a"])
write("\n", [""])
write("b\n", ["b"])
write("c\n\n", ["c", ""])
write("d\ne", ["d", "e"])
write("xx", ["xx"])
write("f\n\ng", ["f", "", "g"])
write("\n", [""])
# "\r\n" should be translated into "\n".
write("hello\r\n", ["hello"])
write("hello\r\nworld\r\n", ["hello", "world"])
write("\r\n", [""])
# Non-standard line separators should be preserved.
write("before form feed\x0cafter form feed\n",
["before form feed\x0cafter form feed"])
write("before line separator\u2028after line separator\n",
["before line separator\u2028after line separator"])
# 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()
write(CustomStr("custom\n"), ["custom"], write_len=7)
# 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__}"
):
stream.write(obj)
# Manual flushing is supported.
write("hello", [])
stream.flush()
self.assert_log(level, tag, "hello")
write("hello", [])
write("world", [])
stream.flush()
self.assert_log(level, tag, "helloworld")
# Long lines are split into blocks of 1000 characters
# (MAX_CHARS_PER_WRITE in _android_support.py), but
# TextIOWrapper should then join them back together as much as
# possible without exceeding 4000 UTF-8 bytes
# (MAX_BYTES_PER_WRITE).
#
# ASCII (1 byte per character)
write(("foobar" * 700) + "\n", # 4200 bytes in
[("foobar" * 666) + "foob", # 4000 bytes out
"ar" + ("foobar" * 33)]) # 200 bytes out
# "Full-width" digits 0-9 (3 bytes per character)
s = "\uff10\uff11\uff12\uff13\uff14\uff15\uff16\uff17\uff18\uff19"
write((s * 150) + "\n", # 4500 bytes in
[s * 100, # 3000 bytes out
s * 50]) # 1500 bytes out
s = "0123456789"
write(s * 200, []) # 2000 bytes in
write(s * 150, []) # 1500 bytes in
write(s * 51, [s * 350]) # 510 bytes in, 3500 bytes out
write("\n", [s * 51]) # 0 bytes in, 510 bytes out
def test_bytes(self):
for stream_name, level, fileno in STREAM_INFO:
with self.stream_context(stream_name, level):
stream = getattr(sys, stream_name).buffer
tag = f"python.{stream_name}"
self.assertEqual(f"<BinaryLogStream '{tag}'>", repr(stream))
self.assertIs(stream.writable(), True)
self.assertIs(stream.readable(), False)
self.assertEqual(stream.fileno(), fileno)
def write(b, lines=None, *, write_len=None):
if write_len is None:
write_len = len(b)
self.assertEqual(write_len, stream.write(b))
if lines is None:
lines = [b.decode()]
self.assert_logs(level, tag, lines)
# Single-line messages,
write(b"", [])
write(b"a")
write(b"Hello")
write(b"Hello world")
write(b" ")
write(b" ")
# Non-ASCII text
write(b"ol\xc3\xa9") # Spanish
write(b"\xe4\xb8\xad\xe6\x96\x87") # Chinese
# Non-BMP emoji
write(b"\xf0\x9f\x98\x80")
# Null bytes are logged using "modified UTF-8".
write(b"\x00", [r"\xc0\x80"])
write(b"a\x00", [r"a\xc0\x80"])
write(b"\x00b", [r"\xc0\x80b"])
write(b"a\x00b", [r"a\xc0\x80b"])
# Invalid UTF-8
write(b"\xff", [r"\xff"])
write(b"a\xff", [r"a\xff"])
write(b"\xffb", [r"\xffb"])
write(b"a\xffb", [r"a\xffb"])
# Log entries containing newlines are shown differently by
# `logcat -v tag`, `logcat -v long`, and Android Studio. We
# currently use `logcat -v tag`, which shows each line as if it
# was a separate log entry, but strips a single trailing
# newline.
#
# On newer versions of Android, all three of the above tools (or
# maybe Logcat itself) will also strip any number of leading
# newlines.
write(b"\nx", ["", "x"] if api_level < 30 else ["x"])
write(b"\na\n", ["", "a"] if api_level < 30 else ["a"])
write(b"\n", [""])
write(b"b\n", ["b"])
write(b"c\n\n", ["c", ""])
write(b"d\ne", ["d", "e"])
write(b"xx", ["xx"])
write(b"f\n\ng", ["f", "", "g"])
write(b"\n", [""])
# "\r\n" should be translated into "\n".
write(b"hello\r\n", ["hello"])
write(b"hello\r\nworld\r\n", ["hello", "world"])
write(b"\r\n", [""])
# Other bytes-like objects are accepted.
write(bytearray(b"bytearray"))
mv = memoryview(b"memoryview")
write(mv, ["memoryview"]) # Continuous
write(mv[::2], ["mmrve"]) # Discontinuous
write(
# Android only supports little-endian architectures, so the
# bytes representation is as follows:
array("H", [
0, # 00 00
1, # 01 00
65534, # FE FF
65535, # FF FF
]),
# After encoding null bytes with modified UTF-8, the only
# valid UTF-8 sequence is \x01. All other bytes are handled
# by backslashreplace.
["\\xc0\\x80\\xc0\\x80"
"\x01\\xc0\\x80"
"\\xfe\\xff"
"\\xff\\xff"],
write_len=8,
)
# Non-bytes-like classes are not accepted.
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__}"
):
stream.write(obj)
class TestAndroidRateLimit(unittest.TestCase):
def test_rate_limit(self):
# https://cs.android.com/android/platform/superproject/+/android-14.0.0_r1:system/logging/liblog/include/log/log_read.h;l=39
PER_MESSAGE_OVERHEAD = 28
# https://developer.android.com/ndk/reference/group/logging
ANDROID_LOG_DEBUG = 3
# To avoid flooding the test script output, use a different tag rather
# than stdout or stderr.
tag = "python.rate_limit"
stream = TextLogStream(ANDROID_LOG_DEBUG, tag)
# Make a test message which consumes 1 KB of the logcat buffer.
message = "Line {:03d} "
message += "." * (
1024 - PER_MESSAGE_OVERHEAD - len(tag) - len(message.format(0))
) + "\n"
# To avoid depending on the performance of the test device, we mock the
# passage of time.
mock_now = time()
def mock_time():
# Avoid division by zero by simulating a small delay.
mock_sleep(0.0001)
return mock_now
def mock_sleep(duration):
nonlocal mock_now
mock_now += duration
# See _android_support.py. The default values of these parameters work
# well across a wide range of devices, but we'll use smaller values to
# ensure a quick and reliable test that doesn't flood the log too much.
MAX_KB_PER_SECOND = 100
BUCKET_KB = 10
with (
patch("_android_support.MAX_BYTES_PER_SECOND", MAX_KB_PER_SECOND * 1024),
patch("_android_support.BUCKET_SIZE", BUCKET_KB * 1024),
patch("_android_support.sleep", mock_sleep),
patch("_android_support.time", mock_time),
):
# Make sure the token bucket is full.
stream.write("Initial message to reset _prev_write_time")
mock_sleep(BUCKET_KB / MAX_KB_PER_SECOND)
line_num = 0
# Write BUCKET_KB messages, and return the rate at which they were
# accepted in KB per second.
def write_bucketful():
nonlocal line_num
start = mock_time()
max_line_num = line_num + BUCKET_KB
while line_num < max_line_num:
stream.write(message.format(line_num))
line_num += 1
return BUCKET_KB / (mock_time() - start)
# The first bucketful should be written with minimal delay. The
# factor of 2 here is not arbitrary: it verifies that the system can
# write fast enough to empty the bucket within two bucketfuls, which
# the next part of the test depends on.
self.assertGreater(write_bucketful(), MAX_KB_PER_SECOND * 2)
# Write another bucketful to empty the token bucket completely.
write_bucketful()
# The next bucketful should be written at the rate limit.
self.assertAlmostEqual(
write_bucketful(), MAX_KB_PER_SECOND,
delta=MAX_KB_PER_SECOND * 0.1
)
# Once the token bucket refills, we should go back to full speed.
mock_sleep(BUCKET_KB / MAX_KB_PER_SECOND)
self.assertGreater(write_bucketful(), MAX_KB_PER_SECOND * 2)

View File

@@ -1,373 +0,0 @@
Typthon on iOS README
:Authors:
Russell Keith-Magee (2023-11)
This document provides a quick overview of some iOS specific features in the
Typthon distribution.
These instructions are only needed if you're planning to compile Typthon for iOS
yourself. Most users should *not* need to do this. If you're looking to
experiment with writing an iOS app in Typthon, tools such as `BeeWare's Briefcase
<https://briefcase.readthedocs.io>`__ and `Kivy's Buildozer
<https://buildozer.readthedocs.io>`__ will provide a much more approachable
user experience.
Compilers for building on iOS
=============================
Building for iOS requires the use of Apple's Xcode tooling. It is strongly
recommended that you use the most recent stable release of Xcode. This will
require the use of the most (or second-most) recently released macOS version,
as Apple does not maintain Xcode for older macOS versions. The Xcode Command
Line Tools are not sufficient for iOS development; you need a *full* Xcode
install.
If you want to run your code on the iOS simulator, you'll also need to install
an iOS Simulator Platform. You should be prompted to select an iOS Simulator
Platform when you first run Xcode. Alternatively, you can add an iOS Simulator
Platform by selecting an open the Platforms tab of the Xcode Settings panel.
iOS specific arguments to configure
===================================
* ``--enable-framework[=DIR]``
This argument specifies the location where the Typthon.framework will be
installed. If ``DIR`` is not specified, the framework will be installed into
a subdirectory of the ``iOS/Frameworks`` folder.
This argument *must* be provided when configuring iOS builds. iOS does not
support non-framework builds.
* ``--with-framework-name=NAME``
Specify the name for the Typthon.framework; defaults to ``Python``.
.. admonition:: Use this option with care!
Unless you know what you're doing, changing the name of the Python
framework on iOS is not advised. If you use this option, you won't be able
to run the ``make testios`` target without making significant manual
alterations, and you won't be able to use any binary packages unless you
compile them yourself using your own framework name.
Building Typthon on iOS
=======================
ABIs and Architectures
----------------------
iOS apps can be deployed on physical devices, and on the iOS simulator. Although
the API used on these devices is identical, the ABI is different - you need to
link against different libraries for an iOS device build (``iphoneos``) or an
iOS simulator build (``iphonesimulator``).
Apple uses the ``XCframework`` format to allow specifying a single dependency
that supports multiple ABIs. An ``XCframework`` is a wrapper around multiple
ABI-specific frameworks that share a common API.
iOS can also support different CPU architectures within each ABI. At present,
there is only a single supported architecture on physical devices - ARM64.
However, the *simulator* supports 2 architectures - ARM64 (for running on Apple
Silicon machines), and x86_64 (for running on older Intel-based machines).
To support multiple CPU architectures on a single platform, Apple uses a "fat
binary" format - a single physical file that contains support for multiple
architectures. It is possible to compile and use a "thin" single architecture
version of a binary for testing purposes; however, the "thin" binary will not be
portable to machines using other architectures.
Building a single-architecture framework
----------------------------------------
The Python build system will create a ``Typthon.framework`` that supports a
*single* ABI with a *single* architecture. Unlike macOS, iOS does not allow a
framework to contain non-library content, so the iOS build will produce a
``bin`` and ``lib`` folder in the same output folder as ``Typthon.framework``.
The ``lib`` folder will be needed at runtime to support the Python library.
If you want to use Python in a real iOS project, you need to produce multiple
``Typthon.framework`` builds, one for each ABI and architecture. iOS builds of
Python *must* be constructed as framework builds. To support this, you must
provide the ``--enable-framework`` flag when configuring the build. The build
also requires the use of cross-compilation. The minimal commands for building
Typthon for the ARM64 iOS simulator will look something like::
$ export PATH="$(pwd)/iOS/Resources/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Library/Apple/usr/bin"
$ ./configure \
--enable-framework \
--host=arm64-apple-ios-simulator \
--build=arm64-apple-darwin \
--with-build-python=/path/to/python.exe
$ make
$ make install
In this invocation:
* ``iOS/Resources/bin`` has been added to the path, providing some shims for the
compilers and linkers needed by the build. Xcode requires the use of ``xcrun``
to invoke compiler tooling. However, if ``xcrun`` is pre-evaluated and the
result passed to ``configure``, these results can embed user- and
version-specific paths into the sysconfig data, which limits the portability
of the compiled Python. Alternatively, if ``xcrun`` is used *as* the compiler,
it requires that compiler variables like ``CC`` include spaces, which can
cause significant problems with many C configuration systems which assume that
``CC`` will be a single executable.
To work around this problem, the ``iOS/Resources/bin`` folder contains some
wrapper scripts that present as simple compilers and linkers, but wrap
underlying calls to ``xcrun``. This allows configure to use a ``CC``
definition without spaces, and without user- or version-specific paths, while
retaining the ability to adapt to the local Xcode install. These scripts are
included in the ``bin`` directory of an iOS install.
These scripts will, by default, use the currently active Xcode installation.
If you want to use a different Xcode installation, you can use
``xcode-select`` to set a new default Xcode globally, or you can use the
``DEVELOPER_DIR`` environment variable to specify an Xcode install. The
scripts will use the default ``iphoneos``/``iphonesimulator`` SDK version for
the select Xcode install; if you want to use a different SDK, you can set the
``IOS_SDK_VERSION`` environment variable. (e.g, setting
``IOS_SDK_VERSION=17.1`` would cause the scripts to use the ``iphoneos17.1``
and ``iphonesimulator17.1`` SDKs, regardless of the Xcode default.)
The path has also been cleared of any user customizations. A common source of
bugs is for tools like Homebrew to accidentally leak macOS binaries into an iOS
build. Resetting the path to a known "bare bones" value is the easiest way to
avoid these problems.
* ``--host`` is the architecture and ABI that you want to build, in GNU compiler
triple format. This will be one of:
- ``arm64-apple-ios`` for ARM64 iOS devices.
- ``arm64-apple-ios-simulator`` for the iOS simulator running on Apple
Silicon devices.
- ``x86_64-apple-ios-simulator`` for the iOS simulator running on Intel
devices.
* ``--build`` is the GNU compiler triple for the machine that will be running
the compiler. This is one of:
- ``arm64-apple-darwin`` for Apple Silicon devices.
- ``x86_64-apple-darwin`` for Intel devices.
* ``/path/to/python.exe`` is the path to a Python binary on the machine that
will be running the compiler. This is needed because the Python compilation
process involves running some Python code. On a normal desktop build of
Python, you can compile a python interpreter and then use that interpreter to
run Python code. However, the binaries produced for iOS won't run on macOS, so
you need to provide an external Python interpreter. This interpreter must be
the same version as the Python that is being compiled. To be completely safe,
this should be the *exact* same commit hash. However, the longer a Python
release has been stable, the more likely it is that this constraint can be
relaxed - the same micro version will often be sufficient.
* The ``install`` target for iOS builds is slightly different to other
platforms. On most platforms, ``make install`` will install the build into
the final runtime location. This won't be the case for iOS, as the final
runtime location will be on a physical device.
However, you still need to run the ``install`` target for iOS builds, as it
performs some final framework assembly steps. The location specified with
``--enable-framework`` will be the location where ``make install`` will
assemble the complete iOS framework. This completed framework can then
be copied and relocated as required.
For a full CPython build, you also need to specify the paths to iOS builds of
the binary libraries that CPython depends on (XZ, BZip2, LibFFI and OpenSSL).
This can be done by defining the ``LIBLZMA_CFLAGS``, ``LIBLZMA_LIBS``,
``BZIP2_CFLAGS``, ``BZIP2_LIBS``, ``LIBFFI_CFLAGS``, and ``LIBFFI_LIBS``
environment variables, and the ``--with-openssl`` configure option. Versions of
these libraries pre-compiled for iOS can be found in `this repository
<https://github.com/beeware/cpython-apple-source-deps/releases>`__. LibFFI is
especially important, as many parts of the standard library (including the
``platform``, ``sysconfig`` and ``webbrowser`` modules) require the use of the
``ctypes`` module at runtime.
By default, Python will be compiled with an iOS deployment target (i.e., the
minimum supported iOS version) of 13.0. To specify a different deployment
target, provide the version number as part of the ``--host`` argument - for
example, ``--host=arm64-apple-ios15.4-simulator`` would compile an ARM64
simulator build with a deployment target of 15.4.
Merge thin frameworks into fat frameworks
-----------------------------------------
Once you've built a ``Typthon.framework`` for each ABI and architecture, you
must produce a "fat" framework for each ABI that contains all the architectures
for that ABI.
The ``iphoneos`` build only needs to support a single architecture, so it can be
used without modification.
If you only want to support a single simulator architecture, (e.g., only support
ARM64 simulators), you can use a single architecture ``Typthon.framework`` build.
However, if you want to create ``Python.xcframework`` that supports *all*
architectures, you'll need to merge the ``iphonesimulator`` builds for ARM64 and
x86_64 into a single "fat" framework.
The "fat" framework can be constructed by performing a directory merge of the
content of the two "thin" ``Typthon.framework`` directories, plus the ``bin`` and
``lib`` folders for each thin framework. When performing this merge:
* The pure Python standard library content is identical for each architecture,
except for a handful of platform-specific files (such as the ``sysconfig``
module). Ensure that the "fat" framework has the union of all standard library
files.
* Any binary files in the standard library, plus the main
``libPython3.X.dylib``, can be merged using the ``lipo`` tool, provide by
Xcode::
$ lipo -create -output module.dylib path/to/x86_64/module.dylib path/to/arm64/module.dylib
* The header files will be identical on both architectures, except for
``pyconfig.h``. Copy all the headers from one platform (say, arm64), rename
``pyconfig.h`` to ``pyconfig-arm64.h``, and copy the ``pyconfig.h`` for the
other architecture into the merged header folder as ``pyconfig-x86_64.h``.
Then copy the ``iOS/Resources/pyconfig.h`` file from the CPython sources into
the merged headers folder. This will allow the two Python architectures to
share a common ``pyconfig.h`` header file.
At this point, you should have 2 Typthon.framework folders - one for ``iphoneos``,
and one for ``iphonesimulator`` that is a merge of x86+64 and ARM64 content.
Merge frameworks into an XCframework
------------------------------------
Now that we have 2 (potentially fat) ABI-specific frameworks, we can merge those
frameworks into a single ``XCframework``.
The initial skeleton of an ``XCframework`` is built using::
xcodebuild -create-xcframework -output Python.xcframework -framework path/to/iphoneos/Typthon.framework -framework path/to/iphonesimulator/Typthon.framework
Then, copy the ``bin`` and ``lib`` folders into the architecture-specific slices of
the XCframework::
cp path/to/iphoneos/bin Python.xcframework/ios-arm64
cp path/to/iphoneos/lib Python.xcframework/ios-arm64
cp path/to/iphonesimulator/bin Python.xcframework/ios-arm64_x86_64-simulator
cp path/to/iphonesimulator/lib Python.xcframework/ios-arm64_x86_64-simulator
Note that the name of the architecture-specific slice for the simulator will
depend on the CPU architecture(s) that you build.
You now have a Python.xcframework that can be used in a project.
Testing Python on iOS
=====================
The ``iOS/testbed`` folder that contains an Xcode project that is able to run
the iOS test suite. This project converts the Python test suite into a single
test case in Xcode's XCTest framework. The single XCTest passes if the test
suite passes.
To run the test suite, configure a Python build for an iOS simulator (i.e.,
``--host=arm64-apple-ios-simulator`` or ``--host=x86_64-apple-ios-simulator``
), specifying a framework build (i.e. ``--enable-framework``). Ensure that your
``PATH`` has been configured to include the ``iOS/Resources/bin`` folder and
exclude any non-iOS tools, then run::
$ make all
$ make install
$ make testios
This will:
* Build an iOS framework for your chosen architecture;
* Finalize the single-platform framework;
* Make a clean copy of the testbed project;
* Install the Python iOS framework into the copy of the testbed project; and
* Run the test suite on an "iPhone SE (3rd generation)" simulator.
On success, the test suite will exit and report successful completion of the
test suite. On a 2022 M1 MacBook Pro, the test suite takes approximately 15
minutes to run; a couple of extra minutes is required to compile the testbed
project, and then boot and prepare the iOS simulator.
Debugging test failures
-----------------------
Running ``make test`` 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.
You can generate your own standalone testbed instance by running::
$ python iOS/testbed clone --framework iOS/Frameworks/arm64-iphonesimulator my-testbed
This invocation assumes that ``iOS/Frameworks/arm64-iphonesimulator`` is the
path to the iOS simulator framework for your platform (ARM64 in this case);
``my-testbed`` is the name of the folder for the new testbed clone.
You can then use the ``my-testbed`` folder to run the Python test suite,
passing in any command line arguments you may require. For example, if you're
trying to diagnose a failure in the ``os`` module, you might run::
$ python my-testbed run -- test -W test_os
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.
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.
Testing on an iOS device
^^^^^^^^^^^^^^^^^^^^^^^^
To test on an iOS device, the app needs to be signed with known developer
credentials. To obtain these credentials, you must have an iOS Developer
account, and your Xcode install will need to be logged into your account (see
the Accounts tab of the Preferences dialog).
Once the project is open, and you're signed into your Apple Developer account,
select the root node of the project tree (labeled "iOSTestbed"), then the
"Signing & Capabilities" tab in the details page. Select a development team
(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.

View File

@@ -1,34 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist SYSTEM "file://localhost/System/Library/DTDs/PropertyList.dtd">
<plist version="0.9">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>Python</string>
<key>CFBundleGetInfoString</key>
<string>Python Runtime and Library</string>
<key>CFBundleIdentifier</key>
<string>@PYTHONFRAMEWORKIDENTIFIER@</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Python</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>@VERSION@</string>
<key>CFBundleLongVersionString</key>
<string>%VERSION%, (c) 2001-2024 Python Software Foundation.</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>CFBundleSupportedPlatforms</key>
<array>
<string>iPhoneOS</string>
</array>
<key>MinimumOSVersion</key>
<string>@IPHONEOS_DEPLOYMENT_TARGET@</string>
</dict>
</plist>

View File

@@ -1,2 +0,0 @@
#!/bin/sh
xcrun --sdk iphoneos${IOS_SDK_VERSION} ar "$@"

View File

@@ -1,2 +0,0 @@
#!/bin/sh
xcrun --sdk iphoneos${IOS_SDK_VERSION} clang -target arm64-apple-ios${IPHONEOS_DEPLOYMENT_TARGET} "$@"

View File

@@ -1,2 +0,0 @@
#!/bin/sh
xcrun --sdk iphoneos${IOS_SDK_VERSION} clang++ -target arm64-apple-ios${IPHONEOS_DEPLOYMENT_TARGET} "$@"

View File

@@ -1,2 +0,0 @@
#!/bin/sh
xcrun --sdk iphoneos${IOS_SDK_VERSION} clang -target arm64-apple-ios${IPHONEOS_DEPLOYMENT_TARGET} -E "$@"

View File

@@ -1,2 +0,0 @@
#!/bin/sh
xcrun --sdk iphonesimulator${IOS_SDK_VERSION} ar "$@"

View File

@@ -1,2 +0,0 @@
#!/bin/sh
xcrun --sdk iphonesimulator${IOS_SDK_VERSION} clang -target arm64-apple-ios${IPHONEOS_DEPLOYMENT_TARGET}-simulator "$@"

View File

@@ -1,2 +0,0 @@
#!/bin/sh
xcrun --sdk iphonesimulator${IOS_SDK_VERSION} clang++ -target arm64-apple-ios${IPHONEOS_DEPLOYMENT_TARGET}-simulator "$@"

View File

@@ -1,2 +0,0 @@
#!/bin/sh
xcrun --sdk iphonesimulator${IOS_SDK_VERSION} clang -target arm64-apple-ios${IPHONEOS_DEPLOYMENT_TARGET}-simulator -E "$@"

View File

@@ -1,2 +0,0 @@
#!/bin/sh
xcrun --sdk iphonesimulator${IOS_SDK_VERSION} strip -arch arm64 "$@"

View File

@@ -1,2 +0,0 @@
#!/bin/sh
xcrun --sdk iphoneos${IOS_SDK_VERSION} strip -arch arm64 "$@"

View File

@@ -1,2 +0,0 @@
#!/bin/sh
xcrun --sdk iphonesimulator${IOS_SDK_VERSION} ar "$@"

View File

@@ -1,2 +0,0 @@
#!/bin/sh
xcrun --sdk iphonesimulator${IOS_SDK_VERSION} clang -target x86_64-apple-ios${IPHONEOS_DEPLOYMENT_TARGET}-simulator "$@"

View File

@@ -1,2 +0,0 @@
#!/bin/sh
xcrun --sdk iphonesimulator${IOS_SDK_VERSION} clang++ -target x86_64-apple-ios${IPHONEOS_DEPLOYMENT_TARGET}-simulator "$@"

View File

@@ -1,2 +0,0 @@
#!/bin/sh
xcrun --sdk iphonesimulator${IOS_SDK_VERSION} clang -target x86_64-apple-ios${IPHONEOS_DEPLOYMENT_TARGET}-simulator -E "$@"

View File

@@ -1,2 +0,0 @@
#!/bin/sh
xcrun --sdk iphonesimulator${IOS_SDK_VERSION} strip -arch x86_64 "$@"

View File

@@ -1,26 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string></string>
<key>CFBundleIdentifier</key>
<string></string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSupportedPlatforms</key>
<array>
<string>iPhoneOS</string>
</array>
<key>MinimumOSVersion</key>
<string>12.0</string>
<key>CFBundleVersion</key>
<string>1</string>
</dict>
</plist>

View File

@@ -1,7 +0,0 @@
#ifdef __arm64__
#include "pyconfig-arm64.h"
#endif
#ifdef __x86_64__
#include "pyconfig-x86_64.h"
#endif

View File

@@ -1,44 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>AvailableLibraries</key>
<array>
<dict>
<key>BinaryPath</key>
<string>Python.framework/Python</string>
<key>LibraryIdentifier</key>
<string>ios-arm64</string>
<key>LibraryPath</key>
<string>Python.framework</string>
<key>SupportedArchitectures</key>
<array>
<string>arm64</string>
</array>
<key>SupportedPlatform</key>
<string>ios</string>
</dict>
<dict>
<key>BinaryPath</key>
<string>Python.framework/Python</string>
<key>LibraryIdentifier</key>
<string>ios-arm64_x86_64-simulator</string>
<key>LibraryPath</key>
<string>Python.framework</string>
<key>SupportedArchitectures</key>
<array>
<string>arm64</string>
<string>x86_64</string>
</array>
<key>SupportedPlatform</key>
<string>ios</string>
<key>SupportedPlatformVariant</key>
<string>simulator</string>
</dict>
</array>
<key>CFBundlePackageType</key>
<string>XFWK</string>
<key>XCFrameworkFormatVersion</key>
<string>1.0</string>
</dict>
</plist>

View File

@@ -1,4 +0,0 @@
This directory is intentionally empty.
It should be used as a target for `--enable-framework` when compiling an iOS on-device
build for testing purposes.

View File

@@ -1,4 +0,0 @@
This directory is intentionally empty.
It should be used as a target for `--enable-framework` when compiling an iOS simulator
build for testing purposes (either x86_64 or ARM64).

View File

@@ -1,548 +0,0 @@
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) ...
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+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():
# List the testing simulators, in JSON format
raw_json = await async_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
# consistent over time. Older Xcode versions will use "iPhone SE (Nth
# generation)"; As of 2025, they've started using "iPhone 16e".
#
# When Xcode is updated after a new release, new devices will be available
# and old ones will be dropped from the set available on the latest iOS
# version. Select the one with the highest minimum runtime version - this
# is an indicator of the "newest" released device, which should always be
# supported on the "most recent" iOS version.
se_simulators = sorted(
(devicetype["minRuntimeVersion"], devicetype["name"])
for devicetype in json_data["devicetypes"]
if devicetype["productFamily"] == "iPhone"
and (
("iPhone " in devicetype["name"] and devicetype["name"].endswith("e"))
or "iPhone SE " in devicetype["name"]
)
)
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).
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"]
async with async_process(
*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()
status = await asyncio.wait_for(process.wait(), timeout=1)
exit(status)
def clone_testbed(
source: Path,
target: Path,
framework: Path,
apps: list[Path],
) -> None:
if target.exists():
print(f"{target} already exists; aborting without creating project.")
sys.exit(10)
if framework is None:
if not (
source / "Python.xcframework/ios-arm64_x86_64-simulator/bin"
).is_dir():
print(
f"The testbed being cloned ({source}) does not contain "
f"a simulator framework. Re-run with --framework"
)
sys.exit(11)
else:
if not framework.is_dir():
print(f"{framework} does not exist.")
sys.exit(12)
elif not (
framework.suffix == ".xcframework"
or (framework / "Python.framework").is_dir()
):
print(
f"{framework} is not an XCframework, "
f"or a simulator slice of a framework build."
)
sys.exit(13)
print("Cloning testbed project:")
print(f" Cloning {source}...", end="", flush=True)
shutil.copytree(source, target, symlinks=True)
print(" done")
xc_framework_path = target / "Python.xcframework"
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)
if xc_framework_path.is_dir():
shutil.rmtree(xc_framework_path)
else:
xc_framework_path.unlink(missing_ok=True)
xc_framework_path.symlink_to(
framework.relative_to(xc_framework_path.parent, walk_up=True)
)
print(" done")
else:
print(" Installing simulator framework...", end="", flush=True)
if sim_framework_path.is_dir():
shutil.rmtree(sim_framework_path)
else:
sim_framework_path.unlink(missing_ok=True)
sim_framework_path.symlink_to(
framework.relative_to(sim_framework_path.parent, walk_up=True)
)
print(" done")
else:
if (
xc_framework_path.is_symlink()
and not xc_framework_path.readlink().is_absolute()
):
# XCFramework is a relative symlink. Rewrite the symlink relative
# to the new location.
print(" Rewriting symlink to XCframework...", end="", flush=True)
orig_xc_framework_path = (
source
/ xc_framework_path.readlink()
).resolve()
xc_framework_path.unlink()
xc_framework_path.symlink_to(
orig_xc_framework_path.relative_to(
xc_framework_path.parent, walk_up=True
)
)
print(" done")
elif (
sim_framework_path.is_symlink()
and not sim_framework_path.readlink().is_absolute()
):
print(" Rewriting symlink to simulator framework...", end="", flush=True)
# 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()
).resolve()
sim_framework_path.unlink()
sim_framework_path.symlink_to(
orig_sim_framework_path.relative_to(
sim_framework_path.parent, walk_up=True
)
)
print(" done")
else:
print(" Using pre-existing iOS framework.")
for app_src in apps:
print(f" Installing app {app_src.name!r}...", end="", flush=True)
app_target = target / f"iOSTestbed/app/{app_src.name}"
if app_target.is_dir():
shutil.rmtree(app_target)
shutil.copytree(app_src, app_target)
print(" done")
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)
info["TestArgs"] = args
with info_plist.open("wb") as f:
plistlib.dump(info, f)
async 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)
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()
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()
def main():
parser = argparse.ArgumentParser(
description=(
"Manages the process of testing a Python project in the iOS simulator."
),
)
subcommands = parser.add_subparsers(dest="subcommand")
clone = subcommands.add_parser(
"clone",
description=(
"Clone the testbed project, copying in an iOS Python framework and"
"any specified application code."
),
help="Clone a testbed project to a new location.",
)
clone.add_argument(
"--framework",
help=(
"The location of the XCFramework (or simulator-only slice of an "
"XCFramework) to use when running the testbed"
),
)
clone.add_argument(
"--app",
dest="apps",
action="append",
default=[],
help="The location of any code to include in the testbed project",
)
clone.add_argument(
"location",
help="The path where the testbed will be cloned.",
)
run = subcommands.add_parser(
"run",
usage="%(prog)s [-h] [--simulator SIMULATOR] -- <test arg> [<test arg> ...]",
description=(
"Run a testbed project. The arguments provided after `--` will be "
"passed to the running iOS process as if they were arguments to "
"`python -m`."
),
help="Run a testbed project",
)
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."
)
)
run.add_argument(
"-v", "--verbose",
action="store_true",
help="Enable verbose output",
)
try:
pos = sys.argv.index("--")
testbed_args = sys.argv[1:pos]
test_args = sys.argv[pos + 1 :]
except ValueError:
testbed_args = sys.argv[1:]
test_args = []
context = parser.parse_args(testbed_args)
if context.subcommand == "clone":
clone_testbed(
source=Path(__file__).parent.resolve(),
target=Path(context.location).resolve(),
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"
).is_dir():
print(
f"Testbed does not contain a compiled iOS framework. Use "
f"`python {sys.argv[0]} clone ...` to create a runnable "
f"clone of this testbed."
)
sys.exit(20)
asyncio.run(
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()
parser.print_help(sys.stderr)
sys.exit(21)
else:
parser.print_help(sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -1,580 +0,0 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 56;
objects = {
/* Begin PBXBuildFile section */
607A66172B0EFA380010BFC8 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 607A66162B0EFA380010BFC8 /* AppDelegate.m */; };
607A66222B0EFA390010BFC8 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 607A66212B0EFA390010BFC8 /* Assets.xcassets */; };
607A66252B0EFA390010BFC8 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 607A66232B0EFA390010BFC8 /* LaunchScreen.storyboard */; };
607A66282B0EFA390010BFC8 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 607A66272B0EFA390010BFC8 /* main.m */; };
607A66322B0EFA3A0010BFC8 /* iOSTestbedTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 607A66312B0EFA3A0010BFC8 /* iOSTestbedTests.m */; };
607A664C2B0EFC080010BFC8 /* Python.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 607A664A2B0EFB310010BFC8 /* Python.xcframework */; };
607A664D2B0EFC080010BFC8 /* Python.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 607A664A2B0EFB310010BFC8 /* Python.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
607A66502B0EFFE00010BFC8 /* Python.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 607A664A2B0EFB310010BFC8 /* Python.xcframework */; };
607A66512B0EFFE00010BFC8 /* Python.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 607A664A2B0EFB310010BFC8 /* Python.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
607A66582B0F079F0010BFC8 /* dylib-Info-template.plist in Resources */ = {isa = PBXBuildFile; fileRef = 607A66572B0F079F0010BFC8 /* dylib-Info-template.plist */; };
608619542CB77BA900F46182 /* app_packages in Resources */ = {isa = PBXBuildFile; fileRef = 608619532CB77BA900F46182 /* app_packages */; };
608619562CB7819B00F46182 /* app in Resources */ = {isa = PBXBuildFile; fileRef = 608619552CB7819B00F46182 /* app */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
607A662E2B0EFA3A0010BFC8 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 607A660A2B0EFA380010BFC8 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 607A66112B0EFA380010BFC8;
remoteInfo = iOSTestbed;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
607A664E2B0EFC080010BFC8 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
607A664D2B0EFC080010BFC8 /* Python.xcframework in Embed Frameworks */,
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
607A66522B0EFFE00010BFC8 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
607A66512B0EFFE00010BFC8 /* Python.xcframework in Embed Frameworks */,
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
607A66122B0EFA380010BFC8 /* iOSTestbed.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iOSTestbed.app; sourceTree = BUILT_PRODUCTS_DIR; };
607A66152B0EFA380010BFC8 /* AppDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = "<group>"; };
607A66162B0EFA380010BFC8 /* AppDelegate.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = "<group>"; };
607A66212B0EFA390010BFC8 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
607A66242B0EFA390010BFC8 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
607A66272B0EFA390010BFC8 /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = "<group>"; };
607A662D2B0EFA3A0010BFC8 /* iOSTestbedTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = iOSTestbedTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
607A66312B0EFA3A0010BFC8 /* iOSTestbedTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = iOSTestbedTests.m; sourceTree = "<group>"; };
607A664A2B0EFB310010BFC8 /* Python.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = Python.xcframework; sourceTree = "<group>"; };
607A66572B0F079F0010BFC8 /* dylib-Info-template.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "dylib-Info-template.plist"; sourceTree = "<group>"; };
607A66592B0F08600010BFC8 /* iOSTestbed-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "iOSTestbed-Info.plist"; sourceTree = "<group>"; };
608619532CB77BA900F46182 /* app_packages */ = {isa = PBXFileReference; lastKnownFileType = folder; path = app_packages; sourceTree = "<group>"; };
608619552CB7819B00F46182 /* app */ = {isa = PBXFileReference; lastKnownFileType = folder; path = app; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
607A660F2B0EFA380010BFC8 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
607A664C2B0EFC080010BFC8 /* Python.xcframework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
607A662A2B0EFA3A0010BFC8 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
607A66502B0EFFE00010BFC8 /* Python.xcframework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
607A66092B0EFA380010BFC8 = {
isa = PBXGroup;
children = (
607A664A2B0EFB310010BFC8 /* Python.xcframework */,
607A66142B0EFA380010BFC8 /* iOSTestbed */,
607A66302B0EFA3A0010BFC8 /* iOSTestbedTests */,
607A66132B0EFA380010BFC8 /* Products */,
607A664F2B0EFFE00010BFC8 /* Frameworks */,
);
sourceTree = "<group>";
};
607A66132B0EFA380010BFC8 /* Products */ = {
isa = PBXGroup;
children = (
607A66122B0EFA380010BFC8 /* iOSTestbed.app */,
607A662D2B0EFA3A0010BFC8 /* iOSTestbedTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
607A66142B0EFA380010BFC8 /* iOSTestbed */ = {
isa = PBXGroup;
children = (
608619552CB7819B00F46182 /* app */,
608619532CB77BA900F46182 /* app_packages */,
607A66592B0F08600010BFC8 /* iOSTestbed-Info.plist */,
607A66572B0F079F0010BFC8 /* dylib-Info-template.plist */,
607A66152B0EFA380010BFC8 /* AppDelegate.h */,
607A66162B0EFA380010BFC8 /* AppDelegate.m */,
607A66212B0EFA390010BFC8 /* Assets.xcassets */,
607A66232B0EFA390010BFC8 /* LaunchScreen.storyboard */,
607A66272B0EFA390010BFC8 /* main.m */,
);
path = iOSTestbed;
sourceTree = "<group>";
};
607A66302B0EFA3A0010BFC8 /* iOSTestbedTests */ = {
isa = PBXGroup;
children = (
607A66312B0EFA3A0010BFC8 /* iOSTestbedTests.m */,
);
path = iOSTestbedTests;
sourceTree = "<group>";
};
607A664F2B0EFFE00010BFC8 /* Frameworks */ = {
isa = PBXGroup;
children = (
);
name = Frameworks;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
607A66112B0EFA380010BFC8 /* iOSTestbed */ = {
isa = PBXNativeTarget;
buildConfigurationList = 607A66412B0EFA3A0010BFC8 /* Build configuration list for PBXNativeTarget "iOSTestbed" */;
buildPhases = (
607A660E2B0EFA380010BFC8 /* Sources */,
607A660F2B0EFA380010BFC8 /* Frameworks */,
607A66102B0EFA380010BFC8 /* Resources */,
607A66552B0F061D0010BFC8 /* Install Target Specific Python Standard Library */,
607A66562B0F06200010BFC8 /* Prepare Python Binary Modules */,
607A664E2B0EFC080010BFC8 /* Embed Frameworks */,
);
buildRules = (
);
dependencies = (
);
name = iOSTestbed;
productName = iOSTestbed;
productReference = 607A66122B0EFA380010BFC8 /* iOSTestbed.app */;
productType = "com.apple.product-type.application";
};
607A662C2B0EFA3A0010BFC8 /* iOSTestbedTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 607A66442B0EFA3A0010BFC8 /* Build configuration list for PBXNativeTarget "iOSTestbedTests" */;
buildPhases = (
607A66292B0EFA3A0010BFC8 /* Sources */,
607A662A2B0EFA3A0010BFC8 /* Frameworks */,
607A662B2B0EFA3A0010BFC8 /* Resources */,
607A66522B0EFFE00010BFC8 /* Embed Frameworks */,
);
buildRules = (
);
dependencies = (
607A662F2B0EFA3A0010BFC8 /* PBXTargetDependency */,
);
name = iOSTestbedTests;
productName = iOSTestbedTests;
productReference = 607A662D2B0EFA3A0010BFC8 /* iOSTestbedTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
607A660A2B0EFA380010BFC8 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastUpgradeCheck = 1500;
TargetAttributes = {
607A66112B0EFA380010BFC8 = {
CreatedOnToolsVersion = 15.0.1;
};
607A662C2B0EFA3A0010BFC8 = {
CreatedOnToolsVersion = 15.0.1;
TestTargetID = 607A66112B0EFA380010BFC8;
};
};
};
buildConfigurationList = 607A660D2B0EFA380010BFC8 /* Build configuration list for PBXProject "iOSTestbed" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 607A66092B0EFA380010BFC8;
productRefGroup = 607A66132B0EFA380010BFC8 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
607A66112B0EFA380010BFC8 /* iOSTestbed */,
607A662C2B0EFA3A0010BFC8 /* iOSTestbedTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
607A66102B0EFA380010BFC8 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
607A66252B0EFA390010BFC8 /* LaunchScreen.storyboard in Resources */,
607A66582B0F079F0010BFC8 /* dylib-Info-template.plist in Resources */,
608619562CB7819B00F46182 /* app in Resources */,
607A66222B0EFA390010BFC8 /* Assets.xcassets in Resources */,
608619542CB77BA900F46182 /* app_packages in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
607A662B2B0EFA3A0010BFC8 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
607A66552B0F061D0010BFC8 /* Install Target Specific Python Standard Library */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "Install Target Specific Python Standard Library";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "set -e\n\nmkdir -p \"$CODESIGNING_FOLDER_PATH/python/lib\"\nif [ \"$EFFECTIVE_PLATFORM_NAME\" = \"-iphonesimulator\" ]; then\n echo \"Installing Python modules for iOS Simulator\"\n rsync -au --delete \"$PROJECT_DIR/Python.xcframework/ios-arm64_x86_64-simulator/lib/\" \"$CODESIGNING_FOLDER_PATH/python/lib/\" \nelse\n echo \"Installing Python modules for iOS Device\"\n rsync -au --delete \"$PROJECT_DIR/Python.xcframework/ios-arm64/lib/\" \"$CODESIGNING_FOLDER_PATH/python/lib/\" \nfi\n";
showEnvVarsInLog = 0;
};
607A66562B0F06200010BFC8 /* Prepare Python Binary Modules */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "Prepare Python Binary Modules";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "set -e\n\ninstall_dylib () {\n INSTALL_BASE=$1\n FULL_EXT=$2\n\n # The name of the extension file\n EXT=$(basename \"$FULL_EXT\")\n # The location of the extension file, relative to the bundle\n RELATIVE_EXT=${FULL_EXT#$CODESIGNING_FOLDER_PATH/} \n # The path to the extension file, relative to the install base\n PYTHON_EXT=${RELATIVE_EXT/$INSTALL_BASE/}\n # The full dotted name of the extension module, constructed from the file path.\n FULL_MODULE_NAME=$(echo $PYTHON_EXT | cut -d \".\" -f 1 | tr \"/\" \".\"); \n # A bundle identifier; not actually used, but required by Xcode framework packaging\n FRAMEWORK_BUNDLE_ID=$(echo $PRODUCT_BUNDLE_IDENTIFIER.$FULL_MODULE_NAME | tr \"_\" \"-\")\n # The name of the framework folder.\n FRAMEWORK_FOLDER=\"Frameworks/$FULL_MODULE_NAME.framework\"\n\n # If the framework folder doesn't exist, create it.\n if [ ! -d \"$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER\" ]; then\n echo \"Creating framework for $RELATIVE_EXT\" \n mkdir -p \"$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER\"\n cp \"$CODESIGNING_FOLDER_PATH/dylib-Info-template.plist\" \"$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER/Info.plist\"\n plutil -replace CFBundleExecutable -string \"$FULL_MODULE_NAME\" \"$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER/Info.plist\"\n plutil -replace CFBundleIdentifier -string \"$FRAMEWORK_BUNDLE_ID\" \"$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER/Info.plist\"\n fi\n \n echo \"Installing binary for $FRAMEWORK_FOLDER/$FULL_MODULE_NAME\" \n mv \"$FULL_EXT\" \"$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER/$FULL_MODULE_NAME\"\n # Create a placeholder .fwork file where the .so was\n echo \"$FRAMEWORK_FOLDER/$FULL_MODULE_NAME\" > ${FULL_EXT%.so}.fwork\n # Create a back reference to the .so file location in the framework\n echo \"${RELATIVE_EXT%.so}.fwork\" > \"$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER/$FULL_MODULE_NAME.origin\" \n}\n\nPYTHON_VER=$(ls -1 \"$CODESIGNING_FOLDER_PATH/python/lib\")\necho \"Install Python $PYTHON_VER standard library extension modules...\"\nfind \"$CODESIGNING_FOLDER_PATH/python/lib/$PYTHON_VER/lib-dynload\" -name \"*.so\" | while read FULL_EXT; do\n install_dylib python/lib/$PYTHON_VER/lib-dynload/ \"$FULL_EXT\"\ndone\necho \"Install app package extension modules...\"\nfind \"$CODESIGNING_FOLDER_PATH/app_packages\" -name \"*.so\" | while read FULL_EXT; do\n install_dylib app_packages/ \"$FULL_EXT\"\ndone\necho \"Install app extension modules...\"\nfind \"$CODESIGNING_FOLDER_PATH/app\" -name \"*.so\" | while read FULL_EXT; do\n install_dylib app/ \"$FULL_EXT\"\ndone\n\n# Clean up dylib template \nrm -f \"$CODESIGNING_FOLDER_PATH/dylib-Info-template.plist\"\necho \"Signing frameworks as $EXPANDED_CODE_SIGN_IDENTITY_NAME ($EXPANDED_CODE_SIGN_IDENTITY)...\"\nfind \"$CODESIGNING_FOLDER_PATH/Frameworks\" -name \"*.framework\" -exec /usr/bin/codesign --force --sign \"$EXPANDED_CODE_SIGN_IDENTITY\" ${OTHER_CODE_SIGN_FLAGS:-} -o runtime --timestamp=none --preserve-metadata=identifier,entitlements,flags --generate-entitlement-der \"{}\" \\; \n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
607A660E2B0EFA380010BFC8 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
607A66172B0EFA380010BFC8 /* AppDelegate.m in Sources */,
607A66282B0EFA390010BFC8 /* main.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
607A66292B0EFA3A0010BFC8 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
607A66322B0EFA3A0010BFC8 /* iOSTestbedTests.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
607A662F2B0EFA3A0010BFC8 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 607A66112B0EFA380010BFC8 /* iOSTestbed */;
targetProxy = 607A662E2B0EFA3A0010BFC8 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
607A66232B0EFA390010BFC8 /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
607A66242B0EFA390010BFC8 /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
607A663F2B0EFA3A0010BFC8 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
};
name = Debug;
};
607A66402B0EFA3A0010BFC8 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
607A66422B0EFA3A0010BFC8 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "";
ENABLE_USER_SCRIPT_SANDBOXING = NO;
HEADER_SEARCH_PATHS = "\"$(BUILT_PRODUCTS_DIR)/Python.framework/Headers\"";
INFOPLIST_FILE = "iOSTestbed/iOSTestbed-Info.plist";
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
INFOPLIST_KEY_UIMainStoryboardFile = Main;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 3.13.0a1;
PRODUCT_BUNDLE_IDENTIFIER = org.python.iOSTestbed;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
607A66432B0EFA3A0010BFC8 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "";
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
HEADER_SEARCH_PATHS = "\"$(BUILT_PRODUCTS_DIR)/Python.framework/Headers\"";
INFOPLIST_FILE = "iOSTestbed/iOSTestbed-Info.plist";
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
INFOPLIST_KEY_UIMainStoryboardFile = Main;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 3.13.0a1;
PRODUCT_BUNDLE_IDENTIFIER = org.python.iOSTestbed;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
607A66452B0EFA3A0010BFC8 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 3HEZE76D99;
GENERATE_INFOPLIST_FILE = YES;
HEADER_SEARCH_PATHS = "\"$(BUILT_PRODUCTS_DIR)/Python.framework/Headers\"";
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = org.python.iOSTestbedTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/iOSTestbed.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/iOSTestbed";
};
name = Debug;
};
607A66462B0EFA3A0010BFC8 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 3HEZE76D99;
GENERATE_INFOPLIST_FILE = YES;
HEADER_SEARCH_PATHS = "\"$(BUILT_PRODUCTS_DIR)/Python.framework/Headers\"";
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = org.python.iOSTestbedTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/iOSTestbed.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/iOSTestbed";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
607A660D2B0EFA380010BFC8 /* Build configuration list for PBXProject "iOSTestbed" */ = {
isa = XCConfigurationList;
buildConfigurations = (
607A663F2B0EFA3A0010BFC8 /* Debug */,
607A66402B0EFA3A0010BFC8 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
607A66412B0EFA3A0010BFC8 /* Build configuration list for PBXNativeTarget "iOSTestbed" */ = {
isa = XCConfigurationList;
buildConfigurations = (
607A66422B0EFA3A0010BFC8 /* Debug */,
607A66432B0EFA3A0010BFC8 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
607A66442B0EFA3A0010BFC8 /* Build configuration list for PBXNativeTarget "iOSTestbedTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
607A66452B0EFA3A0010BFC8 /* Debug */,
607A66462B0EFA3A0010BFC8 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 607A660A2B0EFA380010BFC8 /* Project object */;
}

View File

@@ -1,11 +0,0 @@
//
// AppDelegate.h
// iOSTestbed
//
#import <UIKit/UIKit.h>
@interface AppDelegate : UIResponder <UIApplicationDelegate>
@end

View File

@@ -1,19 +0,0 @@
//
// AppDelegate.m
// iOSTestbed
//
#import "AppDelegate.h"
@interface AppDelegate ()
@end
@implementation AppDelegate
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
return YES;
}
@end

View File

@@ -1,11 +0,0 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -1,13 +0,0 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -1,6 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="22155" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22131"/>
</dependencies>
<scenes/>
</document>

View File

@@ -1,7 +0,0 @@
This folder can contain any Python application code.
During the build, any binary modules found in this folder will be processed into
iOS Framework form.
When the test suite runs, this folder will be on the PYTHONPATH, and will be the
working directory for the test suite.

View File

@@ -1,7 +0,0 @@
This folder can be a target for installing any Python dependencies needed by the
test suite.
During the build, any binary modules found in this folder will be processed into
iOS Framework form.
When the test suite runs, this folder will be on the PYTHONPATH.

View File

@@ -1,26 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string></string>
<key>CFBundleIdentifier</key>
<string></string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSupportedPlatforms</key>
<array>
<string>iPhoneOS</string>
</array>
<key>MinimumOSVersion</key>
<string>12.0</string>
<key>CFBundleVersion</key>
<string>1</string>
</dict>
</plist>

View File

@@ -1,64 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>${PRODUCT_NAME}</string>
<key>CFBundleExecutable</key>
<string>${EXECUTABLE_NAME}</string>
<key>CFBundleIdentifier</key>
<string>org.python.iOSTestbed</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>${PRODUCT_NAME}</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIRequiresFullScreen</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>Launch Screen</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>TestArgs</key>
<array>
<string>test</string> <!-- Invoke "python -m test" -->
<string>-uall</string> <!-- Enable all resources -->
<string>--single-process</string> <!-- always run all tests sequentially in a single process -->
<string>--rerun</string> <!-- Re-run failed tests in verbose mode -->
<string>-W</string> <!-- Display test output on failure -->
<!-- To run a subset of tests, add the test names below; e.g.,
<string>test_os</string>
<string>test_sys</string>
-->
</array>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict/>
</dict>
</dict>
</plist>

View File

@@ -1,16 +0,0 @@
//
// main.m
// iOSTestbed
//
#import <UIKit/UIKit.h>
#import "AppDelegate.h"
int main(int argc, char * argv[]) {
NSString * appDelegateClassName;
@autoreleasepool {
appDelegateClassName = NSStringFromClass([AppDelegate class]);
return UIApplicationMain(argc, argv, nil, appDelegateClassName);
}
}

View File

@@ -1,193 +0,0 @@
#import <XCTest/XCTest.h>
#import <Python/Python.h>
@interface iOSTestbedTests : XCTestCase
@end
@implementation iOSTestbedTests
- (void)testPython {
const char **argv;
int exit_code;
int failed;
PyStatus status;
PyPreConfig preconfig;
PyConfig config;
PyObject *app_packages_path;
PyObject *method_args;
PyObject *result;
PyObject *site_module;
PyObject *site_addsitedir_attr;
PyObject *sys_module;
PyObject *sys_path_attr;
NSArray *test_args;
NSString *python_home;
NSString *path;
wchar_t *wtmp_str;
NSString *resourcePath = [[NSBundle mainBundle] resourcePath];
// Set some other common environment indicators to disable color, as the
// Xcode log can't display color. Stdout will report that it is *not* a
// TTY.
setenv("NO_COLOR", "1", true);
setenv("PYTHON_COLORS", "0", true);
// 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"];
if (test_args == NULL) {
NSLog(@"Unable to identify test arguments.");
}
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];
}
NSLog(@"Test command: %@", test_args);
// Generate an isolated Python configuration.
NSLog(@"Configuring isolated Python...");
PyPreConfig_InitIsolatedConfig(&preconfig);
PyConfig_InitIsolatedConfig(&config);
// Configure the Python interpreter:
// Enforce UTF-8 encoding for stderr, stdout, file-system encoding and locale.
// See https://docs.python.org/3/library/os.html#python-utf-8-mode.
preconfig.utf8_mode = 1;
// Use the system logger for stdout/err
config.use_system_logger = 1;
// Don't buffer stdio. We want output to appears in the log immediately
config.buffered_stdio = 0;
// Don't write bytecode; we can't modify the app bundle
// after it has been signed.
config.write_bytecode = 0;
// 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);
// For debugging - enable verbose mode.
// config.verbose = 1;
NSLog(@"Pre-initializing Python runtime...");
status = Py_PreInitialize(&preconfig);
if (PyStatus_Exception(status)) {
XCTFail(@"Unable to pre-initialize Python interpreter: %s", status.err_msg);
PyConfig_Clear(&config);
return;
}
// Set the home for the Python interpreter
python_home = [NSString stringWithFormat:@"%@/python", resourcePath, nil];
NSLog(@"PythonHome: %@", python_home);
wtmp_str = Py_DecodeLocale([python_home UTF8String], NULL);
status = PyConfig_SetString(&config, &config.home, wtmp_str);
if (PyStatus_Exception(status)) {
XCTFail(@"Unable to set PYTHONHOME: %s", status.err_msg);
PyConfig_Clear(&config);
return;
}
PyMem_RawFree(wtmp_str);
// Read the site config
status = PyConfig_Read(&config);
if (PyStatus_Exception(status)) {
XCTFail(@"Unable to read site config: %s", status.err_msg);
PyConfig_Clear(&config);
return;
}
NSLog(@"Configure argc/argv...");
status = PyConfig_SetBytesArgv(&config, [test_args count], (char**) argv);
if (PyStatus_Exception(status)) {
XCTFail(@"Unable to configure argc/argv: %s", status.err_msg);
PyConfig_Clear(&config);
return;
}
NSLog(@"Initializing Python runtime...");
status = Py_InitializeFromConfig(&config);
if (PyStatus_Exception(status)) {
XCTFail(@"Unable to initialize Python interpreter: %s", status.err_msg);
PyConfig_Clear(&config);
return;
}
// Add app_packages as a site directory. This both adds to sys.path,
// and ensures that any .pth files in that directory will be executed.
site_module = PyImport_ImportModule("site");
if (site_module == NULL) {
XCTFail(@"Could not import site module");
return;
}
site_addsitedir_attr = PyObject_GetAttrString(site_module, "addsitedir");
if (site_addsitedir_attr == NULL || !PyCallable_Check(site_addsitedir_attr)) {
XCTFail(@"Could not access site.addsitedir");
return;
}
path = [NSString stringWithFormat:@"%@/app_packages", resourcePath, nil];
NSLog(@"App packages path: %@", path);
wtmp_str = Py_DecodeLocale([path UTF8String], NULL);
app_packages_path = PyUnicode_FromWideChar(wtmp_str, wcslen(wtmp_str));
if (app_packages_path == NULL) {
XCTFail(@"Could not convert app_packages path to unicode");
return;
}
PyMem_RawFree(wtmp_str);
method_args = Py_BuildValue("(O)", app_packages_path);
if (method_args == NULL) {
XCTFail(@"Could not create arguments for site.addsitedir");
return;
}
result = PyObject_CallObject(site_addsitedir_attr, method_args);
if (result == NULL) {
XCTFail(@"Could not add app_packages directory using site.addsitedir");
return;
}
// Add test code to sys.path
sys_module = PyImport_ImportModule("sys");
if (sys_module == NULL) {
XCTFail(@"Could not import sys module");
return;
}
sys_path_attr = PyObject_GetAttrString(sys_module, "path");
if (sys_path_attr == NULL) {
XCTFail(@"Could not access sys.path");
return;
}
path = [NSString stringWithFormat:@"%@/app", resourcePath, nil];
NSLog(@"App path: %@", path);
wtmp_str = Py_DecodeLocale([path UTF8String], NULL);
failed = PyList_Insert(sys_path_attr, 0, PyUnicode_FromString([path UTF8String]));
if (failed) {
XCTFail(@"Unable to add app to sys.path");
return;
}
PyMem_RawFree(wtmp_str);
// Ensure the working directory is the app folder.
chdir([path UTF8String]);
// Start the test suite. Print a separator to differentiate Python startup logs from app logs
NSLog(@"---------------------------------------------------------------------------");
exit_code = Py_RunMain();
XCTAssertEqual(exit_code, 0, @"Test suite did not pass");
NSLog(@"---------------------------------------------------------------------------");
Py_Finalize();
}
@end