3
3
import argparse
4
4
from copy import deepcopy
5
5
import csv
6
- from dataclasses import dataclass , field
6
+ from dataclasses import dataclass
7
7
from email import generator , message , parser
8
8
from glob import glob
9
9
import jsonschema
46
46
# TODO: break out the build script fragments which get the actual version numbers from the
47
47
# toolchain, and call them here.
48
48
COMPILER_LIBS = {
49
- "libc++_shared.so" : ("chaquopy-libcxx" , "10000 " ),
49
+ "libc++_shared.so" : ("chaquopy-libcxx" , "11000 " ),
50
50
"libomp.so" : ("chaquopy-libomp" , "9.0.9" ),
51
51
}
52
52
55
55
class Abi :
56
56
name : str # Android ABI name.
57
57
tool_prefix : str # GCC target triplet.
58
- cflags : str = field (default = "" )
59
- ldflags : str = field (default = "" )
58
+ api_level : int
60
59
61
- # If any flags are changed, consider also updating target/build-common-tools.sh.
62
60
ABIS = {abi .name : abi for abi in [
63
- Abi ("armeabi-v7a" , "arm-linux-androideabi" ,
64
- cflags = "-march=armv7-a -mfloat-abi=softfp -mfpu=vfpv3-d16 -mthumb" , # See standalone
65
- ldflags = "-march=armv7-a -Wl,--fix-cortex-a8" ), # toolchain docs.
66
- Abi ("arm64-v8a" , "aarch64-linux-android" ),
67
- Abi ("x86" , "i686-linux-android" ),
68
- Abi ("x86_64" , "x86_64-linux-android" ),
61
+ Abi ("armeabi-v7a" , "arm-linux-androideabi" , 16 ),
62
+ Abi ("arm64-v8a" , "aarch64-linux-android" , 21 ),
63
+ Abi ("x86" , "i686-linux-android" , 16 ),
64
+ Abi ("x86_64" , "x86_64-linux-android" , 21 ),
69
65
]}
70
66
71
67
@@ -106,8 +102,12 @@ def main(self):
106
102
sys .exit (1 )
107
103
108
104
def unpack_and_build (self ):
105
+ platform_tag = f"android_{ self .api_level } _{ self .abi .replace ('-' , '_' )} "
106
+ self .non_python_compat_tag = f"py3-none-{ platform_tag } "
109
107
if self .needs_python :
110
108
self .find_python ()
109
+ python_tag = "cp" + self .python .replace ('.' , '' )
110
+ self .compat_tag = f"{ python_tag } -{ python_tag } -{ platform_tag } "
111
111
else :
112
112
self .compat_tag = self .non_python_compat_tag
113
113
@@ -156,17 +156,22 @@ def parse_args(self):
156
156
157
157
ap .add_argument ("--no-reqs" , action = "store_true" , help = "Skip extracting requirements "
158
158
"(any existing build/.../requirements directory will be reused)" )
159
- ap .add_argument ("--toolchain" , metavar = "DIR" , type = abspath , required = True ,
160
- help = "Path to toolchain" )
159
+ ap .add_argument ("--abi" , metavar = "ABI" , required = True , choices = ABIS ,
160
+ help = "Android ABI: choices=%(choices)s" )
161
+ default_api_level = {abi .name : abi .api_level for abi in ABIS .values ()}
162
+ ap .add_argument ("--api-level" , metavar = "LEVEL" ,
163
+ help = f"Android API level: default={ default_api_level } " )
161
164
ap .add_argument ("--python" , metavar = "X.Y" , help = "Python version (required for "
162
165
"Python packages)" ),
163
166
ap .add_argument ("package" , help = f"Name of a package in { RECIPES_DIR } , or if it "
164
167
f"contains a slash, path to a recipe directory" )
165
168
ap .parse_args (namespace = self )
166
169
167
- self .detect_toolchain ()
168
- self .platform_tag = f"android_{ self .api_level } _{ self .abi .replace ('-' , '_' )} "
169
- self .non_python_compat_tag = f"py3-none-{ self .platform_tag } "
170
+ if not self .api_level :
171
+ self .api_level = default_api_level [self .abi ]
172
+ self .standard_libs = sum ((names for min_level , names in STANDARD_LIBS
173
+ if self .api_level >= min_level ),
174
+ start = [])
170
175
171
176
def find_python (self ):
172
177
if self .python is None :
@@ -185,17 +190,16 @@ def find_python(self):
185
190
except ValueError :
186
191
raise ERROR
187
192
188
- self . python_include_dir = f"{ self . toolchain } /sysroot/usr/include/ python{ self . python } "
189
- assert_isdir ( self .python_include_dir )
190
- libpython = f"libpython { self . python } .so"
191
- self . python_lib = f" { self .toolchain } /sysroot/usr/lib/ { libpython } "
192
- assert_exists ( self . python_lib )
193
- self .standard_libs . append ( libpython )
193
+ target_dir = abspath ( f"{ PYPI_DIR } /../../maven/com/chaquo/ python/target" )
194
+ versions = [ ver for ver in os . listdir ( target_dir ) if ver . startswith ( self .python )]
195
+ if not versions :
196
+ raise CommandError ( f"Can't find Python { self .python } in { target_dir } " )
197
+ max_ver = max ( versions , key = lambda ver : map ( int , re . split ( r"[.-]" , ver )) )
198
+ self .python_maven_dir = f" { target_dir } / { max_ver } "
194
199
200
+ # Many setup.py scripts will behave differently depending on the Python version,
201
+ # so we run pip with a matching version.
195
202
self .pip = f"python{ self .python } -m pip --disable-pip-version-check"
196
- self .compat_tag = (f"cp{ self .python .replace ('.' , '' )} -"
197
- f"cp{ self .python .replace ('.' , '' )} -"
198
- f"{ self .platform_tag } " )
199
203
200
204
def unpack_source (self ):
201
205
source = self .meta ["source" ]
@@ -309,11 +313,13 @@ def build_wheel(self):
309
313
310
314
def extract_requirements (self ):
311
315
ensure_empty (self .reqs_dir )
312
- reqs = self .get_requirements ("host" )
313
- if not reqs :
314
- return
316
+ for subdir in ["include" , "lib" ]:
317
+ ensure_dir (f"{ self .reqs_dir } /chaquopy/{ subdir } " )
318
+ self .create_dummy_libs ()
319
+ if self .needs_python :
320
+ self .extract_python ()
315
321
316
- for package , version in reqs :
322
+ for package , version in self . get_requirements ( "host" ) :
317
323
dist_dir = f"{ PYPI_DIR } /dist/{ normalize_name_pypi (package )} "
318
324
matches = []
319
325
if exists (dist_dir ):
@@ -352,14 +358,34 @@ def extract_requirements(self):
352
358
(r"^(lib.*?)\d+\.so$" , r"\1.so" ), # e.g. libpng
353
359
(r"^(lib.*)_chaquopy\.so$" , r"\1.so" )] # e.g. libjpeg
354
360
reqs_lib_dir = f"{ self .reqs_dir } /chaquopy/lib"
355
- if exists (reqs_lib_dir ):
356
- for filename in os .listdir (reqs_lib_dir ):
357
- for pattern , repl in SONAME_PATTERNS :
358
- link_filename = re .sub (pattern , repl , filename )
359
- if link_filename in self .standard_libs :
360
- continue # e.g. torch has libc10.so, which would become libc.so.
361
- if link_filename != filename :
362
- run (f"ln -s { filename } { reqs_lib_dir } /{ link_filename } " )
361
+ for filename in os .listdir (reqs_lib_dir ):
362
+ for pattern , repl in SONAME_PATTERNS :
363
+ link_filename = re .sub (pattern , repl , filename )
364
+ if link_filename in self .standard_libs :
365
+ continue # e.g. torch has libc10.so, which would become libc.so.
366
+ if link_filename != filename :
367
+ run (f"ln -s { filename } { reqs_lib_dir } /{ link_filename } " )
368
+
369
+ # On Android, some libraries are incorporated into libc. Create empty .a files so we
370
+ # don't have to patch everything that links against them.
371
+ def create_dummy_libs (self ):
372
+ for name in ["pthread" , "rt" ]:
373
+ run (f"ar rc { self .reqs_dir } /chaquopy/lib/lib{ name } .a" )
374
+
375
+ def extract_python (self ):
376
+ run (f"unzip -q -d { self .reqs_dir } /chaquopy "
377
+ f"{ self .python_maven_dir } /target-*-{ self .abi } .zip "
378
+ f"include/* jniLibs/*" )
379
+ run (f"mv { self .reqs_dir } /chaquopy/jniLibs/{ self .abi } /* { self .reqs_dir } /chaquopy/lib" ,
380
+ shell = True )
381
+ run (f"rm -r { self .reqs_dir } /chaquopy/jniLibs" )
382
+
383
+ self .python_include_dir = f"{ self .reqs_dir } /chaquopy/include/python{ self .python } "
384
+ assert_exists (self .python_include_dir )
385
+ libpython = f"libpython{ self .python } .so"
386
+ self .python_lib = f"{ self .reqs_dir } /chaquopy/lib/{ libpython } "
387
+ assert_exists (self .python_lib )
388
+ self .standard_libs .append (libpython )
363
389
364
390
def build_with_script (self , build_script ):
365
391
prefix_dir = f"{ self .build_dir } /prefix"
@@ -379,13 +405,21 @@ def build_with_pip(self):
379
405
wheel_filename , = glob ("*.whl" ) # Note comma
380
406
return abspath (wheel_filename )
381
407
382
- # The environment variables set in this function are used for native builds by
383
- # distutils.sysconfig.customize_compiler. To make builds as consistent as possible, we
384
- # define values for all environment variables used by distutils in any supported Python
385
- # version. We also define some common variables like LD and STRIP which aren't used
386
- # by distutils, but might be used by custom build scripts.
387
408
def update_env (self ):
388
409
env = {}
410
+ for line in run (
411
+ f"abi={ self .abi } ; api_level={ self .api_level } ; prefix={ self .reqs_dir } /chaquopy; "
412
+ f". { PYPI_DIR } /../../target/build-common.sh; export" ,
413
+ shell = True , text = True , capture_output = True
414
+ ).stdout .splitlines ():
415
+ match = re .search (r"^export (\w+)='(.*)'$" , line )
416
+ if match :
417
+ key , value = match .groups ()
418
+ if os .environ .get (key ) != value :
419
+ env [key ] = value
420
+
421
+ # See env/bin/pkg-config.
422
+ del env ["PKG_CONFIG" ]
389
423
390
424
env_dir = f"{ PYPI_DIR } /env"
391
425
env ["PATH" ] = os .pathsep .join ([
@@ -400,72 +434,16 @@ def update_env(self):
400
434
pythonpath .append (os .environ ["PYTHONPATH" ])
401
435
env ["PYTHONPATH" ] = os .pathsep .join (pythonpath )
402
436
403
- abi = ABIS [self .abi ]
404
- for tool in ["ar" , "as" , ("cc" , "gcc" ), ("cxx" , "g++" ),
405
- ("fc" , "gfortran" ), # Used by openblas
406
- ("f77" , "gfortran" ), ("f90" , "gfortran" ), # Used by numpy.distutils
407
- "ld" , "nm" , "ranlib" , "readelf" , "strip" ]:
408
- var , suffix = (tool , tool ) if isinstance (tool , str ) else tool
409
- filename = f"{ self .toolchain } /bin/{ abi .tool_prefix } -{ suffix } "
410
- if suffix != "gfortran" : # Only required for SciPy and OpenBLAS.
411
- assert_exists (filename )
412
- env [var .upper ()] = filename
413
- env ["LDSHARED" ] = f"{ env ['CC' ]} -shared"
437
+ # This flag often catches errors in .so files which would otherwise be delayed
438
+ # until runtime. (Some of the more complex build.sh scripts need to remove this, or
439
+ # use it more selectively.)
440
+ env ["LDFLAGS" ] += " -Wl,--no-undefined"
414
441
415
- # If any flags are changed, consider also updating target/build-common-tools.sh.
416
- gcc_flags = " " .join ([
417
- "-fPIC" , # See standalone toolchain docs, and note below about -pie
418
- abi .cflags ])
419
- env ["CFLAGS" ] = gcc_flags
420
- env ["FARCH" ] = gcc_flags # Used by numpy.distutils Fortran compilation.
421
-
422
- # If any flags are changed, consider also updating target/build-common-tools.sh.
423
- #
424
- # Not including -pie despite recommendation in standalone toolchain docs, because it
425
- # causes the linker to forget it's supposed to be building a shared library
426
- # (https://lists.debian.org/debian-devel/2016/05/msg00302.html). It can be added
427
- # manually for packages which require it (e.g. hdf5).
428
- env ["LDFLAGS" ] = " " .join ([
429
- # This flag often catches errors in .so files which would otherwise be delayed
430
- # until runtime. (Some of the more complex build.sh scripts need to remove this, or
431
- # use it more selectively.)
432
- #
433
- # I tried also adding -Werror=implicit-function-declaration to CFLAGS, but that
434
- # breaks too many things (e.g. `has_function` in distutils.ccompiler).
435
- "-Wl,--no-undefined" ,
436
-
437
- # This currently only affects armeabi-v7a, but could affect other ABIs if the
438
- # unwinder implementation changes in a future NDK version
439
- # (https://android.googlesource.com/platform/ndk/+/ndk-release-r21/docs/BuildSystemMaintainers.md#Unwinding).
440
- # See also comment in build-fortran.sh.
441
- "-Wl,--exclude-libs,libgcc.a" , # NDK r18
442
- "-Wl,--exclude-libs,libgcc_real.a" , # NDK r19 and later
443
- "-Wl,--exclude-libs,libunwind.a" ,
444
-
445
- # Many packages get away with omitting this on standard Linux.
446
- "-lm" ,
447
-
448
- abi .ldflags ])
449
-
450
- reqs_prefix = f"{ self .reqs_dir } /chaquopy"
451
- if exists (reqs_prefix ):
452
- env ["PKG_CONFIG_LIBDIR" ] = f"{ reqs_prefix } /lib/pkgconfig"
453
- env ["CFLAGS" ] += f" -I{ reqs_prefix } /include"
454
-
455
- # --rpath-link only affects arm64, because it's the only ABI which uses ld.bfd. The
456
- # others all use ld.gold, which doesn't try to resolve transitive shared library
457
- # dependencies. When we upgrade to a later version of the NDK which uses LLD, we
458
- # can probably remove this flag, along with all requirements in meta.yaml files
459
- # which are tagged with "ld.bfd".
460
- env ["LDFLAGS" ] += (f" -L{ reqs_prefix } /lib"
461
- f" -Wl,--rpath-link,{ reqs_prefix } /lib" )
462
-
463
- env ["ARFLAGS" ] = "rc"
464
-
465
- # Set all unused overridable variables to the empty string to prevent the host Python
466
- # values (if any) from taking effect.
467
- for var in ["CPPFLAGS" , "CXXFLAGS" ]:
468
- env [var ] = ""
442
+ # Set all other variables used by distutils to prevent the host Python values (if
443
+ # any) from taking effect.
444
+ env ["CPPFLAGS" ] = ""
445
+ env ["CXXFLAGS" ] = ""
446
+ env ["LDSHARED" ] = f"{ env ['CC' ]} -shared"
469
447
470
448
# Use -idirafter so that package-specified -I directories take priority (e.g. in grpcio
471
449
# and typed-ast).
@@ -498,9 +476,16 @@ def update_env(self):
498
476
if self .needs_cmake :
499
477
self .generate_cmake_toolchain ()
500
478
501
- # Define the minimum necessary to keep CMake happy. To avoid duplication, we still want to
502
- # configure as much as possible via update_env.
503
479
def generate_cmake_toolchain (self ):
480
+ raise CommandError ("TODO: CMake support needs to be updated." )
481
+ # TODO: Generate a toolchain file which sets ANDROID_ABI, ANDROID_PLATFORM, and
482
+ # any other necessary variables (see
483
+ # https://developer.android.com/ndk/guides/cmake#build-command -- though these
484
+ # might not all be necessary with the current NDK), and then includes the
485
+ # toolchain file from the NDK. To avoid needing to patch every package that uses
486
+ # CMake, we can then set the CMAKE_TOOLCHAIN_FILE environment variable, which was
487
+ # added in CMake 3.21.
488
+
504
489
# See build/cmake/android.toolchain.cmake in the NDK.
505
490
CMAKE_PROCESSORS = {
506
491
"armeabi-v7a" : "armv7-a" ,
@@ -510,6 +495,9 @@ def generate_cmake_toolchain(self):
510
495
}
511
496
clang_target = f"{ ABIS [self .abi ].tool_prefix } { self .api_level } " .replace ("arm-" , "armv7a-" )
512
497
498
+ # Define the minimum necessary to keep CMake happy. To avoid confusion about where
499
+ # settings are coming from, we still want to configure as much as possible via
500
+ # environment variables.
513
501
toolchain_filename = join (self .build_dir , "chaquopy.toolchain.cmake" )
514
502
log (f"Generating { toolchain_filename } " )
515
503
with open (toolchain_filename , "w" ) as toolchain_file :
@@ -555,31 +543,6 @@ def generate_cmake_toolchain(self):
555
543
SET(PYTHON_MODULE_EXTENSION .so)
556
544
""" ), file = toolchain_file )
557
545
558
- def detect_toolchain (self ):
559
- clang = f"{ self .toolchain } /bin/clang"
560
- for word in open (clang ).read ().split ():
561
- if word .startswith ("--target" ):
562
- match = re .search (r"^--target=(.+?)(\d+)$" , word )
563
- if not match :
564
- raise CommandError (f"Couldn't parse '{ word } ' in { clang } " )
565
-
566
- for abi in ABIS .values ():
567
- if match [1 ] == abi .tool_prefix .replace ("arm-" , "armv7a-" ):
568
- self .abi = abi .name
569
- break
570
- else :
571
- raise CommandError (f"Unknown triplet '{ match [1 ]} ' in { clang } " )
572
-
573
- self .api_level = int (match [2 ])
574
- self .standard_libs = sum ((names for min_level , names in STANDARD_LIBS
575
- if self .api_level >= min_level ),
576
- start = [])
577
- break
578
- else :
579
- raise CommandError (f"Couldn't find target in { clang } " )
580
-
581
- log (f"Toolchain ABI: { self .abi } , API level: { self .api_level } " )
582
-
583
546
def fix_wheel (self , in_filename ):
584
547
tmp_dir = f"{ self .build_dir } /fix_wheel"
585
548
ensure_empty (tmp_dir )
@@ -796,10 +759,15 @@ def normalize_version(version):
796
759
return str (pkg_resources .parse_version (version ))
797
760
798
761
799
- def run (command , check = True ):
762
+ def run (command , ** kwargs ):
800
763
log (command )
764
+ kwargs .setdefault ("check" , True )
765
+ kwargs .setdefault ("shell" , False )
766
+
767
+ if isinstance (command , str ) and not kwargs ["shell" ]:
768
+ command = shlex .split (command )
801
769
try :
802
- return subprocess .run (shlex . split ( command ), check = check )
770
+ return subprocess .run (command , ** kwargs )
803
771
except subprocess .CalledProcessError as e :
804
772
raise CommandError (f"Command returned exit status { e .returncode } " )
805
773
0 commit comments