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

kivy / python-for-android / 34599450232

11 Sep 2026 12:32PM UTC coverage: 62.963% (-0.08%) from 63.041%
34599450232

Pull #3379

github

web-flow
Merge 0ac6a9627 into e772ad93f
Pull Request #3379: recipes: pyjnius: drop stale six dependency

1833 of 3176 branches covered (57.71%)

Branch coverage included in aggregate %.

1 of 1 new or added line in 1 file covered. (100.0%)

9 existing lines in 2 files now uncovered.

5409 of 8326 relevant lines covered (64.97%)

3.9 hits per line

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

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

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

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

19

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

42

43
default_recipe_priorities = [
6✔
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):
6✔
53
    def rank_bootstrap(bootstrap):
6✔
54
        """ Returns a ranking index for each bootstrap,
55
            with higher priority ranked with higher number. """
56
        if bootstrap.name in default_recipe_priorities:
6✔
57
            return default_recipe_priorities.index(bootstrap.name) + 1
6✔
58
        return 0
6✔
59

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

71

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

79
    bootstrap_dir = None
6✔
80

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

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

88
    can_be_chosen_automatically = True
6✔
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
6✔
100
    def dist_dir(self):
6✔
101
        '''The dist dir at which to place the finished distribution.'''
102
        if self.distribution is None:
6✔
103
            raise BuildInterruptingException(
6✔
104
                'Internal error: tried to access {}.dist_dir, but {}.distribution '
105
                'is None'.format(self, self))
106
        return self.distribution.dist_dir
6✔
107

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

112
    def check_recipe_choices(self):
6✔
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 = []
6✔
117
        built_recipes = self.ctx.recipe_build_order or []
6✔
118
        for recipe in self.recipe_depends:
6✔
119
            if isinstance(recipe, (tuple, list)):
6!
120
                for alternative in recipe:
×
121
                    if alternative in built_recipes:
×
122
                        recipes.append(alternative)
×
123
                        break
×
124
        return sorted(recipes)
6✔
125

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

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

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

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

142
    def get_bootstrap_dirs(self):
6✔
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]
6✔
147
        bootstrap_names = [cls.name for cls in classes] + ['common']
6✔
148
        bootstrap_dirs = [
6✔
149
            join(self.ctx.root_dir, 'bootstraps', bootstrap_name)
150
            for bootstrap_name in reversed(bootstrap_names)
151
        ]
152
        return bootstrap_dirs
6✔
153

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

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

171
    def prepare_build_dir(self):
6✔
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()
6✔
175
        # now do a cumulative copy of all bootstrap dirs
176
        self.build_dir = self.get_build_dir()
6✔
177
        for bootstrap_dir in bootstrap_dirs:
6✔
178
            copy_files(join(bootstrap_dir, 'build'), self.build_dir, symlink=self.ctx.symlink_bootstrap_files)
6✔
179

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

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

187
    def _assemble_distribution_for_arch(self, arch):
6✔
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)])
6✔
194
        self.distribute_aars(arch)
6✔
195

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

204
    def assemble_distribution(self):
6✔
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})')
6✔
212

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

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

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

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

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

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

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

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

250
    @classmethod
6✔
251
    def get_usable_bootstraps_for_recipes(cls, recipes, ctx):
6✔
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.')
6✔
255
        bootstraps = [cls.get_bootstrap(name, ctx)
6✔
256
                      for name in cls.all_bootstraps()]
257
        acceptable_bootstraps = set()
6✔
258

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

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

293
    @classmethod
6✔
294
    def get_bootstrap_from_recipes(cls, recipes, ctx):
6✔
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
6✔
300
        recipes_with_deps_lists = expand_dependencies(recipes, ctx)
6✔
301
        acceptable_bootstraps = cls.get_usable_bootstraps_for_recipes(
6✔
302
            recipes, ctx
303
        )
304

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

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

318
        # Special rule: return SDL3 bootstrap if there's an sdl3 dep:
319
        if (have_dependency_in_recipes("sdl3") and
6!
320
                "sdl3" in [b.name for b in acceptable_bootstraps]
321
                ):
322
            info('Using sdl3 bootstrap since it is in dependencies')
×
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:
6✔
327
            if have_dependency_in_recipes(possible_web_dep):
6✔
328
                # We have a web package dep!
329
                if "webview" in [b.name for b in acceptable_bootstraps]:
6!
330
                    info('Using webview bootstrap since common web packages '
6✔
331
                         'were found {}'.format(
332
                             known_web_packages.intersection(recipes)
333
                         ))
334
                    return cls.get_bootstrap("webview", ctx)
6✔
335

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

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

347
    @classmethod
6✔
348
    def get_bootstrap(cls, name, ctx):
6✔
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:
6!
355
            return None
×
356
        if not hasattr(cls, 'bootstraps'):
6✔
357
            cls.bootstraps = {}
6✔
358
        if name in cls.bootstraps:
6!
359
            return cls.bootstraps[name]
×
360
        mod = importlib.import_module('pythonforandroid.bootstraps.{}'
6✔
361
                                      .format(name))
362
        if len(logger.handlers) > 1:
6!
363
            logger.removeHandler(logger.handlers[1])
×
364
        bootstrap = mod.bootstrap
6✔
365
        bootstrap.bootstrap_dir = join(ctx.root_dir, 'bootstraps', name)
6✔
366
        bootstrap.ctx = ctx
6✔
367
        return bootstrap
6✔
368

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

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

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

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

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

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

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

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

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

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

452

453
def expand_dependencies(recipes, ctx):
6✔
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)
6✔
463
    for entry in recipes:
6✔
464
        if not isinstance(entry, (tuple, list)) or len(entry) == 1:
6✔
465
            if isinstance(entry, (tuple, list)):
6✔
466
                entry = entry[0]
6✔
467
            try:
6✔
468
                recipe = Recipe.get_recipe(entry, ctx)
6✔
469
                recipes_with_deps += recipe.depends
6✔
470
            except ValueError:
6✔
471
                # it's a pure python package without a recipe, so we
472
                # don't know the dependencies...skipping for now
473
                pass
6✔
474

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