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

kivy / python-for-android / 22020238684

14 Feb 2026 03:59PM UTC coverage: 63.451% (-0.4%) from 63.887%
22020238684

Pull #3280

github

web-flow
Merge 59a62c3db into 1fc026943
Pull Request #3280: Add support for prebuilt wheels

1827 of 3145 branches covered (58.09%)

Branch coverage included in aggregate %.

52 of 124 new or added lines in 5 files covered. (41.94%)

2 existing lines in 1 file now uncovered.

5315 of 8111 relevant lines covered (65.53%)

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

15
import sh
8✔
16

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

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

33

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

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

51

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

59

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

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

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

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

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

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

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

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

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

91
    ndk = None
8✔
92

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

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

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

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

102
    java_build_tool = 'auto'
8✔
103

104
    skip_prebuilt = False
8✔
105

106
    extra_index_urls = []
8✔
107

108
    use_prebuilt_version_for = []
8✔
109

110
    save_wheel_dir = ''
8✔
111

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

235
        '''
236

237
        self.ensure_dirs()
8✔
238

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

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

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

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

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

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

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

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

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

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

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

379
        self._build_env_prepared = False
8✔
380

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

486

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

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

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

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

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

513
        info_main('# Prebuilding recipes')
×
514
        # 2) prebuild packages
515
        for recipe in recipes:
×
516
            info_main('Prebuilding {} for {}'.format(recipe.name, arch.arch))
×
517
            recipe.prebuild_arch(arch)
×
518
            recipe.apply_patches(arch)
×
519

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

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

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

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

554

555
def project_has_setup_py(project_dir):
8✔
556
    return (project_dir is not None and
8✔
557
            (exists(join(project_dir, "setup.py")) or
558
             exists(join(project_dir, "pyproject.toml"))
559
            ))
560

561

562
def run_setuppy_install(ctx, project_dir, env=None, arch=None):
8✔
563
    env = env or {}
×
564

565
    with current_directory(project_dir):
×
566
        info('got setup.py or similar, running project install. ' +
×
567
             '(disable this behavior with --ignore-setup-py)')
568

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

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

588
            info('Populating venv\'s site-packages with '
×
589
                 'ctx.get_site_packages_dir()...')
590

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

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

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

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

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

672

673
def is_wheel_platform_independent(whl_name):
8✔
UNCOV
674
    name, version, build, tags = parse_wheel_filename(whl_name)
×
UNCOV
675
    return all(tag.platform == "any" for tag in tags)
×
676

677

678
def is_wheel_compatible(whl_name, arch, ctx):
8✔
679
    name, version, build, tags = parse_wheel_filename(whl_name)
8✔
680
    supported_tags = PyProjectRecipe.get_wheel_platform_tag(None, arch.arch, ctx=ctx)
8✔
681
    supported_tags.append("any")
8✔
682
    result = all(tag.platform in supported_tags for tag in tags)
8✔
683
    if not result:
8✔
684
        warning(f"Incompatible module : {whl_name}")
8✔
685
    return result
8✔
686

687

688
def process_python_modules(ctx, modules, arch):
8✔
689
    """Use pip --dry-run to resolve dependencies and filter for pure-Python packages
690
    """
691
    modules = list(modules)
8✔
692
    build_order = list(ctx.recipe_build_order)
8✔
693

694
    _requirement_names = []
8✔
695
    processed_modules = []
8✔
696

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

708
    if len(processed_modules) > 0:
8✔
709
        warning(f'Ignored by module resolver : {processed_modules}')
8✔
710

711
    # preserve the original module list
712
    processed_modules.extend(modules)
8✔
713

714
    if len(modules) == 0:
8✔
715
        return processed_modules
8✔
716

717
    # temp file for pip report
718
    fd, path = tempfile.mkstemp()
8✔
719
    os.close(fd)
8✔
720

721
    # setup hostpython recipe
722
    env = environ.copy()
8✔
723
    host_recipe = None
8✔
724
    try:
8✔
725
        host_recipe = Recipe.get_recipe("hostpython3", ctx)
8✔
726
        _python_path = host_recipe.get_path_to_python()
×
727
        libdir = glob.glob(join(_python_path, "build", "lib*"))
×
728
        env['PYTHONPATH'] = host_recipe.site_dir + ":" + join(
×
729
            _python_path, "Modules") + ":" + (libdir[0] if libdir else "")
730
        pip = host_recipe.pip
×
731
    except Exception:
8✔
732
        # hostpython3 non available so we use system pip (like in tests)
733
        pip = sh.Command("pip")
8✔
734

735
    # add platform tags
736
    platforms = []
8✔
737
    tags = PyProjectRecipe.get_wheel_platform_tag(None, arch.arch, ctx=ctx)
8✔
738
    for tag in tags:
8✔
739
        platforms.append(f"--platform={tag}")
8✔
740

741
    if host_recipe is not None:
8!
NEW
742
        platforms.extend(["--python-version", host_recipe.version])
×
743
    else:
744
        # tests?
745
        platforms.extend(["--python-version", "3.13.4"])
8✔
746

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

762
    with open(path, "r") as f:
8✔
763
        try:
8✔
764
            report = json.load(f)
8✔
765
        except Exception:
8✔
766
            report = {}
8✔
767

768
    os.remove(path)
8✔
769

770
    if "install" not in report.keys():
8✔
771
        # pip changed json reporting format?
772
        warning("Auto module resolution failed: invalid json!")
8✔
773
        return processed_modules
8✔
774

775
    info('Extra resolved pure python dependencies :')
8✔
776

777
    ignored_str = " (ignored)"
8✔
778
    # did we find any non pure python package?
779
    any_not_pure_python = False
8✔
780

781
    # just for style
782
    info(" ")
8✔
783
    for module in report["install"]:
8✔
784

785
        mname = module["metadata"]["name"]
8✔
786
        mver = module["metadata"]["version"]
8✔
787
        filename = basename(module["download_info"]["url"])
8✔
788
        pure_python = True
8✔
789

790
        if (
8!
791
                filename.endswith(".whl") and not is_wheel_compatible(filename, arch, ctx)
792
        ):
793
            any_not_pure_python = True
×
794
            pure_python = False
×
795

796
        # does this module matches any recipe name?
797
        if mname.lower().replace("-", "_") in _requirement_names:
8!
798
            continue
8✔
799

800
        color = Out_Fore.GREEN if pure_python else Out_Fore.RED
×
801
        ignored = "" if pure_python else ignored_str
×
802

803
        info(
×
804
            f"  {color}{mname}{Out_Fore.WHITE} : "
805
            f"{Out_Style.BRIGHT}{mver}{Out_Style.RESET_ALL}"
806
            f"{ignored}"
807
        )
808

809
        if pure_python:
×
810
            processed_modules.append(f"{mname}=={mver}")
×
811
    info(" ")
8✔
812

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

817
    return processed_modules
8✔
818

819

820
def run_pymodules_install(ctx, arch, modules, project_dir=None,
8✔
821
                          ignore_setup_py=False):
822
    """ This function will take care of all non-recipe things, by:
823

824
        1. Processing them from --requirements (the modules argument)
825
           and installing them
826

827
        2. Installing the user project/app itself via setup.py if
828
           ignore_setup_py=True
829

830
    """
831

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

834
    modules = process_python_modules(ctx, modules, arch)
8✔
835

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

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

842
    # Bail out if no python deps and no setup.py to process:
843
    if not modules and (
8✔
844
            ignore_setup_py or
845
            not project_has_setup_py(project_dir)
846
            ):
847
        info('No Python modules and no setup.py to process, skipping')
8✔
848
        return
8✔
849

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

866
    # Use our hostpython to create the virtualenv
867
    host_python = sh.Command(ctx.hostpython)
8✔
868
    with current_directory(join(ctx.build_dir)):
8✔
869
        shprint(host_python, '-m', 'venv', 'venv')
8✔
870

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

879
        # Install Cython in case modules need it to build:
880
        info('Install Cython in case one of the modules needs it to build')
8✔
881
        shprint(sh.bash, '-c', (
8✔
882
            "venv/bin/pip install Cython"
883
        ), _env=copy.copy(base_env))
884

885
        # Get environment variables for build (with CC/compiler set):
886
        standard_recipe = CythonRecipe()
8✔
887
        standard_recipe.ctx = ctx
8✔
888
        # (note: following line enables explicit -lpython... linker options)
889
        standard_recipe.call_hostpython_via_targetpython = False
8✔
890
        recipe_env = standard_recipe.get_recipe_env(ctx.archs[0])
8✔
891
        env = copy.copy(base_env)
8✔
892
        env.update(recipe_env)
8✔
893

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

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

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

925
            shprint(sh.bash, '-c', (
8✔
926
                "venv/bin/pip " +
927
                "install -v --target '{0}' --no-deps -r requirements.txt"
928
            ).format(ctx.get_site_packages_dir(arch).replace("'", "'\"'\"'")),
929
                    _env=copy.copy(env))
930

931
        # Afterwards, run setup.py if present:
932
        if project_has_setup_py(project_dir) and not ignore_setup_py:
8!
933
            run_setuppy_install(ctx, project_dir, env, arch)
×
934
        elif not ignore_setup_py:
8!
935
            info("No setup.py found in project directory: " + str(project_dir))
8✔
936

937
        # Strip object files after potential Cython or native code builds:
938
        if not ctx.with_debug_symbols:
8✔
939
            standard_recipe.strip_object_files(
8✔
940
                arch, env, build_dir=ctx.build_dir
941
            )
942

943

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

966
    env = arch.get_env()
×
967
    env['LDFLAGS'] = env['LDFLAGS'] + ' -L{}'.format(
×
968
        join(ctx.bootstrap.build_dir, 'obj', 'local', arch.arch))
969

970
    if not len(glob.glob(join(obj_dir, '*'))):
×
971
        info('There seem to be no libraries to biglink, skipping.')
×
972
        return
×
973
    info('Biglinking')
×
974
    info('target {}'.format(join(ctx.get_libs_dir(arch.arch),
×
975
                                 'libpymodules.so')))
976
    do_biglink = copylibs_function if ctx.copy_libs else biglink_function
×
977

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

989

990
def biglink_function(soname, objs_paths, extra_link_dirs=None, env=None):
8✔
991
    if extra_link_dirs is None:
×
992
        extra_link_dirs = []
×
993
    print('objs_paths are', objs_paths)
×
994
    sofiles = []
×
995

996
    for directory in objs_paths:
×
997
        for fn in os.listdir(directory):
×
998
            fn = os.path.join(directory, fn)
×
999

1000
            if not fn.endswith(".so.o"):
×
1001
                continue
×
1002
            if not os.path.exists(fn[:-2] + ".libs"):
×
1003
                continue
×
1004

1005
            sofiles.append(fn[:-2])
×
1006

1007
    # The raw argument list.
1008
    args = []
×
1009

1010
    for fn in sofiles:
×
1011
        afn = fn + ".o"
×
1012
        libsfn = fn + ".libs"
×
1013

1014
        args.append(afn)
×
1015
        with open(libsfn) as fd:
×
1016
            data = fd.read()
×
1017
            args.extend(data.split(" "))
×
1018

1019
    unique_args = []
×
1020
    while args:
×
1021
        a = args.pop()
×
1022
        if a in ('-L', ):
×
1023
            continue
×
1024
        if a not in unique_args:
×
1025
            unique_args.insert(0, a)
×
1026

1027
    for dir in extra_link_dirs:
×
1028
        link = '-L{}'.format(dir)
×
1029
        if link not in unique_args:
×
1030
            unique_args.append(link)
×
1031

1032
    cc_name = env['CC']
×
1033
    cc = sh.Command(cc_name.split()[0])
×
1034
    cc = cc.bake(*cc_name.split()[1:])
×
1035

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

1038

1039
def copylibs_function(soname, objs_paths, extra_link_dirs=None, env=None):
8✔
1040
    if extra_link_dirs is None:
×
1041
        extra_link_dirs = []
×
1042
    print('objs_paths are', objs_paths)
×
1043

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

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

1080
    dest = dirname(soname)
×
1081

1082
    for directory in objs_paths:
×
1083
        for fn in os.listdir(directory):
×
1084
            fn = join(directory, fn)
×
1085

1086
            if not fn.endswith('.libs'):
×
1087
                continue
×
1088

1089
            dirfn = fn[:-1] + 'dirs'
×
1090
            if not exists(dirfn):
×
1091
                continue
×
1092

1093
            with open(fn) as f:
×
1094
                libs = f.read().strip().split(' ')
×
1095
                needed_libs = [lib for lib in libs
×
1096
                               if lib and
1097
                               lib not in blacklist_libs and
1098
                               lib not in found_libs]
1099

1100
            while needed_libs:
×
1101
                print('need libs:\n\t' + '\n\t'.join(needed_libs))
×
1102

1103
                start_needed_libs = needed_libs[:]
×
1104
                found_sofiles = []
×
1105

1106
                with open(dirfn) as f:
×
1107
                    libdirs = f.read().split()
×
1108
                    for libdir in libdirs:
×
1109
                        if not needed_libs:
×
1110
                            break
×
1111

1112
                        if libdir == dest:
×
1113
                            # don't need to copy from dest to dest!
1114
                            continue
×
1115

1116
                        libdir = libdir.strip()
×
1117
                        print('scanning', libdir)
×
1118
                        for lib in needed_libs[:]:
×
1119
                            if lib in found_libs:
×
1120
                                continue
×
1121

1122
                            if lib.endswith('.a'):
×
1123
                                needed_libs.remove(lib)
×
1124
                                found_libs.append(lib)
×
1125
                                continue
×
1126

1127
                            lib_a = 'lib' + lib + '.a'
×
1128
                            libpath_a = join(libdir, lib_a)
×
1129
                            lib_so = 'lib' + lib + '.so'
×
1130
                            libpath_so = join(libdir, lib_so)
×
1131
                            plain_so = lib + '.so'
×
1132
                            plainpath_so = join(libdir, plain_so)
×
1133

1134
                            sopath = None
×
1135
                            if exists(libpath_so):
×
1136
                                sopath = libpath_so
×
1137
                            elif exists(plainpath_so):
×
1138
                                sopath = plainpath_so
×
1139

1140
                            if sopath:
×
1141
                                print('found', lib, 'in', libdir)
×
1142
                                found_sofiles.append(sopath)
×
1143
                                needed_libs.remove(lib)
×
1144
                                found_libs.append(lib)
×
1145
                                continue
×
1146

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

1153
                for sofile in found_sofiles:
×
1154
                    print('scanning dependencies for', sofile)
×
1155
                    out = readelf(sofile)
×
1156
                    for line in out.splitlines():
×
1157
                        needso = re_needso.match(line)
×
1158
                        if needso:
×
1159
                            lib = needso.group(1)
×
1160
                            if (lib not in needed_libs
×
1161
                                    and lib not in found_libs
1162
                                    and lib not in blacklist_libs):
1163
                                needed_libs.append(needso.group(1))
×
1164

1165
                sofiles += found_sofiles
×
1166

1167
                if needed_libs == start_needed_libs:
×
1168
                    raise RuntimeError(
×
1169
                            'Failed to locate needed libraries!\n\t' +
1170
                            '\n\t'.join(needed_libs))
1171

1172
    print('Copying libraries')
×
1173
    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