Skip to content

Commit 8ec52ce

Browse files
committed
toolchain: auto resolve deps
1 parent 6494ac1 commit 8ec52ce

File tree

6 files changed

+214
-14
lines changed

6 files changed

+214
-14
lines changed

pythonforandroid/build.py

Lines changed: 120 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,24 @@
22
import copy
33
import glob
44
import os
5+
import json
6+
import tempfile
57
from os import environ
68
from os.path import (
7-
abspath, join, realpath, dirname, expanduser, exists
9+
abspath, join, realpath, dirname, expanduser, exists, basename
810
)
911
import re
1012
import shutil
1113
import subprocess
1214

1315
import sh
1416

17+
from packaging.utils import parse_wheel_filename
18+
from packaging.requirements import Requirement
19+
1520
from pythonforandroid.androidndk import AndroidNDK
1621
from pythonforandroid.archs import ArchARM, ArchARMv7_a, ArchAarch_64, Archx86, Archx86_64
17-
from pythonforandroid.logger import (info, warning, info_notify, info_main, shprint)
22+
from pythonforandroid.logger import (info, warning, info_notify, info_main, shprint, Out_Style, Out_Fore)
1823
from pythonforandroid.pythonpackage import get_package_name
1924
from pythonforandroid.recipe import CythonRecipe, Recipe
2025
from pythonforandroid.recommendations import (
@@ -90,6 +95,8 @@ class Context:
9095

9196
recipe_build_order = None # Will hold the list of all built recipes
9297

98+
python_modules = None # Will hold resolved pure python packages
99+
93100
symlink_bootstrap_files = False # If True, will symlink instead of copying during build
94101

95102
java_build_tool = 'auto'
@@ -444,6 +451,12 @@ def has_package(self, name, arch=None):
444451
# Failed to look up any meaningful name.
445452
return False
446453

454+
# normalize name to remove version tags
455+
try:
456+
name = Requirement(name).name
457+
except Exception:
458+
pass
459+
447460
# Try to look up recipe by name:
448461
try:
449462
recipe = Recipe.get_recipe(name, self)
@@ -649,6 +662,107 @@ def run_setuppy_install(ctx, project_dir, env=None, arch=None):
649662
os.remove("._tmp_p4a_recipe_constraints.txt")
650663

651664

665+
def is_wheel_platform_independent(whl_name):
666+
name, version, build, tags = parse_wheel_filename(whl_name)
667+
return all(tag.platform == "any" for tag in tags)
668+
669+
670+
def process_python_modules(ctx, modules):
671+
"""Use pip --dry-run to resolve dependencies and filter for pure-Python packages
672+
"""
673+
modules = list(modules)
674+
build_order = list(ctx.recipe_build_order)
675+
676+
_requirement_names = []
677+
processed_modules = []
678+
679+
for module in modules+build_order:
680+
try:
681+
# we need to normalize names
682+
# eg Requests>=2.0 becomes requests
683+
_requirement_names.append(Requirement(module).name)
684+
except Exception:
685+
# name parsing failed; skip processing this module via pip
686+
processed_modules.append(module)
687+
if module in modules:
688+
modules.remove(module)
689+
690+
if len(processed_modules) > 0:
691+
warning(f'Ignored by module resolver : {processed_modules}')
692+
693+
# preserve the original module list
694+
processed_modules.extend(modules)
695+
696+
if len(modules) == 0:
697+
return modules
698+
699+
# temp file for pip report
700+
fd, path = tempfile.mkstemp()
701+
os.close(fd)
702+
703+
# setup hostpython recipe
704+
host_recipe = Recipe.get_recipe("hostpython3", ctx)
705+
706+
env = environ.copy()
707+
_python_path = host_recipe.get_path_to_python()
708+
libdir = glob.glob(join(_python_path, "build", "lib*"))
709+
env['PYTHONPATH'] = host_recipe.site_dir + ":" + join(
710+
_python_path, "Modules") + ":" + (libdir[0] if libdir else "")
711+
712+
shprint(
713+
host_recipe.pip, 'install', *modules,
714+
'--dry-run', '--break-system-packages', '--ignore-installed',
715+
'--report', path, '-q', _env=env
716+
)
717+
718+
with open(path, "r") as f:
719+
report = json.load(f)
720+
721+
os.remove(path)
722+
723+
info('Extra resolved pure python dependencies :')
724+
725+
ignored_str = " (ignored)"
726+
# did we find any non pure python package?
727+
any_not_pure_python = False
728+
729+
# just for style
730+
info(" ")
731+
for module in report["install"]:
732+
733+
mname = module["metadata"]["name"]
734+
mver = module["metadata"]["version"]
735+
filename = basename(module["download_info"]["url"])
736+
pure_python = True
737+
738+
if (filename.endswith(".whl") and not is_wheel_platform_independent(filename)):
739+
any_not_pure_python = True
740+
pure_python = False
741+
742+
# does this module matches any recipe name?
743+
if mname.lower() in _requirement_names:
744+
continue
745+
746+
color = Out_Fore.GREEN if pure_python else Out_Fore.RED
747+
ignored = "" if pure_python else ignored_str
748+
749+
info(
750+
f" {color}{mname}{Out_Fore.WHITE} : "
751+
f"{Out_Style.BRIGHT}{mver}{Out_Style.RESET_ALL}"
752+
f"{ignored}"
753+
)
754+
755+
if pure_python:
756+
processed_modules.append(f"{mname}=={mver}")
757+
info(" ")
758+
759+
if any_not_pure_python:
760+
warning("Some packages were ignored because they are not pure Python.")
761+
warning("To install the ignored packages, explicitly list them in your requirements file.")
762+
763+
return processed_modules
764+
765+
652766
def run_pymodules_install(ctx, arch, modules, project_dir=None,
653767
ignore_setup_py=False):
654768
""" This function will take care of all non-recipe things, by:
@@ -663,6 +777,10 @@ def run_pymodules_install(ctx, arch, modules, project_dir=None,
663777

664778
info('*** PYTHON PACKAGE / PROJECT INSTALL STAGE FOR ARCH: {} ***'.format(arch))
665779

780+
# don't run process_python_modules in tests
781+
if ctx.recipe_build_order.__class__.__name__ != "Mock":
782+
modules = process_python_modules(ctx, modules)
783+
666784
modules = [m for m in modules if ctx.not_has_package(m, arch)]
667785

668786
# We change current working directory later, so this has to be an absolute

pythonforandroid/recipe.py

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -979,7 +979,7 @@ def get_recipe_env(self, arch=None, with_flags_in_cc=True):
979979
env['LANG'] = "en_GB.UTF-8"
980980

981981
# Binaries made by packages installed by pip
982-
self.patch_shebangs(self._host_recipe.local_bin, self.real_hostpython_location)
982+
self.patch_shebangs(self._host_recipe.local_bin, self._host_recipe.python_exe)
983983
env["PATH"] = self._host_recipe.local_bin + ":" + self._host_recipe.site_bin + ":" + env["PATH"]
984984

985985
host_env = self.get_hostrecipe_env(arch)
@@ -1022,10 +1022,9 @@ def install_python_package(self, arch, name=None, env=None, is_dir=True):
10221022

10231023
info('Installing {} into site-packages'.format(self.name))
10241024

1025-
hostpython = sh.Command(self.hostpython_location)
10261025
hpenv = env.copy()
10271026
with current_directory(self.get_build_dir(arch.arch)):
1028-
shprint(hostpython, '-m', 'pip', 'install', '.',
1027+
shprint(self._host_recipe.pip, 'install', '.',
10291028
'--compile', '--target',
10301029
self.ctx.get_python_install_dir(arch.arch),
10311030
_env=hpenv, *self.setup_extra_args
@@ -1045,8 +1044,7 @@ def hostpython_site_dir(self):
10451044

10461045
def install_hostpython_package(self, arch):
10471046
env = self.get_hostrecipe_env(arch)
1048-
real_hostpython = sh.Command(self.real_hostpython_location)
1049-
shprint(real_hostpython, '-m', 'pip', 'install', '.',
1047+
shprint(self._host_recipe.pip, 'install', '.',
10501048
'--compile',
10511049
'--root={}'.format(self._host_recipe.site_root),
10521050
_env=env, *self.setup_extra_args)
@@ -1075,8 +1073,7 @@ def install_hostpython_prerequisites(self, packages=None, force_upgrade=True):
10751073
pip_options.append("--upgrade")
10761074
# Use system's pip
10771075
pip_env = self.get_hostrecipe_env()
1078-
pip_env["HOME"] = "/tmp"
1079-
shprint(sh.Command(self.real_hostpython_location), "-m", "pip", *pip_options, _env=pip_env)
1076+
shprint(self._host_recipe.pip, *pip_options, _env=pip_env)
10801077

10811078
def restore_hostpython_prerequisites(self, packages):
10821079
_packages = []
@@ -1270,10 +1267,14 @@ def get_recipe_env(self, arch, **kwargs):
12701267
return env
12711268

12721269
def get_wheel_platform_tag(self, arch):
1270+
# https://peps.python.org/pep-0738/#packaging
1271+
# official python only supports 64 bit:
1272+
# android_21_arm64_v8a
1273+
# android_21_x86_64
12731274
return f"android_{self.ctx.ndk_api}_" + {
1274-
"armeabi-v7a": "arm",
1275-
"arm64-v8a": "aarch64",
1275+
"arm64-v8a": "arm64_v8a",
12761276
"x86_64": "x86_64",
1277+
"armeabi-v7a": "arm",
12771278
"x86": "i686",
12781279
}[arch.arch]
12791280

pythonforandroid/recipes/hostpython3/__init__.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ class HostPython3Recipe(Recipe):
3636
:class:`~pythonforandroid.python.HostPythonRecipe`
3737
'''
3838

39-
version = '3.14.0'
39+
version = '3.14.2'
4040

4141
url = 'https://github.com/python/cpython/archive/refs/tags/v{version}.tar.gz'
4242
'''The default url to download our host python recipe. This url will
@@ -46,6 +46,8 @@ class HostPython3Recipe(Recipe):
4646
'''Specify the sub build directory for the hostpython3 recipe. Defaults
4747
to ``native-build``.'''
4848

49+
patches = ["fix_ensurepip.patch"]
50+
4951
@property
5052
def _exe_name(self):
5153
'''
@@ -113,6 +115,33 @@ def site_dir(self):
113115
f"usr/local/lib/python{p_version.major}.{p_version.minor}/site-packages/"
114116
)
115117

118+
@property
119+
def _pip(self):
120+
return join(self.local_bin, "pip3")
121+
122+
@property
123+
def pip(self):
124+
return sh.Command(self._pip)
125+
126+
def fix_pip_shebangs(self):
127+
128+
if not os.path.exists(self.local_bin):
129+
return
130+
131+
for filename in os.listdir(self.local_bin):
132+
if not filename.startswith("pip"):
133+
continue
134+
135+
pip_path = os.path.join(self.local_bin, filename)
136+
137+
with open(pip_path, "rb") as file:
138+
file_lines = file.read().splitlines()
139+
140+
file_lines[0] = f"#!{self.python_exe}".encode()
141+
142+
with open(pip_path, "wb") as file:
143+
file.write(b"\n".join(file_lines) + b"\n")
144+
116145
def build_arch(self, arch):
117146
env = self.get_recipe_env(arch)
118147

@@ -160,11 +189,14 @@ def build_arch(self, arch):
160189

161190
ensure_dir(self.site_root)
162191
self.ctx.hostpython = self.python_exe
192+
163193
if build_configured:
194+
164195
shprint(
165196
sh.Command(self.python_exe), "-m", "ensurepip", "--root", self.site_root, "-U",
166-
_env={"HOME": "/tmp"}
197+
_env={"HOME": "/tmp", "PATH": self.local_bin}
167198
)
199+
self.fix_pip_shebangs()
168200

169201

170202
recipe = HostPython3Recipe()
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
diff '--color=auto' -uNr cpython-3.14.0/Lib/ensurepip/__init__.py cpython-3.14.0.mod/Lib/ensurepip/__init__.py
2+
--- cpython-3.14.0/Lib/ensurepip/__init__.py 2025-10-07 15:04:52.000000000 +0530
3+
+++ cpython-3.14.0.mod/Lib/ensurepip/__init__.py 2025-12-20 18:18:13.884914683 +0530
4+
@@ -69,7 +69,15 @@
5+
code = f"""
6+
import runpy
7+
import sys
8+
-sys.path = {additional_paths or []} + sys.path
9+
+
10+
+# tell ensurepip to ignore site-packages
11+
+
12+
+paths = []
13+
+for path in sys.path:
14+
+ if "site-packages" not in path:
15+
+ paths.append(path)
16+
+
17+
+sys.path = {additional_paths or []} + paths
18+
sys.argv[1:] = {args}
19+
runpy.run_module("pip", run_name="__main__", alter_sys=True)
20+
"""

pythonforandroid/recipes/python3/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ class Python3Recipe(TargetPythonRecipe):
5454
:class:`~pythonforandroid.python.GuestPythonRecipe`
5555
'''
5656

57-
version = '3.14.0'
57+
version = '3.14.2'
5858
_p_version = Version(version)
5959
url = 'https://github.com/python/cpython/archive/refs/tags/v{version}.tar.gz'
6060
name = 'python3'
@@ -78,6 +78,7 @@ class Python3Recipe(TargetPythonRecipe):
7878

7979
if _p_version.minor >= 14:
8080
patches.append('patches/3.14_armv7l_fix.patch')
81+
patches.append('patches/3.14_fix_remote_debug.patch')
8182

8283
if shutil.which('lld') is not None:
8384
if _p_version.minor == 7:
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
diff '--color=auto' -uNr cpython-3.14.2/Modules/_remote_debugging_module.c cpython-3.14.2.mod/Modules/_remote_debugging_module.c
2+
--- cpython-3.14.2/Modules/_remote_debugging_module.c 2025-12-05 22:19:16.000000000 +0530
3+
+++ cpython-3.14.2.mod/Modules/_remote_debugging_module.c 2025-12-13 20:22:44.011497868 +0530
4+
@@ -812,7 +812,9 @@
5+
PyErr_SetString(PyExc_RuntimeError, "Failed to find the AsyncioDebug section in the process.");
6+
_PyErr_ChainExceptions1(exc);
7+
}
8+
-#elif defined(__linux__)
9+
+
10+
+// https://github.com/python/cpython/commit/1963e701001839389cfb1b11d803b0743f4705d7
11+
+#elif defined(__linux__) && HAVE_PROCESS_VM_READV
12+
// On Linux, search for asyncio debug in executable or DLL
13+
address = search_linux_map_for_section(handle, "AsyncioDebug", "_asyncio.cpython");
14+
if (address == 0) {
15+
diff '--color=auto' -uNr cpython-3.14.2/Python/remote_debug.h cpython-3.14.2.mod/Python/remote_debug.h
16+
--- cpython-3.14.2/Python/remote_debug.h 2025-12-05 22:19:16.000000000 +0530
17+
+++ cpython-3.14.2.mod/Python/remote_debug.h 2025-12-13 20:23:27.917518543 +0530
18+
@@ -881,7 +881,9 @@
19+
handle->pid);
20+
_PyErr_ChainExceptions1(exc);
21+
}
22+
-#elif defined(__linux__)
23+
+
24+
+// https://github.com/python/cpython/commit/1963e701001839389cfb1b11d803b0743f4705d7
25+
+#elif defined(__linux__) && HAVE_PROCESS_VM_READV
26+
// On Linux, search for 'python' in executable or DLL
27+
address = search_linux_map_for_section(handle, "PyRuntime", "python");
28+
if (address == 0) {

0 commit comments

Comments
 (0)