• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

kivy / python-for-android / 40578488838

27 Oct 2026 08:29PM UTC coverage: 63.297% (+0.02%) from 63.278%
40578488838

Pull #3400

github

web-flow
Merge ebd38e747 into 26b6ef072
Pull Request #3400: Fix android.broadcast.BroadcastReceiver on Android 14+: pass RECEIVER_EXPORTED / RECEIVER_NOT_EXPORTED

1859 of 3196 branches covered (58.17%)

Branch coverage included in aggregate %.

5448 of 8348 relevant lines covered (65.26%)

3.91 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

51.36
/pythonforandroid/build.py
1
import glob
6 ✔
2
import os
6 ✔
3
import json
6 ✔
4
import tempfile
6 ✔
5
from os import environ
6 ✔
6
from os.path import (
6 ✔
7
    abspath, join, realpath, dirname, expanduser, exists, basename
8
)
9
import re
6 ✔
10
import shutil
6 ✔
11
import subprocess
6 ✔
12
import sys
6 ✔
13

14
import sh
6 ✔
15

16
from packaging.utils import parse_wheel_filename
6 ✔
17
from packaging.requirements import Requirement
6 ✔
18

19
from pythonforandroid.androidndk import AndroidNDK
6 ✔
20
from pythonforandroid.archs import ArchARM, ArchARMv7_a, ArchAarch_64, Archx86, Archx86_64
6 ✔
21
from pythonforandroid.logger import (info, warning, info_notify, info_main, shprint, Out_Style, Out_Fore)
6 ✔
22
from pythonforandroid.pythonpackage import get_package_name
6 ✔
23
from pythonforandroid.recipe import Recipe, PyProjectRecipe
6 ✔
24
from pythonforandroid.recommendations import (
6 ✔
25
    check_ndk_version, check_target_api, check_ndk_api,
26
    RECOMMENDED_NDK_API, RECOMMENDED_TARGET_API)
27
from pythonforandroid.util import (
6 ✔
28
    current_directory, ensure_dir,
29
    BuildInterruptingException
30
)
31

32

33
def get_targets(sdk_dir):
6 ✔
34
    if exists(join(sdk_dir, 'cmdline-tools', 'latest', 'bin', 'avdmanager')):
×
35
        avdmanager = sh.Command(join(sdk_dir, 'cmdline-tools', 'latest', 'bin', 'avdmanager'))
×
36
        targets = avdmanager('list', 'target').split('\n')
×
37

38
    elif exists(join(sdk_dir, 'tools', 'bin', 'avdmanager')):
×
39
        avdmanager = sh.Command(join(sdk_dir, 'tools', 'bin', 'avdmanager'))
×
40
        targets = avdmanager('list', 'target').split('\n')
×
41
    elif exists(join(sdk_dir, 'tools', 'android')):
×
42
        android = sh.Command(join(sdk_dir, 'tools', 'android'))
×
43
        targets = android('list').split('\n')
×
44
    else:
45
        raise BuildInterruptingException(
×
46
            'Could not find `android` or `sdkmanager` binaries in Android SDK',
47
            instructions='Make sure the path to the Android SDK is correct')
48
    return targets
×
49

50

51
def get_available_apis(sdk_dir):
6 ✔
52
    targets = get_targets(sdk_dir)
×
53
    apis = [s for s in targets if re.match(r'^ *API level: ', s)]
×
54
    apis = [re.findall(r'[0-9]+', s) for s in apis]
×
55
    apis = [int(s[0]) for s in apis if s]
×
56
    return apis
×
57

58

59
class Context:
6 ✔
60
    '''A build context. If anything will be built, an instance this class
61
    will be instantiated and used to hold all the build state.'''
62

63
    # Whether to make a debug or release build
64
    build_as_debuggable = False
6 ✔
65

66
    # Whether to strip debug symbols in `.so` files
67
    with_debug_symbols = False
6 ✔
68

69
    # Whether to byte-compile the python stdlib and site-packages with -OO
70
    optimize_python = True
6 ✔
71

72
    env = environ.copy()
6 ✔
73
    # the filepath of toolchain.py
74
    root_dir = None
6 ✔
75
    # the root dir where builds and dists will be stored
76
    storage_dir = None
6 ✔
77

78
    # in which bootstraps are copied for building
79
    # and recipes are built
80
    build_dir = None
6 ✔
81

82
    distribution = None
6 ✔
83
    """The Distribution object representing the current build target location."""
6 ✔
84

85
    # the Android project folder where everything ends up
86
    dist_dir = None
6 ✔
87

88
    # Whether setup.py or similar should be used if present:
89
    use_setup_py = False
6 ✔
90

91
    ccache = None  # whether to use ccache
6 ✔
92

93
    ndk = None
6 ✔
94

95
    bootstrap = None
6 ✔
96
    bootstrap_build_dir = None
6 ✔
97

98
    recipe_build_order = None  # Will hold the list of all built recipes
6 ✔
99

100
    python_modules = None  # Will hold resolved pure python packages
6 ✔
101

102
    symlink_bootstrap_files = False  # If True, will symlink instead of copying during build
6 ✔
103

104
    java_build_tool = 'auto'
6 ✔
105

106
    skip_prebuilt = False
6 ✔
107

108
    extra_index_urls = []
6 ✔
109

110
    use_prebuilt_version_for = []
6 ✔
111

112
    save_wheel_dir = ''
6 ✔
113

114
    @property
6 ✔
115
    def packages_path(self):
6 ✔
116
        '''Where packages are downloaded before being unpacked'''
117
        return join(self.storage_dir, 'packages')
6 ✔
118

119
    @property
6 ✔
120
    def templates_dir(self):
6 ✔
121
        return join(self.root_dir, 'templates')
×
122

123
    @property
6 ✔
124
    def libs_dir(self):
6 ✔
125
        """
126
        where Android libs are cached after build
127
        but before being placed in dists
128
        """
129
        # Was previously hardcoded as self.build_dir/libs
130
        directory = join(self.build_dir, 'libs_collections',
6 ✔
131
                         self.bootstrap.distribution.name)
132
        ensure_dir(directory)
6 ✔
133
        return directory
6 ✔
134

135
    @property
6 ✔
136
    def javaclass_dir(self):
6 ✔
137
        # Was previously hardcoded as self.build_dir/java
138
        directory = join(self.build_dir, 'javaclasses',
6 ✔
139
                         self.bootstrap.distribution.name)
140
        ensure_dir(directory)
6 ✔
141
        return directory
6 ✔
142

143
    @property
6 ✔
144
    def aars_dir(self):
6 ✔
145
        directory = join(self.build_dir, 'aars', self.bootstrap.distribution.name)
6 ✔
146
        ensure_dir(directory)
6 ✔
147
        return directory
6 ✔
148

149
    @property
6 ✔
150
    def python_installs_dir(self):
6 ✔
151
        directory = join(self.build_dir, 'python-installs')
6 ✔
152
        ensure_dir(directory)
6 ✔
153
        return directory
6 ✔
154

155
    def get_python_install_dir(self, arch):
6 ✔
156
        return join(self.python_installs_dir, self.bootstrap.distribution.name, arch)
6 ✔
157

158
    def setup_dirs(self, storage_dir):
6 ✔
159
        '''Calculates all the storage and build dirs, and makes sure
160
        the directories exist where necessary.'''
161
        self.storage_dir = expanduser(storage_dir)
6 ✔
162
        if ' ' in self.storage_dir:
6 !
163
            raise ValueError('storage dir path cannot contain spaces, please '
×
164
                             'specify a path with --storage-dir')
165
        self.build_dir = join(self.storage_dir, 'build')
6 ✔
166
        self.dist_dir = join(self.storage_dir, 'dists')
6 ✔
167

168
    def ensure_dirs(self):
6 ✔
169
        ensure_dir(self.storage_dir)
6 ✔
170
        ensure_dir(self.build_dir)
6 ✔
171
        ensure_dir(self.dist_dir)
6 ✔
172
        ensure_dir(join(self.build_dir, 'bootstrap_builds'))
6 ✔
173
        ensure_dir(join(self.build_dir, 'other_builds'))
6 ✔
174

175
    @property
6 ✔
176
    def android_api(self):
6 ✔
177
        '''The Android API being targeted.'''
178
        if self._android_api is None:
6 !
179
            raise ValueError('Tried to access android_api but it has not '
×
180
                             'been set - this should not happen, something '
181
                             'went wrong!')
182
        return self._android_api
6 ✔
183

184
    @android_api.setter
6 ✔
185
    def android_api(self, value):
6 ✔
186
        self._android_api = value
6 ✔
187

188
    @property
6 ✔
189
    def ndk_api(self):
6 ✔
190
        '''The API number compile against'''
191
        if self._ndk_api is None:
6 !
192
            raise ValueError('Tried to access ndk_api but it has not '
×
193
                             'been set - this should not happen, something '
194
                             'went wrong!')
195
        return self._ndk_api
6 ✔
196

197
    @ndk_api.setter
6 ✔
198
    def ndk_api(self, value):
6 ✔
199
        self._ndk_api = value
6 ✔
200

201
    @property
6 ✔
202
    def sdk_dir(self):
6 ✔
203
        '''The path to the Android SDK.'''
204
        if self._sdk_dir is None:
6 !
205
            raise ValueError('Tried to access sdk_dir but it has not '
×
206
                             'been set - this should not happen, something '
207
                             'went wrong!')
208
        return self._sdk_dir
6 ✔
209

210
    @sdk_dir.setter
6 ✔
211
    def sdk_dir(self, value):
6 ✔
212
        self._sdk_dir = value
6 ✔
213

214
    @property
6 ✔
215
    def ndk_dir(self):
6 ✔
216
        '''The path to the Android NDK.'''
217
        if self._ndk_dir is None:
6 !
218
            raise ValueError('Tried to access ndk_dir but it has not '
×
219
                             'been set - this should not happen, something '
220
                             'went wrong!')
221
        return self._ndk_dir
6 ✔
222

223
    @ndk_dir.setter
6 ✔
224
    def ndk_dir(self, value):
6 ✔
225
        self._ndk_dir = value
6 ✔
226

227
    def prepare_build_environment(self,
6 ✔
228
                                  user_sdk_dir,
229
                                  user_ndk_dir,
230
                                  user_android_api,
231
                                  user_ndk_api):
232
        '''Checks that build dependencies exist and sets internal variables
233
        for the Android SDK etc.
234

235
        ..warning:: This *must* be called before trying any build stuff
236

237
        '''
238

239
        self.ensure_dirs()
6 ✔
240

241
        if self._build_env_prepared:
6 !
242
            return
×
243

244
        # Work out where the Android SDK is
245
        sdk_dir = None
6 ✔
246
        if user_sdk_dir:
6 ✔
247
            sdk_dir = user_sdk_dir
6 ✔
248
        # This is the old P4A-specific var
249
        if sdk_dir is None:
6 ✔
250
            sdk_dir = environ.get('ANDROIDSDK', None)
6 ✔
251
        # This seems used more conventionally
252
        if sdk_dir is None:
6 ✔
253
            sdk_dir = environ.get('ANDROID_HOME', None)
6 ✔
254
        # Checks in the buildozer SDK dir, useful for debug tests of p4a
255
        if sdk_dir is None:
6 ✔
256
            possible_dirs = glob.glob(expanduser(join(
6 ✔
257
                '~', '.buildozer', 'android', 'platform', 'android-sdk-*')))
258
            possible_dirs = [d for d in possible_dirs if not
6 ✔
259
                             d.endswith(('.bz2', '.gz'))]
260
            if possible_dirs:
6 !
261
                info('Found possible SDK dirs in buildozer dir: {}'.format(
×
262
                    ', '.join(d.split(os.sep)[-1] for d in possible_dirs)))
263
                info('Will attempt to use SDK at {}'.format(possible_dirs[0]))
×
264
                warning('This SDK lookup is intended for debug only, if you '
×
265
                        'use python-for-android much you should probably '
266
                        'maintain your own SDK download.')
267
                sdk_dir = possible_dirs[0]
×
268
        if sdk_dir is None:
6 ✔
269
            raise BuildInterruptingException('Android SDK dir was not specified, exiting.')
6 ✔
270
        self.sdk_dir = realpath(sdk_dir)
6 ✔
271

272
        # Check what Android API we're using
273
        android_api = None
6 ✔
274
        if user_android_api:
6 !
275
            android_api = user_android_api
×
276
            info('Getting Android API version from user argument: {}'.format(android_api))
×
277
        elif 'ANDROIDAPI' in environ:
6 !
278
            android_api = environ['ANDROIDAPI']
×
279
            info('Found Android API target in $ANDROIDAPI: {}'.format(android_api))
×
280
        else:
281
            info('Android API target was not set manually, using '
6 ✔
282
                 'the default of {}'.format(RECOMMENDED_TARGET_API))
283
            android_api = RECOMMENDED_TARGET_API
6 ✔
284
        android_api = int(android_api)
6 ✔
285
        self.android_api = android_api
6 ✔
286

287
        for arch in self.archs:
6 ✔
288
            # Maybe We could remove this one in a near future (ARMv5 is definitely old)
289
            check_target_api(android_api, arch)
6 ✔
290
        apis = get_available_apis(self.sdk_dir)
6 ✔
291
        info('Available Android APIs are ({})'.format(
6 ✔
292
            ', '.join(map(str, apis))))
293
        if android_api in apis:
6 !
294
            info(('Requested API target {} is available, '
6 ✔
295
                  'continuing.').format(android_api))
296
        else:
297
            raise BuildInterruptingException(
×
298
                ('Requested API target {} is not available, install '
299
                 'it with the SDK android tool.').format(android_api))
300

301
        # Find the Android NDK
302
        # Could also use ANDROID_NDK, but doesn't look like many tools use this
303
        ndk_dir = None
6 ✔
304
        if user_ndk_dir:
6 !
305
            ndk_dir = user_ndk_dir
6 ✔
306
            info('Getting NDK dir from from user argument')
6 ✔
307
        if ndk_dir is None:  # The old P4A-specific dir
6 !
308
            ndk_dir = environ.get('ANDROIDNDK', None)
×
309
            if ndk_dir is not None:
×
310
                info('Found NDK dir in $ANDROIDNDK: {}'.format(ndk_dir))
×
311
        if ndk_dir is None:  # Apparently the most common convention
6 !
312
            ndk_dir = environ.get('NDK_HOME', None)
×
313
            if ndk_dir is not None:
×
314
                info('Found NDK dir in $NDK_HOME: {}'.format(ndk_dir))
×
315
        if ndk_dir is None:  # Another convention (with maven?)
6 !
316
            ndk_dir = environ.get('ANDROID_NDK_HOME', None)
×
317
            if ndk_dir is not None:
×
318
                info('Found NDK dir in $ANDROID_NDK_HOME: {}'.format(ndk_dir))
×
319
        if ndk_dir is None:  # Checks in the buildozer NDK dir, useful
6 !
320
            #                # for debug tests of p4a
321
            possible_dirs = glob.glob(expanduser(join(
×
322
                '~', '.buildozer', 'android', 'platform', 'android-ndk-r*')))
323
            if possible_dirs:
×
324
                info('Found possible NDK dirs in buildozer dir: {}'.format(
×
325
                    ', '.join(d.split(os.sep)[-1] for d in possible_dirs)))
326
                info('Will attempt to use NDK at {}'.format(possible_dirs[0]))
×
327
                warning('This NDK lookup is intended for debug only, if you '
×
328
                        'use python-for-android much you should probably '
329
                        'maintain your own NDK download.')
330
                ndk_dir = possible_dirs[0]
×
331
        if ndk_dir is None:
6 !
332
            raise BuildInterruptingException('Android NDK dir was not specified')
×
333
        self.ndk_dir = realpath(ndk_dir)
6 ✔
334
        check_ndk_version(ndk_dir)
6 ✔
335

336
        ndk_api = None
6 ✔
337
        if user_ndk_api:
6 !
338
            ndk_api = user_ndk_api
×
339
            info('Getting NDK API version (i.e. minimum supported API) from user argument')
×
340
        elif 'NDKAPI' in environ:
6 !
341
            ndk_api = environ.get('NDKAPI', None)
×
342
            info('Found Android API target in $NDKAPI')
×
343
        else:
344
            ndk_api = min(self.android_api, RECOMMENDED_NDK_API)
6 ✔
345
            warning('NDK API target was not set manually, using '
6 ✔
346
                    'the default of {} = min(android-api={}, default ndk-api={})'.format(
347
                        ndk_api, self.android_api, RECOMMENDED_NDK_API))
348
        ndk_api = int(ndk_api)
6 ✔
349
        self.ndk_api = ndk_api
6 ✔
350

351
        check_ndk_api(ndk_api, self.android_api)
6 ✔
352

353
        self.ndk = AndroidNDK(self.ndk_dir)
6 ✔
354

355
        # path to some tools
356
        self.ccache = shutil.which("ccache")
6 ✔
357
        if not self.ccache:
6 !
358
            info('ccache is missing, the build will not be optimized in the '
6 ✔
359
                 'future.')
360
        try:
6 ✔
361
            subprocess.check_output([
6 ✔
362
                "python3", "-m", "cython", "--help",
363
            ])
364
        except subprocess.CalledProcessError:
6 ✔
365
            warning('Cython for python3 missing. If you are building for '
6 ✔
366
                    ' a python 3 target (which is the default)'
367
                    ' then THINGS WILL BREAK.')
368

369
        self.env["PATH"] = ":".join(
6 ✔
370
            [
371
                self.ndk.llvm_bin_dir,
372
                self.ndk_dir,
373
                f"{self.sdk_dir}/tools",
374
                environ.get("PATH"),
375
            ]
376
        )
377

378
    def __init__(self):
6 ✔
379
        self.include_dirs = []
6 ✔
380

381
        self._build_env_prepared = False
6 ✔
382

383
        self._sdk_dir = None
6 ✔
384
        self._ndk_dir = None
6 ✔
385
        self._android_api = None
6 ✔
386
        self._ndk_api = None
6 ✔
387
        self.ndk = None
6 ✔
388

389
        self.local_recipes = None
6 ✔
390
        self.copy_libs = False
6 ✔
391

392
        self.activity_class_name = u'org.kivy.android.PythonActivity'
6 ✔
393
        self.service_class_name = u'org.kivy.android.PythonService'
6 ✔
394

395
        # this list should contain all Archs, it is pruned later
396
        self.archs = (
6 ✔
397
            ArchARM(self),
398
            ArchARMv7_a(self),
399
            Archx86(self),
400
            Archx86_64(self),
401
            ArchAarch_64(self),
402
            )
403

404
        self.root_dir = realpath(dirname(__file__))
6 ✔
405

406
        # remove the most obvious flags that can break the compilation
407
        self.env.pop("LDFLAGS", None)
6 ✔
408
        self.env.pop("ARCHFLAGS", None)
6 ✔
409
        self.env.pop("CFLAGS", None)
6 ✔
410

411
        self.python_recipe = None  # Set by TargetPythonRecipe
6 ✔
412

413
    def set_archs(self, arch_names):
6 ✔
414
        all_archs = self.archs
6 ✔
415
        new_archs = set()
6 ✔
416
        for name in arch_names:
6 ✔
417
            matching = [arch for arch in all_archs if arch.arch == name]
6 ✔
418
            for match in matching:
6 ✔
419
                new_archs.add(match)
6 ✔
420
        self.archs = list(new_archs)
6 ✔
421
        if not self.archs:
6 !
422
            raise BuildInterruptingException('Asked to compile for no Archs, so failing.')
×
423
        info('Will compile for the following archs: {}'.format(
6 ✔
424
            ', '.join(arch.arch for arch in self.archs)))
425

426
    def prepare_bootstrap(self, bootstrap):
6 ✔
427
        if not bootstrap:
6 !
428
            raise TypeError("None is not allowed for bootstrap")
×
429
        bootstrap.ctx = self
6 ✔
430
        self.bootstrap = bootstrap
6 ✔
431
        self.bootstrap.prepare_build_dir()
6 ✔
432
        self.bootstrap_build_dir = self.bootstrap.build_dir
6 ✔
433

434
    def prepare_dist(self):
6 ✔
435
        self.bootstrap.prepare_dist_dir()
6 ✔
436

437
    def get_site_packages_dir(self, arch):
6 ✔
438
        '''Returns the location of site-packages in the python-install build
439
        dir.
440
        '''
441
        return self.get_python_install_dir(arch.arch)
×
442

443
    def get_libs_dir(self, arch):
6 ✔
444
        '''The libs dir for a given arch.'''
445
        ensure_dir(join(self.libs_dir, arch))
6 ✔
446
        return join(self.libs_dir, arch)
6 ✔
447

448
    def has_lib(self, arch, lib):
6 ✔
449
        return exists(join(self.get_libs_dir(arch), lib))
6 ✔
450

451
    def has_package(self, name, arch=None):
6 ✔
452
        # If this is a file path, it'll need special handling:
453
        if (name.find("/") >= 0 or name.find("\\") >= 0) and \
×
454
                name.find("://") < 0:  # (:// would indicate an url)
455
            if not os.path.exists(name):
×
456
                # Non-existing dir, cannot look this up.
457
                return False
×
458
            try:
×
459
                name = get_package_name(os.path.abspath(name))
×
460
            except ValueError:
×
461
                # Failed to look up any meaningful name.
462
                return False
×
463

464
        # normalize name to remove version tags
465
        try:
×
466
            name = Requirement(name).name
×
467
        except Exception:
×
468
            pass
×
469

470
        # Try to look up recipe by name:
471
        try:
×
472
            recipe = Recipe.get_recipe(name, self)
×
473
        except ValueError:
×
474
            pass
×
475
        else:
476
            name = getattr(recipe, 'site_packages_name', None) or name
×
477
        name = name.replace('.', '/')
×
478
        site_packages_dir = self.get_site_packages_dir(arch)
×
479
        return (exists(join(site_packages_dir, name)) or
×
480
                exists(join(site_packages_dir, name + '.py')) or
481
                exists(join(site_packages_dir, name + '.pyc')) or
482
                exists(join(site_packages_dir, name + '.so')) or
483
                glob.glob(join(site_packages_dir, name + '-*.egg')))
484

485

486
def build_recipes(build_order, python_modules, ctx, project_dir,
6 ✔
487
                  ignore_project_setup_py=False
488
                 ):
489
    # Put recipes in correct build order
490
    info_notify("Recipe build order is {}".format(build_order))
×
491
    if python_modules:
×
492
        python_modules = sorted(set(python_modules))
×
493
        info_notify(
×
494
            ('The requirements ({}) were not found as recipes, they will be '
495
             'installed with pip.').format(', '.join(python_modules)))
496

497
    recipes = [Recipe.get_recipe(name, ctx) for name in build_order]
×
498

499
    # download is arch independent
500
    info_main('# Downloading recipes ')
×
501
    for recipe in recipes:
×
502
        recipe.download_if_necessary()
×
503

504
    for arch in ctx.archs:
×
505
        info_main('# Building all recipes for arch {}'.format(arch.arch))
×
506

507
        info_main('# Unpacking recipes')
×
508
        for recipe in recipes:
×
509
            ensure_dir(recipe.get_build_container_dir(arch.arch))
×
510
            recipe.prepare_build_dir(arch.arch)
×
511

512
        info_main('# Prebuilding recipes')
×
513
        # ensure we have `ctx.python_recipe` and `ctx.hostpython`
514
        Recipe.get_recipe("python3", ctx).prebuild_arch(arch)
×
515
        ctx.hostpython = Recipe.get_recipe("hostpython3", ctx).python_exe
×
516

517
        # 2) prebuild packages
518
        for recipe in recipes:
×
519
            info_main('Prebuilding {} for {}'.format(recipe.name, arch.arch))
×
520
            recipe.prebuild_arch(arch)
×
521
            recipe.apply_patches(arch)
×
522

523
        # 3) build packages
524
        info_main('# Building recipes')
×
525
        for recipe in recipes:
×
526
            info_main('Building {} for {}'.format(recipe.name, arch.arch))
×
527
            if recipe.should_build(arch):
×
528
                recipe.build_arch(arch)
×
529
            else:
530
                info('{} said it is already built, skipping'
×
531
                     .format(recipe.name))
532
            recipe.install_libraries(arch)
×
533

534
        # 4) biglink everything
535
        info_main('# Biglinking object files')
×
536
        if not ctx.python_recipe:
×
537
            biglink(ctx, arch)
×
538
        else:
539
            warning(
×
540
                "Context's python recipe found, "
541
                "skipping biglink (will this work?)"
542
            )
543

544
        # 5) postbuild packages
545
        info_main('# Postbuilding recipes')
×
546
        for recipe in recipes:
×
547
            info_main('Postbuilding {} for {}'.format(recipe.name, arch.arch))
×
548
            recipe.postbuild_arch(arch)
×
549

550
    info_main('# Installing pure Python modules')
×
551
    for arch in ctx.archs:
×
552
        run_pymodules_install(
×
553
            ctx, arch, python_modules, project_dir,
554
            ignore_setup_py=ignore_project_setup_py
555
        )
556

557

558
def project_has_setup_py(project_dir):
6 ✔
559
    return (project_dir is not None and
6 ✔
560
            (exists(join(project_dir, "setup.py")) or
561
             exists(join(project_dir, "pyproject.toml"))
562
            ))
563

564

565
def is_wheel_platform_independent(whl_name):
6 ✔
566
    name, version, build, tags = parse_wheel_filename(whl_name)
×
567
    return all(tag.platform == "any" for tag in tags)
×
568

569

570
def is_wheel_compatible(whl_name, arch, ctx):
6 ✔
571
    name, version, build, tags = parse_wheel_filename(whl_name)
6 ✔
572
    supported_tags = PyProjectRecipe.get_wheel_platform_tags(arch.arch, ctx)
6 ✔
573
    supported_tags.append("any")
6 ✔
574
    result = all(tag.platform in supported_tags for tag in tags)
6 ✔
575
    if not result:
6 ✔
576
        warning(f"Incompatible module : {whl_name}")
6 ✔
577
    return result
6 ✔
578

579

580
def process_python_modules(ctx, modules, arch):
6 ✔
581
    """Use pip --dry-run to resolve dependencies and filter for pure-Python packages
582
    """
583
    modules = list(modules)
6 ✔
584
    build_order = list(ctx.recipe_build_order)
6 ✔
585

586
    _requirement_names = []
6 ✔
587
    processed_modules = []
6 ✔
588

589
    for module in modules+build_order:
6 ✔
590
        try:
6 ✔
591
            # we need to normalize names
592
            # eg Requests>=2.0 becomes requests
593
            _requirement_names.append(Requirement(module).name)
6 ✔
594
        except Exception:
6 ✔
595
            # name parsing failed; skip processing this module via pip
596
            processed_modules.append(module)
6 ✔
597
            if module in modules:
6 !
598
                modules.remove(module)
6 ✔
599

600
    if len(processed_modules) > 0:
6 ✔
601
        warning(f'Ignored by module resolver : {processed_modules}')
6 ✔
602

603
    # preserve the original module list
604
    processed_modules.extend(modules)
6 ✔
605

606
    # temp file for pip report
607
    fd, path = tempfile.mkstemp()
6 ✔
608
    os.close(fd)
6 ✔
609

610
    # setup hostpython recipe
611
    env = environ.copy()
6 ✔
612
    host_recipe = None
6 ✔
613
    try:
6 ✔
614
        host_recipe = Recipe.get_recipe("hostpython3", ctx)
6 ✔
615
        pip = host_recipe.pip
×
616
    except Exception:
6 ✔
617
        # hostpython3 is unavailable, so fall back to system pip
618
        pip = sh.Command("pip")
6 ✔
619

620
    # add platform tags
621
    platforms = []
6 ✔
622
    tags = PyProjectRecipe.get_wheel_platform_tags(arch.arch, ctx)
6 ✔
623
    for tag in tags:
6 ✔
624
        platforms.append(f"--platform={tag}")
6 ✔
625

626
    if host_recipe is not None:
6 !
627
        platforms.extend(["--python-version", host_recipe.version])
×
628
    else:
629
        # use the version of the currently running Python interpreter
630
        current_version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
6 ✔
631
        platforms.extend(["--python-version", current_version])
6 ✔
632

633
    indices = []
6 ✔
634
    # add extra index urls
635
    for index in ctx.extra_index_urls:
6 !
636
        indices.extend(["--extra-index-url", index])
×
637

638
    state = [pip, platforms, indices, env]
6 ✔
639

640
    try:
6 ✔
641
        shprint(
6 ✔
642
            pip, 'install', *modules,
643
            '--dry-run', '--break-system-packages', '--ignore-installed',
644
            '--disable-pip-version-check', '--only-binary=:all:',
645
            '--report', path, '-q', *platforms, *indices, _env=env
646
        )
647
    except Exception as e:
6 ✔
648
        warning(f"Auto module resolution failed: {e}")
6 ✔
649
        return processed_modules, state
6 ✔
650

651
    with open(path, "r") as f:
6 ✔
652
        try:
6 ✔
653
            report = json.load(f)
6 ✔
654
        except Exception:
×
655
            report = {}
×
656

657
    os.remove(path)
6 ✔
658

659
    if "install" not in report.keys():
6 !
660
        # pip changed json reporting format?
661
        warning("Auto module resolution failed: invalid json!")
×
662
        return processed_modules, state
×
663

664
    info('Extra resolved python platform dependencies :')
6 ✔
665

666
    ignored_str = " (ignored)"
6 ✔
667
    # did we find any non pure python package?
668
    any_not_pure_python = False
6 ✔
669

670
    # just for style
671
    info(" ")
6 ✔
672
    for module in report["install"]:
6 ✔
673

674
        mname = module["metadata"]["name"]
6 ✔
675
        mver = module["metadata"]["version"]
6 ✔
676
        filename = basename(module["download_info"]["url"])
6 ✔
677
        pure_python = True
6 ✔
678

679
        if (
6 !
680
                filename.endswith(".whl") and not is_wheel_compatible(filename, arch, ctx)
681
        ):
682
            any_not_pure_python = True
×
683
            pure_python = False
×
684

685
        if mname.lower().replace("-", "_") in _requirement_names or mname.lower() in _requirement_names:
6 !
686
            continue
6 ✔
687

688
        color = Out_Fore.GREEN if pure_python else Out_Fore.RED
×
689
        ignored = "" if pure_python else ignored_str
×
690

691
        info(
×
692
            f"  {color}{mname}{Out_Fore.WHITE} : "
693
            f"{Out_Style.BRIGHT}{mver}{Out_Style.RESET_ALL}"
694
            f"{ignored}"
695
        )
696

697
        if pure_python:
×
698
            # Direct whl file to avoid resolving again
699
            processed_modules.append(module["download_info"]["url"])
×
700
    info(" ")
6 ✔
701

702
    if any_not_pure_python:
6 !
703
        warning("Some packages were ignored because they are not pure Python.")
×
704
        warning("To install the ignored packages, explicitly list them in your requirements file.")
×
705

706
    return processed_modules, state
6 ✔
707

708

709
def run_pymodules_install(ctx, arch, modules, project_dir=None,
6 ✔
710
                          ignore_setup_py=False):
711
    """ This function will take care of all non-recipe things, by:
712

713
        1. Processing them from --requirements (the modules argument)
714
           and installing them
715

716
        2. Installing the user project/app itself via setup.py if
717
           ignore_setup_py=True
718

719
    """
720

721
    info('*** PYTHON PACKAGE / PROJECT INSTALL STAGE FOR ARCH: {} ***'.format(arch))
6 ✔
722

723
    # Restore version strings from environment
724
    for index, module in enumerate(modules):
6 !
725
        if (m_version := os.environ.get(f'VERSION_{module}', None)) is not None:
×
726
            modules[index] = f'{module}=={m_version}'
×
727

728
    modules, state = process_python_modules(ctx, modules, arch)
6 ✔
729
    # Reuse the state constructed
730
    pip, platforms, indices, env = state
6 ✔
731

732
    # We change current working directory later, so this has to be an absolute
733
    # path or `None` in case that we didn't supply the `project_dir` via kwargs
734
    project_dir = abspath(project_dir) if project_dir else None
6 ✔
735

736
    # Bail out if no python deps and no setup.py to process:
737
    if not modules and (
6 ✔
738
            ignore_setup_py or
739
            not project_has_setup_py(project_dir)
740
            ):
741
        info('No Python modules and no setup.py to process, skipping')
6 ✔
742
        return
6 ✔
743

744
    # Output messages about what we're going to do:
745
    if modules:
6 !
746
        info(
6 ✔
747
            "The requirements ({}) don\'t have recipes, attempting to "
748
            "install them with pip".format(', '.join(modules))
749
        )
750
        info(
6 ✔
751
            "If this fails, it may mean that the module has compiled "
752
            "components and needs a recipe."
753
        )
754

755
    if project_has_setup_py(project_dir) and not ignore_setup_py:
6 !
756
        info(
×
757
            "Will process project install, if it fails then the "
758
            "project may not be compatible for Android install."
759
        )
760

761
    if not modules:
6 !
762
        info('There are no Python modules to install, skipping')
×
763
    else:
764

765
        info('Installing Python modules with pip')
6 ✔
766
        info(
6 ✔
767
            "IF THIS FAILS, THE MODULES MAY NEED A RECIPE. "
768
            "A reason for this is often modules compiling "
769
            "native code that is unaware of Android cross-compilation "
770
            "and does not work without additional "
771
            "changes / workarounds."
772
        )
773
        # --no-deps is required here as auto resolution is already done above
774
        shprint(
6 ✔
775
            pip, 'install', *modules,
776
            '--target', ctx.get_site_packages_dir(arch),
777
            '--upgrade', '--ignore-installed', '--no-deps',
778
            '--disable-pip-version-check', '--only-binary=:all:',
779
            *platforms, *indices, _env=env
780
        )
781

782
    # Afterwards, run setup.py if present:
783
    if project_has_setup_py(project_dir) and not ignore_setup_py:
6 !
784
        with current_directory(project_dir):
×
785
            # TODO: It will only work for basic python projects with no compiled components
786
            shprint(
×
787
                pip, 'install', ".", "--no-deps", "--only-binary=:all:",
788
                '--target', ctx.get_site_packages_dir(arch),
789
                '--disable-pip-version-check', '--upgrade',
790
                *platforms, *indices, _env=env
791
            )
792
    elif not ignore_setup_py:
6 !
793
        info("No setup.py found in project directory: " + str(project_dir))
6 ✔
794

795
    # Strip object files after potential Cython or native code builds:
796
    arch_env = arch.get_env()
6 ✔
797
    if not ctx.with_debug_symbols and arch_env.get("STRIP", None) is not None:
6 ✔
798
        info('Stripping object files')
6 ✔
799
        shprint(
6 ✔
800
            sh.find, ctx.get_site_packages_dir(arch), '-iname', '*.so',
801
            '-exec', arch_env['STRIP'].split(' ')[0],
802
            '--strip-unneeded', '{}', ';',
803
            _env=arch_env,
804
        )
805

806

807
def biglink(ctx, arch):
6 ✔
808
    # First, collate object files from each recipe
809
    info('Collating object files from each recipe')
×
810
    obj_dir = join(ctx.bootstrap.build_dir, 'collated_objects')
×
811
    ensure_dir(obj_dir)
×
812
    recipes = [Recipe.get_recipe(name, ctx) for name in ctx.recipe_build_order]
×
813
    for recipe in recipes:
×
814
        recipe_obj_dir = join(recipe.get_build_container_dir(arch.arch),
×
815
                              'objects_{}'.format(recipe.name))
816
        if not exists(recipe_obj_dir):
×
817
            info('{} recipe has no biglinkable files dir, skipping'
×
818
                 .format(recipe.name))
819
            continue
×
820
        files = glob.glob(join(recipe_obj_dir, '*'))
×
821
        if not len(files):
×
822
            info('{} recipe has no biglinkable files, skipping'
×
823
                 .format(recipe.name))
824
            continue
×
825
        info('{} recipe has object files, copying'.format(recipe.name))
×
826
        files.append(obj_dir)
×
827
        shprint(sh.cp, '-r', *files)
×
828

829
    env = arch.get_env()
×
830
    env['LDFLAGS'] = env['LDFLAGS'] + ' -L{}'.format(
×
831
        join(ctx.bootstrap.build_dir, 'obj', 'local', arch.arch))
832

833
    if not len(glob.glob(join(obj_dir, '*'))):
×
834
        info('There seem to be no libraries to biglink, skipping.')
×
835
        return
×
836
    info('Biglinking')
×
837
    info('target {}'.format(join(ctx.get_libs_dir(arch.arch),
×
838
                                 'libpymodules.so')))
839
    do_biglink = copylibs_function if ctx.copy_libs else biglink_function
×
840

841
    # Move to the directory containing crtstart_so.o and crtend_so.o
842
    # This is necessary with newer NDKs? A gcc bug?
843
    with current_directory(arch.ndk_lib_dir):
×
844
        do_biglink(
×
845
            join(ctx.get_libs_dir(arch.arch), 'libpymodules.so'),
846
            obj_dir.split(' '),
847
            extra_link_dirs=[join(ctx.bootstrap.build_dir,
848
                                  'obj', 'local', arch.arch),
849
                             os.path.abspath('.')],
850
            env=env)
851

852

853
def biglink_function(soname, objs_paths, extra_link_dirs=None, env=None):
6 ✔
854
    if extra_link_dirs is None:
×
855
        extra_link_dirs = []
×
856
    print('objs_paths are', objs_paths)
×
857
    sofiles = []
×
858

859
    for directory in objs_paths:
×
860
        for fn in os.listdir(directory):
×
861
            fn = os.path.join(directory, fn)
×
862

863
            if not fn.endswith(".so.o"):
×
864
                continue
×
865
            if not os.path.exists(fn[:-2] + ".libs"):
×
866
                continue
×
867

868
            sofiles.append(fn[:-2])
×
869

870
    # The raw argument list.
871
    args = []
×
872

873
    for fn in sofiles:
×
874
        afn = fn + ".o"
×
875
        libsfn = fn + ".libs"
×
876

877
        args.append(afn)
×
878
        with open(libsfn) as fd:
×
879
            data = fd.read()
×
880
            args.extend(data.split(" "))
×
881

882
    unique_args = []
×
883
    while args:
×
884
        a = args.pop()
×
885
        if a in ('-L', ):
×
886
            continue
×
887
        if a not in unique_args:
×
888
            unique_args.insert(0, a)
×
889

890
    for dir in extra_link_dirs:
×
891
        link = '-L{}'.format(dir)
×
892
        if link not in unique_args:
×
893
            unique_args.append(link)
×
894

895
    cc_name = env['CC']
×
896
    cc = sh.Command(cc_name.split()[0])
×
897
    cc = cc.bake(*cc_name.split()[1:])
×
898

899
    shprint(cc, '-shared', '-O3', '-o', soname, *unique_args, _env=env)
×
900

901

902
def copylibs_function(soname, objs_paths, extra_link_dirs=None, env=None):
6 ✔
903
    if extra_link_dirs is None:
×
904
        extra_link_dirs = []
×
905
    print('objs_paths are', objs_paths)
×
906

907
    re_needso = re.compile(r'^.*\(NEEDED\)\s+Shared library: \[lib(.*)\.so\]\s*$')
×
908
    blacklist_libs = (
×
909
        'c',
910
        'stdc++',
911
        'dl',
912
        'python2.7',
913
        'sdl',
914
        'sdl_image',
915
        'sdl_ttf',
916
        'z',
917
        'm',
918
        'GLESv2',
919
        'jpeg',
920
        'png',
921
        'log',
922

923
        # bootstrap takes care of sdl2 libs (if applicable)
924
        'SDL2',
925
        'SDL2_ttf',
926
        'SDL2_image',
927
        'SDL2_mixer',
928
        'SDL3',
929
        'SDL3_ttf',
930
        'SDL3_image',
931
        'SDL3_mixer',
932
    )
933
    found_libs = []
×
934
    sofiles = []
×
935
    if env and 'READELF' in env:
×
936
        readelf = env['READELF']
×
937
    elif 'READELF' in os.environ:
×
938
        readelf = os.environ['READELF']
×
939
    else:
940
        readelf = shutil.which('readelf').strip()
×
941
    readelf = sh.Command(readelf).bake('-d')
×
942

943
    dest = dirname(soname)
×
944

945
    for directory in objs_paths:
×
946
        for fn in os.listdir(directory):
×
947
            fn = join(directory, fn)
×
948

949
            if not fn.endswith('.libs'):
×
950
                continue
×
951

952
            dirfn = fn[:-1] + 'dirs'
×
953
            if not exists(dirfn):
×
954
                continue
×
955

956
            with open(fn) as f:
×
957
                libs = f.read().strip().split(' ')
×
958
                needed_libs = [lib for lib in libs
×
959
                               if lib and
960
                               lib not in blacklist_libs and
961
                               lib not in found_libs]
962

963
            while needed_libs:
×
964
                print('need libs:\n\t' + '\n\t'.join(needed_libs))
×
965

966
                start_needed_libs = needed_libs[:]
×
967
                found_sofiles = []
×
968

969
                with open(dirfn) as f:
×
970
                    libdirs = f.read().split()
×
971
                    for libdir in libdirs:
×
972
                        if not needed_libs:
×
973
                            break
×
974

975
                        if libdir == dest:
×
976
                            # don't need to copy from dest to dest!
977
                            continue
×
978

979
                        libdir = libdir.strip()
×
980
                        print('scanning', libdir)
×
981
                        for lib in needed_libs[:]:
×
982
                            if lib in found_libs:
×
983
                                continue
×
984

985
                            if lib.endswith('.a'):
×
986
                                needed_libs.remove(lib)
×
987
                                found_libs.append(lib)
×
988
                                continue
×
989

990
                            lib_a = 'lib' + lib + '.a'
×
991
                            libpath_a = join(libdir, lib_a)
×
992
                            lib_so = 'lib' + lib + '.so'
×
993
                            libpath_so = join(libdir, lib_so)
×
994
                            plain_so = lib + '.so'
×
995
                            plainpath_so = join(libdir, plain_so)
×
996

997
                            sopath = None
×
998
                            if exists(libpath_so):
×
999
                                sopath = libpath_so
×
1000
                            elif exists(plainpath_so):
×
1001
                                sopath = plainpath_so
×
1002

1003
                            if sopath:
×
1004
                                print('found', lib, 'in', libdir)
×
1005
                                found_sofiles.append(sopath)
×
1006
                                needed_libs.remove(lib)
×
1007
                                found_libs.append(lib)
×
1008
                                continue
×
1009

1010
                            if exists(libpath_a):
×
1011
                                print('found', lib, '(static) in', libdir)
×
1012
                                needed_libs.remove(lib)
×
1013
                                found_libs.append(lib)
×
1014
                                continue
×
1015

1016
                for sofile in found_sofiles:
×
1017
                    print('scanning dependencies for', sofile)
×
1018
                    out = readelf(sofile)
×
1019
                    for line in out.splitlines():
×
1020
                        needso = re_needso.match(line)
×
1021
                        if needso:
×
1022
                            lib = needso.group(1)
×
1023
                            if (lib not in needed_libs
×
1024
                                    and lib not in found_libs
1025
                                    and lib not in blacklist_libs):
1026
                                needed_libs.append(needso.group(1))
×
1027

1028
                sofiles += found_sofiles
×
1029

1030
                if needed_libs == start_needed_libs:
×
1031
                    raise RuntimeError(
×
1032
                            'Failed to locate needed libraries!\n\t' +
1033
                            '\n\t'.join(needed_libs))
1034

1035
    print('Copying libraries')
×
1036
    shprint(sh.cp, *sofiles, dest)
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc