From 87506446b9e332e222e4be94d0d25ddaad3edd68 Mon Sep 17 00:00:00 2001 From: Ansh Dadwal Date: Sat, 14 Feb 2026 20:42:03 +0530 Subject: [PATCH 1/5] Add support for prebuilt wheels --- pythonforandroid/build.py | 46 +++++++++- pythonforandroid/recipe.py | 91 ++++++++++++++++--- .../recipes/hostpython3/__init__.py | 12 ++- pythonforandroid/recipes/python3/__init__.py | 59 ++++++------ pythonforandroid/toolchain.py | 36 ++++++++ tests/test_build.py | 30 +++++- 6 files changed, 227 insertions(+), 47 deletions(-) diff --git a/pythonforandroid/build.py b/pythonforandroid/build.py index 7758bcfd8c..1c2505c504 100644 --- a/pythonforandroid/build.py +++ b/pythonforandroid/build.py @@ -21,7 +21,7 @@ from pythonforandroid.archs import ArchARM, ArchARMv7_a, ArchAarch_64, Archx86, Archx86_64 from pythonforandroid.logger import (info, warning, info_notify, info_main, shprint, Out_Style, Out_Fore) from pythonforandroid.pythonpackage import get_package_name -from pythonforandroid.recipe import CythonRecipe, Recipe +from pythonforandroid.recipe import CythonRecipe, Recipe, PyProjectRecipe from pythonforandroid.recommendations import ( check_ndk_version, check_target_api, check_ndk_api, RECOMMENDED_NDK_API, RECOMMENDED_TARGET_API) @@ -101,6 +101,12 @@ class Context: java_build_tool = 'auto' + skip_prebuilt = False + + extra_index_urls = [] + + use_prebuilt_version_for = [] + @property def packages_path(self): '''Where packages are downloaded before being unpacked''' @@ -667,7 +673,17 @@ def is_wheel_platform_independent(whl_name): return all(tag.platform == "any" for tag in tags) -def process_python_modules(ctx, modules): +def is_wheel_compatible(whl_name, arch, ctx): + name, version, build, tags = parse_wheel_filename(whl_name) + supported_tags = PyProjectRecipe.get_wheel_platform_tag(None, arch.arch, ctx=ctx) + supported_tags.append("any") + result = all(tag.platform in supported_tags for tag in tags) + if not result: + warning(f"Incompatible module : {whl_name}") + return result + + +def process_python_modules(ctx, modules, arch): """Use pip --dry-run to resolve dependencies and filter for pure-Python packages """ modules = list(modules) @@ -702,6 +718,7 @@ def process_python_modules(ctx, modules): # setup hostpython recipe env = environ.copy() + host_recipe = None try: host_recipe = Recipe.get_recipe("hostpython3", ctx) _python_path = host_recipe.get_path_to_python() @@ -713,11 +730,28 @@ def process_python_modules(ctx, modules): # hostpython3 non available so we use system pip (like in tests) pip = sh.Command("pip") + # add platform tags + platforms = [] + tags = PyProjectRecipe.get_wheel_platform_tag(None, arch.arch, ctx=ctx) + for tag in tags: + platforms.append(f"--platform={tag}") + + if host_recipe is not None: + platforms.extend(["--python-version", host_recipe.version]) + else: + # tests? + platforms.extend(["--python-version", "3.13.4"]) + + indices = [] + # add extra index urls + for index in ctx.extra_index_urls: + indices.extend(["--extra-index-url", index]) try: shprint( pip, 'install', *modules, '--dry-run', '--break-system-packages', '--ignore-installed', - '--report', path, '-q', _env=env + '--disable-pip-version-check', '--only-binary=:all:', + '--report', path, '-q', *platforms, *indices, _env=env ) except Exception as e: warning(f"Auto module resolution failed: {e}") @@ -751,7 +785,9 @@ def process_python_modules(ctx, modules): filename = basename(module["download_info"]["url"]) pure_python = True - if (filename.endswith(".whl") and not is_wheel_platform_independent(filename)): + if ( + filename.endswith(".whl") and not is_wheel_compatible(filename, arch, ctx) + ): any_not_pure_python = True pure_python = False @@ -793,7 +829,7 @@ def run_pymodules_install(ctx, arch, modules, project_dir=None, info('*** PYTHON PACKAGE / PROJECT INSTALL STAGE FOR ARCH: {} ***'.format(arch)) - modules = process_python_modules(ctx, modules) + modules = process_python_modules(ctx, modules, arch) modules = [m for m in modules if ctx.not_has_package(m, arch)] diff --git a/pythonforandroid/recipe.py b/pythonforandroid/recipe.py index 32bc2ec2b0..1285ca12d3 100644 --- a/pythonforandroid/recipe.py +++ b/pythonforandroid/recipe.py @@ -923,8 +923,7 @@ def real_hostpython_location(self): if host_name == 'hostpython3': return self._host_recipe.python_exe else: - python_recipe = self.ctx.python_recipe - return 'python{}'.format(python_recipe.version) + return 'python{}'.format(self.ctx.python_recipe.version) @property def hostpython_location(self): @@ -1248,6 +1247,55 @@ class PyProjectRecipe(PythonRecipe): extra_build_args = [] call_hostpython_via_targetpython = False + def get_pip_name(self): + name_str = self.name + if self.name not in self.ctx.use_prebuilt_version_for: + name_str += f"=={self.version}" + return name_str + + def get_pip_install_args(self, arch): + python_recipe = Recipe.get_recipe("python3", self.ctx) + opts = [ + "install", + self.get_pip_name(), + "--disable-pip-version-check", + "--python-version", + python_recipe.version, + "--only-binary=:all:", + ] + # add platform tags + tags = self.get_wheel_platform_tag(arch.arch) + for tag in tags: + opts.append(f"--platform={tag}") + + # add extra index urls + for index in self.ctx.extra_index_urls: + opts.extend(["--extra-index-url", index]) + + return opts + + def lookup_prebuilt(self, arch): + pip_options = self.get_pip_install_args(arch) + # do not install + pip_options.extend(["--dry-run"]) + pip_env = self.get_hostrecipe_env() + try: + shprint(self._host_recipe.pip, *pip_options, _env=pip_env) + except Exception as e: + warning(f"Lookup fail result: {e}") + return False + return True + + def check_prebuilt(self, arch, msg=""): + if self.ctx.skip_prebuilt: + return False + + if self.lookup_prebuilt(arch): + if msg != "": + info(f"Prebuilt pip wheel found, {msg}") + return True + return + def get_recipe_env(self, arch, **kwargs): # Custom hostpython self.ctx.python_recipe.python_exe = join( @@ -1259,24 +1307,40 @@ def get_recipe_env(self, arch, **kwargs): with open(build_opts, "w") as file: file.write("[bdist_wheel]\nplat_name={}".format( - self.get_wheel_platform_tag(arch) + self.get_wheel_platform_tag(arch.arch)[0] )) file.close() env["DIST_EXTRA_CONFIG"] = build_opts return env - def get_wheel_platform_tag(self, arch): + def get_wheel_platform_tag(self, arch, ctx=None): + if ctx is None: + ctx = self.ctx # https://peps.python.org/pep-0738/#packaging # official python only supports 64 bit: # android_21_arm64_v8a # android_21_x86_64 - return f"android_{self.ctx.ndk_api}_" + { - "arm64-v8a": "arm64_v8a", - "x86_64": "x86_64", - "armeabi-v7a": "arm", - "x86": "i686", - }[arch.arch] + _suffix = { + "arm64-v8a": ["arm64_v8a", "aarch64"], + "x86_64": ["x86_64"], + "armeabi-v7a": ["arm"], + "x86": ["i686"], + }[arch] + return [f"android_{ctx.ndk_api}_" + _ for _ in _suffix] + + def install_prebuilt_wheel(self, arch): + info("Installing prebuilt built wheel") + destination = self.ctx.get_python_install_dir(arch.arch) + pip_options = self.get_pip_install_args(arch) + pip_options.extend(["--target", destination]) + pip_options.append("--upgrade") + pip_env = self.get_hostrecipe_env() + try: + shprint(self._host_recipe.pip, *pip_options, _env=pip_env) + except Exception: + return False + return True def install_wheel(self, arch, built_wheels): with patch_wheel_setuptools_logging(): @@ -1287,7 +1351,7 @@ def install_wheel(self, arch, built_wheels): # Fix wheel platform tag wheel_tag = wheel_tags( _wheel, - platform_tags=self.get_wheel_platform_tag(arch), + platform_tags=self.get_wheel_platform_tag(arch.arch)[0], remove=True, ) selected_wheel = join(built_wheel_dir, wheel_tag) @@ -1305,6 +1369,11 @@ def install_wheel(self, arch, built_wheels): wf.close() def build_arch(self, arch): + if self.check_prebuilt(arch, "skipping build_arch") is not None: + result = self.install_prebuilt_wheel(arch) + if result: + return + warning("Failed to install prebuilt wheel, falling back to build_arch") build_dir = self.get_build_dir(arch.arch) if not (isfile(join(build_dir, "pyproject.toml")) or isfile(join(build_dir, "setup.py"))): diff --git a/pythonforandroid/recipes/hostpython3/__init__.py b/pythonforandroid/recipes/hostpython3/__init__.py index 199cdb50c3..4c31859bfe 100644 --- a/pythonforandroid/recipes/hostpython3/__init__.py +++ b/pythonforandroid/recipes/hostpython3/__init__.py @@ -6,7 +6,7 @@ from os.path import join from packaging.version import Version -from pythonforandroid.logger import shprint +from pythonforandroid.logger import shprint, error from pythonforandroid.recipe import Recipe from pythonforandroid.util import ( BuildInterruptingException, @@ -48,6 +48,16 @@ class HostPython3Recipe(Recipe): patches = ["fix_ensurepip.patch"] + # apply version guard + def download(self): + python_recipe = Recipe.get_recipe("python3", self.ctx) + if python_recipe.version != self.version: + error( + f"python3 should have same version as hostpython3, {python_recipe.version} != {self.version}" + ) + exit(1) + super().download() + @property def _exe_name(self): ''' diff --git a/pythonforandroid/recipes/python3/__init__.py b/pythonforandroid/recipes/python3/__init__.py index 3ff1f147b0..c0040a7541 100644 --- a/pythonforandroid/recipes/python3/__init__.py +++ b/pythonforandroid/recipes/python3/__init__.py @@ -55,36 +55,45 @@ class Python3Recipe(TargetPythonRecipe): ''' version = '3.14.2' - _p_version = Version(version) url = 'https://github.com/python/cpython/archive/refs/tags/v{version}.tar.gz' name = 'python3' - patches = [ - 'patches/pyconfig_detection.patch', - 'patches/reproducible-buildinfo.diff', - ] + @property + def _p_version(self): + # as version is dynamic + return Version(self.version) - if _p_version.major == 3 and _p_version.minor == 7: - patches += [ - 'patches/py3.7.1_fix-ctypes-util-find-library.patch', - 'patches/py3.7.1_fix-zlib-version.patch', + @property + def patches(self): + patches = [ + 'patches/pyconfig_detection.patch', + 'patches/reproducible-buildinfo.diff', ] + _p_version = self._p_version - if 8 <= _p_version.minor <= 10: - patches.append('patches/py3.8.1.patch') + if _p_version.major == 3 and _p_version.minor == 7: + patches += [ + 'patches/py3.7.1_fix-ctypes-util-find-library.patch', + 'patches/py3.7.1_fix-zlib-version.patch', + ] - if _p_version.minor >= 11: - patches.append('patches/cpython-311-ctypes-find-library.patch') + if 8 <= _p_version.minor <= 10: + patches.append('patches/py3.8.1.patch') - if _p_version.minor >= 14: - patches.append('patches/3.14_armv7l_fix.patch') - patches.append('patches/3.14_fix_remote_debug.patch') + if _p_version.minor >= 11: + patches.append('patches/cpython-311-ctypes-find-library.patch') - if shutil.which('lld') is not None: - if _p_version.minor == 7: - patches.append("patches/py3.7.1_fix_cortex_a8.patch") - elif _p_version.minor >= 8: - patches.append("patches/py3.8.1_fix_cortex_a8.patch") + if _p_version.minor >= 14: + patches.append('patches/3.14_armv7l_fix.patch') + patches.append('patches/3.14_fix_remote_debug.patch') + + if shutil.which('lld') is not None: + if _p_version.minor == 7: + patches.append("patches/py3.7.1_fix_cortex_a8.patch") + elif _p_version.minor >= 8: + patches.append("patches/py3.8.1_fix_cortex_a8.patch") + + return patches depends = ['hostpython3', 'sqlite3', 'openssl', 'libffi'] # those optional depends allow us to build python compression modules: @@ -116,11 +125,6 @@ class Python3Recipe(TargetPythonRecipe): 'ac_cv_header_bzlib_h=no', ] - if _p_version.minor >= 11: - configure_args.extend([ - '--with-build-python={python_host_bin}', - ]) - '''The configure arguments needed to build the python recipe. Those are used in method :meth:`build_arch` (if not overwritten like python3's recipe does). @@ -317,6 +321,9 @@ def add_flags(include_flags, link_dirs, link_libs): env['ZLIB_VERSION'] = line.replace('#define ZLIB_VERSION ', '') add_flags(' -I' + zlib_includes, ' -L' + zlib_lib_path, ' -lz') + if self._p_version.minor >= 11: + self.configure_args.append('--with-build-python={python_host_bin}') + if self._p_version.minor >= 13 and self.disable_gil: self.configure_args.append("--disable-gil") diff --git a/pythonforandroid/toolchain.py b/pythonforandroid/toolchain.py index 3987647f9b..63f06802f8 100644 --- a/pythonforandroid/toolchain.py +++ b/pythonforandroid/toolchain.py @@ -188,6 +188,7 @@ class NoAbbrevParser(argparse.ArgumentParser): This subclass alternative is follows the suggestion at https://bugs.python.org/issue14910. """ + def _get_option_tuples(self, option_string): return [] @@ -267,6 +268,37 @@ def __init__(self): '--arch', help='The archs to build for.', action='append', default=[]) + generic_parser.add_argument( + '--extra-index-url', + help=( + 'Extra package indexes to look for prebuilt Android wheels. ' + 'Can be used multiple times.' + ), + action='append', + default=[], + dest="extra_index_urls", + ) + + generic_parser.add_argument( + '--skip-prebuilt', + help='Always build from source; do not use prebuilt wheels.', + action='store_true', + default=False, + dest="skip_prebuilt", + ) + + generic_parser.add_argument( + '--use-prebuilt-version-for', + help=( + 'For these packages, ignore pinned versions and use the latest ' + 'prebuilt version from the extra index if available.' + 'Only applies to packages with a recipe.' + ), + action='append', + default=[], + dest="use_prebuilt_version_for", + ) + # Options for specifying the Distribution generic_parser.add_argument( '--dist-name', '--dist_name', @@ -672,6 +704,10 @@ def add_parser(subparsers, *args, **kwargs): self.ctx.activity_class_name = args.activity_class_name self.ctx.service_class_name = args.service_class_name + self.ctx.extra_index_urls = args.extra_index_urls + self.ctx.skip_prebuilt = args.skip_prebuilt + self.ctx.use_prebuilt_version_for = args.use_prebuilt_version_for + # Each subparser corresponds to a method command = args.subparser_name.replace('-', '_') getattr(self, command)(args) diff --git a/tests/test_build.py b/tests/test_build.py index 92055400e6..e0583e750f 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -5,7 +5,7 @@ import jinja2 from pythonforandroid.build import ( - Context, RECOMMENDED_TARGET_API, run_pymodules_install, process_python_modules + Context, RECOMMENDED_TARGET_API, run_pymodules_install, process_python_modules, is_wheel_compatible ) from pythonforandroid.archs import ArchARMv7_a, ArchAarch_64 @@ -29,20 +29,42 @@ def test_run_pymodules_install_optional_project_dir(self): def test_python_module_parser(self): ctx = mock.Mock(recipe_build_order=[]) ctx.archs = [ArchARMv7_a(ctx), ArchAarch_64(ctx)] + ctx.extra_index_urls = [] + ctx.ndk_api = 24 + arch = ctx.archs[0] + # should not alter original module name (like with adding version number) - assert "kivy_garden.frostedglass" in process_python_modules(ctx, ["kivy_garden.frostedglass"]) + assert "kivy_garden.frostedglass" in process_python_modules(ctx, ["kivy_garden.frostedglass"], arch) # should skip urls and other unsupported format modules = ["https://example.com/some.zip", "git+https://github.com/kivy/python-for-android@develop"] - result = process_python_modules(ctx, modules) + result = process_python_modules(ctx, modules, arch) assert modules == result + def test_is_wheel_compatible(self): + ctx = mock.Mock(recipe_build_order=[]) + ctx.archs = [ArchARMv7_a(ctx), ArchAarch_64(ctx)] + ctx.ndk_api = 24 + arch = ctx.archs[0] + + assert is_wheel_compatible("test-7.1.0-0-cp314-cp314-android_24_aarch64.whl", ctx.archs[1], ctx) + assert is_wheel_compatible("test-7.1.0-0-cp314-cp314-android_24_arm.whl", ctx.archs[0], ctx) + assert is_wheel_compatible("certifi-2026.1.4-py3-none-any.whl", arch, ctx) + + # arches are diff + assert not is_wheel_compatible("test-7.1.0-0-cp314-cp314-android_24_aarch64.whl", ctx.archs[0], ctx) + + # other os + assert not is_wheel_compatible("test-7.1.0-0-cp313-cp313-some_other_os.whl", arch, ctx) + assert not is_wheel_compatible("mmh3-5.2.0-cp314-cp314t-win_amd64.whl", arch, ctx) + def test_strip_if_with_debug_symbols(self): ctx = mock.Mock(recipe_build_order=[]) ctx.python_recipe.major_minor_version_string = "3.6" ctx.get_site_packages_dir.return_value = "test-doesntexist" ctx.build_dir = "nonexistant_directory" - ctx.archs = ["arm64"] + ctx.extra_index_urls = [] + ctx.archs = [ArchAarch_64(ctx)] modules = ["mymodule"] project_dir = None From 5054a7e89feb5de401f2037065bafc907d687686 Mon Sep 17 00:00:00 2001 From: Ansh Dadwal Date: Sat, 14 Feb 2026 21:04:57 +0530 Subject: [PATCH 2/5] fix version string --- pythonforandroid/recipe.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pythonforandroid/recipe.py b/pythonforandroid/recipe.py index 1285ca12d3..b981a2132a 100644 --- a/pythonforandroid/recipe.py +++ b/pythonforandroid/recipe.py @@ -1249,8 +1249,10 @@ class PyProjectRecipe(PythonRecipe): def get_pip_name(self): name_str = self.name - if self.name not in self.ctx.use_prebuilt_version_for: - name_str += f"=={self.version}" + if self.name not in self.ctx.use_prebuilt_version_for and self.version is not None: + # Like: v2.3.0 -> 2.3.0 + cleaned_version = self.version.replace("v", "") + name_str += f"=={cleaned_version}" return name_str def get_pip_install_args(self, arch): From 742d3535b8a7e99372aff1e5f84f46df8ecfd7b7 Mon Sep 17 00:00:00 2001 From: Ansh Dadwal Date: Sat, 14 Feb 2026 21:20:14 +0530 Subject: [PATCH 3/5] fix pip args --- pythonforandroid/recipe.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pythonforandroid/recipe.py b/pythonforandroid/recipe.py index b981a2132a..212631631c 100644 --- a/pythonforandroid/recipe.py +++ b/pythonforandroid/recipe.py @@ -1260,10 +1260,12 @@ def get_pip_install_args(self, arch): opts = [ "install", self.get_pip_name(), + "--ignore-installed", "--disable-pip-version-check", "--python-version", python_recipe.version, "--only-binary=:all:", + "--no-deps", ] # add platform tags tags = self.get_wheel_platform_tag(arch.arch) From 59a62c3db4e846a2c05f15bc22105dce372d5a7e Mon Sep 17 00:00:00 2001 From: Ansh Dadwal Date: Sat, 14 Feb 2026 21:29:44 +0530 Subject: [PATCH 4/5] add save_wheel_dir --- pythonforandroid/build.py | 2 ++ pythonforandroid/recipe.py | 6 ++---- pythonforandroid/toolchain.py | 8 ++++++++ 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/pythonforandroid/build.py b/pythonforandroid/build.py index 1c2505c504..fa5fe3e601 100644 --- a/pythonforandroid/build.py +++ b/pythonforandroid/build.py @@ -107,6 +107,8 @@ class Context: use_prebuilt_version_for = [] + save_wheel_dir = '' + @property def packages_path(self): '''Where packages are downloaded before being unpacked''' diff --git a/pythonforandroid/recipe.py b/pythonforandroid/recipe.py index 212631631c..1e958fc4a0 100644 --- a/pythonforandroid/recipe.py +++ b/pythonforandroid/recipe.py @@ -1360,10 +1360,8 @@ def install_wheel(self, arch, built_wheels): ) selected_wheel = join(built_wheel_dir, wheel_tag) - _dev_wheel_dir = environ.get("P4A_WHEEL_DIR", False) - if _dev_wheel_dir: - ensure_dir(_dev_wheel_dir) - shprint(sh.cp, selected_wheel, _dev_wheel_dir) + if exists(self.ctx.save_wheel_dir): + shprint(sh.cp, selected_wheel, self.ctx.save_wheel_dir) info(f"Installing built wheel: {wheel_tag}") destination = self.ctx.get_python_install_dir(arch.arch) diff --git a/pythonforandroid/toolchain.py b/pythonforandroid/toolchain.py index 63f06802f8..75c95aa40c 100644 --- a/pythonforandroid/toolchain.py +++ b/pythonforandroid/toolchain.py @@ -299,6 +299,13 @@ def __init__(self): dest="use_prebuilt_version_for", ) + generic_parser.add_argument( + '--save-wheel-dir', + dest='save_wheel_dir', + default='', + help='Directory to store wheels built by PyProjectRecipe.', + ) + # Options for specifying the Distribution generic_parser.add_argument( '--dist-name', '--dist_name', @@ -707,6 +714,7 @@ def add_parser(subparsers, *args, **kwargs): self.ctx.extra_index_urls = args.extra_index_urls self.ctx.skip_prebuilt = args.skip_prebuilt self.ctx.use_prebuilt_version_for = args.use_prebuilt_version_for + self.ctx.save_wheel_dir = args.save_wheel_dir # Each subparser corresponds to a method command = args.subparser_name.replace('-', '_') From 206839aff49fb063a94d9e2cebc48ffa396b96cd Mon Sep 17 00:00:00 2001 From: Ansh Dadwal Date: Sun, 15 Feb 2026 10:20:36 +0530 Subject: [PATCH 5/5] improve warning --- pythonforandroid/recipe.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/pythonforandroid/recipe.py b/pythonforandroid/recipe.py index 1e958fc4a0..aae14fa693 100644 --- a/pythonforandroid/recipe.py +++ b/pythonforandroid/recipe.py @@ -1281,12 +1281,11 @@ def get_pip_install_args(self, arch): def lookup_prebuilt(self, arch): pip_options = self.get_pip_install_args(arch) # do not install - pip_options.extend(["--dry-run"]) + pip_options.extend(["--dry-run", "-q"]) pip_env = self.get_hostrecipe_env() try: shprint(self._host_recipe.pip, *pip_options, _env=pip_env) - except Exception as e: - warning(f"Lookup fail result: {e}") + except Exception: return False return True