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

kivy / python-for-android / 22259302242

21 Feb 2026 03:24PM UTC coverage: 63.887% (+4.7%) from 59.214%
22259302242

Pull #3198

github

web-flow
Merge 758a52847 into 1fc026943
Pull Request #3198: Bump SDL3 (`3.4.2`) and SDL3_image (`3.4.0`) to the latest stable releases.

1823 of 3111 branches covered (58.6%)

Branch coverage included in aggregate %.

3 of 3 new or added lines in 2 files covered. (100.0%)

788 existing lines in 24 files 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

92.7
/pythonforandroid/bootstrap.py
1
import functools
8✔
2
import glob
8✔
3
import importlib
8✔
4
import os
8✔
5
from os.path import (join, dirname, isdir, normpath, splitext, basename)
8✔
6
from os import listdir, walk, sep
8✔
7
import sh
8✔
8
import shlex
8✔
9
import shutil
8✔
10

11
from pythonforandroid.logger import (shprint, info, info_main, logger, debug)
8✔
12
from pythonforandroid.util import (
8✔
13
    current_directory, ensure_dir, temp_directory, BuildInterruptingException,
14
    rmdir, move)
15
from pythonforandroid.recipe import Recipe
8✔
16

17
SDL_BOOTSTRAPS = ("sdl2", "sdl3")
8✔
18

19

20
def copy_files(src_root, dest_root, override=True, symlink=False):
8✔
21
    for root, dirnames, filenames in walk(src_root):
8✔
22
        for filename in filenames:
8✔
23
            subdir = normpath(root.replace(src_root, ""))
8✔
24
            if subdir.startswith(sep):  # ensure it is relative
8✔
25
                subdir = subdir[1:]
8✔
26
            dest_dir = join(dest_root, subdir)
8✔
27
            if not os.path.exists(dest_dir):
8✔
28
                os.makedirs(dest_dir)
8✔
29
            src_file = join(root, filename)
8✔
30
            dest_file = join(dest_dir, filename)
8✔
31
            if os.path.isfile(src_file):
8!
32
                if override and os.path.exists(dest_file):
8✔
33
                    os.unlink(dest_file)
8✔
34
                if not os.path.exists(dest_file):
8✔
35
                    if symlink:
8!
36
                        os.symlink(src_file, dest_file)
×
37
                    else:
38
                        shutil.copy(src_file, dest_file)
8✔
39
            else:
40
                os.makedirs(dest_file)
×
41

42

43
default_recipe_priorities = [
8✔
44
    "webview", "sdl2", "sdl3", "service_only"  # last is highest
45
]
46
# ^^ NOTE: these are just the default priorities if no special rules
47
# apply (which you can find in the code below), so basically if no
48
# known graphical lib or web lib is used - in which case service_only
49
# is the most reasonable guess.
50

51

52
def _cmp_bootstraps_by_priority(a, b):
8✔
53
    def rank_bootstrap(bootstrap):
8✔
54
        """ Returns a ranking index for each bootstrap,
55
            with higher priority ranked with higher number. """
56
        if bootstrap.name in default_recipe_priorities:
8✔
57
            return default_recipe_priorities.index(bootstrap.name) + 1
8✔
58
        return 0
8✔
59

60
    # Rank bootstraps in order:
61
    rank_a = rank_bootstrap(a)
8✔
62
    rank_b = rank_bootstrap(b)
8✔
63
    if rank_a != rank_b:
8✔
64
        return (rank_b - rank_a)
8✔
65
    else:
66
        if a.name < b.name:  # alphabetic sort for determinism
8✔
67
            return -1
8✔
68
        else:
69
            return 1
8✔
70

71

72
class Bootstrap:
8✔
73
    '''An Android project template, containing recipe stuff for
74
    compilation and templated fields for APK info.
75
    '''
76
    jni_subdir = '/jni'
8✔
77
    ctx = None
8✔
78

79
    bootstrap_dir = None
8✔
80

81
    build_dir = None
8✔
82
    dist_name = None
8✔
83
    distribution = None
8✔
84

85
    # All bootstraps should include Python in some way:
86
    recipe_depends = ['python3', 'android']
8✔
87

88
    can_be_chosen_automatically = True
8✔
89
    '''Determines whether the bootstrap can be chosen as one that
6✔
90
    satisfies user requirements. If False, it will not be returned
91
    from Bootstrap.get_bootstrap_from_recipes.
92
    '''
93

94
    # Other things a Bootstrap might need to track (maybe separately):
95
    # ndk_main.c
96
    # whitelist.txt
97
    # blacklist.txt
98

99
    @property
8✔
100
    def dist_dir(self):
8✔
101
        '''The dist dir at which to place the finished distribution.'''
102
        if self.distribution is None:
8✔
103
            raise BuildInterruptingException(
8✔
104
                'Internal error: tried to access {}.dist_dir, but {}.distribution '
105
                'is None'.format(self, self))
106
        return self.distribution.dist_dir
8✔
107

108
    @property
8✔
109
    def jni_dir(self):
8✔
110
        return self.name + self.jni_subdir
8✔
111

112
    def check_recipe_choices(self):
8✔
113
        '''Checks what recipes are being built to see which of the alternative
114
        and optional dependencies are being used,
115
        and returns a list of these.'''
116
        recipes = []
8✔
117
        built_recipes = self.ctx.recipe_build_order or []
8✔
118
        for recipe in self.recipe_depends:
8✔
119
            if isinstance(recipe, (tuple, list)):
8!
120
                for alternative in recipe:
×
121
                    if alternative in built_recipes:
×
122
                        recipes.append(alternative)
×
123
                        break
×
124
        return sorted(recipes)
8✔
125

126
    def get_build_dir_name(self):
8✔
127
        choices = self.check_recipe_choices()
8✔
128
        dir_name = '-'.join([self.name] + choices)
8✔
129
        return dir_name
8✔
130

131
    def get_build_dir(self):
8✔
132
        return join(self.ctx.build_dir, 'bootstrap_builds', self.get_build_dir_name())
8✔
133

134
    def get_dist_dir(self, name):
8✔
135
        return join(self.ctx.dist_dir, name)
8✔
136

137
    @property
8✔
138
    def name(self):
8✔
139
        modname = self.__class__.__module__
×
140
        return modname.split(".", 2)[-1]
×
141

142
    def get_bootstrap_dirs(self):
8✔
143
        """get all bootstrap directories, following the MRO path"""
144

145
        # get all bootstrap names along the __mro__, cutting off Bootstrap and object
146
        classes = self.__class__.__mro__[:-2]
8✔
147
        bootstrap_names = [cls.name for cls in classes] + ['common']
8✔
148
        bootstrap_dirs = [
8✔
149
            join(self.ctx.root_dir, 'bootstraps', bootstrap_name)
150
            for bootstrap_name in reversed(bootstrap_names)
151
        ]
152
        return bootstrap_dirs
8✔
153

154
    def _copy_in_final_files(self):
8✔
155
        if self.name in SDL_BOOTSTRAPS:
8✔
156
            # Get the paths for copying SDL's java source code:
157
            sdl_recipe = Recipe.get_recipe(self.name, self.ctx)
8✔
158
            sdl_build_dir = sdl_recipe.get_jni_dir()
8✔
159
            src_dir = join(sdl_build_dir, "SDL", "android-project",
8✔
160
                           "app", "src", "main", "java",
161
                           "org", "libsdl", "app")
162
            target_dir = join(self.dist_dir, 'src', 'main', 'java', 'org',
8✔
163
                              'libsdl', 'app')
164

165
            # Do actual copying:
166
            info('Copying in SDL .java files from: ' + str(src_dir))
8✔
167
            if not os.path.exists(target_dir):
8✔
168
                os.makedirs(target_dir)
8✔
169
            copy_files(src_dir, target_dir, override=True)
8✔
170

171
    def prepare_build_dir(self):
8✔
172
        """Ensure that a build dir exists for the recipe. This same single
173
        dir will be used for building all different archs."""
174
        bootstrap_dirs = self.get_bootstrap_dirs()
8✔
175
        # now do a cumulative copy of all bootstrap dirs
176
        self.build_dir = self.get_build_dir()
8✔
177
        for bootstrap_dir in bootstrap_dirs:
8✔
178
            copy_files(join(bootstrap_dir, 'build'), self.build_dir, symlink=self.ctx.symlink_bootstrap_files)
8✔
179

180
        with current_directory(self.build_dir):
8✔
181
            with open('project.properties', 'w') as fileh:
8✔
182
                fileh.write('target=android-{}'.format(self.ctx.android_api))
8✔
183

184
    def prepare_dist_dir(self):
8✔
185
        ensure_dir(self.dist_dir)
8✔
186

187
    def _assemble_distribution_for_arch(self, arch):
8✔
188
        """Per-architecture distribution assembly.
189

190
        Override this method to customize per-arch behavior.
191
        Called once for each architecture in self.ctx.archs.
192
        """
193
        self.distribute_libs(arch, [self.ctx.get_libs_dir(arch.arch)])
8✔
194
        self.distribute_aars(arch)
8✔
195

196
        python_bundle_dir = join(f'_python_bundle__{arch.arch}', '_python_bundle')
8✔
197
        ensure_dir(python_bundle_dir)
8✔
198
        site_packages_dir = self.ctx.python_recipe.create_python_bundle(
8✔
199
            join(self.dist_dir, python_bundle_dir), arch)
200
        if not self.ctx.with_debug_symbols:
8!
201
            self.strip_libraries(arch)
8✔
202
        self.fry_eggs(site_packages_dir)
8✔
203

204
    def assemble_distribution(self):
8✔
205
        """Assemble the distribution by copying files and creating Python bundle.
206

207
        This default implementation works for most bootstraps. Override
208
        _assemble_distribution_for_arch() for per-arch customization, or
209
        override this entire method for fundamentally different behavior.
210
        """
211
        info_main(f'# Creating Android project ({self.name})')
8✔
212

213
        rmdir(self.dist_dir)
8✔
214
        shprint(sh.cp, '-r', self.build_dir, self.dist_dir)
8✔
215

216
        with current_directory(self.dist_dir):
8✔
217
            with open('local.properties', 'w') as fileh:
8✔
218
                fileh.write('sdk.dir={}'.format(self.ctx.sdk_dir))
8✔
219

220
        with current_directory(self.dist_dir):
8✔
221
            info('Copying Python distribution')
8✔
222

223
            self.distribute_javaclasses(self.ctx.javaclass_dir,
8✔
224
                                        dest_dir=join("src", "main", "java"))
225

226
            for arch in self.ctx.archs:
8✔
227
                self._assemble_distribution_for_arch(arch)
8✔
228

229
            if 'sqlite3' not in self.ctx.recipe_build_order:
8!
230
                with open('blacklist.txt', 'a') as fileh:
8✔
231
                    fileh.write('\nsqlite3/*\nlib-dynload/_sqlite3.so\n')
8✔
232

233
        self._copy_in_final_files()
8✔
234
        self.distribution.save_info(self.dist_dir)
8✔
235

236
    @classmethod
8✔
237
    def all_bootstraps(cls):
8✔
238
        '''Find all the available bootstraps and return them.'''
239
        forbidden_dirs = ('__pycache__', 'common', '_sdl_common')
8✔
240
        bootstraps_dir = join(dirname(__file__), 'bootstraps')
8✔
241
        result = set()
8✔
242
        for name in listdir(bootstraps_dir):
8✔
243
            if name in forbidden_dirs:
8✔
244
                continue
8✔
245
            filen = join(bootstraps_dir, name)
8✔
246
            if isdir(filen):
8✔
247
                result.add(name)
8✔
248
        return result
8✔
249

250
    @classmethod
8✔
251
    def get_usable_bootstraps_for_recipes(cls, recipes, ctx):
8✔
252
        '''Returns all bootstrap whose recipe requirements do not conflict
253
        with the given recipes, in no particular order.'''
254
        info('Trying to find a bootstrap that matches the given recipes.')
8✔
255
        bootstraps = [cls.get_bootstrap(name, ctx)
8✔
256
                      for name in cls.all_bootstraps()]
257
        acceptable_bootstraps = set()
8✔
258

259
        # Find out which bootstraps are acceptable:
260
        for bs in bootstraps:
8✔
261
            if not bs.can_be_chosen_automatically:
8✔
262
                continue
8✔
263
            possible_dependency_lists = expand_dependencies(bs.recipe_depends, ctx)
8✔
264
            for possible_dependencies in possible_dependency_lists:
8✔
265
                ok = True
8✔
266
                # Check if the bootstap's dependencies have an internal conflict:
267
                for recipe in possible_dependencies:
8✔
268
                    recipe = Recipe.get_recipe(recipe, ctx)
8✔
269
                    if any(conflict in recipes for conflict in recipe.conflicts):
8✔
270
                        ok = False
8✔
271
                        break
8✔
272
                # Check if bootstrap's dependencies conflict with chosen
273
                # packages:
274
                for recipe in recipes:
8✔
275
                    try:
8✔
276
                        recipe = Recipe.get_recipe(recipe, ctx)
8✔
277
                    except ValueError:
8✔
278
                        conflicts = []
8✔
279
                    else:
280
                        conflicts = recipe.conflicts
8✔
281
                    if any(conflict in possible_dependencies
8✔
282
                            for conflict in conflicts):
283
                        ok = False
8✔
284
                        break
8✔
285
                if ok and bs not in acceptable_bootstraps:
8✔
286
                    acceptable_bootstraps.add(bs)
8✔
287

288
        info('Found {} acceptable bootstraps: {}'.format(
8✔
289
            len(acceptable_bootstraps),
290
            [bs.name for bs in acceptable_bootstraps]))
291
        return acceptable_bootstraps
8✔
292

293
    @classmethod
8✔
294
    def get_bootstrap_from_recipes(cls, recipes, ctx):
8✔
295
        '''Picks a single recommended default bootstrap out of
296
           all_usable_bootstraps_from_recipes() for the given reicpes,
297
           and returns it.'''
298

299
        known_web_packages = {"flask"}  # to pick webview over service_only
8✔
300
        recipes_with_deps_lists = expand_dependencies(recipes, ctx)
8✔
301
        acceptable_bootstraps = cls.get_usable_bootstraps_for_recipes(
8✔
302
            recipes, ctx
303
        )
304

305
        def have_dependency_in_recipes(dep):
8✔
306
            for dep_list in recipes_with_deps_lists:
8✔
307
                if dep in dep_list:
8✔
308
                    return True
8✔
309
            return False
8✔
310

311
        # Special rule: return SDL2 bootstrap if there's an sdl2 dep:
312
        if (have_dependency_in_recipes("sdl2") and
8✔
313
                "sdl2" in [b.name for b in acceptable_bootstraps]
314
                ):
315
            info('Using sdl2 bootstrap since it is in dependencies')
8✔
316
            return cls.get_bootstrap("sdl2", ctx)
8✔
317

318
        # Special rule: return SDL3 bootstrap if there's an sdl3 dep:
319
        if (have_dependency_in_recipes("sdl3") and
8!
320
                "sdl3" in [b.name for b in acceptable_bootstraps]
321
                ):
322
            info('Using sdl3 bootstrap since it is in dependencies')
×
UNCOV
323
            return cls.get_bootstrap("sdl3", ctx)
×
324

325
        # Special rule: return "webview" if we depend on common web recipe:
326
        for possible_web_dep in known_web_packages:
8✔
327
            if have_dependency_in_recipes(possible_web_dep):
8✔
328
                # We have a web package dep!
329
                if "webview" in [b.name for b in acceptable_bootstraps]:
8!
330
                    info('Using webview bootstrap since common web packages '
8✔
331
                         'were found {}'.format(
332
                             known_web_packages.intersection(recipes)
333
                         ))
334
                    return cls.get_bootstrap("webview", ctx)
8✔
335

336
        prioritized_acceptable_bootstraps = sorted(
8✔
337
            list(acceptable_bootstraps),
338
            key=functools.cmp_to_key(_cmp_bootstraps_by_priority)
339
        )
340

341
        if prioritized_acceptable_bootstraps:
8!
342
            info('Using the highest ranked/first of these: {}'
8✔
343
                 .format(prioritized_acceptable_bootstraps[0].name))
344
            return prioritized_acceptable_bootstraps[0]
8✔
UNCOV
345
        return None
×
346

347
    @classmethod
8✔
348
    def get_bootstrap(cls, name, ctx):
8✔
349
        '''Returns an instance of a bootstrap with the given name.
350

351
        This is the only way you should access a bootstrap class, as
352
        it sets the bootstrap directory correctly.
353
        '''
354
        if name is None:
8!
UNCOV
355
            return None
×
356
        if not hasattr(cls, 'bootstraps'):
8✔
357
            cls.bootstraps = {}
8✔
358
        if name in cls.bootstraps:
8!
UNCOV
359
            return cls.bootstraps[name]
×
360
        mod = importlib.import_module('pythonforandroid.bootstraps.{}'
8✔
361
                                      .format(name))
362
        if len(logger.handlers) > 1:
8!
UNCOV
363
            logger.removeHandler(logger.handlers[1])
×
364
        bootstrap = mod.bootstrap
8✔
365
        bootstrap.bootstrap_dir = join(ctx.root_dir, 'bootstraps', name)
8✔
366
        bootstrap.ctx = ctx
8✔
367
        return bootstrap
8✔
368

369
    def distribute_libs(self, arch, src_dirs, wildcard='*', dest_dir="libs"):
8✔
370
        '''Copy existing arch libs from build dirs to current dist dir.'''
371
        info('Copying libs')
8✔
372
        tgt_dir = join(dest_dir, arch.arch)
8✔
373
        ensure_dir(tgt_dir)
8✔
374
        for src_dir in src_dirs:
8✔
375
            libs = glob.glob(join(src_dir, wildcard))
8✔
376
            if libs:
8✔
377
                shprint(sh.cp, '-a', *libs, tgt_dir)
8✔
378

379
    def distribute_javaclasses(self, javaclass_dir, dest_dir="src"):
8✔
380
        '''Copy existing javaclasses from build dir to current dist dir.'''
381
        info('Copying java files')
8✔
382
        ensure_dir(dest_dir)
8✔
383
        filenames = glob.glob(javaclass_dir)
8✔
384
        shprint(sh.cp, '-a', *filenames, dest_dir)
8✔
385

386
    def distribute_aars(self, arch):
8✔
387
        '''Process existing .aar bundles and copy to current dist dir.'''
388
        info('Unpacking aars')
8✔
389
        for aar in glob.glob(join(self.ctx.aars_dir, '*.aar')):
8✔
390
            self._unpack_aar(aar, arch)
8✔
391

392
    def _unpack_aar(self, aar, arch):
8✔
393
        '''Unpack content of .aar bundle and copy to current dist dir.'''
394
        with temp_directory() as temp_dir:
8✔
395
            name = splitext(basename(aar))[0]
8✔
396
            jar_name = name + '.jar'
8✔
397
            info("unpack {} aar".format(name))
8✔
398
            debug("  from {}".format(aar))
8✔
399
            debug("  to {}".format(temp_dir))
8✔
400
            shprint(sh.unzip, '-o', aar, '-d', temp_dir)
8✔
401

402
            jar_src = join(temp_dir, 'classes.jar')
8✔
403
            jar_tgt = join('libs', jar_name)
8✔
404
            debug("copy {} jar".format(name))
8✔
405
            debug("  from {}".format(jar_src))
8✔
406
            debug("  to {}".format(jar_tgt))
8✔
407
            ensure_dir('libs')
8✔
408
            shprint(sh.cp, '-a', jar_src, jar_tgt)
8✔
409

410
            so_src_dir = join(temp_dir, 'jni', arch.arch)
8✔
411
            so_tgt_dir = join('libs', arch.arch)
8✔
412
            debug("copy {} .so".format(name))
8✔
413
            debug("  from {}".format(so_src_dir))
8✔
414
            debug("  to {}".format(so_tgt_dir))
8✔
415
            ensure_dir(so_tgt_dir)
8✔
416
            so_files = glob.glob(join(so_src_dir, '*.so'))
8✔
417
            shprint(sh.cp, '-a', *so_files, so_tgt_dir)
8✔
418

419
    def strip_libraries(self, arch):
8✔
420
        info('Stripping libraries')
8✔
421
        env = arch.get_env()
8✔
422
        tokens = shlex.split(env['STRIP'])
8✔
423
        strip = sh.Command(tokens[0])
8✔
424
        if len(tokens) > 1:
8!
425
            strip = strip.bake(tokens[1:])
8✔
426

427
        libs_dir = join(self.dist_dir, f'_python_bundle__{arch.arch}',
8✔
428
                        '_python_bundle', 'modules')
429
        filens = shprint(sh.find, libs_dir, join(self.dist_dir, 'libs'),
8✔
430
                         '-iname', '*.so', _env=env).stdout.decode('utf-8')
431

432
        logger.info('Stripping libraries in private dir')
8✔
433
        for filen in filens.split('\n'):
8!
UNCOV
434
            if not filen:
×
UNCOV
435
                continue  # skip the last ''
×
UNCOV
436
            try:
×
UNCOV
437
                strip(filen, _env=env)
×
UNCOV
438
            except sh.ErrorReturnCode_1:
×
UNCOV
439
                logger.debug('Failed to strip ' + filen)
×
440

441
    def fry_eggs(self, sitepackages):
8✔
442
        info('Frying eggs in {}'.format(sitepackages))
8✔
443
        for d in listdir(sitepackages):
8✔
444
            rd = join(sitepackages, d)
8✔
445
            if isdir(rd) and d.endswith('.egg'):
8✔
446
                info('  ' + d)
8✔
447
                files = [join(rd, f) for f in listdir(rd) if f != 'EGG-INFO']
8✔
448
                for f in files:
8✔
449
                    move(f, sitepackages)
8✔
450
                rmdir(d)
8✔
451

452

453
def expand_dependencies(recipes, ctx):
8✔
454
    """ This function expands to lists of all different available
455
        alternative recipe combinations, with the dependencies added in
456
        ONLY for all the not-with-alternative recipes.
457
        (So this is like the deps graph very simplified and incomplete, but
458
         hopefully good enough for most basic bootstrap compatibility checks)
459
    """
460

461
    # Add in all the deps of recipes where there is no alternative:
462
    recipes_with_deps = list(recipes)
8✔
463
    for entry in recipes:
8✔
464
        if not isinstance(entry, (tuple, list)) or len(entry) == 1:
8✔
465
            if isinstance(entry, (tuple, list)):
8✔
466
                entry = entry[0]
8✔
467
            try:
8✔
468
                recipe = Recipe.get_recipe(entry, ctx)
8✔
469
                recipes_with_deps += recipe.depends
8✔
470
            except ValueError:
8✔
471
                # it's a pure python package without a recipe, so we
472
                # don't know the dependencies...skipping for now
473
                pass
8✔
474

475
    # Split up lists by available alternatives:
476
    recipe_lists = [[]]
8✔
477
    for recipe in recipes_with_deps:
8✔
478
        if isinstance(recipe, (tuple, list)):
8✔
479
            new_recipe_lists = []
8✔
480
            for alternative in recipe:
8✔
481
                for old_list in recipe_lists:
8✔
482
                    new_list = [i for i in old_list]
8✔
483
                    new_list.append(alternative)
8✔
484
                    new_recipe_lists.append(new_list)
8✔
485
            recipe_lists = new_recipe_lists
8✔
486
        else:
487
            for existing_list in recipe_lists:
8✔
488
                existing_list.append(recipe)
8✔
489
    return recipe_lists
8✔
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