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

kivy / python-for-android / 14420258271

12 Apr 2025 01:55PM UTC coverage: 59.141% (+0.03%) from 59.111%
14420258271

push

github

web-flow
Add `SDL3` bootstrap (alongside `SDL3`, `SDL3_ttf`, `SDL3_mixer`, `SDL3_image` recipes) for Kivy `3.0.0` (#3125)

* Add SDL3 bootstrap

* Avoid some DRY issues + minor fixes + version bump

1054 of 2383 branches covered (44.23%)

Branch coverage included in aggregate %.

112 of 160 new or added lines in 14 files covered. (70.0%)

1 existing line in 1 file now uncovered.

4960 of 7786 relevant lines covered (63.7%)

2.54 hits per line

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

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

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

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

19

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

42

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

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

71

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

79
    bootstrap_dir = None
4✔
80

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

264
        def have_dependency_in_recipes(dep):
4✔
265
            for dep_list in recipes_with_deps_lists:
4✔
266
                if dep in dep_list:
4✔
267
                    return True
4✔
268
            return False
4✔
269

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

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

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

295
        prioritized_acceptable_bootstraps = sorted(
4✔
296
            list(acceptable_bootstraps),
297
            key=functools.cmp_to_key(_cmp_bootstraps_by_priority)
298
        )
299

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

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

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

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

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

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

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

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

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

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

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

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

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

411

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

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

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