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

kivy / python-for-android / 32347666627

20 Aug 2026 08:12AM UTC coverage: 62.777% (+0.01%) from 62.763%
32347666627

Pull #3366

github

web-flow
Merge e131de844 into 7af1d1325
Pull Request #3366: Remove venv creation for python package install stage

1819 of 3170 branches covered (57.38%)

Branch coverage included in aggregate %.

9 of 25 new or added lines in 1 file covered. (36.0%)

7 existing lines in 1 file now uncovered.

5396 of 8323 relevant lines covered (64.83%)

3.89 hits per line

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

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

14
import sh
6✔
15

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

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

32

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

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

50

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

58

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

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

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

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

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

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

82
    # the Android project folder where everything ends up
83
    dist_dir = None
6✔
84

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

88
    ccache = None  # whether to use ccache
6✔
89

90
    ndk = None
6✔
91

92
    bootstrap = None
6✔
93
    bootstrap_build_dir = None
6✔
94

95
    recipe_build_order = None  # Will hold the list of all built recipes
6✔
96

97
    python_modules = None  # Will hold resolved pure python packages
6✔
98

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

101
    java_build_tool = 'auto'
6✔
102

103
    skip_prebuilt = False
6✔
104

105
    extra_index_urls = []
6✔
106

107
    use_prebuilt_version_for = []
6✔
108

109
    save_wheel_dir = ''
6✔
110

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

234
        '''
235

236
        self.ensure_dirs()
6✔
237

238
        if self._build_env_prepared:
6!
239
            return
×
240

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

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

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

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

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

348
        check_ndk_api(ndk_api, self.android_api)
6✔
349

350
        self.ndk = AndroidNDK(self.ndk_dir)
6✔
351

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

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

375
    def __init__(self):
6✔
376
        self.include_dirs = []
6✔
377

378
        self._build_env_prepared = False
6✔
379

380
        self._sdk_dir = None
6✔
381
        self._ndk_dir = None
6✔
382
        self._android_api = None
6✔
383
        self._ndk_api = None
6✔
384
        self.ndk = None
6✔
385

386
        self.local_recipes = None
6✔
387
        self.copy_libs = False
6✔
388

389
        self.activity_class_name = u'org.kivy.android.PythonActivity'
6✔
390
        self.service_class_name = u'org.kivy.android.PythonService'
6✔
391

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

401
        self.root_dir = realpath(dirname(__file__))
6✔
402

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

408
        self.python_recipe = None  # Set by TargetPythonRecipe
6✔
409

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

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

431
    def prepare_dist(self):
6✔
432
        self.bootstrap.prepare_dist_dir()
6✔
433

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

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

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

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

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

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

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

485

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

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

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

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

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

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

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

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

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

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

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

557

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

564

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

569

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

579

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

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

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

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

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

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

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

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

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

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

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

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

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

657
    os.remove(path)
6✔
658

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

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

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

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

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

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

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

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

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

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

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

706
    return processed_modules, state
6✔
707

708

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

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

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

719
    """
720

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

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

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

732
    # It always runs with --upgrade so this is not required as it skips if module already exists
733
    # modules = [m for m in modules if ctx.not_has_package(m, arch)]
734

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

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

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

758
    if project_has_setup_py(project_dir) and not ignore_setup_py:
×
759
        info(
×
760
            "Will process project install, if it fails then the "
761
            "project may not be compatible for Android install."
762
        )
763

NEW
764
    if not modules:
×
NEW
765
        info('There are no Python modules to install, skipping')
×
766
    else:
767

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

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

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

808

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

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

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

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

854

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

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

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

870
            sofiles.append(fn[:-2])
×
871

872
    # The raw argument list.
873
    args = []
×
874

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

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

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

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

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

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

903

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

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

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

945
    dest = dirname(soname)
×
946

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

951
            if not fn.endswith('.libs'):
×
952
                continue
×
953

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

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

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

968
                start_needed_libs = needed_libs[:]
×
969
                found_sofiles = []
×
970

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

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

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

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

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

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

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

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

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

1030
                sofiles += found_sofiles
×
1031

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

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

© 2026 Coveralls, Inc