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

kivy / python-for-android / 20391670964

20 Dec 2025 08:18AM UTC coverage: 63.319%. First build
20391670964

Pull #3271

github

web-flow
Merge 3ada98070 into 6494ac165
Pull Request #3271: `toolchain`: auto resolve deps

1781 of 3073 branches covered (57.96%)

Branch coverage included in aggregate %.

19 of 87 new or added lines in 5 files covered. (21.84%)

5217 of 7979 relevant lines covered (65.38%)

5.22 hits per line

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

42.68
/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✔
NEW
666
    name, version, build, tags = parse_wheel_filename(whl_name)
×
NEW
667
    return all(tag.platform == "any" for tag in tags)
×
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
    """
NEW
673
    modules = list(modules)
×
NEW
674
    build_order = list(ctx.recipe_build_order)
×
675

NEW
676
    _requirement_names = []
×
NEW
677
    processed_modules = []
×
678

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

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

693
    # preserve the original module list
NEW
694
    processed_modules.extend(modules)
×
695

696
    # temp file for pip report
NEW
697
    fd, path = tempfile.mkstemp()
×
NEW
698
    os.close(fd)
×
699

700
    # setup hostpython recipe
NEW
701
    host_recipe = Recipe.get_recipe("hostpython3", ctx)
×
702

NEW
703
    env = environ.copy()
×
NEW
704
    _python_path = host_recipe.get_path_to_python()
×
NEW
705
    libdir = glob.glob(join(_python_path, "build", "lib*"))
×
NEW
706
    env['PYTHONPATH'] = host_recipe.site_dir + ":" + join(
×
707
        _python_path, "Modules") + ":" + (libdir[0] if libdir else "")
708

NEW
709
    shprint(
×
710
        host_recipe.pip, 'install', *modules,
711
        '--dry-run', '--break-system-packages', '--ignore-installed',
712
        '--report', path, '-q', _env=env
713
    )
714

NEW
715
    with open(path, "r") as f:
×
NEW
716
        report = json.load(f)
×
717

NEW
718
    os.remove(path)
×
719

NEW
720
    info('Extra resolved pure python dependencies :')
×
721

NEW
722
    ignored_str = " (ignored)"
×
723
    # did we find any non pure python package?
NEW
724
    any_not_pure_python = False
×
725

726
    # just for style
NEW
727
    info(" ")
×
NEW
728
    for module in report["install"]:
×
729

NEW
730
        mname = module["metadata"]["name"]
×
NEW
731
        mver = module["metadata"]["version"]
×
NEW
732
        filename = basename(module["download_info"]["url"])
×
NEW
733
        pure_python = True
×
734

NEW
735
        if (filename.endswith(".whl") and not is_wheel_platform_independent(filename)):
×
NEW
736
            any_not_pure_python = True
×
NEW
737
            pure_python = False
×
738

739
        # does this module matches any recipe name?
NEW
740
        if mname.lower() in _requirement_names:
×
NEW
741
            continue
×
742

NEW
743
        color = Out_Fore.GREEN if pure_python else Out_Fore.RED
×
NEW
744
        ignored = "" if pure_python else ignored_str
×
745

NEW
746
        info(
×
747
            f"  {color}{mname}{Out_Fore.WHITE} : "
748
            f"{Out_Style.BRIGHT}{mver}{Out_Style.RESET_ALL}"
749
            f"{ignored}"
750
        )
751

NEW
752
        if pure_python:
×
NEW
753
            processed_modules.append(f"{mname}=={mver}")
×
NEW
754
    info(" ")
×
755

NEW
756
    if any_not_pure_python:
×
NEW
757
        warning("Some packages were ignored because they are not pure Python.")
×
NEW
758
        warning("To install the ignored packages, explicitly list them in your requirements file.")
×
759

NEW
760
    return processed_modules
×
761

762

763
def run_pymodules_install(ctx, arch, modules, project_dir=None,
8✔
764
                          ignore_setup_py=False):
765
    """ This function will take care of all non-recipe things, by:
766

767
        1. Processing them from --requirements (the modules argument)
768
           and installing them
769

770
        2. Installing the user project/app itself via setup.py if
771
           ignore_setup_py=True
772

773
    """
774

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

777
    # don't run process_python_modules in tests
778
    if ctx.recipe_build_order.__class__.__name__ != "Mock":
8!
NEW
779
        modules = process_python_modules(ctx, modules)
×
780

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

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

787
    # Bail out if no python deps and no setup.py to process:
788
    if not modules and (
8✔
789
            ignore_setup_py or
790
            not project_has_setup_py(project_dir)
791
            ):
792
        info('No Python modules and no setup.py to process, skipping')
8✔
793
        return
8✔
794

795
    # Output messages about what we're going to do:
796
    if modules:
8!
797
        info(
8✔
798
            "The requirements ({}) don\'t have recipes, attempting to "
799
            "install them with pip".format(', '.join(modules))
800
        )
801
        info(
8✔
802
            "If this fails, it may mean that the module has compiled "
803
            "components and needs a recipe."
804
        )
805
    if project_has_setup_py(project_dir) and not ignore_setup_py:
8!
806
        info(
×
807
            "Will process project install, if it fails then the "
808
            "project may not be compatible for Android install."
809
        )
810

811
    # Use our hostpython to create the virtualenv
812
    host_python = sh.Command(ctx.hostpython)
8✔
813
    with current_directory(join(ctx.build_dir)):
8✔
814
        shprint(host_python, '-m', 'venv', 'venv')
8✔
815

816
        # Prepare base environment and upgrade pip:
817
        base_env = dict(copy.copy(os.environ))
8✔
818
        base_env["PYTHONPATH"] = ctx.get_site_packages_dir(arch)
8✔
819
        info('Upgrade pip to latest version')
8✔
820
        shprint(sh.bash, '-c', (
8✔
821
            "source venv/bin/activate && pip install -U pip"
822
        ), _env=copy.copy(base_env))
823

824
        # Install Cython in case modules need it to build:
825
        info('Install Cython in case one of the modules needs it to build')
8✔
826
        shprint(sh.bash, '-c', (
8✔
827
            "venv/bin/pip install Cython"
828
        ), _env=copy.copy(base_env))
829

830
        # Get environment variables for build (with CC/compiler set):
831
        standard_recipe = CythonRecipe()
8✔
832
        standard_recipe.ctx = ctx
8✔
833
        # (note: following line enables explicit -lpython... linker options)
834
        standard_recipe.call_hostpython_via_targetpython = False
8✔
835
        recipe_env = standard_recipe.get_recipe_env(ctx.archs[0])
8✔
836
        env = copy.copy(base_env)
8✔
837
        env.update(recipe_env)
8✔
838

839
        # Make sure our build package dir is available, and the virtualenv
840
        # site packages come FIRST (so the proper pip version is used):
841
        env["PYTHONPATH"] += ":" + ctx.get_site_packages_dir(arch)
8✔
842
        env["PYTHONPATH"] = os.path.abspath(join(
8✔
843
            ctx.build_dir, "venv", "lib",
844
            "python" + ctx.python_recipe.major_minor_version_string,
845
            "site-packages")) + ":" + env["PYTHONPATH"]
846

847
        # Install the manually specified requirements first:
848
        if not modules:
8!
849
            info('There are no Python modules to install, skipping')
×
850
        else:
851
            info('Creating a requirements.txt file for the Python modules')
8✔
852
            with open('requirements.txt', 'w') as fileh:
8✔
853
                for module in modules:
8✔
854
                    key = 'VERSION_' + module
8✔
855
                    if key in environ:
8!
856
                        line = '{}=={}\n'.format(module, environ[key])
×
857
                    else:
858
                        line = '{}\n'.format(module)
8✔
859
                    fileh.write(line)
8✔
860

861
            info('Installing Python modules with pip')
8✔
862
            info(
8✔
863
                "IF THIS FAILS, THE MODULES MAY NEED A RECIPE. "
864
                "A reason for this is often modules compiling "
865
                "native code that is unaware of Android cross-compilation "
866
                "and does not work without additional "
867
                "changes / workarounds."
868
            )
869

870
            shprint(sh.bash, '-c', (
8✔
871
                "venv/bin/pip " +
872
                "install -v --target '{0}' --no-deps -r requirements.txt"
873
            ).format(ctx.get_site_packages_dir(arch).replace("'", "'\"'\"'")),
874
                    _env=copy.copy(env))
875

876
        # Afterwards, run setup.py if present:
877
        if project_has_setup_py(project_dir) and not ignore_setup_py:
8!
878
            run_setuppy_install(ctx, project_dir, env, arch)
×
879
        elif not ignore_setup_py:
8!
880
            info("No setup.py found in project directory: " + str(project_dir))
8✔
881

882
        # Strip object files after potential Cython or native code builds:
883
        if not ctx.with_debug_symbols:
8✔
884
            standard_recipe.strip_object_files(
8✔
885
                arch, env, build_dir=ctx.build_dir
886
            )
887

888

889
def biglink(ctx, arch):
8✔
890
    # First, collate object files from each recipe
891
    info('Collating object files from each recipe')
×
892
    obj_dir = join(ctx.bootstrap.build_dir, 'collated_objects')
×
893
    ensure_dir(obj_dir)
×
894
    recipes = [Recipe.get_recipe(name, ctx) for name in ctx.recipe_build_order]
×
895
    for recipe in recipes:
×
896
        recipe_obj_dir = join(recipe.get_build_container_dir(arch.arch),
×
897
                              'objects_{}'.format(recipe.name))
898
        if not exists(recipe_obj_dir):
×
899
            info('{} recipe has no biglinkable files dir, skipping'
×
900
                 .format(recipe.name))
901
            continue
×
902
        files = glob.glob(join(recipe_obj_dir, '*'))
×
903
        if not len(files):
×
904
            info('{} recipe has no biglinkable files, skipping'
×
905
                 .format(recipe.name))
906
            continue
×
907
        info('{} recipe has object files, copying'.format(recipe.name))
×
908
        files.append(obj_dir)
×
909
        shprint(sh.cp, '-r', *files)
×
910

911
    env = arch.get_env()
×
912
    env['LDFLAGS'] = env['LDFLAGS'] + ' -L{}'.format(
×
913
        join(ctx.bootstrap.build_dir, 'obj', 'local', arch.arch))
914

915
    if not len(glob.glob(join(obj_dir, '*'))):
×
916
        info('There seem to be no libraries to biglink, skipping.')
×
917
        return
×
918
    info('Biglinking')
×
919
    info('target {}'.format(join(ctx.get_libs_dir(arch.arch),
×
920
                                 'libpymodules.so')))
921
    do_biglink = copylibs_function if ctx.copy_libs else biglink_function
×
922

923
    # Move to the directory containing crtstart_so.o and crtend_so.o
924
    # This is necessary with newer NDKs? A gcc bug?
925
    with current_directory(arch.ndk_lib_dir):
×
926
        do_biglink(
×
927
            join(ctx.get_libs_dir(arch.arch), 'libpymodules.so'),
928
            obj_dir.split(' '),
929
            extra_link_dirs=[join(ctx.bootstrap.build_dir,
930
                                  'obj', 'local', arch.arch),
931
                             os.path.abspath('.')],
932
            env=env)
933

934

935
def biglink_function(soname, objs_paths, extra_link_dirs=None, env=None):
8✔
936
    if extra_link_dirs is None:
×
937
        extra_link_dirs = []
×
938
    print('objs_paths are', objs_paths)
×
939
    sofiles = []
×
940

941
    for directory in objs_paths:
×
942
        for fn in os.listdir(directory):
×
943
            fn = os.path.join(directory, fn)
×
944

945
            if not fn.endswith(".so.o"):
×
946
                continue
×
947
            if not os.path.exists(fn[:-2] + ".libs"):
×
948
                continue
×
949

950
            sofiles.append(fn[:-2])
×
951

952
    # The raw argument list.
953
    args = []
×
954

955
    for fn in sofiles:
×
956
        afn = fn + ".o"
×
957
        libsfn = fn + ".libs"
×
958

959
        args.append(afn)
×
960
        with open(libsfn) as fd:
×
961
            data = fd.read()
×
962
            args.extend(data.split(" "))
×
963

964
    unique_args = []
×
965
    while args:
×
966
        a = args.pop()
×
967
        if a in ('-L', ):
×
968
            continue
×
969
        if a not in unique_args:
×
970
            unique_args.insert(0, a)
×
971

972
    for dir in extra_link_dirs:
×
973
        link = '-L{}'.format(dir)
×
974
        if link not in unique_args:
×
975
            unique_args.append(link)
×
976

977
    cc_name = env['CC']
×
978
    cc = sh.Command(cc_name.split()[0])
×
979
    cc = cc.bake(*cc_name.split()[1:])
×
980

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

983

984
def copylibs_function(soname, objs_paths, extra_link_dirs=None, env=None):
8✔
985
    if extra_link_dirs is None:
×
986
        extra_link_dirs = []
×
987
    print('objs_paths are', objs_paths)
×
988

989
    re_needso = re.compile(r'^.*\(NEEDED\)\s+Shared library: \[lib(.*)\.so\]\s*$')
×
990
    blacklist_libs = (
×
991
        'c',
992
        'stdc++',
993
        'dl',
994
        'python2.7',
995
        'sdl',
996
        'sdl_image',
997
        'sdl_ttf',
998
        'z',
999
        'm',
1000
        'GLESv2',
1001
        'jpeg',
1002
        'png',
1003
        'log',
1004

1005
        # bootstrap takes care of sdl2 libs (if applicable)
1006
        'SDL2',
1007
        'SDL2_ttf',
1008
        'SDL2_image',
1009
        'SDL2_mixer',
1010
        'SDL3',
1011
        'SDL3_ttf',
1012
        'SDL3_image',
1013
        'SDL3_mixer',
1014
    )
1015
    found_libs = []
×
1016
    sofiles = []
×
1017
    if env and 'READELF' in env:
×
1018
        readelf = env['READELF']
×
1019
    elif 'READELF' in os.environ:
×
1020
        readelf = os.environ['READELF']
×
1021
    else:
1022
        readelf = shutil.which('readelf').strip()
×
1023
    readelf = sh.Command(readelf).bake('-d')
×
1024

1025
    dest = dirname(soname)
×
1026

1027
    for directory in objs_paths:
×
1028
        for fn in os.listdir(directory):
×
1029
            fn = join(directory, fn)
×
1030

1031
            if not fn.endswith('.libs'):
×
1032
                continue
×
1033

1034
            dirfn = fn[:-1] + 'dirs'
×
1035
            if not exists(dirfn):
×
1036
                continue
×
1037

1038
            with open(fn) as f:
×
1039
                libs = f.read().strip().split(' ')
×
1040
                needed_libs = [lib for lib in libs
×
1041
                               if lib and
1042
                               lib not in blacklist_libs and
1043
                               lib not in found_libs]
1044

1045
            while needed_libs:
×
1046
                print('need libs:\n\t' + '\n\t'.join(needed_libs))
×
1047

1048
                start_needed_libs = needed_libs[:]
×
1049
                found_sofiles = []
×
1050

1051
                with open(dirfn) as f:
×
1052
                    libdirs = f.read().split()
×
1053
                    for libdir in libdirs:
×
1054
                        if not needed_libs:
×
1055
                            break
×
1056

1057
                        if libdir == dest:
×
1058
                            # don't need to copy from dest to dest!
1059
                            continue
×
1060

1061
                        libdir = libdir.strip()
×
1062
                        print('scanning', libdir)
×
1063
                        for lib in needed_libs[:]:
×
1064
                            if lib in found_libs:
×
1065
                                continue
×
1066

1067
                            if lib.endswith('.a'):
×
1068
                                needed_libs.remove(lib)
×
1069
                                found_libs.append(lib)
×
1070
                                continue
×
1071

1072
                            lib_a = 'lib' + lib + '.a'
×
1073
                            libpath_a = join(libdir, lib_a)
×
1074
                            lib_so = 'lib' + lib + '.so'
×
1075
                            libpath_so = join(libdir, lib_so)
×
1076
                            plain_so = lib + '.so'
×
1077
                            plainpath_so = join(libdir, plain_so)
×
1078

1079
                            sopath = None
×
1080
                            if exists(libpath_so):
×
1081
                                sopath = libpath_so
×
1082
                            elif exists(plainpath_so):
×
1083
                                sopath = plainpath_so
×
1084

1085
                            if sopath:
×
1086
                                print('found', lib, 'in', libdir)
×
1087
                                found_sofiles.append(sopath)
×
1088
                                needed_libs.remove(lib)
×
1089
                                found_libs.append(lib)
×
1090
                                continue
×
1091

1092
                            if exists(libpath_a):
×
1093
                                print('found', lib, '(static) in', libdir)
×
1094
                                needed_libs.remove(lib)
×
1095
                                found_libs.append(lib)
×
1096
                                continue
×
1097

1098
                for sofile in found_sofiles:
×
1099
                    print('scanning dependencies for', sofile)
×
1100
                    out = readelf(sofile)
×
1101
                    for line in out.splitlines():
×
1102
                        needso = re_needso.match(line)
×
1103
                        if needso:
×
1104
                            lib = needso.group(1)
×
1105
                            if (lib not in needed_libs
×
1106
                                    and lib not in found_libs
1107
                                    and lib not in blacklist_libs):
1108
                                needed_libs.append(needso.group(1))
×
1109

1110
                sofiles += found_sofiles
×
1111

1112
                if needed_libs == start_needed_libs:
×
1113
                    raise RuntimeError(
×
1114
                            'Failed to locate needed libraries!\n\t' +
1115
                            '\n\t'.join(needed_libs))
1116

1117
    print('Copying libraries')
×
1118
    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

© 2025 Coveralls, Inc