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

kivy / python-for-android / 21338334827

25 Jan 2026 07:34PM UTC coverage: 63.887% (+0.06%) from 63.828%
21338334827

push

github

web-flow
`toolchain`: auto resolve deps (#3271)

* toolchain: auto resolve deps

* fix tests

1823 of 3111 branches covered (58.6%)

Branch coverage included in aggregate %.

73 of 107 new or added lines in 4 files covered. (68.22%)

3 existing lines in 1 file now uncovered.

5287 of 8018 relevant lines covered (65.94%)

5.26 hits per line

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

49.44
/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
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
    @property
8✔
105
    def packages_path(self):
8✔
106
        '''Where packages are downloaded before being unpacked'''
107
        return join(self.storage_dir, 'packages')
8✔
108

109
    @property
8✔
110
    def templates_dir(self):
8✔
111
        return join(self.root_dir, 'templates')
×
112

113
    @property
8✔
114
    def libs_dir(self):
8✔
115
        """
116
        where Android libs are cached after build
117
        but before being placed in dists
118
        """
119
        # Was previously hardcoded as self.build_dir/libs
120
        directory = join(self.build_dir, 'libs_collections',
8✔
121
                         self.bootstrap.distribution.name)
122
        ensure_dir(directory)
8✔
123
        return directory
8✔
124

125
    @property
8✔
126
    def javaclass_dir(self):
8✔
127
        # Was previously hardcoded as self.build_dir/java
128
        directory = join(self.build_dir, 'javaclasses',
8✔
129
                         self.bootstrap.distribution.name)
130
        ensure_dir(directory)
8✔
131
        return directory
8✔
132

133
    @property
8✔
134
    def aars_dir(self):
8✔
135
        directory = join(self.build_dir, 'aars', self.bootstrap.distribution.name)
8✔
136
        ensure_dir(directory)
8✔
137
        return directory
8✔
138

139
    @property
8✔
140
    def python_installs_dir(self):
8✔
141
        directory = join(self.build_dir, 'python-installs')
8✔
142
        ensure_dir(directory)
8✔
143
        return directory
8✔
144

145
    def get_python_install_dir(self, arch):
8✔
146
        return join(self.python_installs_dir, self.bootstrap.distribution.name, arch)
8✔
147

148
    def setup_dirs(self, storage_dir):
8✔
149
        '''Calculates all the storage and build dirs, and makes sure
150
        the directories exist where necessary.'''
151
        self.storage_dir = expanduser(storage_dir)
8✔
152
        if ' ' in self.storage_dir:
8!
153
            raise ValueError('storage dir path cannot contain spaces, please '
×
154
                             'specify a path with --storage-dir')
155
        self.build_dir = join(self.storage_dir, 'build')
8✔
156
        self.dist_dir = join(self.storage_dir, 'dists')
8✔
157

158
    def ensure_dirs(self):
8✔
159
        ensure_dir(self.storage_dir)
8✔
160
        ensure_dir(self.build_dir)
8✔
161
        ensure_dir(self.dist_dir)
8✔
162
        ensure_dir(join(self.build_dir, 'bootstrap_builds'))
8✔
163
        ensure_dir(join(self.build_dir, 'other_builds'))
8✔
164

165
    @property
8✔
166
    def android_api(self):
8✔
167
        '''The Android API being targeted.'''
168
        if self._android_api is None:
8!
169
            raise ValueError('Tried to access android_api but it has not '
×
170
                             'been set - this should not happen, something '
171
                             'went wrong!')
172
        return self._android_api
8✔
173

174
    @android_api.setter
8✔
175
    def android_api(self, value):
8✔
176
        self._android_api = value
8✔
177

178
    @property
8✔
179
    def ndk_api(self):
8✔
180
        '''The API number compile against'''
181
        if self._ndk_api is None:
8!
182
            raise ValueError('Tried to access ndk_api but it has not '
×
183
                             'been set - this should not happen, something '
184
                             'went wrong!')
185
        return self._ndk_api
8✔
186

187
    @ndk_api.setter
8✔
188
    def ndk_api(self, value):
8✔
189
        self._ndk_api = value
8✔
190

191
    @property
8✔
192
    def sdk_dir(self):
8✔
193
        '''The path to the Android SDK.'''
194
        if self._sdk_dir is None:
8!
195
            raise ValueError('Tried to access sdk_dir but it has not '
×
196
                             'been set - this should not happen, something '
197
                             'went wrong!')
198
        return self._sdk_dir
8✔
199

200
    @sdk_dir.setter
8✔
201
    def sdk_dir(self, value):
8✔
202
        self._sdk_dir = value
8✔
203

204
    @property
8✔
205
    def ndk_dir(self):
8✔
206
        '''The path to the Android NDK.'''
207
        if self._ndk_dir is None:
8!
208
            raise ValueError('Tried to access ndk_dir but it has not '
×
209
                             'been set - this should not happen, something '
210
                             'went wrong!')
211
        return self._ndk_dir
8✔
212

213
    @ndk_dir.setter
8✔
214
    def ndk_dir(self, value):
8✔
215
        self._ndk_dir = value
8✔
216

217
    def prepare_build_environment(self,
8✔
218
                                  user_sdk_dir,
219
                                  user_ndk_dir,
220
                                  user_android_api,
221
                                  user_ndk_api):
222
        '''Checks that build dependencies exist and sets internal variables
223
        for the Android SDK etc.
224

225
        ..warning:: This *must* be called before trying any build stuff
226

227
        '''
228

229
        self.ensure_dirs()
8✔
230

231
        if self._build_env_prepared:
8!
232
            return
×
233

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

262
        # Check what Android API we're using
263
        android_api = None
8✔
264
        if user_android_api:
8!
265
            android_api = user_android_api
×
266
            info('Getting Android API version from user argument: {}'.format(android_api))
×
267
        elif 'ANDROIDAPI' in environ:
8!
268
            android_api = environ['ANDROIDAPI']
×
269
            info('Found Android API target in $ANDROIDAPI: {}'.format(android_api))
×
270
        else:
271
            info('Android API target was not set manually, using '
8✔
272
                 'the default of {}'.format(RECOMMENDED_TARGET_API))
273
            android_api = RECOMMENDED_TARGET_API
8✔
274
        android_api = int(android_api)
8✔
275
        self.android_api = android_api
8✔
276

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

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

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

341
        check_ndk_api(ndk_api, self.android_api)
8✔
342

343
        self.ndk = AndroidNDK(self.ndk_dir)
8✔
344

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

359
        self.env["PATH"] = ":".join(
8✔
360
            [
361
                self.ndk.llvm_bin_dir,
362
                self.ndk_dir,
363
                f"{self.sdk_dir}/tools",
364
                environ.get("PATH"),
365
            ]
366
        )
367

368
    def __init__(self):
8✔
369
        self.include_dirs = []
8✔
370

371
        self._build_env_prepared = False
8✔
372

373
        self._sdk_dir = None
8✔
374
        self._ndk_dir = None
8✔
375
        self._android_api = None
8✔
376
        self._ndk_api = None
8✔
377
        self.ndk = None
8✔
378

379
        self.local_recipes = None
8✔
380
        self.copy_libs = False
8✔
381

382
        self.activity_class_name = u'org.kivy.android.PythonActivity'
8✔
383
        self.service_class_name = u'org.kivy.android.PythonService'
8✔
384

385
        # this list should contain all Archs, it is pruned later
386
        self.archs = (
8✔
387
            ArchARM(self),
388
            ArchARMv7_a(self),
389
            Archx86(self),
390
            Archx86_64(self),
391
            ArchAarch_64(self),
392
            )
393

394
        self.root_dir = realpath(dirname(__file__))
8✔
395

396
        # remove the most obvious flags that can break the compilation
397
        self.env.pop("LDFLAGS", None)
8✔
398
        self.env.pop("ARCHFLAGS", None)
8✔
399
        self.env.pop("CFLAGS", None)
8✔
400

401
        self.python_recipe = None  # Set by TargetPythonRecipe
8✔
402

403
    def set_archs(self, arch_names):
8✔
404
        all_archs = self.archs
8✔
405
        new_archs = set()
8✔
406
        for name in arch_names:
8✔
407
            matching = [arch for arch in all_archs if arch.arch == name]
8✔
408
            for match in matching:
8✔
409
                new_archs.add(match)
8✔
410
        self.archs = list(new_archs)
8✔
411
        if not self.archs:
8!
412
            raise BuildInterruptingException('Asked to compile for no Archs, so failing.')
×
413
        info('Will compile for the following archs: {}'.format(
8✔
414
            ', '.join(arch.arch for arch in self.archs)))
415

416
    def prepare_bootstrap(self, bootstrap):
8✔
417
        if not bootstrap:
8!
418
            raise TypeError("None is not allowed for bootstrap")
×
419
        bootstrap.ctx = self
8✔
420
        self.bootstrap = bootstrap
8✔
421
        self.bootstrap.prepare_build_dir()
8✔
422
        self.bootstrap_build_dir = self.bootstrap.build_dir
8✔
423

424
    def prepare_dist(self):
8✔
425
        self.bootstrap.prepare_dist_dir()
8✔
426

427
    def get_site_packages_dir(self, arch):
8✔
428
        '''Returns the location of site-packages in the python-install build
429
        dir.
430
        '''
431
        return self.get_python_install_dir(arch.arch)
×
432

433
    def get_libs_dir(self, arch):
8✔
434
        '''The libs dir for a given arch.'''
435
        ensure_dir(join(self.libs_dir, arch))
8✔
436
        return join(self.libs_dir, arch)
8✔
437

438
    def has_lib(self, arch, lib):
8✔
439
        return exists(join(self.get_libs_dir(arch), lib))
8✔
440

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

454
        # normalize name to remove version tags
NEW
455
        try:
×
NEW
456
            name = Requirement(name).name
×
NEW
457
        except Exception:
×
NEW
458
            pass
×
459

460
        # Try to look up recipe by name:
461
        try:
×
462
            recipe = Recipe.get_recipe(name, self)
×
463
        except ValueError:
×
464
            pass
×
465
        else:
466
            name = getattr(recipe, 'site_packages_name', None) or name
×
467
        name = name.replace('.', '/')
×
468
        site_packages_dir = self.get_site_packages_dir(arch)
×
469
        return (exists(join(site_packages_dir, name)) or
×
470
                exists(join(site_packages_dir, name + '.py')) or
471
                exists(join(site_packages_dir, name + '.pyc')) or
472
                exists(join(site_packages_dir, name + '.so')) or
473
                glob.glob(join(site_packages_dir, name + '-*.egg')))
474

475
    def not_has_package(self, name, arch=None):
8✔
476
        return not self.has_package(name, arch)
×
477

478

479
def build_recipes(build_order, python_modules, ctx, project_dir,
8✔
480
                  ignore_project_setup_py=False
481
                 ):
482
    # Put recipes in correct build order
483
    info_notify("Recipe build order is {}".format(build_order))
×
484
    if python_modules:
×
485
        python_modules = sorted(set(python_modules))
×
486
        info_notify(
×
487
            ('The requirements ({}) were not found as recipes, they will be '
488
             'installed with pip.').format(', '.join(python_modules)))
489

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

492
    # download is arch independent
493
    info_main('# Downloading recipes ')
×
494
    for recipe in recipes:
×
495
        recipe.download_if_necessary()
×
496

497
    for arch in ctx.archs:
×
498
        info_main('# Building all recipes for arch {}'.format(arch.arch))
×
499

500
        info_main('# Unpacking recipes')
×
501
        for recipe in recipes:
×
502
            ensure_dir(recipe.get_build_container_dir(arch.arch))
×
503
            recipe.prepare_build_dir(arch.arch)
×
504

505
        info_main('# Prebuilding recipes')
×
506
        # 2) prebuild packages
507
        for recipe in recipes:
×
508
            info_main('Prebuilding {} for {}'.format(recipe.name, arch.arch))
×
509
            recipe.prebuild_arch(arch)
×
510
            recipe.apply_patches(arch)
×
511

512
        # 3) build packages
513
        info_main('# Building recipes')
×
514
        for recipe in recipes:
×
515
            info_main('Building {} for {}'.format(recipe.name, arch.arch))
×
516
            if recipe.should_build(arch):
×
517
                recipe.build_arch(arch)
×
518
            else:
519
                info('{} said it is already built, skipping'
×
520
                     .format(recipe.name))
521
            recipe.install_libraries(arch)
×
522

523
        # 4) biglink everything
524
        info_main('# Biglinking object files')
×
525
        if not ctx.python_recipe:
×
526
            biglink(ctx, arch)
×
527
        else:
528
            warning(
×
529
                "Context's python recipe found, "
530
                "skipping biglink (will this work?)"
531
            )
532

533
        # 5) postbuild packages
534
        info_main('# Postbuilding recipes')
×
535
        for recipe in recipes:
×
536
            info_main('Postbuilding {} for {}'.format(recipe.name, arch.arch))
×
537
            recipe.postbuild_arch(arch)
×
538

539
    info_main('# Installing pure Python modules')
×
540
    for arch in ctx.archs:
×
541
        run_pymodules_install(
×
542
            ctx, arch, python_modules, project_dir,
543
            ignore_setup_py=ignore_project_setup_py
544
        )
545

546

547
def project_has_setup_py(project_dir):
8✔
548
    return (project_dir is not None and
8✔
549
            (exists(join(project_dir, "setup.py")) or
550
             exists(join(project_dir, "pyproject.toml"))
551
            ))
552

553

554
def run_setuppy_install(ctx, project_dir, env=None, arch=None):
8✔
555
    env = env or {}
×
556

557
    with current_directory(project_dir):
×
558
        info('got setup.py or similar, running project install. ' +
×
559
             '(disable this behavior with --ignore-setup-py)')
560

561
        # Compute & output the constraints we will use:
562
        info('Contents that will be used for constraints.txt:')
×
563
        constraints = subprocess.check_output([
×
564
            join(
565
                ctx.build_dir, "venv", "bin", "pip"
566
            ),
567
            "freeze"
568
        ], env=copy.copy(env))
569
        with suppress(AttributeError):
×
570
            constraints = constraints.decode("utf-8", "replace")
×
571
        info(constraints)
×
572

573
        # Make sure all packages found are fixed in version
574
        # by writing a constraint file, to avoid recipes being
575
        # upgraded & reinstalled:
576
        with open('._tmp_p4a_recipe_constraints.txt', 'wb') as fileh:
×
577
            fileh.write(constraints.encode("utf-8", "replace"))
×
578
        try:
×
579

580
            info('Populating venv\'s site-packages with '
×
581
                 'ctx.get_site_packages_dir()...')
582

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

617
            # Get listing of virtualenv's site-packages, to see the
618
            # newly added things afterwards & copy them back into
619
            # the distribution folder / build context site-packages:
620
            previous_venv_contents = os.listdir(
×
621
                venv_site_packages_dir
622
            )
623

624
            # Actually run setup.py:
625
            info('Launching package install...')
×
626
            shprint(sh.bash, '-c', (
×
627
                "'" + join(
628
                    ctx.build_dir, "venv", "bin", "pip"
629
                ).replace("'", "'\"'\"'") + "' " +
630
                "install -c ._tmp_p4a_recipe_constraints.txt -v ."
631
            ).format(ctx.get_site_packages_dir(arch).
632
                     replace("'", "'\"'\"'")),
633
                    _env=copy.copy(env))
634

635
            # Go over all new additions and copy them back:
636
            info('Copying additions resulting from setup.py back '
×
637
                 'into ctx.get_site_packages_dir()...')
638
            new_venv_additions = []
×
639
            for f in (set(os.listdir(venv_site_packages_dir)) -
×
640
                      set(previous_venv_contents)):
641
                new_venv_additions.append(f)
×
642
                full_path = os.path.join(venv_site_packages_dir, f)
×
643
                if os.path.isdir(full_path):
×
644
                    shutil.copytree(full_path, os.path.join(
×
645
                        ctx_site_packages_dir, f
646
                    ))
647
                else:
648
                    shutil.copy2(full_path, os.path.join(
×
649
                        ctx_site_packages_dir, f
650
                    ))
651

652
            # Undo all the changes we did to the venv-site packages:
653
            info('Reverting additions to '
×
654
                 'virtualenv\'s site-packages...')
655
            for f in set(copied_over_contents + new_venv_additions):
×
656
                full_path = os.path.join(venv_site_packages_dir, f)
×
657
                if os.path.isdir(full_path):
×
658
                    rmdir(full_path)
×
659
                else:
660
                    os.remove(full_path)
×
661
        finally:
662
            os.remove("._tmp_p4a_recipe_constraints.txt")
×
663

664

665
def is_wheel_platform_independent(whl_name):
8✔
666
    name, version, build, tags = parse_wheel_filename(whl_name)
8✔
667
    return all(tag.platform == "any" for tag in tags)
8✔
668

669

670
def process_python_modules(ctx, modules):
8✔
671
    """Use pip --dry-run to resolve dependencies and filter for pure-Python packages
672
    """
673
    modules = list(modules)
8✔
674
    build_order = list(ctx.recipe_build_order)
8✔
675

676
    _requirement_names = []
8✔
677
    processed_modules = []
8✔
678

679
    for module in modules+build_order:
8✔
680
        try:
8✔
681
            # we need to normalize names
682
            # eg Requests>=2.0 becomes requests
683
            _requirement_names.append(Requirement(module).name)
8✔
684
        except Exception:
8✔
685
            # name parsing failed; skip processing this module via pip
686
            processed_modules.append(module)
8✔
687
            if module in modules:
8!
688
                modules.remove(module)
8✔
689

690
    if len(processed_modules) > 0:
8✔
691
        warning(f'Ignored by module resolver : {processed_modules}')
8✔
692

693
    # preserve the original module list
694
    processed_modules.extend(modules)
8✔
695

696
    if len(modules) == 0:
8✔
697
        return processed_modules
8✔
698

699
    # temp file for pip report
700
    fd, path = tempfile.mkstemp()
8✔
701
    os.close(fd)
8✔
702

703
    # setup hostpython recipe
704
    env = environ.copy()
8✔
705
    try:
8✔
706
        host_recipe = Recipe.get_recipe("hostpython3", ctx)
8✔
NEW
707
        _python_path = host_recipe.get_path_to_python()
×
NEW
708
        libdir = glob.glob(join(_python_path, "build", "lib*"))
×
NEW
709
        env['PYTHONPATH'] = host_recipe.site_dir + ":" + join(
×
710
            _python_path, "Modules") + ":" + (libdir[0] if libdir else "")
NEW
711
        pip = host_recipe.pip
×
712
    except Exception:
8✔
713
        # hostpython3 non available so we use system pip (like in tests)
714
        pip = sh.Command("pip")
8✔
715

716
    try:
8✔
717
        shprint(
8✔
718
            pip, 'install', *modules,
719
            '--dry-run', '--break-system-packages', '--ignore-installed',
720
            '--report', path, '-q', _env=env
721
        )
NEW
722
    except Exception as e:
×
NEW
723
        warning(f"Auto module resolution failed: {e}")
×
NEW
724
        return processed_modules
×
725

726
    with open(path, "r") as f:
8✔
727
        try:
8✔
728
            report = json.load(f)
8✔
729
        except Exception:
8✔
730
            report = {}
8✔
731

732
    os.remove(path)
8✔
733

734
    if "install" not in report.keys():
8✔
735
        # pip changed json reporting format?
736
        warning("Auto module resolution failed: invalid json!")
8✔
737
        return processed_modules
8✔
738

739
    info('Extra resolved pure python dependencies :')
8✔
740

741
    ignored_str = " (ignored)"
8✔
742
    # did we find any non pure python package?
743
    any_not_pure_python = False
8✔
744

745
    # just for style
746
    info(" ")
8✔
747
    for module in report["install"]:
8✔
748

749
        mname = module["metadata"]["name"]
8✔
750
        mver = module["metadata"]["version"]
8✔
751
        filename = basename(module["download_info"]["url"])
8✔
752
        pure_python = True
8✔
753

754
        if (filename.endswith(".whl") and not is_wheel_platform_independent(filename)):
8!
NEW
755
            any_not_pure_python = True
×
NEW
756
            pure_python = False
×
757

758
        # does this module matches any recipe name?
759
        if mname.lower().replace("-", "_") in _requirement_names:
8!
760
            continue
8✔
761

NEW
762
        color = Out_Fore.GREEN if pure_python else Out_Fore.RED
×
NEW
763
        ignored = "" if pure_python else ignored_str
×
764

NEW
765
        info(
×
766
            f"  {color}{mname}{Out_Fore.WHITE} : "
767
            f"{Out_Style.BRIGHT}{mver}{Out_Style.RESET_ALL}"
768
            f"{ignored}"
769
        )
770

NEW
771
        if pure_python:
×
NEW
772
            processed_modules.append(f"{mname}=={mver}")
×
773
    info(" ")
8✔
774

775
    if any_not_pure_python:
8!
NEW
776
        warning("Some packages were ignored because they are not pure Python.")
×
NEW
777
        warning("To install the ignored packages, explicitly list them in your requirements file.")
×
778

779
    return processed_modules
8✔
780

781

782
def run_pymodules_install(ctx, arch, modules, project_dir=None,
8✔
783
                          ignore_setup_py=False):
784
    """ This function will take care of all non-recipe things, by:
785

786
        1. Processing them from --requirements (the modules argument)
787
           and installing them
788

789
        2. Installing the user project/app itself via setup.py if
790
           ignore_setup_py=True
791

792
    """
793

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

796
    modules = process_python_modules(ctx, modules)
8✔
797

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

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

804
    # Bail out if no python deps and no setup.py to process:
805
    if not modules and (
8✔
806
            ignore_setup_py or
807
            not project_has_setup_py(project_dir)
808
            ):
809
        info('No Python modules and no setup.py to process, skipping')
8✔
810
        return
8✔
811

812
    # Output messages about what we're going to do:
813
    if modules:
8!
814
        info(
8✔
815
            "The requirements ({}) don\'t have recipes, attempting to "
816
            "install them with pip".format(', '.join(modules))
817
        )
818
        info(
8✔
819
            "If this fails, it may mean that the module has compiled "
820
            "components and needs a recipe."
821
        )
822
    if project_has_setup_py(project_dir) and not ignore_setup_py:
8!
823
        info(
×
824
            "Will process project install, if it fails then the "
825
            "project may not be compatible for Android install."
826
        )
827

828
    # Use our hostpython to create the virtualenv
829
    host_python = sh.Command(ctx.hostpython)
8✔
830
    with current_directory(join(ctx.build_dir)):
8✔
831
        shprint(host_python, '-m', 'venv', 'venv')
8✔
832

833
        # Prepare base environment and upgrade pip:
834
        base_env = dict(copy.copy(os.environ))
8✔
835
        base_env["PYTHONPATH"] = ctx.get_site_packages_dir(arch)
8✔
836
        info('Upgrade pip to latest version')
8✔
837
        shprint(sh.bash, '-c', (
8✔
838
            "source venv/bin/activate && pip install -U pip"
839
        ), _env=copy.copy(base_env))
840

841
        # Install Cython in case modules need it to build:
842
        info('Install Cython in case one of the modules needs it to build')
8✔
843
        shprint(sh.bash, '-c', (
8✔
844
            "venv/bin/pip install Cython"
845
        ), _env=copy.copy(base_env))
846

847
        # Get environment variables for build (with CC/compiler set):
848
        standard_recipe = CythonRecipe()
8✔
849
        standard_recipe.ctx = ctx
8✔
850
        # (note: following line enables explicit -lpython... linker options)
851
        standard_recipe.call_hostpython_via_targetpython = False
8✔
852
        recipe_env = standard_recipe.get_recipe_env(ctx.archs[0])
8✔
853
        env = copy.copy(base_env)
8✔
854
        env.update(recipe_env)
8✔
855

856
        # Make sure our build package dir is available, and the virtualenv
857
        # site packages come FIRST (so the proper pip version is used):
858
        env["PYTHONPATH"] += ":" + ctx.get_site_packages_dir(arch)
8✔
859
        env["PYTHONPATH"] = os.path.abspath(join(
8✔
860
            ctx.build_dir, "venv", "lib",
861
            "python" + ctx.python_recipe.major_minor_version_string,
862
            "site-packages")) + ":" + env["PYTHONPATH"]
863

864
        # Install the manually specified requirements first:
865
        if not modules:
8!
866
            info('There are no Python modules to install, skipping')
×
867
        else:
868
            info('Creating a requirements.txt file for the Python modules')
8✔
869
            with open('requirements.txt', 'w') as fileh:
8✔
870
                for module in modules:
8✔
871
                    key = 'VERSION_' + module
8✔
872
                    if key in environ:
8!
873
                        line = '{}=={}\n'.format(module, environ[key])
×
874
                    else:
875
                        line = '{}\n'.format(module)
8✔
876
                    fileh.write(line)
8✔
877

878
            info('Installing Python modules with pip')
8✔
879
            info(
8✔
880
                "IF THIS FAILS, THE MODULES MAY NEED A RECIPE. "
881
                "A reason for this is often modules compiling "
882
                "native code that is unaware of Android cross-compilation "
883
                "and does not work without additional "
884
                "changes / workarounds."
885
            )
886

887
            shprint(sh.bash, '-c', (
8✔
888
                "venv/bin/pip " +
889
                "install -v --target '{0}' --no-deps -r requirements.txt"
890
            ).format(ctx.get_site_packages_dir(arch).replace("'", "'\"'\"'")),
891
                    _env=copy.copy(env))
892

893
        # Afterwards, run setup.py if present:
894
        if project_has_setup_py(project_dir) and not ignore_setup_py:
8!
895
            run_setuppy_install(ctx, project_dir, env, arch)
×
896
        elif not ignore_setup_py:
8!
897
            info("No setup.py found in project directory: " + str(project_dir))
8✔
898

899
        # Strip object files after potential Cython or native code builds:
900
        if not ctx.with_debug_symbols:
8✔
901
            standard_recipe.strip_object_files(
8✔
902
                arch, env, build_dir=ctx.build_dir
903
            )
904

905

906
def biglink(ctx, arch):
8✔
907
    # First, collate object files from each recipe
908
    info('Collating object files from each recipe')
×
909
    obj_dir = join(ctx.bootstrap.build_dir, 'collated_objects')
×
910
    ensure_dir(obj_dir)
×
911
    recipes = [Recipe.get_recipe(name, ctx) for name in ctx.recipe_build_order]
×
912
    for recipe in recipes:
×
913
        recipe_obj_dir = join(recipe.get_build_container_dir(arch.arch),
×
914
                              'objects_{}'.format(recipe.name))
915
        if not exists(recipe_obj_dir):
×
916
            info('{} recipe has no biglinkable files dir, skipping'
×
917
                 .format(recipe.name))
918
            continue
×
919
        files = glob.glob(join(recipe_obj_dir, '*'))
×
920
        if not len(files):
×
921
            info('{} recipe has no biglinkable files, skipping'
×
922
                 .format(recipe.name))
923
            continue
×
924
        info('{} recipe has object files, copying'.format(recipe.name))
×
925
        files.append(obj_dir)
×
926
        shprint(sh.cp, '-r', *files)
×
927

928
    env = arch.get_env()
×
929
    env['LDFLAGS'] = env['LDFLAGS'] + ' -L{}'.format(
×
930
        join(ctx.bootstrap.build_dir, 'obj', 'local', arch.arch))
931

932
    if not len(glob.glob(join(obj_dir, '*'))):
×
933
        info('There seem to be no libraries to biglink, skipping.')
×
934
        return
×
935
    info('Biglinking')
×
936
    info('target {}'.format(join(ctx.get_libs_dir(arch.arch),
×
937
                                 'libpymodules.so')))
938
    do_biglink = copylibs_function if ctx.copy_libs else biglink_function
×
939

940
    # Move to the directory containing crtstart_so.o and crtend_so.o
941
    # This is necessary with newer NDKs? A gcc bug?
942
    with current_directory(arch.ndk_lib_dir):
×
943
        do_biglink(
×
944
            join(ctx.get_libs_dir(arch.arch), 'libpymodules.so'),
945
            obj_dir.split(' '),
946
            extra_link_dirs=[join(ctx.bootstrap.build_dir,
947
                                  'obj', 'local', arch.arch),
948
                             os.path.abspath('.')],
949
            env=env)
950

951

952
def biglink_function(soname, objs_paths, extra_link_dirs=None, env=None):
8✔
953
    if extra_link_dirs is None:
×
954
        extra_link_dirs = []
×
955
    print('objs_paths are', objs_paths)
×
956
    sofiles = []
×
957

958
    for directory in objs_paths:
×
959
        for fn in os.listdir(directory):
×
960
            fn = os.path.join(directory, fn)
×
961

962
            if not fn.endswith(".so.o"):
×
963
                continue
×
964
            if not os.path.exists(fn[:-2] + ".libs"):
×
965
                continue
×
966

967
            sofiles.append(fn[:-2])
×
968

969
    # The raw argument list.
970
    args = []
×
971

972
    for fn in sofiles:
×
973
        afn = fn + ".o"
×
974
        libsfn = fn + ".libs"
×
975

976
        args.append(afn)
×
977
        with open(libsfn) as fd:
×
978
            data = fd.read()
×
979
            args.extend(data.split(" "))
×
980

981
    unique_args = []
×
982
    while args:
×
983
        a = args.pop()
×
984
        if a in ('-L', ):
×
985
            continue
×
986
        if a not in unique_args:
×
987
            unique_args.insert(0, a)
×
988

989
    for dir in extra_link_dirs:
×
990
        link = '-L{}'.format(dir)
×
991
        if link not in unique_args:
×
992
            unique_args.append(link)
×
993

994
    cc_name = env['CC']
×
995
    cc = sh.Command(cc_name.split()[0])
×
996
    cc = cc.bake(*cc_name.split()[1:])
×
997

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

1000

1001
def copylibs_function(soname, objs_paths, extra_link_dirs=None, env=None):
8✔
1002
    if extra_link_dirs is None:
×
1003
        extra_link_dirs = []
×
1004
    print('objs_paths are', objs_paths)
×
1005

1006
    re_needso = re.compile(r'^.*\(NEEDED\)\s+Shared library: \[lib(.*)\.so\]\s*$')
×
1007
    blacklist_libs = (
×
1008
        'c',
1009
        'stdc++',
1010
        'dl',
1011
        'python2.7',
1012
        'sdl',
1013
        'sdl_image',
1014
        'sdl_ttf',
1015
        'z',
1016
        'm',
1017
        'GLESv2',
1018
        'jpeg',
1019
        'png',
1020
        'log',
1021

1022
        # bootstrap takes care of sdl2 libs (if applicable)
1023
        'SDL2',
1024
        'SDL2_ttf',
1025
        'SDL2_image',
1026
        'SDL2_mixer',
1027
        'SDL3',
1028
        'SDL3_ttf',
1029
        'SDL3_image',
1030
        'SDL3_mixer',
1031
    )
1032
    found_libs = []
×
1033
    sofiles = []
×
1034
    if env and 'READELF' in env:
×
1035
        readelf = env['READELF']
×
1036
    elif 'READELF' in os.environ:
×
1037
        readelf = os.environ['READELF']
×
1038
    else:
1039
        readelf = shutil.which('readelf').strip()
×
1040
    readelf = sh.Command(readelf).bake('-d')
×
1041

1042
    dest = dirname(soname)
×
1043

1044
    for directory in objs_paths:
×
1045
        for fn in os.listdir(directory):
×
1046
            fn = join(directory, fn)
×
1047

1048
            if not fn.endswith('.libs'):
×
1049
                continue
×
1050

1051
            dirfn = fn[:-1] + 'dirs'
×
1052
            if not exists(dirfn):
×
1053
                continue
×
1054

1055
            with open(fn) as f:
×
1056
                libs = f.read().strip().split(' ')
×
1057
                needed_libs = [lib for lib in libs
×
1058
                               if lib and
1059
                               lib not in blacklist_libs and
1060
                               lib not in found_libs]
1061

1062
            while needed_libs:
×
1063
                print('need libs:\n\t' + '\n\t'.join(needed_libs))
×
1064

1065
                start_needed_libs = needed_libs[:]
×
1066
                found_sofiles = []
×
1067

1068
                with open(dirfn) as f:
×
1069
                    libdirs = f.read().split()
×
1070
                    for libdir in libdirs:
×
1071
                        if not needed_libs:
×
1072
                            break
×
1073

1074
                        if libdir == dest:
×
1075
                            # don't need to copy from dest to dest!
1076
                            continue
×
1077

1078
                        libdir = libdir.strip()
×
1079
                        print('scanning', libdir)
×
1080
                        for lib in needed_libs[:]:
×
1081
                            if lib in found_libs:
×
1082
                                continue
×
1083

1084
                            if lib.endswith('.a'):
×
1085
                                needed_libs.remove(lib)
×
1086
                                found_libs.append(lib)
×
1087
                                continue
×
1088

1089
                            lib_a = 'lib' + lib + '.a'
×
1090
                            libpath_a = join(libdir, lib_a)
×
1091
                            lib_so = 'lib' + lib + '.so'
×
1092
                            libpath_so = join(libdir, lib_so)
×
1093
                            plain_so = lib + '.so'
×
1094
                            plainpath_so = join(libdir, plain_so)
×
1095

1096
                            sopath = None
×
1097
                            if exists(libpath_so):
×
1098
                                sopath = libpath_so
×
1099
                            elif exists(plainpath_so):
×
1100
                                sopath = plainpath_so
×
1101

1102
                            if sopath:
×
1103
                                print('found', lib, 'in', libdir)
×
1104
                                found_sofiles.append(sopath)
×
1105
                                needed_libs.remove(lib)
×
1106
                                found_libs.append(lib)
×
1107
                                continue
×
1108

1109
                            if exists(libpath_a):
×
1110
                                print('found', lib, '(static) in', libdir)
×
1111
                                needed_libs.remove(lib)
×
1112
                                found_libs.append(lib)
×
1113
                                continue
×
1114

1115
                for sofile in found_sofiles:
×
1116
                    print('scanning dependencies for', sofile)
×
1117
                    out = readelf(sofile)
×
1118
                    for line in out.splitlines():
×
1119
                        needso = re_needso.match(line)
×
1120
                        if needso:
×
1121
                            lib = needso.group(1)
×
1122
                            if (lib not in needed_libs
×
1123
                                    and lib not in found_libs
1124
                                    and lib not in blacklist_libs):
1125
                                needed_libs.append(needso.group(1))
×
1126

1127
                sofiles += found_sofiles
×
1128

1129
                if needed_libs == start_needed_libs:
×
1130
                    raise RuntimeError(
×
1131
                            'Failed to locate needed libraries!\n\t' +
1132
                            '\n\t'.join(needed_libs))
1133

1134
    print('Copying libraries')
×
1135
    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