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

kivy / python-for-android / 23682074267

28 Mar 2026 09:15AM UTC coverage: 63.382% (-0.3%) from 63.661%
23682074267

Pull #3280

github

web-flow
Merge 2c132e524 into d15f056e6
Pull Request #3280: Add support for prebuilt wheels

1821 of 3143 branches covered (57.94%)

Branch coverage included in aggregate %.

47 of 103 new or added lines in 4 files covered. (45.63%)

3 existing lines in 2 files now uncovered.

5319 of 8122 relevant lines covered (65.49%)

5.23 hits per line

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

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

16
import sh
8✔
17

18
from packaging.utils import parse_wheel_filename
8✔
19
from packaging.requirements import Requirement
8✔
20

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

34

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

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

52

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

60

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

65
    # Whether to make a debug or release build
66
    build_as_debuggable = False
8✔
67

68
    # Whether to strip debug symbols in `.so` files
69
    with_debug_symbols = False
8✔
70

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

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

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

84
    # the Android project folder where everything ends up
85
    dist_dir = None
8✔
86

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

90
    ccache = None  # whether to use ccache
8✔
91

92
    ndk = None
8✔
93

94
    bootstrap = None
8✔
95
    bootstrap_build_dir = None
8✔
96

97
    recipe_build_order = None  # Will hold the list of all built recipes
8✔
98

99
    python_modules = None  # Will hold resolved pure python packages
8✔
100

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

103
    java_build_tool = 'auto'
8✔
104

105
    skip_prebuilt = False
8✔
106

107
    extra_index_urls = []
8✔
108

109
    use_prebuilt_version_for = []
8✔
110

111
    save_wheel_dir = ''
8✔
112

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

236
        '''
237

238
        self.ensure_dirs()
8✔
239

240
        if self._build_env_prepared:
8!
241
            return
×
242

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

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

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

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

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

350
        check_ndk_api(ndk_api, self.android_api)
8✔
351

352
        self.ndk = AndroidNDK(self.ndk_dir)
8✔
353

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

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

377
    def __init__(self):
8✔
378
        self.include_dirs = []
8✔
379

380
        self._build_env_prepared = False
8✔
381

382
        self._sdk_dir = None
8✔
383
        self._ndk_dir = None
8✔
384
        self._android_api = None
8✔
385
        self._ndk_api = None
8✔
386
        self.ndk = None
8✔
387

388
        self.local_recipes = None
8✔
389
        self.copy_libs = False
8✔
390

391
        self.activity_class_name = u'org.kivy.android.PythonActivity'
8✔
392
        self.service_class_name = u'org.kivy.android.PythonService'
8✔
393

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

403
        self.root_dir = realpath(dirname(__file__))
8✔
404

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

410
        self.python_recipe = None  # Set by TargetPythonRecipe
8✔
411

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

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

433
    def prepare_dist(self):
8✔
434
        self.bootstrap.prepare_dist_dir()
8✔
435

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

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

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

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

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

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

484
    def not_has_package(self, name, arch=None):
8✔
485
        return not self.has_package(name, arch)
×
486

487

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

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

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

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

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

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

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

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

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

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

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

559

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

566

567
def run_setuppy_install(ctx, project_dir, env=None, arch=None):
8✔
568
    env = env or {}
×
569

570
    with current_directory(project_dir):
×
571
        info('got setup.py or similar, running project install. ' +
×
572
             '(disable this behavior with --ignore-setup-py)')
573

574
        # Compute & output the constraints we will use:
575
        info('Contents that will be used for constraints.txt:')
×
576
        constraints = subprocess.check_output([
×
577
            join(
578
                ctx.build_dir, "venv", "bin", "pip"
579
            ),
580
            "freeze"
581
        ], env=copy.copy(env))
582
        with suppress(AttributeError):
×
583
            constraints = constraints.decode("utf-8", "replace")
×
584
        info(constraints)
×
585

586
        # Make sure all packages found are fixed in version
587
        # by writing a constraint file, to avoid recipes being
588
        # upgraded & reinstalled:
589
        with open('._tmp_p4a_recipe_constraints.txt', 'wb') as fileh:
×
590
            fileh.write(constraints.encode("utf-8", "replace"))
×
591
        try:
×
592

593
            info('Populating venv\'s site-packages with '
×
594
                 'ctx.get_site_packages_dir()...')
595

596
            # Copy dist contents into site-packages for discovery.
597
            # Why this is needed:
598
            # --target is somewhat evil and messes with discovery of
599
            # packages in PYTHONPATH if that also includes the target
600
            # folder. So we need to use the regular virtualenv
601
            # site-packages folder instead.
602
            # Reference:
603
            # https://github.com/pypa/pip/issues/6223
604
            ctx_site_packages_dir = os.path.normpath(
×
605
                os.path.abspath(ctx.get_site_packages_dir(arch))
606
            )
607
            venv_site_packages_dir = os.path.normpath(os.path.join(
×
608
                ctx.build_dir, "venv", "lib", [
609
                    f for f in os.listdir(os.path.join(
610
                        ctx.build_dir, "venv", "lib"
611
                    )) if f.startswith("python")
612
                ][0], "site-packages"
613
            ))
614
            copied_over_contents = []
×
615
            for f in os.listdir(ctx_site_packages_dir):
×
616
                full_path = os.path.join(ctx_site_packages_dir, f)
×
617
                if not os.path.exists(os.path.join(
×
618
                            venv_site_packages_dir, f
619
                        )):
620
                    if os.path.isdir(full_path):
×
621
                        shutil.copytree(full_path, os.path.join(
×
622
                            venv_site_packages_dir, f
623
                        ))
624
                    else:
625
                        shutil.copy2(full_path, os.path.join(
×
626
                            venv_site_packages_dir, f
627
                        ))
628
                    copied_over_contents.append(f)
×
629

630
            # Get listing of virtualenv's site-packages, to see the
631
            # newly added things afterwards & copy them back into
632
            # the distribution folder / build context site-packages:
633
            previous_venv_contents = os.listdir(
×
634
                venv_site_packages_dir
635
            )
636

637
            # Actually run setup.py:
638
            info('Launching package install...')
×
639
            shprint(sh.bash, '-c', (
×
640
                "'" + join(
641
                    ctx.build_dir, "venv", "bin", "pip"
642
                ).replace("'", "'\"'\"'") + "' " +
643
                "install -c ._tmp_p4a_recipe_constraints.txt -v ."
644
            ).format(ctx.get_site_packages_dir(arch).
645
                     replace("'", "'\"'\"'")),
646
                    _env=copy.copy(env))
647

648
            # Go over all new additions and copy them back:
649
            info('Copying additions resulting from setup.py back '
×
650
                 'into ctx.get_site_packages_dir()...')
651
            new_venv_additions = []
×
652
            for f in (set(os.listdir(venv_site_packages_dir)) -
×
653
                      set(previous_venv_contents)):
654
                new_venv_additions.append(f)
×
655
                full_path = os.path.join(venv_site_packages_dir, f)
×
656
                if os.path.isdir(full_path):
×
657
                    shutil.copytree(full_path, os.path.join(
×
658
                        ctx_site_packages_dir, f
659
                    ))
660
                else:
661
                    shutil.copy2(full_path, os.path.join(
×
662
                        ctx_site_packages_dir, f
663
                    ))
664

665
            # Undo all the changes we did to the venv-site packages:
666
            info('Reverting additions to '
×
667
                 'virtualenv\'s site-packages...')
668
            for f in set(copied_over_contents + new_venv_additions):
×
669
                full_path = os.path.join(venv_site_packages_dir, f)
×
670
                if os.path.isdir(full_path):
×
671
                    rmdir(full_path)
×
672
                else:
673
                    os.remove(full_path)
×
674
        finally:
675
            os.remove("._tmp_p4a_recipe_constraints.txt")
×
676

677

678
def is_wheel_platform_independent(whl_name):
8✔
UNCOV
679
    name, version, build, tags = parse_wheel_filename(whl_name)
×
UNCOV
680
    return all(tag.platform == "any" for tag in tags)
×
681

682

683
def is_wheel_compatible(whl_name, arch, ctx):
8✔
684
    name, version, build, tags = parse_wheel_filename(whl_name)
8✔
685
    supported_tags = PyProjectRecipe.get_wheel_platform_tags(arch.arch, ctx)
8✔
686
    supported_tags.append("any")
8✔
687
    result = all(tag.platform in supported_tags for tag in tags)
8✔
688
    if not result:
8✔
689
        warning(f"Incompatible module : {whl_name}")
8✔
690
    return result
8✔
691

692

693
def process_python_modules(ctx, modules, arch):
8✔
694
    """Use pip --dry-run to resolve dependencies and filter for pure-Python packages
695
    """
696
    modules = list(modules)
8✔
697
    build_order = list(ctx.recipe_build_order)
8✔
698

699
    _requirement_names = []
8✔
700
    processed_modules = []
8✔
701

702
    for module in modules+build_order:
8✔
703
        try:
8✔
704
            # we need to normalize names
705
            # eg Requests>=2.0 becomes requests
706
            _requirement_names.append(Requirement(module).name)
8✔
707
        except Exception:
8✔
708
            # name parsing failed; skip processing this module via pip
709
            processed_modules.append(module)
8✔
710
            if module in modules:
8!
711
                modules.remove(module)
8✔
712

713
    if len(processed_modules) > 0:
8✔
714
        warning(f'Ignored by module resolver : {processed_modules}')
8✔
715

716
    # preserve the original module list
717
    processed_modules.extend(modules)
8✔
718

719
    if len(modules) == 0:
8✔
720
        return processed_modules
8✔
721

722
    # temp file for pip report
723
    fd, path = tempfile.mkstemp()
8✔
724
    os.close(fd)
8✔
725

726
    # setup hostpython recipe
727
    env = environ.copy()
8✔
728
    host_recipe = None
8✔
729
    try:
8✔
730
        host_recipe = Recipe.get_recipe("hostpython3", ctx)
8✔
731
        _python_path = host_recipe.get_path_to_python()
×
732
        libdir = glob.glob(join(_python_path, "build", "lib*"))
×
733
        env['PYTHONPATH'] = host_recipe.site_dir + ":" + join(
×
734
            _python_path, "Modules") + ":" + (libdir[0] if libdir else "")
735
        pip = host_recipe.pip
×
736
    except Exception:
8✔
737
        # hostpython3 is unavailable, so fall back to system pip
738
        pip = sh.Command("pip")
8✔
739

740
    # add platform tags
741
    platforms = []
8✔
742
    tags = PyProjectRecipe.get_wheel_platform_tags(arch.arch, ctx)
8✔
743
    for tag in tags:
8✔
744
        platforms.append(f"--platform={tag}")
8✔
745

746
    if host_recipe is not None:
8!
NEW
747
        platforms.extend(["--python-version", host_recipe.version])
×
748
    else:
749
        # use the version of the currently running Python interpreter
750
        current_version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
8✔
751
        platforms.extend(["--python-version", current_version])
8✔
752

753
    indices = []
8✔
754
    # add extra index urls
755
    for index in ctx.extra_index_urls:
8!
NEW
756
        indices.extend(["--extra-index-url", index])
×
757
    try:
8✔
758
        shprint(
8✔
759
            pip, 'install', *modules,
760
            '--dry-run', '--break-system-packages', '--ignore-installed',
761
            '--disable-pip-version-check', '--only-binary=:all:',
762
            '--report', path, '-q', *platforms, *indices, _env=env
763
        )
764
    except Exception as e:
×
765
        warning(f"Auto module resolution failed: {e}")
×
766
        return processed_modules
×
767

768
    with open(path, "r") as f:
8✔
769
        try:
8✔
770
            report = json.load(f)
8✔
771
        except Exception:
8✔
772
            report = {}
8✔
773

774
    os.remove(path)
8✔
775

776
    if "install" not in report.keys():
8✔
777
        # pip changed json reporting format?
778
        warning("Auto module resolution failed: invalid json!")
8✔
779
        return processed_modules
8✔
780

781
    info('Extra resolved pure python dependencies :')
8✔
782

783
    ignored_str = " (ignored)"
8✔
784
    # did we find any non pure python package?
785
    any_not_pure_python = False
8✔
786

787
    # just for style
788
    info(" ")
8✔
789
    for module in report["install"]:
8✔
790

791
        mname = module["metadata"]["name"]
8✔
792
        mver = module["metadata"]["version"]
8✔
793
        filename = basename(module["download_info"]["url"])
8✔
794
        pure_python = True
8✔
795

796
        if (
8!
797
                filename.endswith(".whl") and not is_wheel_compatible(filename, arch, ctx)
798
        ):
799
            any_not_pure_python = True
×
800
            pure_python = False
×
801

802
        # does this module matches any recipe name?
803
        if mname.lower().replace("-", "_") in _requirement_names:
8!
804
            continue
8✔
805

806
        color = Out_Fore.GREEN if pure_python else Out_Fore.RED
×
807
        ignored = "" if pure_python else ignored_str
×
808

809
        info(
×
810
            f"  {color}{mname}{Out_Fore.WHITE} : "
811
            f"{Out_Style.BRIGHT}{mver}{Out_Style.RESET_ALL}"
812
            f"{ignored}"
813
        )
814

815
        if pure_python:
×
NEW
816
            processed_modules.append(module["download_info"]["url"])
×
817
    info(" ")
8✔
818

819
    if any_not_pure_python:
8!
820
        warning("Some packages were ignored because they are not pure Python.")
×
821
        warning("To install the ignored packages, explicitly list them in your requirements file.")
×
822

823
    return processed_modules
8✔
824

825

826
def run_pymodules_install(ctx, arch, modules, project_dir=None,
8✔
827
                          ignore_setup_py=False):
828
    """ This function will take care of all non-recipe things, by:
829

830
        1. Processing them from --requirements (the modules argument)
831
           and installing them
832

833
        2. Installing the user project/app itself via setup.py if
834
           ignore_setup_py=True
835

836
    """
837

838
    info('*** PYTHON PACKAGE / PROJECT INSTALL STAGE FOR ARCH: {} ***'.format(arch))
8✔
839

840
    modules = process_python_modules(ctx, modules, arch)
8✔
841

842
    modules = [m for m in modules if ctx.not_has_package(m, arch)]
8✔
843

844
    # We change current working directory later, so this has to be an absolute
845
    # path or `None` in case that we didn't supply the `project_dir` via kwargs
846
    project_dir = abspath(project_dir) if project_dir else None
8✔
847

848
    # Bail out if no python deps and no setup.py to process:
849
    if not modules and (
8✔
850
            ignore_setup_py or
851
            not project_has_setup_py(project_dir)
852
            ):
853
        info('No Python modules and no setup.py to process, skipping')
8✔
854
        return
8✔
855

856
    # Output messages about what we're going to do:
857
    if modules:
8!
858
        info(
8✔
859
            "The requirements ({}) don\'t have recipes, attempting to "
860
            "install them with pip".format(', '.join(modules))
861
        )
862
        info(
8✔
863
            "If this fails, it may mean that the module has compiled "
864
            "components and needs a recipe."
865
        )
866
    if project_has_setup_py(project_dir) and not ignore_setup_py:
8!
867
        info(
×
868
            "Will process project install, if it fails then the "
869
            "project may not be compatible for Android install."
870
        )
871

872
    # Use our hostpython to create the virtualenv
873
    host_python = sh.Command(ctx.hostpython)
8✔
874
    with current_directory(join(ctx.build_dir)):
8✔
875
        shprint(host_python, '-m', 'venv', 'venv')
8✔
876

877
        # Prepare base environment and upgrade pip:
878
        base_env = dict(copy.copy(os.environ))
8✔
879
        base_env["PYTHONPATH"] = ctx.get_site_packages_dir(arch)
8✔
880
        info('Upgrade pip to latest version')
8✔
881
        shprint(sh.bash, '-c', (
8✔
882
            "source venv/bin/activate && pip install -U pip"
883
        ), _env=copy.copy(base_env))
884

885
        # Install Cython in case modules need it to build:
886
        info('Install Cython in case one of the modules needs it to build')
8✔
887
        shprint(sh.bash, '-c', (
8✔
888
            "venv/bin/pip install Cython"
889
        ), _env=copy.copy(base_env))
890

891
        # Get environment variables for build (with CC/compiler set):
892
        standard_recipe = CythonRecipe()
8✔
893
        standard_recipe.ctx = ctx
8✔
894
        # (note: following line enables explicit -lpython... linker options)
895
        standard_recipe.call_hostpython_via_targetpython = False
8✔
896
        recipe_env = standard_recipe.get_recipe_env(ctx.archs[0])
8✔
897
        env = copy.copy(base_env)
8✔
898
        env.update(recipe_env)
8✔
899

900
        # Make sure our build package dir is available, and the virtualenv
901
        # site packages come FIRST (so the proper pip version is used):
902
        env["PYTHONPATH"] += ":" + ctx.get_site_packages_dir(arch)
8✔
903
        env["PYTHONPATH"] = os.path.abspath(join(
8✔
904
            ctx.build_dir, "venv", "lib",
905
            "python" + ctx.python_recipe.major_minor_version_string,
906
            "site-packages")) + ":" + env["PYTHONPATH"]
907

908
        # Install the manually specified requirements first:
909
        if not modules:
8!
910
            info('There are no Python modules to install, skipping')
×
911
        else:
912
            info('Creating a requirements.txt file for the Python modules')
8✔
913
            with open('requirements.txt', 'w') as fileh:
8✔
914
                for module in modules:
8✔
915
                    key = 'VERSION_' + module
8✔
916
                    if key in environ:
8!
917
                        line = '{}=={}\n'.format(module, environ[key])
×
918
                    else:
919
                        line = '{}\n'.format(module)
8✔
920
                    fileh.write(line)
8✔
921

922
            info('Installing Python modules with pip')
8✔
923
            info(
8✔
924
                "IF THIS FAILS, THE MODULES MAY NEED A RECIPE. "
925
                "A reason for this is often modules compiling "
926
                "native code that is unaware of Android cross-compilation "
927
                "and does not work without additional "
928
                "changes / workarounds."
929
            )
930

931
            shprint(sh.bash, '-c', (
8✔
932
                "venv/bin/pip " +
933
                "install -v --target '{0}' --no-deps -r requirements.txt"
934
            ).format(ctx.get_site_packages_dir(arch).replace("'", "'\"'\"'")),
935
                    _env=copy.copy(env))
936

937
        # Afterwards, run setup.py if present:
938
        if project_has_setup_py(project_dir) and not ignore_setup_py:
8!
939
            run_setuppy_install(ctx, project_dir, env, arch)
×
940
        elif not ignore_setup_py:
8!
941
            info("No setup.py found in project directory: " + str(project_dir))
8✔
942

943
        # Strip object files after potential Cython or native code builds:
944
        if not ctx.with_debug_symbols:
8✔
945
            standard_recipe.strip_object_files(
8✔
946
                arch, env, build_dir=ctx.build_dir
947
            )
948

949

950
def biglink(ctx, arch):
8✔
951
    # First, collate object files from each recipe
952
    info('Collating object files from each recipe')
×
953
    obj_dir = join(ctx.bootstrap.build_dir, 'collated_objects')
×
954
    ensure_dir(obj_dir)
×
955
    recipes = [Recipe.get_recipe(name, ctx) for name in ctx.recipe_build_order]
×
956
    for recipe in recipes:
×
957
        recipe_obj_dir = join(recipe.get_build_container_dir(arch.arch),
×
958
                              'objects_{}'.format(recipe.name))
959
        if not exists(recipe_obj_dir):
×
960
            info('{} recipe has no biglinkable files dir, skipping'
×
961
                 .format(recipe.name))
962
            continue
×
963
        files = glob.glob(join(recipe_obj_dir, '*'))
×
964
        if not len(files):
×
965
            info('{} recipe has no biglinkable files, skipping'
×
966
                 .format(recipe.name))
967
            continue
×
968
        info('{} recipe has object files, copying'.format(recipe.name))
×
969
        files.append(obj_dir)
×
970
        shprint(sh.cp, '-r', *files)
×
971

972
    env = arch.get_env()
×
973
    env['LDFLAGS'] = env['LDFLAGS'] + ' -L{}'.format(
×
974
        join(ctx.bootstrap.build_dir, 'obj', 'local', arch.arch))
975

976
    if not len(glob.glob(join(obj_dir, '*'))):
×
977
        info('There seem to be no libraries to biglink, skipping.')
×
978
        return
×
979
    info('Biglinking')
×
980
    info('target {}'.format(join(ctx.get_libs_dir(arch.arch),
×
981
                                 'libpymodules.so')))
982
    do_biglink = copylibs_function if ctx.copy_libs else biglink_function
×
983

984
    # Move to the directory containing crtstart_so.o and crtend_so.o
985
    # This is necessary with newer NDKs? A gcc bug?
986
    with current_directory(arch.ndk_lib_dir):
×
987
        do_biglink(
×
988
            join(ctx.get_libs_dir(arch.arch), 'libpymodules.so'),
989
            obj_dir.split(' '),
990
            extra_link_dirs=[join(ctx.bootstrap.build_dir,
991
                                  'obj', 'local', arch.arch),
992
                             os.path.abspath('.')],
993
            env=env)
994

995

996
def biglink_function(soname, objs_paths, extra_link_dirs=None, env=None):
8✔
997
    if extra_link_dirs is None:
×
998
        extra_link_dirs = []
×
999
    print('objs_paths are', objs_paths)
×
1000
    sofiles = []
×
1001

1002
    for directory in objs_paths:
×
1003
        for fn in os.listdir(directory):
×
1004
            fn = os.path.join(directory, fn)
×
1005

1006
            if not fn.endswith(".so.o"):
×
1007
                continue
×
1008
            if not os.path.exists(fn[:-2] + ".libs"):
×
1009
                continue
×
1010

1011
            sofiles.append(fn[:-2])
×
1012

1013
    # The raw argument list.
1014
    args = []
×
1015

1016
    for fn in sofiles:
×
1017
        afn = fn + ".o"
×
1018
        libsfn = fn + ".libs"
×
1019

1020
        args.append(afn)
×
1021
        with open(libsfn) as fd:
×
1022
            data = fd.read()
×
1023
            args.extend(data.split(" "))
×
1024

1025
    unique_args = []
×
1026
    while args:
×
1027
        a = args.pop()
×
1028
        if a in ('-L', ):
×
1029
            continue
×
1030
        if a not in unique_args:
×
1031
            unique_args.insert(0, a)
×
1032

1033
    for dir in extra_link_dirs:
×
1034
        link = '-L{}'.format(dir)
×
1035
        if link not in unique_args:
×
1036
            unique_args.append(link)
×
1037

1038
    cc_name = env['CC']
×
1039
    cc = sh.Command(cc_name.split()[0])
×
1040
    cc = cc.bake(*cc_name.split()[1:])
×
1041

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

1044

1045
def copylibs_function(soname, objs_paths, extra_link_dirs=None, env=None):
8✔
1046
    if extra_link_dirs is None:
×
1047
        extra_link_dirs = []
×
1048
    print('objs_paths are', objs_paths)
×
1049

1050
    re_needso = re.compile(r'^.*\(NEEDED\)\s+Shared library: \[lib(.*)\.so\]\s*$')
×
1051
    blacklist_libs = (
×
1052
        'c',
1053
        'stdc++',
1054
        'dl',
1055
        'python2.7',
1056
        'sdl',
1057
        'sdl_image',
1058
        'sdl_ttf',
1059
        'z',
1060
        'm',
1061
        'GLESv2',
1062
        'jpeg',
1063
        'png',
1064
        'log',
1065

1066
        # bootstrap takes care of sdl2 libs (if applicable)
1067
        'SDL2',
1068
        'SDL2_ttf',
1069
        'SDL2_image',
1070
        'SDL2_mixer',
1071
        'SDL3',
1072
        'SDL3_ttf',
1073
        'SDL3_image',
1074
        'SDL3_mixer',
1075
    )
1076
    found_libs = []
×
1077
    sofiles = []
×
1078
    if env and 'READELF' in env:
×
1079
        readelf = env['READELF']
×
1080
    elif 'READELF' in os.environ:
×
1081
        readelf = os.environ['READELF']
×
1082
    else:
1083
        readelf = shutil.which('readelf').strip()
×
1084
    readelf = sh.Command(readelf).bake('-d')
×
1085

1086
    dest = dirname(soname)
×
1087

1088
    for directory in objs_paths:
×
1089
        for fn in os.listdir(directory):
×
1090
            fn = join(directory, fn)
×
1091

1092
            if not fn.endswith('.libs'):
×
1093
                continue
×
1094

1095
            dirfn = fn[:-1] + 'dirs'
×
1096
            if not exists(dirfn):
×
1097
                continue
×
1098

1099
            with open(fn) as f:
×
1100
                libs = f.read().strip().split(' ')
×
1101
                needed_libs = [lib for lib in libs
×
1102
                               if lib and
1103
                               lib not in blacklist_libs and
1104
                               lib not in found_libs]
1105

1106
            while needed_libs:
×
1107
                print('need libs:\n\t' + '\n\t'.join(needed_libs))
×
1108

1109
                start_needed_libs = needed_libs[:]
×
1110
                found_sofiles = []
×
1111

1112
                with open(dirfn) as f:
×
1113
                    libdirs = f.read().split()
×
1114
                    for libdir in libdirs:
×
1115
                        if not needed_libs:
×
1116
                            break
×
1117

1118
                        if libdir == dest:
×
1119
                            # don't need to copy from dest to dest!
1120
                            continue
×
1121

1122
                        libdir = libdir.strip()
×
1123
                        print('scanning', libdir)
×
1124
                        for lib in needed_libs[:]:
×
1125
                            if lib in found_libs:
×
1126
                                continue
×
1127

1128
                            if lib.endswith('.a'):
×
1129
                                needed_libs.remove(lib)
×
1130
                                found_libs.append(lib)
×
1131
                                continue
×
1132

1133
                            lib_a = 'lib' + lib + '.a'
×
1134
                            libpath_a = join(libdir, lib_a)
×
1135
                            lib_so = 'lib' + lib + '.so'
×
1136
                            libpath_so = join(libdir, lib_so)
×
1137
                            plain_so = lib + '.so'
×
1138
                            plainpath_so = join(libdir, plain_so)
×
1139

1140
                            sopath = None
×
1141
                            if exists(libpath_so):
×
1142
                                sopath = libpath_so
×
1143
                            elif exists(plainpath_so):
×
1144
                                sopath = plainpath_so
×
1145

1146
                            if sopath:
×
1147
                                print('found', lib, 'in', libdir)
×
1148
                                found_sofiles.append(sopath)
×
1149
                                needed_libs.remove(lib)
×
1150
                                found_libs.append(lib)
×
1151
                                continue
×
1152

1153
                            if exists(libpath_a):
×
1154
                                print('found', lib, '(static) in', libdir)
×
1155
                                needed_libs.remove(lib)
×
1156
                                found_libs.append(lib)
×
1157
                                continue
×
1158

1159
                for sofile in found_sofiles:
×
1160
                    print('scanning dependencies for', sofile)
×
1161
                    out = readelf(sofile)
×
1162
                    for line in out.splitlines():
×
1163
                        needso = re_needso.match(line)
×
1164
                        if needso:
×
1165
                            lib = needso.group(1)
×
1166
                            if (lib not in needed_libs
×
1167
                                    and lib not in found_libs
1168
                                    and lib not in blacklist_libs):
1169
                                needed_libs.append(needso.group(1))
×
1170

1171
                sofiles += found_sofiles
×
1172

1173
                if needed_libs == start_needed_libs:
×
1174
                    raise RuntimeError(
×
1175
                            'Failed to locate needed libraries!\n\t' +
1176
                            '\n\t'.join(needed_libs))
1177

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

© 2026 Coveralls, Inc