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

kivy / python-for-android / 37983412207

09 Oct 2026 07:54PM UTC coverage: 62.967% (-0.08%) from 63.046%
37983412207

push

github

web-flow
Merge pull request #3379 from f321x/202609_pyjnius_drop_six

recipes: pyjnius: drop stale six dependency

1831 of 3173 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 8325 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