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

dkrajzew / db2qthelp / 17189004596

24 Aug 2025 12:59PM UTC coverage: 96.223% (+3.1%) from 93.103%
17189004596

push

github

dkrajzew
patching tests

535 of 556 relevant lines covered (96.22%)

9.59 hits per line

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

92.16
/db2qthelp/db2qthelp.py
1
#!/usr/bin/env python
2
# -*- coding: utf-8 -*-
3
"""db2qthelp - a DocBook book to QtHelp project converter"""
4
# ===========================================================================
5
__author__     = "Daniel Krajzewicz"
10✔
6
__copyright__  = "Copyright 2022-2025, Daniel Krajzewicz"
10✔
7
__credits__    = ["Daniel Krajzewicz"]
10✔
8
__license__    = "GPLv3"
10✔
9
__version__    = "0.4.0"
10✔
10
__maintainer__ = "Daniel Krajzewicz"
10✔
11
__email__      = "daniel@krajzewicz.de"
10✔
12
__status__     = "Development"
10✔
13
# ===========================================================================
14
# - https://github.com/dkrajzew/db2qthelp
15
# - http://www.krajzewicz.de/docs/db2qthelp/index.html
16
# - http://www.krajzewicz.de
17
# ===========================================================================
18

19

20
# --- imports ---------------------------------------------------------------
21
import os
10✔
22
import sys
10✔
23
import shutil
10✔
24
import glob
10✔
25
import re
10✔
26
import argparse
10✔
27
import configparser
10✔
28
import subprocess
10✔
29
from typing import List, Set, Tuple
10✔
30

31

32
# --- variables and constants -----------------------------------------------
33
CSS_DEFINITION = """body {
10✔
34
 margin: 0;
35
 padding: 0 0 0 0;
36
 background-color: rgba(255, 255, 255, 1);
37
 font-size: 12pt;
38
}
39
ul { margin: -.8em 0em 1em 0em; }
40
li { margin: 0em 0em 0em 0em; }
41
p { margin: .2em 0em .4em 0em; }
42
h4 { font-size: 14pt; }
43
h3 { font-size: 16pt; }
44
h2 { font-size: 18pt; }
45
h1 { font-size: 20pt; }
46
pre { background-color: rgba(224, 224, 224, 1); }
47
.guimenu, .guimenuitem, .guibutton { font-weight: bold; }
48
table, th, td { border: 1px solid black; border-collapse: collapse; }
49
th, td { padding: 4px; }
50
th { background-color: rgba(204, 212, 255, 1); }
51
div.informalequation { text-align: center; font-style: italic; }
52
.note p { background-color: #e0f0ff; margin: 8px 8px 8px 8px; }
53
.tip p { background-color: #c0ffc0; margin: 8px 8px 8px 8px; }
54
.warning p { background-color: #ffff80; margin: 8px 8px 8px 8px; }
55
"""
56

57
QHP_TEMPLATE = """<?xml version="1.0" encoding="UTF-8"?>
10✔
58
<QtHelpProject version="1.0">
59
    <namespace>%appname%</namespace>
60
    <virtualFolder>doc</virtualFolder>
61
    <filterSection>
62
        <filterAttribute>%appname%</filterAttribute>
63
        <toc>
64
%toc%
65
        </toc>
66
        <keywords>
67
%keywords%
68
        </keywords>
69
        <files>
70
            <file>*.html</file>
71
            <file>*.png</file>
72
            <file>*.gif</file>
73
        </files>
74
    </filterSection>
75
</QtHelpProject>
76
"""
77

78
QCHP = """<?xml version="1.0" encoding="UTF-8"?>
10✔
79
<QHelpCollectionProject version="1.0">
80
    <docFiles>
81
        <generate>
82
            <file>
83
                <input>%appname%.qhp</input>
84
                <output>%appname%.qch</output>
85
            </file>
86
        </generate>
87
        <register>
88
            <file>%appname%.qch</file>
89
        </register>
90
    </docFiles>
91
</QHelpCollectionProject>
92
"""
93

94
# --- functions -------------------------------------------------------------
95
class Db2QtHelp:
10✔
96
    def __init__(self, qt_path : str, xsltproc_path : str, css_definition : str, qhp_template : str):
10✔
97
        """Contructor
98

99
        Args:
100
            qt_path (str): Path to the Qt binaries
101
            xsltproc_path (str): Path to the xsltproc binary
102
            css_definition (str): CSS definition to use
103
            qhp_template (str): Template for the .qhp file
104
        """
105
        self._qt_path = qt_path
10✔
106
        self._xsltproc_path = xsltproc_path
10✔
107
        self._css_definition = css_definition if css_definition is not None else CSS_DEFINITION
10✔
108
        self._css_definition = "\n<style>\n" + self._css_definition + "</style>\n"
10✔
109
        self._qhp_template = qhp_template if qhp_template is not None else QHP_TEMPLATE
10✔
110

111

112
    def _get_id(self, html : str) -> str:
10✔
113
        """Return the docbook ID of the current section.
114

115
        The value of the first a-element's name attribute is assumed to be the docbook ID.
116

117
        Args:
118
            html (str): The HTML snippet to get the next docbook ID from
119

120
        Returns:
121
            (str): The next ID found in the snippet
122
        """
123
        db_id = html[html.find("<a name=\"")+9:]
10✔
124
        db_id = db_id[:db_id.find("\"")]
10✔
125
        return db_id
10✔
126

127

128
    def _get_name(self, html : str) -> str:
10✔
129
        """Return the name of the current section.
130

131
        Args:
132
            html (str): The HTML snippet to get the next name from
133

134
        Returns:
135
            (str): The next name found in the snippet
136
        """
137
        name = html[html.find("</a>")+4:]
10✔
138
        name = name[:name.find("</h")]
10✔
139
        name = name.replace("\"", "'")
10✔
140
        name = name.strip()
10✔
141
        return name
10✔
142

143

144
    def _get_title(self, html : str) -> str:
10✔
145
        """Return the name of the current section.
146

147
        Args:
148
            html (str): The HTML snippet to get the next name from
149

150
        Returns:
151
            (str): The next name found in the snippet
152
        """
153
        name = html[html.find("<title>")+7:]
10✔
154
        name = name[:name.find("</title>")]
10✔
155
        name = name.replace("\"", "'")
10✔
156
        name = name.strip()
10✔
157
        return name
10✔
158

159

160
    def patch_links(self, doc : str, app_name : str, files : Set[str]) -> str:
10✔
161
        """Extracts references to images; patches links to point to main document folder
162

163
        Args:
164
            doc (str): The HTML document to process
165
            app_name (str): The application name
166
            files (Set[str]): The container to store links into
167

168
        Returns:
169
            (str): The changed document
170
        """
171
        srcs = re.findall(r'src\s*=\s*"(.+?)"', doc)
10✔
172
        seen = set()
10✔
173
        for src in srcs:
10✔
174
            filename = os.path.split(src)[1]
10✔
175
            if filename in seen:
10✔
176
                continue
10✔
177
            seen.add(filename)
10✔
178
            nsrc = f"qthelp://{app_name}/doc/{filename}"
10✔
179
            doc = doc.replace(src, nsrc)
10✔
180
            files.add(src)
10✔
181
        return doc
10✔
182

183

184
    def _write_sections_recursive(self, html : str, dst_folder : str, pages : List[Tuple[str, str]], level : int) -> None:
10✔
185
        """Writes the given section and it's sub-sections recursively.
186

187
        The id and the name of the section are retrieved, first.
188

189
        Then, the toc HTML file is extended and the reference to this section is
190
        appended to the returned toc. Keywords are extended by the section's name.
191

192
        The section is then split along the
193
        '&lt;div class="sect&lt;INDENT&gt;"&gt;' elements which are processed
194
        recursively.
195

196
        The (recursively) collected keywords and toc are returned.
197

198
        Args:
199
            html (str): The (string) content of the DocBook book section or appendix
200
            dst_folder (str): The folder to write the section into
201
            pages (List[Tuple[str, str]]): The list of HTML sections to fill
202
            level (int): intendation level
203
        """
204
        db_id = self._get_id(html)
10✔
205
        name = self._get_name(html)
10✔
206
        pages.append([f"{db_id}.html", name])
10✔
207
        subs = html.split(f"<div class=\"sect{level}\">")
10✔
208
        if subs[0].rfind("</div>")>=len(subs[0])-6:
10✔
209
            subs[0] = subs[0][:subs[0].rfind("</div>")]
×
210
        # patch links (convert links to anchors, to the proper links to split pages)
211
        subs[0] = re.sub(r'<a href="#([^"]*)">([^<]*)</a>', r'<a href="\1.html">\2</a>', subs[0])
10✔
212
        subs[0] = re.sub(r'<a class="ulink" href="#([^"]*)">([^<]*)</a>', r'<a class="ulink" href="\1.html">\2</a>', subs[0])
10✔
213
        # write the document part as document
214
        subs[0] = '<html><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>' + self._css_definition + "</head><body>" + subs[0] + "</body></html>"
10✔
215
        with open(dst_folder + f"/{db_id}.html", "w", encoding="utf-8") as fdo:
10✔
216
            fdo.write(subs[0])
10✔
217
        if len(subs)>1:
10✔
218
            for i,sub in enumerate(subs):
10✔
219
                if i==0:
10✔
220
                    continue
10✔
221
                sub = sub[:sub.rfind("</div>")]
10✔
222
                self._write_sections_recursive(sub, dst_folder, pages, level+1)
10✔
223

224

225
    def _process_single(self, source : str, dst_folder : str, pages : List[Tuple[str, str]], files : Set[str], app_name : str) -> None:
10✔
226
        """Processes a single (not chunked) HTML document generated by docbook
227

228
        Args:
229
            source (str): The HTML document to process
230
            dst_folder (str): The folder to write the section into
231
            pages (List[Tuple[str, str]]): The list of HTML sections to fill
232
            files (Set[str]): The set of referenced files (images) to fill
233
            app_name (str): The application name
234
        """
235
        # read doc
236
        with open(source, encoding="utf-8") as fdi:
10✔
237
            doc = fdi.read()
10✔
238
        doc = self.patch_links(doc, app_name, files)
10✔
239
        # process document
240
        chapters = doc.split("<div class=\"chapter\">")
10✔
241
        appendices = chapters[-1].split('<div class="appendix">')
10✔
242
        chapters = chapters[:-1]
10✔
243
        chapters.extend(appendices)
10✔
244
        for c in chapters:
10✔
245
            self._write_sections_recursive(c, dst_folder, pages, 1)
10✔
246

247

248
    def _generate_html(self, source : str, folder : str) -> int:
10✔
249
        """Generates a chunked HTML document from the source docbook document
250

251
        Args:
252
            source (str): The XML DocBook document to process
253
            folder (str): A (temporary) folder to store the xsltproc output to
254
        """
255
        shutil.rmtree(folder, ignore_errors=True)
10✔
256
        chunk_xsl_path = os.path.join(os.path.split(__file__)[0], "data", "chunk_html.xsl")
10✔
257
        try:
10✔
258
            result = subprocess.run([os.path.join(self._xsltproc_path, 'xsltproc'),
10✔
259
                "--stringparam", "base.dir", folder,
260
                chunk_xsl_path, source], check = True)
261
        except subprocess.CalledProcessError:
10✔
262
            raise RuntimeError("could not invoke xsltproc...")
×
263
        except FileNotFoundError:
10✔
264
            raise RuntimeError("could not invoke xsltproc...")
10✔
265
        if isinstance(result, subprocess.CompletedProcess):
×
266
            ret = result.returncode
×
267
        else:
268
            ret = 3
×
269
        return ret
×
270

271

272
    def _process_chunked(self, folder : str, pages : List[Tuple[str, str]], files : Set[str], app_name : str, dst_folder) -> None:
10✔
273
        """Processes a the set of HTML documents generated by chunking docbook
274

275
        Args:
276
            folder (str): A (temporary) folder to store the xsltproc output to
277
            pages (List[Tuple[str, str]]): The list of HTML sections to fill
278
            files (Set[str]): The set of referenced files (images) to fill
279
            app_name (str): The application name
280
        """
281
        # collect entries
282
        for file in glob.glob(os.path.join(folder, "*.html")):
10✔
283
            _, filename = os.path.split(file)
10✔
284
            if filename=="index.html":
10✔
285
                continue
10✔
286
            with open(file, encoding="utf-8") as fd:
10✔
287
                html = fd.read()
10✔
288
            html = self.patch_links(html, app_name, files)
10✔
289
            if html.find('content="text/html; charset=">')>=0:
10✔
290
                html = html.replace('content="text/html; charset=">', 'content="text/html; charset=UTF-8">')
10✔
291
            title_end = html.find("</title>") + 8
10✔
292
            html = html[:title_end] + self._css_definition + html[title_end:]
10✔
293
            title = self._get_title(html)
10✔
294
            pages.append([filename, title])
10✔
295
            _, filename = os.path.split(file)
10✔
296
            with open(os.path.join(dst_folder, filename), "w", encoding="utf-8") as fd:
10✔
297
                fd.write(html)
10✔
298

299

300
    def _copy_files(self, files : Set[str], source : str, dst_folder : str) -> None:
10✔
301
        """Copies referenced files into the destination folder
302

303
        Args:
304
            files (Set[str]): The files to compy
305
            source (str): The origin folder
306
            dst_folder (str): The destination folder
307
        """
308
        base_path = source if os.path.isdir(source) else os.path.split(source)[0]
10✔
309
        for file in files:
10✔
310
            _, filename = os.path.split(file)
10✔
311
            shutil.copy(os.path.join(base_path, file), f"{dst_folder}/{filename}")
10✔
312

313

314
    def build_toc_sections(self, pages : List[Tuple[str, str, List[int]]]) -> str:
10✔
315
        """Generates a hierarchical list of pages to be embedded in the toc-section
316
        of the qhp-file.
317

318
        Args:
319
            pages (List[Tuple[str, str, List[int]]]): The sorted list of pages
320

321
        Returns:
322
            (str): The pages formatted as toc-sections
323
        """
324
        toc = ""
10✔
325
        level = 1
10✔
326
        for ie,e in enumerate(pages):
10✔
327
            filename = e[0]
10✔
328
            title = e[1]
10✔
329
            nlevel = len(e[2])
10✔
330
            while ie!=0 and nlevel<=level:
10✔
331
                indent = " "*(level*4+8)
10✔
332
                toc += indent + "</section>\n"
10✔
333
                level -= 1
10✔
334
            level = nlevel
10✔
335
            indent = " "*(level*4+8)
10✔
336
            toc += indent + f"<section title=\"{title}\" ref=\"{filename}\">\n"
10✔
337
        while level>0:
10✔
338
            indent = " "*(level*4+8)
10✔
339
            toc += indent + "</section>\n"
10✔
340
            level -= 1
10✔
341
        return toc
10✔
342

343

344
    def process(self, source : str, dst_folder : str, app_name : str) -> None:
10✔
345
        """Performs the conversion
346

347
        Args:
348
            source (str): The input file or folder
349
            dst_folder (str): The destination folder (where the documentation is built)
350
            app_name (str): The name of the application
351
        """
352
        # clear output folder
353
        shutil.rmtree(dst_folder, ignore_errors=True)
10✔
354
        os.makedirs(dst_folder, exist_ok=True)
10✔
355
        # process
356
        pages = []
10✔
357
        files = set()
10✔
358
        if os.path.isdir(source):
10✔
359
            print(f"Processing chunked HTML output from '{source}'")
10✔
360
            self._process_chunked(source, pages, files, app_name, dst_folder)
10✔
361
        elif os.path.isfile(source):
10✔
362
            if source.endswith(".html"):
10✔
363
                print(f"Processing single HTML output from '{source}'")
10✔
364
                self._process_single(source, dst_folder, pages, files, app_name)
10✔
365
            elif source.endswith(".xml"):
10✔
366
                print(f"Processing docboook '{source}'")
10✔
367
                tmp_dir = "_tmp_db2qthelp_dir"
10✔
368
                os.makedirs(tmp_dir, exist_ok=True)
10✔
369
                print("... generating chunked HTML")
10✔
370
                ret = self._generate_html(source, tmp_dir)
10✔
371
                if ret!=0:
×
372
                    raise ValueError(f"xsltproc failed with ret={ret}")
×
373
                print("... processing chunked HTML")
×
374
                self._process_chunked(tmp_dir, pages, files, app_name, dst_folder)
×
375
                shutil.rmtree(tmp_dir, ignore_errors=True)
×
376
            else:
377
                raise ValueError(f"unsupported file extension of '{source}'")
×
378
        else:
379
            raise ValueError(f"unknown file '{source}'")
×
380
        # copy images etc.
381
        self._copy_files(files, source, dst_folder)
10✔
382
        # sort pages
383
        # https://stackoverflow.com/questions/14861843/sorting-chapters-numbers-like-1-2-1-or-1-4-2-4
384
        def expand_chapter(ch, depth):
10✔
385
            ch = ch + [0,] * (depth - len(ch))
10✔
386
            return ch
10✔
387
        max_depth = 0
10✔
388
        for page in pages:
10✔
389
            if page[1][0]=='A':
10✔
390
                chapter = [ord(page[1].split()[1][0]) + 1000]
10✔
391
            else:
392
                chapter = [int(x) for x in page[1].split()[0].split(".")[:-1]]
10✔
393
            if len(chapter)==0:
10✔
394
                chapter = [0]
10✔
395
            page.append(chapter)
10✔
396
            max_depth = max(len(chapter), max_depth)
10✔
397
        pages.sort(key = lambda x: expand_chapter(x[2], max_depth))
10✔
398
        #
399
        toc = self.build_toc_sections(pages)
10✔
400
        keywords = "\n".join(" "*12 + f"<keyword name=\"{page[1]}\" ref=\"./{page[0]}\"/>" for page in pages)
10✔
401
        # read template, write extended by collected data
402
        path = f"{dst_folder}/{app_name}"
10✔
403
        with open(path + ".qhp", "w", encoding="utf-8") as fdo:
10✔
404
            fdo.write(self._qhp_template.replace("%toc%", toc).replace("%keywords%", keywords).replace("%appname%", app_name))
10✔
405
        # generate qhcp
406
        with open(path + ".qhcp", "w", encoding="utf-8") as fdo:
10✔
407
            fdo.write(QCHP.replace("%appname%", app_name))
10✔
408
        # generate QtHelp
409
        os.system(f"{os.path.join(self._qt_path, 'qhelpgenerator')} {path}.qhp -o {path}.qch")
10✔
410
        os.system(f"{os.path.join(self._qt_path, 'qhelpgenerator')} {path}.qhcp -o {path}.qhc")
10✔
411

412

413
def main(arguments : List[str] = None) -> int:
10✔
414
    """The main method using parameter from the command line.
415

416
    Args:
417
        arguments (List[str]): A list of command line arguments.
418

419
    Returns:
420
        (int): The exit code (0 for success).
421
    """
422
    # parse options
423
    # https://stackoverflow.com/questions/3609852/which-is-the-best-way-to-allow-configuration-options-be-overridden-at-the-comman
424
    defaults = {}
10✔
425
    conf_parser = argparse.ArgumentParser(prog='db2qthelp', add_help=False)
10✔
426
    conf_parser.add_argument("-c", "--config", metavar="FILE", help="Reads the named configuration file")
10✔
427
    args, remaining_argv = conf_parser.parse_known_args(arguments)
10✔
428
    if args.config is not None:
10✔
429
        if not os.path.exists(args.config):
10✔
430
            print (f"db2qthelp: error: configuration file '{str(args.config)}' does not exist", file=sys.stderr)
10✔
431
            raise SystemExit(2)
10✔
432
        config = configparser.ConfigParser()
×
433
        config.read([args.config])
×
434
        defaults.update(dict(config.items("db2qthelp")))
×
435
    parser = argparse.ArgumentParser(prog='db2qthelp', parents=[conf_parser],
10✔
436
        description="a DocBook book to QtHelp project converter",
437
        epilog='(c) Daniel Krajzewicz 2022-2025')
438
    parser.add_argument("-i", "--input", dest="input", default=None, help="Defines the DocBook HTML document to parse")
10✔
439
    parser.add_argument("-d", "--destination", dest="destination", default="qtdoc", help="Sets the output folder")
10✔
440
    parser.add_argument("-a", "--appname", dest="appname", default="na", help="Sets the name of the application")
10✔
441
    parser.add_argument("--css-definition", dest="css_definition", default=None, help="Defines the CSS definition file to use")
10✔
442
    parser.add_argument("--generate-css-definition", dest="generate_css_definition", action="store_true", default=False, help="If set, a CSS definition file is generated")
10✔
443
    parser.add_argument("--qhp-template", dest="qhp_template", default=None, help="Defines the QtHelp project (.qhp) template to use")
10✔
444
    parser.add_argument("--generate-qhp-template", dest="generate_qhp_template", action="store_true", default=False, help="If set, a QtHelp project (.qhp) template is generated")
10✔
445
    parser.add_argument("-Q", "--qt-path", dest="qt_path", default="", help="Sets the path to the Qt binaries")
10✔
446
    parser.add_argument("-X", "--xslt-path", dest="xslt_path", default="", help="Sets the path to xsltproc")
10✔
447
    parser.add_argument('--version', action='version', version='%(prog)s 0.4.0')
10✔
448
    parser.set_defaults(**defaults)
10✔
449
    args = parser.parse_args(remaining_argv)
10✔
450
    # - generate the css template and quit, if wished
451
    if args.generate_css_definition:
10✔
452
        template_name = args.css_definition if args.css_definition is not None else "template.css"
10✔
453
        with open(template_name, "w", encoding="utf-8") as fdo:
10✔
454
            fdo.write(CSS_DEFINITION)
10✔
455
        print (f"Written css definition to '{template_name}'")
10✔
456
        sys.exit(0)
10✔
457
    # - generate the qhp template and quit, if wished
458
    if args.generate_qhp_template:
10✔
459
        template_name = args.qhp_template if args.qhp_template is not None else "template.qhp"
10✔
460
        with open(template_name, "w", encoding="utf-8") as fdo:
10✔
461
            fdo.write(QHP_TEMPLATE)
10✔
462
        print (f"Written qhp template to '{template_name}'")
10✔
463
        sys.exit(0)
10✔
464
    # check
465
    errors = []
10✔
466
    if args.input is None:
10✔
467
        errors.append("no input file given (use -i <HTML_DOCBOOK>)...")
10✔
468
    elif not os.path.isdir(args.input) and (not args.input.endswith(".html") and not args.input.endswith(".xml")):
10✔
469
        errors.append(f"unrecognized input extension '{os.path.splitext(args.input)[1]}'")
10✔
470
    elif not os.path.exists(args.input):
10✔
471
        errors.append(f"did not find input '{args.input}'")
10✔
472
    if args.qhp_template is not None and not os.path.exists(args.qhp_template):
10✔
473
        errors.append(f"did not find QtHelp project (.qhp) template file '{args.qhp_template}'; you may generate one using the option --generate-qhp-template")
10✔
474
    if args.css_definition is not None and not os.path.exists(args.css_definition):
10✔
475
        errors.append(f"did not find CSS definition file '{args.css_definition}'; you may generate one using the option --generate-css-definition")
10✔
476
    if len(errors)!=0:
10✔
477
        for e in errors:
10✔
478
            print(f"db2qthelp: error: {e}", file=sys.stderr)
10✔
479
        raise SystemExit(2)
10✔
480
    # get settings
481
    qhp_template = None
10✔
482
    if args.qhp_template is not None:
10✔
483
        with open(args.qhp_template, encoding="utf-8") as fdi:
×
484
            qhp_template = fdi.read()
×
485
    css_definition = None
10✔
486
    if args.css_definition is not None:
10✔
487
        with open(args.css_definition, encoding="utf-8") as fdi:
×
488
            css_definition = fdi.read()
×
489
    # process
490
    ret = 0
10✔
491
    db2qthelp = Db2QtHelp(args.qt_path, args.xslt_path, css_definition, qhp_template)
10✔
492
    try:
10✔
493
        db2qthelp.process(args.input, args.destination, args.appname)
10✔
494
    except Exception as e:
10✔
495
        print(f"db2qthelp: error: {str(e)}", file=sys.stderr)
10✔
496
        ret = 2
10✔
497
    return ret
10✔
498

499

500
def script_run() -> int:
10✔
501
    """Execute from command line."""
502
    sys.exit(main(sys.argv[1:])) # pragma: no cover
503

504

505
# -- main check
506
if __name__ == '__main__':
10✔
507
    main(sys.argv[1:]) # pragma: no cover
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