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

kivy / python-for-android / 14318575834

07 Apr 2025 08:13PM UTC coverage: 58.902% (-0.2%) from 59.111%
14318575834

Pull #3125

github

web-flow
Merge 2abaa319a into fbd525588
Pull Request #3125: [WIP] Add SDL3 bootstrap

1053 of 2397 branches covered (43.93%)

Branch coverage included in aggregate %.

77 of 146 new or added lines in 12 files covered. (52.74%)

188 existing lines in 2 files now uncovered.

4955 of 7803 relevant lines covered (63.5%)

3.16 hits per line

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

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

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

17

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

40

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

49

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

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

69

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

77
    bootstrap_dir = None
5✔
78

79
    build_dir = None
5✔
80
    dist_name = None
5✔
81
    distribution = None
5✔
82

83
    # All bootstraps should include Python in some way:
84
    recipe_depends = ['python3', 'android']
5✔
85

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

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

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

106
    @property
5✔
107
    def jni_dir(self):
5✔
108
        return self.name + self.jni_subdir
5✔
109

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

124
    def get_build_dir_name(self):
5✔
125
        choices = self.check_recipe_choices()
5✔
126
        dir_name = '-'.join([self.name] + choices)
5✔
127
        return dir_name
5✔
128

129
    def get_build_dir(self):
5✔
130
        return join(self.ctx.build_dir, 'bootstrap_builds', self.get_build_dir_name())
5✔
131

132
    def get_dist_dir(self, name):
5✔
133
        return join(self.ctx.dist_dir, name)
5✔
134

135
    @property
5✔
136
    def name(self):
5✔
UNCOV
137
        modname = self.__class__.__module__
×
UNCOV
138
        return modname.split(".", 2)[-1]
×
139

140
    def get_bootstrap_dirs(self):
5✔
141
        """get all bootstrap directories, following the MRO path"""
142

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

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

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

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

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

182
    def prepare_dist_dir(self):
5✔
183
        ensure_dir(self.dist_dir)
5✔
184

185
    def assemble_distribution(self):
5✔
186
        ''' Copies all the files into the distribution (this function is
187
            overridden by the specific bootstrap classes to do this)
188
            and add in the distribution info.
189
        '''
190
        self._copy_in_final_files()
5✔
191
        self.distribution.save_info(self.dist_dir)
5✔
192

193
    @classmethod
5✔
194
    def all_bootstraps(cls):
5✔
195
        '''Find all the available bootstraps and return them.'''
196
        forbidden_dirs = ('__pycache__', 'common')
5✔
197
        bootstraps_dir = join(dirname(__file__), 'bootstraps')
5✔
198
        result = set()
5✔
199
        for name in listdir(bootstraps_dir):
5✔
200
            if name in forbidden_dirs:
5✔
201
                continue
5✔
202
            filen = join(bootstraps_dir, name)
5✔
203
            if isdir(filen):
5✔
204
                result.add(name)
5✔
205
        return result
5✔
206

207
    @classmethod
5✔
208
    def get_usable_bootstraps_for_recipes(cls, recipes, ctx):
5✔
209
        '''Returns all bootstrap whose recipe requirements do not conflict
210
        with the given recipes, in no particular order.'''
211
        info('Trying to find a bootstrap that matches the given recipes.')
5✔
212
        bootstraps = [cls.get_bootstrap(name, ctx)
5✔
213
                      for name in cls.all_bootstraps()]
214
        acceptable_bootstraps = set()
5✔
215

216
        # Find out which bootstraps are acceptable:
217
        for bs in bootstraps:
5✔
218
            if not bs.can_be_chosen_automatically:
5✔
219
                continue
5✔
220
            possible_dependency_lists = expand_dependencies(bs.recipe_depends, ctx)
5✔
221
            for possible_dependencies in possible_dependency_lists:
5✔
222
                ok = True
5✔
223
                # Check if the bootstap's dependencies have an internal conflict:
224
                for recipe in possible_dependencies:
5✔
225
                    recipe = Recipe.get_recipe(recipe, ctx)
5✔
226
                    if any(conflict in recipes for conflict in recipe.conflicts):
5✔
227
                        ok = False
5✔
228
                        break
5✔
229
                # Check if bootstrap's dependencies conflict with chosen
230
                # packages:
231
                for recipe in recipes:
5✔
232
                    try:
5✔
233
                        recipe = Recipe.get_recipe(recipe, ctx)
5✔
UNCOV
234
                    except ValueError:
×
UNCOV
235
                        conflicts = []
×
236
                    else:
237
                        conflicts = recipe.conflicts
5✔
238
                    if any(conflict in possible_dependencies
5✔
239
                            for conflict in conflicts):
240
                        ok = False
5✔
241
                        break
5✔
242
                if ok and bs not in acceptable_bootstraps:
5✔
243
                    acceptable_bootstraps.add(bs)
5✔
244

245
        info('Found {} acceptable bootstraps: {}'.format(
5✔
246
            len(acceptable_bootstraps),
247
            [bs.name for bs in acceptable_bootstraps]))
248
        return acceptable_bootstraps
5✔
249

250
    @classmethod
5✔
251
    def get_bootstrap_from_recipes(cls, recipes, ctx):
5✔
252
        '''Picks a single recommended default bootstrap out of
253
           all_usable_bootstraps_from_recipes() for the given reicpes,
254
           and returns it.'''
255

256
        known_web_packages = {"flask"}  # to pick webview over service_only
5✔
257
        recipes_with_deps_lists = expand_dependencies(recipes, ctx)
5✔
258
        acceptable_bootstraps = cls.get_usable_bootstraps_for_recipes(
5✔
259
            recipes, ctx
260
        )
261

262
        def have_dependency_in_recipes(dep):
5✔
263
            for dep_list in recipes_with_deps_lists:
5✔
264
                if dep in dep_list:
5✔
265
                    return True
5✔
266
            return False
5✔
267

268
        # Special rule: return SDL2 bootstrap if there's an sdl2 dep:
269
        if (have_dependency_in_recipes("sdl2") and
5✔
270
                "sdl2" in [b.name for b in acceptable_bootstraps]
271
                ):
272
            info('Using sdl2 bootstrap since it is in dependencies')
5✔
273
            return cls.get_bootstrap("sdl2", ctx)
5✔
274

275
        # Special rule: return SDL3 bootstrap if there's an sdl3 dep:
276
        if (have_dependency_in_recipes("sdl3") and
5!
277
                "sdl3" in [b.name for b in acceptable_bootstraps]
278
                ):
NEW
279
            info('Using sdl3 bootstrap since it is in dependencies')
×
NEW
280
            return cls.get_bootstrap("sdl3", ctx)
×
281

282
        # Special rule: return "webview" if we depend on common web recipe:
283
        for possible_web_dep in known_web_packages:
5✔
284
            if have_dependency_in_recipes(possible_web_dep):
5✔
285
                # We have a web package dep!
286
                if "webview" in [b.name for b in acceptable_bootstraps]:
5!
287
                    info('Using webview bootstrap since common web packages '
5✔
288
                         'were found {}'.format(
289
                             known_web_packages.intersection(recipes)
290
                         ))
291
                    return cls.get_bootstrap("webview", ctx)
5✔
292

293
        prioritized_acceptable_bootstraps = sorted(
5✔
294
            list(acceptable_bootstraps),
295
            key=functools.cmp_to_key(_cmp_bootstraps_by_priority)
296
        )
297

298
        if prioritized_acceptable_bootstraps:
5!
299
            info('Using the highest ranked/first of these: {}'
5✔
300
                 .format(prioritized_acceptable_bootstraps[0].name))
301
            return prioritized_acceptable_bootstraps[0]
5✔
UNCOV
302
        return None
×
303

304
    @classmethod
5✔
305
    def get_bootstrap(cls, name, ctx):
5✔
306
        '''Returns an instance of a bootstrap with the given name.
307

308
        This is the only way you should access a bootstrap class, as
309
        it sets the bootstrap directory correctly.
310
        '''
311
        if name is None:
5!
UNCOV
312
            return None
×
313
        if not hasattr(cls, 'bootstraps'):
5✔
314
            cls.bootstraps = {}
5✔
315
        if name in cls.bootstraps:
5!
UNCOV
316
            return cls.bootstraps[name]
×
317
        mod = importlib.import_module('pythonforandroid.bootstraps.{}'
5✔
318
                                      .format(name))
319
        if len(logger.handlers) > 1:
5!
UNCOV
320
            logger.removeHandler(logger.handlers[1])
×
321
        bootstrap = mod.bootstrap
5✔
322
        bootstrap.bootstrap_dir = join(ctx.root_dir, 'bootstraps', name)
5✔
323
        bootstrap.ctx = ctx
5✔
324
        return bootstrap
5✔
325

326
    def distribute_libs(self, arch, src_dirs, wildcard='*', dest_dir="libs"):
5✔
327
        '''Copy existing arch libs from build dirs to current dist dir.'''
328
        info('Copying libs')
5✔
329
        tgt_dir = join(dest_dir, arch.arch)
5✔
330
        ensure_dir(tgt_dir)
5✔
331
        for src_dir in src_dirs:
5✔
332
            libs = glob.glob(join(src_dir, wildcard))
5✔
333
            if libs:
5✔
334
                shprint(sh.cp, '-a', *libs, tgt_dir)
5✔
335

336
    def distribute_javaclasses(self, javaclass_dir, dest_dir="src"):
5✔
337
        '''Copy existing javaclasses from build dir to current dist dir.'''
338
        info('Copying java files')
5✔
339
        ensure_dir(dest_dir)
5✔
340
        filenames = glob.glob(javaclass_dir)
5✔
341
        shprint(sh.cp, '-a', *filenames, dest_dir)
5✔
342

343
    def distribute_aars(self, arch):
5✔
344
        '''Process existing .aar bundles and copy to current dist dir.'''
345
        info('Unpacking aars')
5✔
346
        for aar in glob.glob(join(self.ctx.aars_dir, '*.aar')):
5✔
347
            self._unpack_aar(aar, arch)
5✔
348

349
    def _unpack_aar(self, aar, arch):
5✔
350
        '''Unpack content of .aar bundle and copy to current dist dir.'''
351
        with temp_directory() as temp_dir:
5✔
352
            name = splitext(basename(aar))[0]
5✔
353
            jar_name = name + '.jar'
5✔
354
            info("unpack {} aar".format(name))
5✔
355
            debug("  from {}".format(aar))
5✔
356
            debug("  to {}".format(temp_dir))
5✔
357
            shprint(sh.unzip, '-o', aar, '-d', temp_dir)
5✔
358

359
            jar_src = join(temp_dir, 'classes.jar')
5✔
360
            jar_tgt = join('libs', jar_name)
5✔
361
            debug("copy {} jar".format(name))
5✔
362
            debug("  from {}".format(jar_src))
5✔
363
            debug("  to {}".format(jar_tgt))
5✔
364
            ensure_dir('libs')
5✔
365
            shprint(sh.cp, '-a', jar_src, jar_tgt)
5✔
366

367
            so_src_dir = join(temp_dir, 'jni', arch.arch)
5✔
368
            so_tgt_dir = join('libs', arch.arch)
5✔
369
            debug("copy {} .so".format(name))
5✔
370
            debug("  from {}".format(so_src_dir))
5✔
371
            debug("  to {}".format(so_tgt_dir))
5✔
372
            ensure_dir(so_tgt_dir)
5✔
373
            so_files = glob.glob(join(so_src_dir, '*.so'))
5✔
374
            shprint(sh.cp, '-a', *so_files, so_tgt_dir)
5✔
375

376
    def strip_libraries(self, arch):
5✔
377
        info('Stripping libraries')
5✔
378
        env = arch.get_env()
5✔
379
        tokens = shlex.split(env['STRIP'])
5✔
380
        strip = sh.Command(tokens[0])
5✔
381
        if len(tokens) > 1:
5!
382
            strip = strip.bake(tokens[1:])
5✔
383

384
        libs_dir = join(self.dist_dir, f'_python_bundle__{arch.arch}',
5✔
385
                        '_python_bundle', 'modules')
386
        filens = shprint(sh.find, libs_dir, join(self.dist_dir, 'libs'),
5✔
387
                         '-iname', '*.so', _env=env).stdout.decode('utf-8')
388

389
        logger.info('Stripping libraries in private dir')
5✔
390
        for filen in filens.split('\n'):
5!
UNCOV
391
            if not filen:
×
UNCOV
392
                continue  # skip the last ''
×
393
            try:
×
394
                strip(filen, _env=env)
×
395
            except sh.ErrorReturnCode_1:
×
396
                logger.debug('Failed to strip ' + filen)
×
397

398
    def fry_eggs(self, sitepackages):
5✔
399
        info('Frying eggs in {}'.format(sitepackages))
5✔
400
        for d in listdir(sitepackages):
5✔
401
            rd = join(sitepackages, d)
5✔
402
            if isdir(rd) and d.endswith('.egg'):
5✔
403
                info('  ' + d)
5✔
404
                files = [join(rd, f) for f in listdir(rd) if f != 'EGG-INFO']
5✔
405
                for f in files:
5✔
406
                    move(f, sitepackages)
5✔
407
                rmdir(d)
5✔
408

409

410
def expand_dependencies(recipes, ctx):
5✔
411
    """ This function expands to lists of all different available
412
        alternative recipe combinations, with the dependencies added in
413
        ONLY for all the not-with-alternative recipes.
414
        (So this is like the deps graph very simplified and incomplete, but
415
         hopefully good enough for most basic bootstrap compatibility checks)
416
    """
417

418
    # Add in all the deps of recipes where there is no alternative:
419
    recipes_with_deps = list(recipes)
5✔
420
    for entry in recipes:
5✔
421
        if not isinstance(entry, (tuple, list)) or len(entry) == 1:
5✔
422
            if isinstance(entry, (tuple, list)):
5✔
423
                entry = entry[0]
5✔
424
            try:
5✔
425
                recipe = Recipe.get_recipe(entry, ctx)
5✔
426
                recipes_with_deps += recipe.depends
5✔
427
            except ValueError:
5✔
428
                # it's a pure python package without a recipe, so we
429
                # don't know the dependencies...skipping for now
430
                pass
5✔
431

432
    # Split up lists by available alternatives:
433
    recipe_lists = [[]]
5✔
434
    for recipe in recipes_with_deps:
5✔
435
        if isinstance(recipe, (tuple, list)):
5✔
436
            new_recipe_lists = []
5✔
437
            for alternative in recipe:
5✔
438
                for old_list in recipe_lists:
5✔
439
                    new_list = [i for i in old_list]
5✔
440
                    new_list.append(alternative)
5✔
441
                    new_recipe_lists.append(new_list)
5✔
442
            recipe_lists = new_recipe_lists
5✔
443
        else:
444
            for existing_list in recipe_lists:
5✔
445
                existing_list.append(recipe)
5✔
446
    return recipe_lists
5✔
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