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

kivy / python-for-android / 23385055951

21 Mar 2026 05:39PM UTC coverage: 63.356% (-0.3%) from 63.661%
23385055951

Pull #3280

github

web-flow
Merge 6813d66a8 into a4a8b2814
Pull Request #3280: Add support for prebuilt wheels

1825 of 3152 branches covered (57.9%)

Branch coverage included in aggregate %.

56 of 122 new or added lines in 6 files covered. (45.9%)

2 existing lines in 1 file now uncovered.

5326 of 8135 relevant lines covered (65.47%)

5.22 hits per line

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

90.23
/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
from pathlib import Path
8✔
11

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

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

20

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

43

44
def copytree_filtered(src, dst, skip_dirs=None):
8✔
45
    """
46
    Copy directory tree while skipping explicitly specified directories in src.
47
    """
48

49
    info(f"Copying {src} to {dst} with skip dirs: {skip_dirs}")
8✔
50

51
    src = Path(src)
8✔
52
    dst = Path(dst)
8✔
53
    skip_dirs = set(Path(p) for p in (skip_dirs or []))
8✔
54

55
    def should_skip(rel_path):
8✔
56
        # match exact directory path only
NEW
57
        return any(rel_path == skip or skip in rel_path.parents for skip in skip_dirs)
×
58

59
    for item in src.rglob("*"):
8!
NEW
60
        rel = item.relative_to(src)
×
61

NEW
62
        if should_skip(rel):
×
NEW
63
            continue
×
64

NEW
65
        target = dst / rel
×
66

NEW
67
        if item.is_dir():
×
NEW
68
            target.mkdir(parents=True, exist_ok=True)
×
69
        else:
NEW
70
            target.parent.mkdir(parents=True, exist_ok=True)
×
NEW
71
            shutil.copy2(item, target)
×
72

73

74
default_recipe_priorities = [
8✔
75
    "webview", "sdl2", "sdl3", "service_only"  # last is highest
76
]
77
# ^^ NOTE: these are just the default priorities if no special rules
78
# apply (which you can find in the code below), so basically if no
79
# known graphical lib or web lib is used - in which case service_only
80
# is the most reasonable guess.
81

82

83
def _cmp_bootstraps_by_priority(a, b):
8✔
84
    def rank_bootstrap(bootstrap):
8✔
85
        """ Returns a ranking index for each bootstrap,
86
            with higher priority ranked with higher number. """
87
        if bootstrap.name in default_recipe_priorities:
8✔
88
            return default_recipe_priorities.index(bootstrap.name) + 1
8✔
89
        return 0
8✔
90

91
    # Rank bootstraps in order:
92
    rank_a = rank_bootstrap(a)
8✔
93
    rank_b = rank_bootstrap(b)
8✔
94
    if rank_a != rank_b:
8✔
95
        return (rank_b - rank_a)
8✔
96
    else:
97
        if a.name < b.name:  # alphabetic sort for determinism
8✔
98
            return -1
8✔
99
        else:
100
            return 1
8✔
101

102

103
class Bootstrap:
8✔
104
    '''An Android project template, containing recipe stuff for
105
    compilation and templated fields for APK info.
106
    '''
107
    jni_subdir = '/jni'
8✔
108
    ctx = None
8✔
109

110
    bootstrap_dir = None
8✔
111

112
    build_dir = None
8✔
113
    dist_name = None
8✔
114
    distribution = None
8✔
115

116
    # All bootstraps should include Python in some way:
117
    recipe_depends = ['python3', 'android']
8✔
118

119
    can_be_chosen_automatically = True
8✔
120
    '''Determines whether the bootstrap can be chosen as one that
6✔
121
    satisfies user requirements. If False, it will not be returned
122
    from Bootstrap.get_bootstrap_from_recipes.
123
    '''
124

125
    # Directories to exclude during copy (relative to src root).
126
    # Used to reduce final build size and exclude unnecessary sources for final build.
127
    skip_dirs = []
8✔
128

129
    # Other things a Bootstrap might need to track (maybe separately):
130
    # ndk_main.c
131
    # whitelist.txt
132
    # blacklist.txt
133

134
    @property
8✔
135
    def dist_dir(self):
8✔
136
        '''The dist dir at which to place the finished distribution.'''
137
        if self.distribution is None:
8✔
138
            raise BuildInterruptingException(
8✔
139
                'Internal error: tried to access {}.dist_dir, but {}.distribution '
140
                'is None'.format(self, self))
141
        return self.distribution.dist_dir
8✔
142

143
    @property
8✔
144
    def jni_dir(self):
8✔
145
        return self.name + self.jni_subdir
8✔
146

147
    def check_recipe_choices(self):
8✔
148
        '''Checks what recipes are being built to see which of the alternative
149
        and optional dependencies are being used,
150
        and returns a list of these.'''
151
        recipes = []
8✔
152
        built_recipes = self.ctx.recipe_build_order or []
8✔
153
        for recipe in self.recipe_depends:
8✔
154
            if isinstance(recipe, (tuple, list)):
8!
155
                for alternative in recipe:
×
156
                    if alternative in built_recipes:
×
157
                        recipes.append(alternative)
×
158
                        break
×
159
        return sorted(recipes)
8✔
160

161
    def get_build_dir_name(self):
8✔
162
        choices = self.check_recipe_choices()
8✔
163
        dir_name = '-'.join([self.name] + choices)
8✔
164
        return dir_name
8✔
165

166
    def get_build_dir(self):
8✔
167
        return join(self.ctx.build_dir, 'bootstrap_builds', self.get_build_dir_name())
8✔
168

169
    def get_dist_dir(self, name):
8✔
170
        return join(self.ctx.dist_dir, name)
8✔
171

172
    @property
8✔
173
    def name(self):
8✔
174
        modname = self.__class__.__module__
×
175
        return modname.split(".", 2)[-1]
×
176

177
    def get_bootstrap_dirs(self):
8✔
178
        """get all bootstrap directories, following the MRO path"""
179

180
        # get all bootstrap names along the __mro__, cutting off Bootstrap and object
181
        classes = self.__class__.__mro__[:-2]
8✔
182
        bootstrap_names = [cls.name for cls in classes] + ['common']
8✔
183
        bootstrap_dirs = [
8✔
184
            join(self.ctx.root_dir, 'bootstraps', bootstrap_name)
185
            for bootstrap_name in reversed(bootstrap_names)
186
        ]
187
        return bootstrap_dirs
8✔
188

189
    def _copy_in_final_files(self):
8✔
190
        if self.name in SDL_BOOTSTRAPS:
8✔
191
            # Get the paths for copying SDL's java source code:
192
            sdl_recipe = Recipe.get_recipe(self.name, self.ctx)
8✔
193
            sdl_build_dir = sdl_recipe.get_jni_dir()
8✔
194
            src_dir = join(sdl_build_dir, "SDL", "android-project",
8✔
195
                           "app", "src", "main", "java",
196
                           "org", "libsdl", "app")
197
            target_dir = join(self.dist_dir, 'src', 'main', 'java', 'org',
8✔
198
                              'libsdl', 'app')
199

200
            # Do actual copying:
201
            info('Copying in SDL .java files from: ' + str(src_dir))
8✔
202
            if not os.path.exists(target_dir):
8✔
203
                os.makedirs(target_dir)
8✔
204
            copy_files(src_dir, target_dir, override=True)
8✔
205

206
    def prepare_build_dir(self):
8✔
207
        """Ensure that a build dir exists for the recipe. This same single
208
        dir will be used for building all different archs."""
209
        bootstrap_dirs = self.get_bootstrap_dirs()
8✔
210
        # now do a cumulative copy of all bootstrap dirs
211
        self.build_dir = self.get_build_dir()
8✔
212
        for bootstrap_dir in bootstrap_dirs:
8✔
213
            copy_files(join(bootstrap_dir, 'build'), self.build_dir, symlink=self.ctx.symlink_bootstrap_files)
8✔
214

215
        with current_directory(self.build_dir):
8✔
216
            with open('project.properties', 'w') as fileh:
8✔
217
                fileh.write('target=android-{}'.format(self.ctx.android_api))
8✔
218

219
    def prepare_dist_dir(self):
8✔
220
        ensure_dir(self.dist_dir)
8✔
221

222
    def _assemble_distribution_for_arch(self, arch):
8✔
223
        """Per-architecture distribution assembly.
224

225
        Override this method to customize per-arch behavior.
226
        Called once for each architecture in self.ctx.archs.
227
        """
228
        self.distribute_libs(arch, [self.ctx.get_libs_dir(arch.arch)])
8✔
229
        self.distribute_aars(arch)
8✔
230

231
        python_bundle_dir = join(f'_python_bundle__{arch.arch}', '_python_bundle')
8✔
232
        ensure_dir(python_bundle_dir)
8✔
233
        site_packages_dir = self.ctx.python_recipe.create_python_bundle(
8✔
234
            join(self.dist_dir, python_bundle_dir), arch)
235
        if not self.ctx.with_debug_symbols:
8!
236
            self.strip_libraries(arch)
8✔
237
        self.fry_eggs(site_packages_dir)
8✔
238

239
    def assemble_distribution(self):
8✔
240
        """Assemble the distribution by copying files and creating Python bundle.
241

242
        This default implementation works for most bootstraps. Override
243
        _assemble_distribution_for_arch() for per-arch customization, or
244
        override this entire method for fundamentally different behavior.
245
        """
246
        info_main(f'# Creating Android project ({self.name})')
8✔
247

248
        rmdir(self.dist_dir)
8✔
249
        copytree_filtered(self.build_dir, self.dist_dir, skip_dirs=self.skip_dirs)
8✔
250

251
        with current_directory(self.dist_dir):
8✔
252
            with open('local.properties', 'w') as fileh:
8✔
253
                fileh.write('sdk.dir={}'.format(self.ctx.sdk_dir))
8✔
254

255
        with current_directory(self.dist_dir):
8✔
256
            info('Copying Python distribution')
8✔
257

258
            self.distribute_javaclasses(self.ctx.javaclass_dir,
8✔
259
                                        dest_dir=join("src", "main", "java"))
260

261
            for arch in self.ctx.archs:
8✔
262
                self._assemble_distribution_for_arch(arch)
8✔
263

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

268
        self._copy_in_final_files()
8✔
269
        self.distribution.save_info(self.dist_dir)
8✔
270

271
    @classmethod
8✔
272
    def all_bootstraps(cls):
8✔
273
        '''Find all the available bootstraps and return them.'''
274
        forbidden_dirs = ('__pycache__', 'common', '_sdl_common')
8✔
275
        bootstraps_dir = join(dirname(__file__), 'bootstraps')
8✔
276
        result = set()
8✔
277
        for name in listdir(bootstraps_dir):
8✔
278
            if name in forbidden_dirs:
8✔
279
                continue
8✔
280
            filen = join(bootstraps_dir, name)
8✔
281
            if isdir(filen):
8✔
282
                result.add(name)
8✔
283
        return result
8✔
284

285
    @classmethod
8✔
286
    def get_usable_bootstraps_for_recipes(cls, recipes, ctx):
8✔
287
        '''Returns all bootstrap whose recipe requirements do not conflict
288
        with the given recipes, in no particular order.'''
289
        info('Trying to find a bootstrap that matches the given recipes.')
8✔
290
        bootstraps = [cls.get_bootstrap(name, ctx)
8✔
291
                      for name in cls.all_bootstraps()]
292
        acceptable_bootstraps = set()
8✔
293

294
        # Find out which bootstraps are acceptable:
295
        for bs in bootstraps:
8✔
296
            if not bs.can_be_chosen_automatically:
8✔
297
                continue
8✔
298
            possible_dependency_lists = expand_dependencies(bs.recipe_depends, ctx)
8✔
299
            for possible_dependencies in possible_dependency_lists:
8✔
300
                ok = True
8✔
301
                # Check if the bootstap's dependencies have an internal conflict:
302
                for recipe in possible_dependencies:
8✔
303
                    recipe = Recipe.get_recipe(recipe, ctx)
8✔
304
                    if any(conflict in recipes for conflict in recipe.conflicts):
8✔
305
                        ok = False
8✔
306
                        break
8✔
307
                # Check if bootstrap's dependencies conflict with chosen
308
                # packages:
309
                for recipe in recipes:
8✔
310
                    try:
8✔
311
                        recipe = Recipe.get_recipe(recipe, ctx)
8✔
312
                    except ValueError:
8✔
313
                        conflicts = []
8✔
314
                    else:
315
                        conflicts = recipe.conflicts
8✔
316
                    if any(conflict in possible_dependencies
8✔
317
                            for conflict in conflicts):
318
                        ok = False
8✔
319
                        break
8✔
320
                if ok and bs not in acceptable_bootstraps:
8✔
321
                    acceptable_bootstraps.add(bs)
8✔
322

323
        info('Found {} acceptable bootstraps: {}'.format(
8✔
324
            len(acceptable_bootstraps),
325
            [bs.name for bs in acceptable_bootstraps]))
326
        return acceptable_bootstraps
8✔
327

328
    @classmethod
8✔
329
    def get_bootstrap_from_recipes(cls, recipes, ctx):
8✔
330
        '''Picks a single recommended default bootstrap out of
331
           all_usable_bootstraps_from_recipes() for the given reicpes,
332
           and returns it.'''
333

334
        known_web_packages = {"flask"}  # to pick webview over service_only
8✔
335
        recipes_with_deps_lists = expand_dependencies(recipes, ctx)
8✔
336
        acceptable_bootstraps = cls.get_usable_bootstraps_for_recipes(
8✔
337
            recipes, ctx
338
        )
339

340
        def have_dependency_in_recipes(dep):
8✔
341
            for dep_list in recipes_with_deps_lists:
8✔
342
                if dep in dep_list:
8✔
343
                    return True
8✔
344
            return False
8✔
345

346
        # Special rule: return SDL2 bootstrap if there's an sdl2 dep:
347
        if (have_dependency_in_recipes("sdl2") and
8✔
348
                "sdl2" in [b.name for b in acceptable_bootstraps]
349
                ):
350
            info('Using sdl2 bootstrap since it is in dependencies')
8✔
351
            return cls.get_bootstrap("sdl2", ctx)
8✔
352

353
        # Special rule: return SDL3 bootstrap if there's an sdl3 dep:
354
        if (have_dependency_in_recipes("sdl3") and
8!
355
                "sdl3" in [b.name for b in acceptable_bootstraps]
356
                ):
357
            info('Using sdl3 bootstrap since it is in dependencies')
×
358
            return cls.get_bootstrap("sdl3", ctx)
×
359

360
        # Special rule: return "webview" if we depend on common web recipe:
361
        for possible_web_dep in known_web_packages:
8✔
362
            if have_dependency_in_recipes(possible_web_dep):
8✔
363
                # We have a web package dep!
364
                if "webview" in [b.name for b in acceptable_bootstraps]:
8!
365
                    info('Using webview bootstrap since common web packages '
8✔
366
                         'were found {}'.format(
367
                             known_web_packages.intersection(recipes)
368
                         ))
369
                    return cls.get_bootstrap("webview", ctx)
8✔
370

371
        prioritized_acceptable_bootstraps = sorted(
8✔
372
            list(acceptable_bootstraps),
373
            key=functools.cmp_to_key(_cmp_bootstraps_by_priority)
374
        )
375

376
        if prioritized_acceptable_bootstraps:
8!
377
            info('Using the highest ranked/first of these: {}'
8✔
378
                 .format(prioritized_acceptable_bootstraps[0].name))
379
            return prioritized_acceptable_bootstraps[0]
8✔
380
        return None
×
381

382
    @classmethod
8✔
383
    def get_bootstrap(cls, name, ctx):
8✔
384
        '''Returns an instance of a bootstrap with the given name.
385

386
        This is the only way you should access a bootstrap class, as
387
        it sets the bootstrap directory correctly.
388
        '''
389
        if name is None:
8!
390
            return None
×
391
        if not hasattr(cls, 'bootstraps'):
8✔
392
            cls.bootstraps = {}
8✔
393
        if name in cls.bootstraps:
8!
394
            return cls.bootstraps[name]
×
395
        mod = importlib.import_module('pythonforandroid.bootstraps.{}'
8✔
396
                                      .format(name))
397
        if len(logger.handlers) > 1:
8!
398
            logger.removeHandler(logger.handlers[1])
×
399
        bootstrap = mod.bootstrap
8✔
400
        bootstrap.bootstrap_dir = join(ctx.root_dir, 'bootstraps', name)
8✔
401
        bootstrap.ctx = ctx
8✔
402
        return bootstrap
8✔
403

404
    def distribute_libs(self, arch, src_dirs, wildcard='*', dest_dir="libs"):
8✔
405
        '''Copy existing arch libs from build dirs to current dist dir.'''
406
        info('Copying libs')
8✔
407
        tgt_dir = join(dest_dir, arch.arch)
8✔
408
        ensure_dir(tgt_dir)
8✔
409
        for src_dir in src_dirs:
8✔
410
            libs = glob.glob(join(src_dir, wildcard))
8✔
411
            if libs:
8✔
412
                shprint(sh.cp, '-a', *libs, tgt_dir)
8✔
413

414
    def distribute_javaclasses(self, javaclass_dir, dest_dir="src"):
8✔
415
        '''Copy existing javaclasses from build dir to current dist dir.'''
416
        info('Copying java files')
8✔
417
        ensure_dir(dest_dir)
8✔
418
        filenames = glob.glob(javaclass_dir)
8✔
419
        shprint(sh.cp, '-a', *filenames, dest_dir)
8✔
420

421
    def distribute_aars(self, arch):
8✔
422
        '''Process existing .aar bundles and copy to current dist dir.'''
423
        info('Unpacking aars')
8✔
424
        for aar in glob.glob(join(self.ctx.aars_dir, '*.aar')):
8✔
425
            self._unpack_aar(aar, arch)
8✔
426

427
    def _unpack_aar(self, aar, arch):
8✔
428
        '''Unpack content of .aar bundle and copy to current dist dir.'''
429
        with temp_directory() as temp_dir:
8✔
430
            name = splitext(basename(aar))[0]
8✔
431
            jar_name = name + '.jar'
8✔
432
            info("unpack {} aar".format(name))
8✔
433
            debug("  from {}".format(aar))
8✔
434
            debug("  to {}".format(temp_dir))
8✔
435
            shprint(sh.unzip, '-o', aar, '-d', temp_dir)
8✔
436

437
            jar_src = join(temp_dir, 'classes.jar')
8✔
438
            jar_tgt = join('libs', jar_name)
8✔
439
            debug("copy {} jar".format(name))
8✔
440
            debug("  from {}".format(jar_src))
8✔
441
            debug("  to {}".format(jar_tgt))
8✔
442
            ensure_dir('libs')
8✔
443
            shprint(sh.cp, '-a', jar_src, jar_tgt)
8✔
444

445
            so_src_dir = join(temp_dir, 'jni', arch.arch)
8✔
446
            so_tgt_dir = join('libs', arch.arch)
8✔
447
            debug("copy {} .so".format(name))
8✔
448
            debug("  from {}".format(so_src_dir))
8✔
449
            debug("  to {}".format(so_tgt_dir))
8✔
450
            ensure_dir(so_tgt_dir)
8✔
451
            so_files = glob.glob(join(so_src_dir, '*.so'))
8✔
452
            shprint(sh.cp, '-a', *so_files, so_tgt_dir)
8✔
453

454
    def strip_libraries(self, arch):
8✔
455
        info('Stripping libraries')
8✔
456
        env = arch.get_env()
8✔
457
        tokens = shlex.split(env['STRIP'])
8✔
458
        strip = sh.Command(tokens[0])
8✔
459
        if len(tokens) > 1:
8!
460
            strip = strip.bake(tokens[1:])
8✔
461

462
        libs_dir = join(self.dist_dir, f'_python_bundle__{arch.arch}',
8✔
463
                        '_python_bundle', 'modules')
464
        filens = shprint(sh.find, libs_dir, join(self.dist_dir, 'libs'),
8✔
465
                         '-iname', '*.so', _env=env).stdout.decode('utf-8')
466

467
        logger.info('Stripping libraries in private dir')
8✔
468
        for filen in filens.split('\n'):
8!
469
            if not filen:
×
470
                continue  # skip the last ''
×
471
            try:
×
472
                strip(filen, _env=env)
×
473
            except sh.ErrorReturnCode_1:
×
474
                logger.debug('Failed to strip ' + filen)
×
475

476
    def fry_eggs(self, sitepackages):
8✔
477
        info('Frying eggs in {}'.format(sitepackages))
8✔
478
        for d in listdir(sitepackages):
8✔
479
            rd = join(sitepackages, d)
8✔
480
            if isdir(rd) and d.endswith('.egg'):
8✔
481
                info('  ' + d)
8✔
482
                files = [join(rd, f) for f in listdir(rd) if f != 'EGG-INFO']
8✔
483
                for f in files:
8✔
484
                    move(f, sitepackages)
8✔
485
                rmdir(d)
8✔
486

487

488
def expand_dependencies(recipes, ctx):
8✔
489
    """ This function expands to lists of all different available
490
        alternative recipe combinations, with the dependencies added in
491
        ONLY for all the not-with-alternative recipes.
492
        (So this is like the deps graph very simplified and incomplete, but
493
         hopefully good enough for most basic bootstrap compatibility checks)
494
    """
495

496
    # Add in all the deps of recipes where there is no alternative:
497
    recipes_with_deps = list(recipes)
8✔
498
    for entry in recipes:
8✔
499
        if not isinstance(entry, (tuple, list)) or len(entry) == 1:
8✔
500
            if isinstance(entry, (tuple, list)):
8✔
501
                entry = entry[0]
8✔
502
            try:
8✔
503
                recipe = Recipe.get_recipe(entry, ctx)
8✔
504
                recipes_with_deps += recipe.depends
8✔
505
            except ValueError:
8✔
506
                # it's a pure python package without a recipe, so we
507
                # don't know the dependencies...skipping for now
508
                pass
8✔
509

510
    # Split up lists by available alternatives:
511
    recipe_lists = [[]]
8✔
512
    for recipe in recipes_with_deps:
8✔
513
        if isinstance(recipe, (tuple, list)):
8✔
514
            new_recipe_lists = []
8✔
515
            for alternative in recipe:
8✔
516
                for old_list in recipe_lists:
8✔
517
                    new_list = [i for i in old_list]
8✔
518
                    new_list.append(alternative)
8✔
519
                    new_recipe_lists.append(new_list)
8✔
520
            recipe_lists = new_recipe_lists
8✔
521
        else:
522
            for existing_list in recipe_lists:
8✔
523
                existing_list.append(recipe)
8✔
524
    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