mirror of
				https://github.com/python/cpython.git
				synced 2025-11-03 23:21:29 +00:00 
			
		
		
		
	[3.13] gh-131531: android.py enhancements to support cibuildwheel (GH-132870) (#135164)
Modifies the environment handling and execution arguments of the Android management
script to support the compilation of third-party binaries, and the use of the testbed to
invoke third-party test code.
(cherry picked from commit 2e1544fd2b)
Co-authored-by: Malcolm Smith <smith@chaquo.com>
Co-authored-by: Adam Turner <9087854+AA-Turner@users.noreply.github.com>
Co-authored-by: Russell Keith-Magee <russell@keith-magee.com>
			
			
This commit is contained in:
		
							parent
							
								
									68f8eed6ff
								
							
						
					
					
						commit
						fd39aa3a7f
					
				
					 11 changed files with 241 additions and 92 deletions
				
			
		
							
								
								
									
										4
									
								
								.github/CODEOWNERS
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/CODEOWNERS
									
										
									
									
										vendored
									
									
								
							| 
						 | 
					@ -244,8 +244,8 @@ Modules/_interp*module.c      @ericsnowcurrently
 | 
				
			||||||
Lib/test/test_interpreters/   @ericsnowcurrently
 | 
					Lib/test/test_interpreters/   @ericsnowcurrently
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Android
 | 
					# Android
 | 
				
			||||||
**/*Android*                  @mhsmith
 | 
					**/*Android*                  @mhsmith @freakboy3742
 | 
				
			||||||
**/*android*                  @mhsmith
 | 
					**/*android*                  @mhsmith @freakboy3742
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# iOS (but not termios)
 | 
					# iOS (but not termios)
 | 
				
			||||||
**/iOS*                       @freakboy3742
 | 
					**/iOS*                       @freakboy3742
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -156,6 +156,10 @@ ## Testing
 | 
				
			||||||
and architecture-specific files such as sysconfigdata, will not take effect
 | 
					and architecture-specific files such as sysconfigdata, will not take effect
 | 
				
			||||||
until you re-run `android.py make-host` or `build`.
 | 
					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
 | 
					## Using in your own app
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -3,7 +3,7 @@
 | 
				
			||||||
: "${HOST:?}"  # GNU target triplet
 | 
					: "${HOST:?}"  # GNU target triplet
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# You may also override the following:
 | 
					# You may also override the following:
 | 
				
			||||||
: "${api_level:=21}"  # Minimum Android API level the build will run on
 | 
					: "${ANDROID_API_LEVEL:=21}"  # Minimum Android API level the build will run on
 | 
				
			||||||
: "${PREFIX:-}"  # Path in which to find required libraries
 | 
					: "${PREFIX:-}"  # Path in which to find required libraries
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -24,7 +24,7 @@ fail() {
 | 
				
			||||||
# * https://android.googlesource.com/platform/ndk/+/ndk-rXX-release/docs/BuildSystemMaintainers.md
 | 
					# * 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.:
 | 
					#   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
 | 
					#   https://android.googlesource.com/platform/ndk/+/ndk-r25-release..ndk-r26-release/docs/BuildSystemMaintainers.md
 | 
				
			||||||
ndk_version=27.1.12297006
 | 
					ndk_version=27.2.12479018
 | 
				
			||||||
 | 
					
 | 
				
			||||||
ndk=$ANDROID_HOME/ndk/$ndk_version
 | 
					ndk=$ANDROID_HOME/ndk/$ndk_version
 | 
				
			||||||
if ! [ -e "$ndk" ]; then
 | 
					if ! [ -e "$ndk" ]; then
 | 
				
			||||||
| 
						 | 
					@ -43,7 +43,7 @@ fi
 | 
				
			||||||
toolchain=$(echo "$ndk"/toolchains/llvm/prebuilt/*)
 | 
					toolchain=$(echo "$ndk"/toolchains/llvm/prebuilt/*)
 | 
				
			||||||
export AR="$toolchain/bin/llvm-ar"
 | 
					export AR="$toolchain/bin/llvm-ar"
 | 
				
			||||||
export AS="$toolchain/bin/llvm-as"
 | 
					export AS="$toolchain/bin/llvm-as"
 | 
				
			||||||
export CC="$toolchain/bin/${clang_triplet}${api_level}-clang"
 | 
					export CC="$toolchain/bin/${clang_triplet}${ANDROID_API_LEVEL}-clang"
 | 
				
			||||||
export CXX="${CC}++"
 | 
					export CXX="${CC}++"
 | 
				
			||||||
export LD="$toolchain/bin/ld"
 | 
					export LD="$toolchain/bin/ld"
 | 
				
			||||||
export NM="$toolchain/bin/llvm-nm"
 | 
					export NM="$toolchain/bin/llvm-nm"
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -14,7 +14,7 @@
 | 
				
			||||||
from contextlib import asynccontextmanager
 | 
					from contextlib import asynccontextmanager
 | 
				
			||||||
from datetime import datetime, timezone
 | 
					from datetime import datetime, timezone
 | 
				
			||||||
from glob import glob
 | 
					from glob import glob
 | 
				
			||||||
from os.path import basename, relpath
 | 
					from os.path import abspath, basename, relpath
 | 
				
			||||||
from pathlib import Path
 | 
					from pathlib import Path
 | 
				
			||||||
from subprocess import CalledProcessError
 | 
					from subprocess import CalledProcessError
 | 
				
			||||||
from tempfile import TemporaryDirectory
 | 
					from tempfile import TemporaryDirectory
 | 
				
			||||||
| 
						 | 
					@ -22,9 +22,13 @@
 | 
				
			||||||
 | 
					
 | 
				
			||||||
SCRIPT_NAME = Path(__file__).name
 | 
					SCRIPT_NAME = Path(__file__).name
 | 
				
			||||||
ANDROID_DIR = Path(__file__).resolve().parent
 | 
					ANDROID_DIR = Path(__file__).resolve().parent
 | 
				
			||||||
CHECKOUT = ANDROID_DIR.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"
 | 
					TESTBED_DIR = ANDROID_DIR / "testbed"
 | 
				
			||||||
CROSS_BUILD_DIR = CHECKOUT / "cross-build"
 | 
					CROSS_BUILD_DIR = PYTHON_DIR / "cross-build"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
HOSTS = ["aarch64-linux-android", "x86_64-linux-android"]
 | 
					HOSTS = ["aarch64-linux-android", "x86_64-linux-android"]
 | 
				
			||||||
APP_ID = "org.python.testbed"
 | 
					APP_ID = "org.python.testbed"
 | 
				
			||||||
| 
						 | 
					@ -76,19 +80,52 @@ def run(command, *, host=None, env=None, log=True, **kwargs):
 | 
				
			||||||
    kwargs.setdefault("check", True)
 | 
					    kwargs.setdefault("check", True)
 | 
				
			||||||
    if env is None:
 | 
					    if env is None:
 | 
				
			||||||
        env = os.environ.copy()
 | 
					        env = os.environ.copy()
 | 
				
			||||||
    original_env = env.copy()
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if host:
 | 
					    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_script = ANDROID_DIR / "android-env.sh"
 | 
				
			||||||
    env_output = subprocess.run(
 | 
					    env_output = subprocess.run(
 | 
				
			||||||
        f"set -eu; "
 | 
					        f"set -eu; "
 | 
				
			||||||
            f"HOST={host}; "
 | 
					        f"export HOST={host}; "
 | 
				
			||||||
            f"PREFIX={subdir(host)}/prefix; "
 | 
					        f"PREFIX={prefix}; "
 | 
				
			||||||
        f". {env_script}; "
 | 
					        f". {env_script}; "
 | 
				
			||||||
        f"export",
 | 
					        f"export",
 | 
				
			||||||
            check=True, shell=True, text=True, stdout=subprocess.PIPE
 | 
					        check=True, shell=True, capture_output=True, encoding='utf-8',
 | 
				
			||||||
    ).stdout
 | 
					    ).stdout
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    env = {}
 | 
				
			||||||
    for line in env_output.splitlines():
 | 
					    for line in env_output.splitlines():
 | 
				
			||||||
        # We don't require every line to match, as there may be some other
 | 
					        # We don't require every line to match, as there may be some other
 | 
				
			||||||
        # output from installing the NDK.
 | 
					        # output from installing the NDK.
 | 
				
			||||||
| 
						 | 
					@ -96,17 +133,13 @@ def run(command, *, host=None, env=None, log=True, **kwargs):
 | 
				
			||||||
            "^(declare -x |export )?(\\w+)=['\"]?(.*?)['\"]?$", line
 | 
					            "^(declare -x |export )?(\\w+)=['\"]?(.*?)['\"]?$", line
 | 
				
			||||||
        ):
 | 
					        ):
 | 
				
			||||||
            key, value = match[2], match[3]
 | 
					            key, value = match[2], match[3]
 | 
				
			||||||
                if env.get(key) != value:
 | 
					            if os.environ.get(key) != value:
 | 
				
			||||||
                    print(line)
 | 
					 | 
				
			||||||
                env[key] = value
 | 
					                env[key] = value
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if env == original_env:
 | 
					    if not env:
 | 
				
			||||||
        raise ValueError(f"Found no variables in {env_script.name} output:\n"
 | 
					        raise ValueError(f"Found no variables in {env_script.name} output:\n"
 | 
				
			||||||
                         + env_output)
 | 
					                         + env_output)
 | 
				
			||||||
 | 
					    return env
 | 
				
			||||||
    if log:
 | 
					 | 
				
			||||||
        print(">", " ".join(map(str, command)))
 | 
					 | 
				
			||||||
    return subprocess.run(command, env=env, **kwargs)
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def build_python_path():
 | 
					def build_python_path():
 | 
				
			||||||
| 
						 | 
					@ -127,7 +160,7 @@ def configure_build_python(context):
 | 
				
			||||||
        clean("build")
 | 
					        clean("build")
 | 
				
			||||||
    os.chdir(subdir("build", create=True))
 | 
					    os.chdir(subdir("build", create=True))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    command = [relpath(CHECKOUT / "configure")]
 | 
					    command = [relpath(PYTHON_DIR / "configure")]
 | 
				
			||||||
    if context.args:
 | 
					    if context.args:
 | 
				
			||||||
        command.extend(context.args)
 | 
					        command.extend(context.args)
 | 
				
			||||||
    run(command)
 | 
					    run(command)
 | 
				
			||||||
| 
						 | 
					@ -139,12 +172,13 @@ def make_build_python(context):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def unpack_deps(host, prefix_dir):
 | 
					def unpack_deps(host, prefix_dir):
 | 
				
			||||||
 | 
					    os.chdir(prefix_dir)
 | 
				
			||||||
    deps_url = "https://github.com/beeware/cpython-android-source-deps/releases/download"
 | 
					    deps_url = "https://github.com/beeware/cpython-android-source-deps/releases/download"
 | 
				
			||||||
    for name_ver in ["bzip2-1.0.8-2", "libffi-3.4.4-3", "openssl-3.0.15-4",
 | 
					    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"]:
 | 
					                     "sqlite-3.49.1-0", "xz-5.4.6-1"]:
 | 
				
			||||||
        filename = f"{name_ver}-{host}.tar.gz"
 | 
					        filename = f"{name_ver}-{host}.tar.gz"
 | 
				
			||||||
        download(f"{deps_url}/{name_ver}/{filename}")
 | 
					        download(f"{deps_url}/{name_ver}/{filename}")
 | 
				
			||||||
        shutil.unpack_archive(filename, prefix_dir)
 | 
					        shutil.unpack_archive(filename)
 | 
				
			||||||
        os.remove(filename)
 | 
					        os.remove(filename)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -167,7 +201,7 @@ def configure_host_python(context):
 | 
				
			||||||
    os.chdir(host_dir)
 | 
					    os.chdir(host_dir)
 | 
				
			||||||
    command = [
 | 
					    command = [
 | 
				
			||||||
        # Basic cross-compiling configuration
 | 
					        # Basic cross-compiling configuration
 | 
				
			||||||
        relpath(CHECKOUT / "configure"),
 | 
					        relpath(PYTHON_DIR / "configure"),
 | 
				
			||||||
        f"--host={context.host}",
 | 
					        f"--host={context.host}",
 | 
				
			||||||
        f"--build={sysconfig.get_config_var('BUILD_GNU_TYPE')}",
 | 
					        f"--build={sysconfig.get_config_var('BUILD_GNU_TYPE')}",
 | 
				
			||||||
        f"--with-build-python={build_python_path()}",
 | 
					        f"--with-build-python={build_python_path()}",
 | 
				
			||||||
| 
						 | 
					@ -196,9 +230,12 @@ def make_host_python(context):
 | 
				
			||||||
    for pattern in ("include/python*", "lib/libpython*", "lib/python*"):
 | 
					    for pattern in ("include/python*", "lib/libpython*", "lib/python*"):
 | 
				
			||||||
        delete_glob(f"{prefix_dir}/{pattern}")
 | 
					        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)
 | 
					    os.chdir(host_dir)
 | 
				
			||||||
    run(["make", "-j", str(os.cpu_count())], host=context.host)
 | 
					    run(["make", "-j", str(os.cpu_count())])
 | 
				
			||||||
    run(["make", "install", f"prefix={prefix_dir}"], host=context.host)
 | 
					    run(["make", "install", f"prefix={prefix_dir}"])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def build_all(context):
 | 
					def build_all(context):
 | 
				
			||||||
| 
						 | 
					@ -228,7 +265,12 @@ def setup_sdk():
 | 
				
			||||||
    if not all((android_home / "licenses" / path).exists() for path in [
 | 
					    if not all((android_home / "licenses" / path).exists() for path in [
 | 
				
			||||||
        "android-sdk-arm-dbt-license", "android-sdk-license"
 | 
					        "android-sdk-arm-dbt-license", "android-sdk-license"
 | 
				
			||||||
    ]):
 | 
					    ]):
 | 
				
			||||||
        run([sdkmanager, "--licenses"], text=True, input="y\n" * 100)
 | 
					        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
 | 
					    # Gradle may install this automatically, but we can't rely on that because
 | 
				
			||||||
    # we need to run adb within the logcat task.
 | 
					    # we need to run adb within the logcat task.
 | 
				
			||||||
| 
						 | 
					@ -474,24 +516,49 @@ async def gradle_task(context):
 | 
				
			||||||
        task_prefix = "connected"
 | 
					        task_prefix = "connected"
 | 
				
			||||||
        env["ANDROID_SERIAL"] = context.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 = [
 | 
					    args = [
 | 
				
			||||||
        gradlew, "--console", "plain", f"{task_prefix}DebugAndroidTest",
 | 
					        gradlew, "--console", "plain", f"{task_prefix}DebugAndroidTest",
 | 
				
			||||||
        "-Pandroid.testInstrumentationRunnerArguments.pythonArgs="
 | 
					    ] + [
 | 
				
			||||||
        + shlex.join(context.args),
 | 
					        # 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
 | 
				
			||||||
    ]
 | 
					    ]
 | 
				
			||||||
    hidden_output = []
 | 
					    if context.verbose >= 2:
 | 
				
			||||||
 | 
					        args.append("--info")
 | 
				
			||||||
 | 
					    log("> " + join_command(args))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    try:
 | 
					    try:
 | 
				
			||||||
        async with async_process(
 | 
					        async with async_process(
 | 
				
			||||||
            *args, cwd=TESTBED_DIR, env=env,
 | 
					            *args, cwd=TESTBED_DIR, env=env,
 | 
				
			||||||
            stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
 | 
					            stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
 | 
				
			||||||
        ) as process:
 | 
					        ) as process:
 | 
				
			||||||
            while line := (await process.stdout.readline()).decode(*DECODE_ARGS):
 | 
					            while line := (await process.stdout.readline()).decode(*DECODE_ARGS):
 | 
				
			||||||
                # Gradle may take several minutes to install SDK packages, so
 | 
					                log(line)
 | 
				
			||||||
                # 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)
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
            status = await wait_for(process.wait(), timeout=1)
 | 
					            status = await wait_for(process.wait(), timeout=1)
 | 
				
			||||||
            if status == 0:
 | 
					            if status == 0:
 | 
				
			||||||
| 
						 | 
					@ -604,6 +671,10 @@ def package(context):
 | 
				
			||||||
        print(f"Wrote {package_path}")
 | 
					        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
 | 
					# 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.
 | 
					# by the buildbot worker, we'll make an attempt to clean up our subprocesses.
 | 
				
			||||||
def install_signal_handler():
 | 
					def install_signal_handler():
 | 
				
			||||||
| 
						 | 
					@ -615,36 +686,41 @@ def signal_handler(*args):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def parse_args():
 | 
					def parse_args():
 | 
				
			||||||
    parser = argparse.ArgumentParser()
 | 
					    parser = argparse.ArgumentParser()
 | 
				
			||||||
    subcommands = parser.add_subparsers(dest="subcommand")
 | 
					    subcommands = parser.add_subparsers(dest="subcommand", required=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # Subcommands
 | 
					    # Subcommands
 | 
				
			||||||
    build = subcommands.add_parser("build", help="Build everything")
 | 
					    build = subcommands.add_parser(
 | 
				
			||||||
    configure_build = subcommands.add_parser("configure-build",
 | 
					        "build", help="Run configure-build, make-build, configure-host and "
 | 
				
			||||||
                                             help="Run `configure` for the "
 | 
					        "make-host")
 | 
				
			||||||
                                             "build Python")
 | 
					    configure_build = subcommands.add_parser(
 | 
				
			||||||
    make_build = subcommands.add_parser("make-build",
 | 
					        "configure-build", help="Run `configure` for the build Python")
 | 
				
			||||||
                                        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(
 | 
					    subcommands.add_parser(
 | 
				
			||||||
        "clean", help="Delete all build and prefix directories")
 | 
					        "make-build", help="Run `make` for the build Python")
 | 
				
			||||||
    subcommands.add_parser(
 | 
					    configure_host = subcommands.add_parser(
 | 
				
			||||||
        "build-testbed", help="Build the testbed app")
 | 
					        "configure-host", help="Run `configure` for Android")
 | 
				
			||||||
    test = subcommands.add_parser(
 | 
					    make_host = subcommands.add_parser(
 | 
				
			||||||
        "test", help="Run the test suite")
 | 
					        "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")
 | 
					    package = subcommands.add_parser("package", help="Make a release package")
 | 
				
			||||||
 | 
					    env = subcommands.add_parser("env", help="Print environment variables")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # Common arguments
 | 
					    # Common arguments
 | 
				
			||||||
    for subcommand in build, configure_build, configure_host:
 | 
					    for subcommand in build, configure_build, configure_host:
 | 
				
			||||||
        subcommand.add_argument(
 | 
					        subcommand.add_argument(
 | 
				
			||||||
            "--clean", action="store_true", default=False, dest="clean",
 | 
					            "--clean", action="store_true", default=False, dest="clean",
 | 
				
			||||||
            help="Delete the relevant build and prefix directories first")
 | 
					            help="Delete the relevant build directories first")
 | 
				
			||||||
    for subcommand in [build, configure_host, make_host, package]:
 | 
					
 | 
				
			||||||
 | 
					    host_commands = [build, configure_host, make_host, package]
 | 
				
			||||||
 | 
					    if in_source_tree:
 | 
				
			||||||
 | 
					        host_commands.append(env)
 | 
				
			||||||
 | 
					    for subcommand in host_commands:
 | 
				
			||||||
        subcommand.add_argument(
 | 
					        subcommand.add_argument(
 | 
				
			||||||
            "host", metavar="HOST", choices=HOSTS,
 | 
					            "host", metavar="HOST", choices=HOSTS,
 | 
				
			||||||
            help="Host triplet: choices=[%(choices)s]")
 | 
					            help="Host triplet: choices=[%(choices)s]")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    for subcommand in build, configure_build, configure_host:
 | 
					    for subcommand in build, configure_build, configure_host:
 | 
				
			||||||
        subcommand.add_argument("args", nargs="*",
 | 
					        subcommand.add_argument("args", nargs="*",
 | 
				
			||||||
                                help="Extra arguments to pass to `configure`")
 | 
					                                help="Extra arguments to pass to `configure`")
 | 
				
			||||||
| 
						 | 
					@ -654,6 +730,7 @@ def parse_args():
 | 
				
			||||||
        "-v", "--verbose", action="count", default=0,
 | 
					        "-v", "--verbose", action="count", default=0,
 | 
				
			||||||
        help="Show Gradle output, and non-Python logcat messages. "
 | 
					        help="Show Gradle output, and non-Python logcat messages. "
 | 
				
			||||||
        "Use twice to include high-volume messages which are rarely useful.")
 | 
					        "Use twice to include high-volume messages which are rarely useful.")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    device_group = test.add_mutually_exclusive_group(required=True)
 | 
					    device_group = test.add_mutually_exclusive_group(required=True)
 | 
				
			||||||
    device_group.add_argument(
 | 
					    device_group.add_argument(
 | 
				
			||||||
        "--connected", metavar="SERIAL", help="Run on a connected device. "
 | 
					        "--connected", metavar="SERIAL", help="Run on a connected device. "
 | 
				
			||||||
| 
						 | 
					@ -661,8 +738,24 @@ def parse_args():
 | 
				
			||||||
    device_group.add_argument(
 | 
					    device_group.add_argument(
 | 
				
			||||||
        "--managed", metavar="NAME", help="Run on a Gradle-managed device. "
 | 
					        "--managed", metavar="NAME", help="Run on a Gradle-managed device. "
 | 
				
			||||||
        "These are defined in `managedDevices` in testbed/app/build.gradle.kts.")
 | 
					        "These are defined in `managedDevices` in testbed/app/build.gradle.kts.")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    test.add_argument(
 | 
					    test.add_argument(
 | 
				
			||||||
        "args", nargs="*", help=f"Arguments for `python -m test`. "
 | 
					        "--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 `--`.")
 | 
					        f"Separate them from {SCRIPT_NAME}'s own arguments with `--`.")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return parser.parse_args()
 | 
					    return parser.parse_args()
 | 
				
			||||||
| 
						 | 
					@ -688,6 +781,7 @@ def main():
 | 
				
			||||||
        "build-testbed": build_testbed,
 | 
					        "build-testbed": build_testbed,
 | 
				
			||||||
        "test": run_testbed,
 | 
					        "test": run_testbed,
 | 
				
			||||||
        "package": package,
 | 
					        "package": package,
 | 
				
			||||||
 | 
					        "env": env,
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    try:
 | 
					    try:
 | 
				
			||||||
| 
						 | 
					@ -708,14 +802,9 @@ def print_called_process_error(e):
 | 
				
			||||||
            if not content.endswith("\n"):
 | 
					            if not content.endswith("\n"):
 | 
				
			||||||
                stream.write("\n")
 | 
					                stream.write("\n")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # Format the command so it can be copied into a shell. shlex uses single
 | 
					    # shlex uses single quotes, so we surround the command with double quotes.
 | 
				
			||||||
    # quotes, so we surround the whole command with double quotes.
 | 
					 | 
				
			||||||
    args_joined = (
 | 
					 | 
				
			||||||
        e.cmd if isinstance(e.cmd, str)
 | 
					 | 
				
			||||||
        else " ".join(shlex.quote(str(arg)) for arg in e.cmd)
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    print(
 | 
					    print(
 | 
				
			||||||
        f'Command "{args_joined}" returned exit status {e.returncode}'
 | 
					        f'Command "{join_command(e.cmd)}" returned exit status {e.returncode}'
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -85,7 +85,7 @@ android {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        minSdk = androidEnvFile.useLines {
 | 
					        minSdk = androidEnvFile.useLines {
 | 
				
			||||||
            for (line in it) {
 | 
					            for (line in it) {
 | 
				
			||||||
                """api_level:=(\d+)""".toRegex().find(line)?.let {
 | 
					                """ANDROID_API_LEVEL:=(\d+)""".toRegex().find(line)?.let {
 | 
				
			||||||
                    return@useLines it.groupValues[1].toInt()
 | 
					                    return@useLines it.groupValues[1].toInt()
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
| 
						 | 
					@ -205,11 +205,29 @@ androidComponents.onVariants { variant ->
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                into("site-packages") {
 | 
					                into("site-packages") {
 | 
				
			||||||
                    from("$projectDir/src/main/python")
 | 
					                    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
 | 
					                duplicatesStrategy = DuplicatesStrategy.EXCLUDE
 | 
				
			||||||
                exclude("**/__pycache__")
 | 
					                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)
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -17,11 +17,11 @@ class PythonSuite {
 | 
				
			||||||
    fun testPython() {
 | 
					    fun testPython() {
 | 
				
			||||||
        val start = System.currentTimeMillis()
 | 
					        val start = System.currentTimeMillis()
 | 
				
			||||||
        try {
 | 
					        try {
 | 
				
			||||||
            val context =
 | 
					            val status = PythonTestRunner(
 | 
				
			||||||
                InstrumentationRegistry.getInstrumentation().targetContext
 | 
					                InstrumentationRegistry.getInstrumentation().targetContext
 | 
				
			||||||
            val args =
 | 
					            ).run(
 | 
				
			||||||
                InstrumentationRegistry.getArguments().getString("pythonArgs", "")
 | 
					                InstrumentationRegistry.getArguments()
 | 
				
			||||||
            val status = PythonTestRunner(context).run(args)
 | 
					            )
 | 
				
			||||||
            assertEquals(0, status)
 | 
					            assertEquals(0, status)
 | 
				
			||||||
        } finally {
 | 
					        } finally {
 | 
				
			||||||
            // Make sure the process lives long enough for the test script to
 | 
					            // Make sure the process lives long enough for the test script to
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -15,17 +15,29 @@ class MainActivity : AppCompatActivity() {
 | 
				
			||||||
    override fun onCreate(savedInstanceState: Bundle?) {
 | 
					    override fun onCreate(savedInstanceState: Bundle?) {
 | 
				
			||||||
        super.onCreate(savedInstanceState)
 | 
					        super.onCreate(savedInstanceState)
 | 
				
			||||||
        setContentView(R.layout.activity_main)
 | 
					        setContentView(R.layout.activity_main)
 | 
				
			||||||
        val status = PythonTestRunner(this).run("-W -uall")
 | 
					        val status = PythonTestRunner(this).run("-m", "test", "-W -uall")
 | 
				
			||||||
        findViewById<TextView>(R.id.tvHello).text = "Exit status $status"
 | 
					        findViewById<TextView>(R.id.tvHello).text = "Exit status $status"
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class PythonTestRunner(val context: Context) {
 | 
					class PythonTestRunner(val context: Context) {
 | 
				
			||||||
    /** @param args Extra arguments for `python -m test`.
 | 
					    fun run(instrumentationArgs: Bundle) = run(
 | 
				
			||||||
     * @return The Python exit status: zero if the tests passed, nonzero if
 | 
					        instrumentationArgs.getString("pythonMode")!!,
 | 
				
			||||||
     * they failed. */
 | 
					        instrumentationArgs.getString("pythonModule")!!,
 | 
				
			||||||
    fun run(args: String = "") : Int {
 | 
					        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)
 | 
					        Os.setenv("PYTHON_ARGS", args, true)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // Python needs this variable to help it find the temporary directory,
 | 
					        // Python needs this variable to help it find the temporary directory,
 | 
				
			||||||
| 
						 | 
					@ -36,8 +48,9 @@ class PythonTestRunner(val context: Context) {
 | 
				
			||||||
        System.loadLibrary("main_activity")
 | 
					        System.loadLibrary("main_activity")
 | 
				
			||||||
        redirectStdioToLogcat()
 | 
					        redirectStdioToLogcat()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // The main module is in src/main/python/main.py.
 | 
					        // The main module is in src/main/python. We don't simply call it
 | 
				
			||||||
        return runPython(pythonHome.toString(), "main")
 | 
					        // "main", as that could clash with third-party test code.
 | 
				
			||||||
 | 
					        return runPython(pythonHome.toString(), "android_testbed_main")
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private fun extractAssets() : File {
 | 
					    private fun extractAssets() : File {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -26,7 +26,23 @@
 | 
				
			||||||
#     test_signals in test_threadsignals.py.
 | 
					#     test_signals in test_threadsignals.py.
 | 
				
			||||||
signal.pthread_sigmask(signal.SIG_UNBLOCK, [signal.SIGUSR1])
 | 
					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"])
 | 
					sys.argv[1:] = shlex.split(os.environ["PYTHON_ARGS"])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# The test module will call sys.exit to indicate whether the tests passed.
 | 
					cwd = f"{sys.prefix}/cwd"
 | 
				
			||||||
runpy.run_module("test")
 | 
					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}")
 | 
				
			||||||
| 
						 | 
					@ -1,5 +1,5 @@
 | 
				
			||||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
 | 
					// Top-level build file where you can add configuration options common to all sub-projects/modules.
 | 
				
			||||||
plugins {
 | 
					plugins {
 | 
				
			||||||
    id("com.android.application") version "8.6.1" apply false
 | 
					    id("com.android.application") version "8.10.0" apply false
 | 
				
			||||||
    id("org.jetbrains.kotlin.android") version "1.9.22" apply false
 | 
					    id("org.jetbrains.kotlin.android") version "1.9.22" apply false
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,6 +1,6 @@
 | 
				
			||||||
#Mon Feb 19 20:29:06 GMT 2024
 | 
					#Mon Feb 19 20:29:06 GMT 2024
 | 
				
			||||||
distributionBase=GRADLE_USER_HOME
 | 
					distributionBase=GRADLE_USER_HOME
 | 
				
			||||||
distributionPath=wrapper/dists
 | 
					distributionPath=wrapper/dists
 | 
				
			||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
 | 
					distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
 | 
				
			||||||
zipStoreBase=GRADLE_USER_HOME
 | 
					zipStoreBase=GRADLE_USER_HOME
 | 
				
			||||||
zipStorePath=wrapper/dists
 | 
					zipStorePath=wrapper/dists
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -63,3 +63,12 @@ link to the relevant file.
 | 
				
			||||||
* Add code to your app to :source:`start Python in embedded mode
 | 
					* 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
 | 
					  <Android/testbed/app/src/main/c/main_activity.c>`. This will need to be C code
 | 
				
			||||||
  called via JNI.
 | 
					  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.
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue