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

zostera / django-bootstrap4 / 5399170771

pending completion
5399170771

push

github

dyve
Remove support for Python 3.7 (EOL)

272 of 344 branches covered (79.07%)

Branch coverage included in aggregate %.

704 of 793 relevant lines covered (88.78%)

3.55 hits per line

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

88.82
/src/bootstrap4/renderers.py
1
from bs4 import BeautifulSoup
4✔
2
from django.forms import (
4✔
3
    BaseForm,
4
    BaseFormSet,
5
    BoundField,
6
    CheckboxInput,
7
    CheckboxSelectMultiple,
8
    DateInput,
9
    EmailInput,
10
    FileInput,
11
    MultiWidget,
12
    NumberInput,
13
    PasswordInput,
14
    RadioSelect,
15
    Select,
16
    SelectDateWidget,
17
    TextInput,
18
    URLInput,
19
)
20
from django.utils.html import conditional_escape, escape, strip_tags
4✔
21
from django.utils.safestring import mark_safe
4✔
22

23
from .bootstrap import get_bootstrap_setting
4✔
24
from .exceptions import BootstrapError
4✔
25
from .forms import (
4✔
26
    FORM_GROUP_CLASS,
27
    is_widget_with_placeholder,
28
    render_field,
29
    render_form,
30
    render_form_group,
31
    render_label,
32
)
33
from .text import text_value
4✔
34
from .utils import DJANGO_VERSION, add_css_class, render_template_file
4✔
35

36
try:
4✔
37
    # If Django is set up without a database, importing this widget gives RuntimeError
38
    from django.contrib.auth.forms import ReadOnlyPasswordHashWidget
4✔
39
except RuntimeError:
×
40
    ReadOnlyPasswordHashWidget = None
×
41

42

43
class BaseRenderer:
4✔
44
    """A content renderer."""
45

46
    def __init__(self, *args, **kwargs):
4✔
47
        self.layout = kwargs.get("layout", "")
4✔
48
        self.form_group_class = kwargs.get("form_group_class", FORM_GROUP_CLASS)
4✔
49
        self.field_class = kwargs.get("field_class", "")
4✔
50
        self.label_class = kwargs.get("label_class", "")
4✔
51
        self.show_help = kwargs.get("show_help", True)
4✔
52
        self.show_label = kwargs.get("show_label", True)
4✔
53
        self.exclude = kwargs.get("exclude", "")
4✔
54

55
        self.set_placeholder = kwargs.get("set_placeholder", True)
4✔
56
        self.size = self.parse_size(kwargs.get("size", ""))
4✔
57
        self.horizontal_label_class = kwargs.get(
4✔
58
            "horizontal_label_class", get_bootstrap_setting("horizontal_label_class")
59
        )
60
        self.horizontal_field_class = kwargs.get(
4✔
61
            "horizontal_field_class", get_bootstrap_setting("horizontal_field_class")
62
        )
63

64
    def parse_size(self, size):
4✔
65
        size = text_value(size).lower().strip()
4✔
66
        if size in ("sm", "small"):
4✔
67
            return "small"
4✔
68
        if size in ("lg", "large"):
4✔
69
            return "large"
4✔
70
        if size in ("md", "medium", ""):
4!
71
            return "medium"
4✔
72
        raise BootstrapError('Invalid value "%s" for parameter "size" (expected "sm", "md", "lg" or "").' % size)
×
73

74
    def get_size_class(self, prefix="form-control"):
4✔
75
        if self.size == "small":
4✔
76
            return prefix + "-sm"
4✔
77
        if self.size == "large":
4✔
78
            return prefix + "-lg"
4✔
79
        return ""
4✔
80

81
    def _render(self):
4✔
82
        return ""
×
83

84
    def render(self):
4✔
85
        return mark_safe(self._render())
4✔
86

87

88
class FormsetRenderer(BaseRenderer):
4✔
89
    """Default formset renderer."""
90

91
    def __init__(self, formset, *args, **kwargs):
4✔
92
        if not isinstance(formset, BaseFormSet):
4✔
93
            raise BootstrapError('Parameter "formset" should contain a valid Django Formset.')
4✔
94
        self.formset = formset
4✔
95
        super().__init__(*args, **kwargs)
4✔
96

97
    def render_management_form(self):
4✔
98
        return text_value(self.formset.management_form)
4✔
99

100
    def render_form(self, form, **kwargs):
4✔
101
        return render_form(form, **kwargs)
4✔
102

103
    def render_forms(self):
4✔
104
        rendered_forms = []
4✔
105
        for form in self.formset.forms:
4✔
106
            rendered_forms.append(
4✔
107
                self.render_form(
108
                    form,
109
                    layout=self.layout,
110
                    form_group_class=self.form_group_class,
111
                    field_class=self.field_class,
112
                    label_class=self.label_class,
113
                    show_label=self.show_label,
114
                    show_help=self.show_help,
115
                    exclude=self.exclude,
116
                    set_placeholder=self.set_placeholder,
117
                    size=self.size,
118
                    horizontal_label_class=self.horizontal_label_class,
119
                    horizontal_field_class=self.horizontal_field_class,
120
                )
121
            )
122
        return "\n".join(rendered_forms)
4✔
123

124
    def get_formset_errors(self):
4✔
125
        return self.formset.non_form_errors()
4✔
126

127
    def render_errors(self):
4✔
128
        formset_errors = self.get_formset_errors()
4✔
129
        if formset_errors:
4!
130
            return render_template_file(
×
131
                "bootstrap4/form_errors.html",
132
                context={"errors": formset_errors, "form": self.formset, "layout": self.layout},
133
            )
134
        return ""
4✔
135

136
    def _render(self):
4✔
137
        return "".join([self.render_errors(), self.render_management_form(), self.render_forms()])
4✔
138

139

140
class FormRenderer(BaseRenderer):
4✔
141
    """Default form renderer."""
142

143
    def __init__(self, form, *args, **kwargs):
4✔
144
        if not isinstance(form, BaseForm):
4✔
145
            raise BootstrapError('Parameter "form" should contain a valid Django Form.')
4✔
146
        self.form = form
4✔
147
        super().__init__(*args, **kwargs)
4✔
148
        self.error_css_class = kwargs.get("error_css_class", None)
4✔
149
        self.required_css_class = kwargs.get("required_css_class", None)
4✔
150
        self.bound_css_class = kwargs.get("bound_css_class", None)
4✔
151
        self.alert_error_type = kwargs.get("alert_error_type", "non_fields")
4✔
152
        self.form_check_class = kwargs.get("form_check_class", "form-check")
4✔
153

154
    def render_fields(self):
4✔
155
        rendered_fields = []
4✔
156
        for field in self.form:
4✔
157
            rendered_fields.append(
4✔
158
                render_field(
159
                    field,
160
                    layout=self.layout,
161
                    form_group_class=self.form_group_class,
162
                    field_class=self.field_class,
163
                    label_class=self.label_class,
164
                    form_check_class=self.form_check_class,
165
                    show_label=self.show_label,
166
                    show_help=self.show_help,
167
                    exclude=self.exclude,
168
                    set_placeholder=self.set_placeholder,
169
                    size=self.size,
170
                    horizontal_label_class=self.horizontal_label_class,
171
                    horizontal_field_class=self.horizontal_field_class,
172
                    error_css_class=self.error_css_class,
173
                    required_css_class=self.required_css_class,
174
                    bound_css_class=self.bound_css_class,
175
                )
176
            )
177
        return "\n".join(rendered_fields)
4✔
178

179
    def get_fields_errors(self):
4✔
180
        form_errors = []
4✔
181
        for field in self.form:
4✔
182
            if not field.is_hidden and field.errors:
4✔
183
                form_errors += field.errors
4✔
184
        return form_errors
4✔
185

186
    def render_errors(self, type="all"):
4✔
187
        form_errors = None
4✔
188
        if type == "all":
4✔
189
            form_errors = self.get_fields_errors() + self.form.non_field_errors()
4✔
190
        elif type == "fields":
4✔
191
            form_errors = self.get_fields_errors()
4✔
192
        elif type == "non_fields":
4✔
193
            form_errors = self.form.non_field_errors()
4✔
194

195
        if form_errors:
4✔
196
            return render_template_file(
4✔
197
                "bootstrap4/form_errors.html",
198
                context={"errors": form_errors, "form": self.form, "layout": self.layout, "type": type},
199
            )
200

201
        return ""
4✔
202

203
    def _render(self):
4✔
204
        return self.render_errors(self.alert_error_type) + self.render_fields()
4✔
205

206

207
class FieldRenderer(BaseRenderer):
4✔
208
    """Default field renderer."""
209

210
    # These widgets will not be wrapped in a form-control class
211
    WIDGETS_NO_FORM_CONTROL = (CheckboxInput, RadioSelect, CheckboxSelectMultiple, FileInput)
4✔
212

213
    def __init__(self, field, *args, **kwargs):
4✔
214
        if not isinstance(field, BoundField):
4✔
215
            raise BootstrapError('Parameter "field" should contain a valid Django BoundField.')
4✔
216
        self.field = field
4✔
217
        super().__init__(*args, **kwargs)
4✔
218

219
        self.widget = field.field.widget
4✔
220
        self.is_multi_widget = isinstance(field.field.widget, MultiWidget)
4✔
221
        self.initial_attrs = self.widget.attrs.copy()
4✔
222
        self.field_help = text_value(mark_safe(field.help_text)) if self.show_help and field.help_text else ""
4✔
223
        self.field_errors = [conditional_escape(text_value(error)) for error in field.errors]
4✔
224
        self.form_check_class = kwargs.get("form_check_class", "form-check")
4✔
225

226
        if "placeholder" in kwargs:
4!
227
            # Find the placeholder in kwargs, even if it's empty
228
            self.placeholder = kwargs["placeholder"]
×
229
        elif get_bootstrap_setting("set_placeholder"):
4!
230
            # If not found, see if we set the label
231
            self.placeholder = field.label
4✔
232
        else:
233
            # Or just set it to empty
234
            self.placeholder = ""
×
235
        if self.placeholder:
4!
236
            self.placeholder = text_value(self.placeholder)
4✔
237

238
        self.addon_before = kwargs.get("addon_before", self.widget.attrs.pop("addon_before", ""))
4✔
239
        self.addon_after = kwargs.get("addon_after", self.widget.attrs.pop("addon_after", ""))
4✔
240
        self.addon_before_class = kwargs.get(
4✔
241
            "addon_before_class", self.widget.attrs.pop("addon_before_class", "input-group-text")
242
        )
243
        self.addon_after_class = kwargs.get(
4✔
244
            "addon_after_class", self.widget.attrs.pop("addon_after_class", "input-group-text")
245
        )
246

247
        # These are set in Django or in the global BOOTSTRAP4 settings, and
248
        # they can be overwritten in the template
249
        error_css_class = kwargs.get("error_css_class", None)
4✔
250
        required_css_class = kwargs.get("required_css_class", None)
4✔
251
        bound_css_class = kwargs.get("bound_css_class", None)
4✔
252
        if error_css_class is not None:
4✔
253
            self.error_css_class = error_css_class
4✔
254
        else:
255
            self.error_css_class = getattr(field.form, "error_css_class", get_bootstrap_setting("error_css_class"))
4✔
256
        if required_css_class is not None:
4✔
257
            self.required_css_class = required_css_class
4✔
258
        else:
259
            self.required_css_class = getattr(
4✔
260
                field.form, "required_css_class", get_bootstrap_setting("required_css_class")
261
            )
262
        if bound_css_class is not None:
4✔
263
            self.success_css_class = bound_css_class
4✔
264
        else:
265
            self.success_css_class = getattr(field.form, "bound_css_class", get_bootstrap_setting("success_css_class"))
4✔
266

267
        # If the form is marked as form.empty_permitted, do not set required class
268
        if self.field.form.empty_permitted:
4✔
269
            self.required_css_class = ""
4✔
270

271
    def restore_widget_attrs(self):
4✔
272
        self.widget.attrs = self.initial_attrs.copy()
4✔
273

274
    def add_class_attrs(self, widget=None):
4✔
275
        if widget is None:
4!
276
            widget = self.widget
×
277
        classes = widget.attrs.get("class", "")
4✔
278
        if ReadOnlyPasswordHashWidget is not None and isinstance(widget, ReadOnlyPasswordHashWidget):
4!
279
            # Render this is a static control
280
            classes = add_css_class(classes, "form-control-static", prepend=True)
×
281
        elif not isinstance(widget, self.WIDGETS_NO_FORM_CONTROL):
4✔
282
            classes = add_css_class(classes, "form-control", prepend=True)
4✔
283
            # For these widget types, add the size class here
284
            classes = add_css_class(classes, self.get_size_class())
4✔
285
        elif isinstance(widget, CheckboxInput):
4✔
286
            classes = add_css_class(classes, "form-check-input", prepend=True)
4✔
287
        elif isinstance(widget, FileInput):
4!
288
            classes = add_css_class(classes, "form-control-file", prepend=True)
×
289

290
        if self.field.errors:
4✔
291
            if self.error_css_class:
4✔
292
                classes = add_css_class(classes, self.error_css_class)
4✔
293
        else:
294
            if self.field.form.is_bound:
4✔
295
                classes = add_css_class(classes, self.success_css_class)
4✔
296

297
        widget.attrs["class"] = classes
4✔
298

299
    def add_placeholder_attrs(self, widget=None):
4✔
300
        if widget is None:
4!
301
            widget = self.widget
×
302
        placeholder = widget.attrs.get("placeholder", self.placeholder)
4✔
303
        if placeholder and self.set_placeholder and is_widget_with_placeholder(widget):
4✔
304
            # TODO: Should this be stripped and/or escaped?
305
            widget.attrs["placeholder"] = placeholder
4✔
306

307
    def add_help_attrs(self, widget=None):
4✔
308
        if widget is None:
4!
309
            widget = self.widget
×
310
        if not isinstance(widget, CheckboxInput):
4✔
311
            widget.attrs["title"] = widget.attrs.get("title", escape(strip_tags(self.field_help)))
4✔
312

313
    def add_widget_attrs(self):
4✔
314
        if self.is_multi_widget:
4✔
315
            widgets = self.widget.widgets
4✔
316
        else:
317
            widgets = [self.widget]
4✔
318
        for widget in widgets:
4✔
319
            self.add_class_attrs(widget)
4✔
320
            self.add_placeholder_attrs(widget)
4✔
321
            self.add_help_attrs(widget)
4✔
322

323
    def list_to_class(self, html, klass):
4✔
324
        classes = add_css_class(klass, self.get_size_class())
4✔
325
        if DJANGO_VERSION >= 4:
4!
326
            soup = BeautifulSoup(html, features="html.parser")
4✔
327
            enclosing_div = soup.find("div")
4✔
328
            enclosing_div.attrs["class"] = classes
4✔
329
            for inner_div in enclosing_div.find_all("div"):
4✔
330
                inner_div.attrs["class"] = inner_div.attrs.get("class", []) + [self.form_check_class]
4✔
331
        else:
332
            mapping = [
×
333
                ("<ul", f'<div class="{classes}"'),
334
                ("</ul>", "</div>"),
335
                ("<li", f'<div class="{self.form_check_class}"'),
336
                ("</li>", "</div>"),
337
            ]
338
            for k, v in mapping:
×
339
                html = html.replace(k, v)
×
340
            soup = BeautifulSoup(html, features="html.parser")
×
341
        # Apply bootstrap4 classes to labels and inputs.
342
        # A simple 'replace' isn't enough as we don't want to have several 'class' attr definition, which would happen
343
        # if we tried to 'html.replace("input", "input class=...")'
344
        enclosing_div = soup.find("div", {"class": classes})
4✔
345
        if enclosing_div:
4!
346
            for label in enclosing_div.find_all("label"):
4✔
347
                label.attrs["class"] = label.attrs.get("class", []) + ["form-check-label"]
4✔
348
                try:
4✔
349
                    label.input.attrs["class"] = label.input.attrs.get("class", []) + ["form-check-input"]
4✔
350
                except AttributeError:
4✔
351
                    pass
4✔
352
        return str(soup)
4✔
353

354
    def add_checkbox_label(self, html):
4✔
355
        return html + render_label(
4✔
356
            content=self.field.label,
357
            label_for=self.field.id_for_label,
358
            label_title=escape(strip_tags(self.field_help)),
359
            label_class="form-check-label",
360
        )
361

362
    def fix_date_select_input(self, html):
4✔
363
        div1 = '<div class="col-4">'
×
364
        div2 = "</div>"
×
365
        html = html.replace("<select", div1 + "<select")
×
366
        html = html.replace("</select>", "</select>" + div2)
×
367
        return f'<div class="row bootstrap4-multi-input">{html}</div>'
×
368

369
    def fix_file_input_label(self, html):
4✔
370
        if self.layout != "horizontal":
×
371
            html = "<br>" + html
×
372
        return html
×
373

374
    def post_widget_render(self, html):
4✔
375
        if isinstance(self.widget, RadioSelect):
4✔
376
            html = self.list_to_class(html, "radio radio-success")
4✔
377
        elif isinstance(self.widget, CheckboxSelectMultiple):
4!
378
            html = self.list_to_class(html, "checkbox")
×
379
        elif isinstance(self.widget, SelectDateWidget):
4!
380
            html = self.fix_date_select_input(html)
×
381
        elif isinstance(self.widget, CheckboxInput):
4✔
382
            html = self.add_checkbox_label(html)
4✔
383
        elif isinstance(self.widget, FileInput):
4!
384
            html = self.fix_file_input_label(html)
×
385
        return html
4✔
386

387
    def wrap_widget(self, html):
4✔
388
        if isinstance(self.widget, CheckboxInput):
4✔
389
            # Wrap checkboxes
390
            # Note checkboxes do not get size classes, see #318
391
            html = f'<div class="form-check">{html}</div>'
4✔
392
        return html
4✔
393

394
    def make_input_group_addon(self, inner_class, outer_class, content):
4✔
395
        if not content:
4✔
396
            return ""
4✔
397
        if inner_class:
4✔
398
            content = f'<span class="{inner_class}">{content}</span>'
4✔
399
        return f'<div class="{outer_class}">{content}</div>'
4✔
400

401
    @property
4✔
402
    def is_input_group(self):
4✔
403
        allowed_widget_types = (TextInput, PasswordInput, DateInput, NumberInput, Select, EmailInput, URLInput)
4✔
404
        return (self.addon_before or self.addon_after) and isinstance(self.widget, allowed_widget_types)
4✔
405

406
    def make_input_group(self, html):
4✔
407
        if self.is_input_group:
4✔
408
            before = self.make_input_group_addon(self.addon_before_class, "input-group-prepend", self.addon_before)
4✔
409
            after = self.make_input_group_addon(self.addon_after_class, "input-group-append", self.addon_after)
4✔
410
            html = self.append_errors(f"{before}{html}{after}")
4✔
411
            html = f'<div class="input-group">{html}</div>'
4✔
412
        return html
4✔
413

414
    def append_help(self, html):
4✔
415
        field_help = self.field_help or None
4✔
416
        if field_help:
4✔
417
            help_html = render_template_file(
4✔
418
                "bootstrap4/field_help_text.html",
419
                context={
420
                    "field": self.field,
421
                    "field_help": field_help,
422
                    "layout": self.layout,
423
                    "show_help": self.show_help,
424
                },
425
            )
426
            html += help_html
4✔
427
        return html
4✔
428

429
    def append_errors(self, html):
4✔
430
        field_errors = self.field_errors
4✔
431
        if field_errors:
4✔
432
            errors_html = render_template_file(
4✔
433
                "bootstrap4/field_errors.html",
434
                context={
435
                    "field": self.field,
436
                    "field_errors": field_errors,
437
                    "layout": self.layout,
438
                    "show_help": self.show_help,
439
                },
440
            )
441
            html += errors_html
4✔
442
        return html
4✔
443

444
    def append_to_field(self, html):
4✔
445
        if isinstance(self.widget, CheckboxInput):
4✔
446
            # we have already appended errors and help to checkboxes
447
            # in append_to_checkbox_field
448
            return html
4✔
449

450
        if not self.is_input_group:
4✔
451
            # we already appended errors for input groups in make_input_group
452
            html = self.append_errors(html)
4✔
453

454
        return self.append_help(html)
4✔
455

456
    def append_to_checkbox_field(self, html):
4✔
457
        if not isinstance(self.widget, CheckboxInput):
4✔
458
            # we will append errors and help to normal fields later in append_to_field
459
            return html
4✔
460

461
        html = self.append_errors(html)
4✔
462
        return self.append_help(html)
4✔
463

464
    def get_field_class(self):
4✔
465
        field_class = self.field_class
4✔
466
        if not field_class and self.layout == "horizontal":
4✔
467
            field_class = self.horizontal_field_class
4✔
468
        return field_class
4✔
469

470
    def wrap_field(self, html):
4✔
471
        field_class = self.get_field_class()
4✔
472
        if field_class:
4✔
473
            html = f'<div class="{field_class}">{html}</div>'
4✔
474
        return html
4✔
475

476
    def get_label_class(self):
4✔
477
        label_class = self.label_class
4✔
478
        if not label_class and self.layout == "horizontal":
4✔
479
            label_class = self.horizontal_label_class
4✔
480
            label_class = add_css_class(label_class, "col-form-label")
4✔
481
        label_class = text_value(label_class)
4✔
482
        if not self.show_label or self.show_label == "sr-only":
4✔
483
            label_class = add_css_class(label_class, "sr-only")
4✔
484
        return label_class
4✔
485

486
    def get_label(self):
4✔
487
        if self.show_label == "skip":
4✔
488
            return None
4✔
489
        elif isinstance(self.widget, CheckboxInput):
4✔
490
            label = None
4✔
491
        else:
492
            label = self.field.label
4✔
493
        if self.layout == "horizontal" and not label:
4✔
494
            return mark_safe("&#160;")
4✔
495
        return label
4✔
496

497
    def add_label(self, html):
4✔
498
        label = self.get_label()
4✔
499
        if label:
4✔
500
            html = render_label(label, label_for=self.field.id_for_label, label_class=self.get_label_class()) + html
4✔
501
        return html
4✔
502

503
    def get_form_group_class(self):
4✔
504
        form_group_class = self.form_group_class
4✔
505
        if self.field.errors:
4✔
506
            if self.error_css_class:
4✔
507
                form_group_class = add_css_class(form_group_class, self.error_css_class)
4✔
508
        else:
509
            if self.field.form.is_bound:
4✔
510
                form_group_class = add_css_class(form_group_class, self.success_css_class)
4✔
511
        if self.field.field.required and self.required_css_class:
4✔
512
            form_group_class = add_css_class(form_group_class, self.required_css_class)
4✔
513
        if self.layout == "horizontal":
4✔
514
            form_group_class = add_css_class(form_group_class, "row")
4✔
515
        return form_group_class
4✔
516

517
    def wrap_label_and_field(self, html):
4✔
518
        return render_form_group(html, self.get_form_group_class())
4✔
519

520
    def _render(self):
4✔
521
        # See if we're not excluded
522
        if self.field.name in self.exclude.replace(" ", "").split(","):
4✔
523
            return ""
4✔
524
        # Hidden input requires no special treatment
525
        if self.field.is_hidden:
4✔
526
            return text_value(self.field)
4✔
527
        # Render the widget
528
        self.add_widget_attrs()
4✔
529
        html = self.field.as_widget(attrs=self.widget.attrs)
4✔
530
        self.restore_widget_attrs()
4✔
531
        # Start post render
532
        html = self.post_widget_render(html)
4✔
533
        html = self.append_to_checkbox_field(html)
4✔
534
        html = self.wrap_widget(html)
4✔
535
        html = self.make_input_group(html)
4✔
536
        html = self.append_to_field(html)
4✔
537
        html = self.wrap_field(html)
4✔
538
        html = self.add_label(html)
4✔
539
        html = self.wrap_label_and_field(html)
4✔
540
        return html
4✔
541

542

543
class InlineFieldRenderer(FieldRenderer):
4✔
544
    """Inline field renderer."""
545

546
    def add_error_attrs(self):
4✔
547
        field_title = self.widget.attrs.get("title", "")
×
548
        field_title += " " + " ".join([strip_tags(e) for e in self.field_errors])
×
549
        self.widget.attrs["title"] = field_title.strip()
×
550

551
    def add_widget_attrs(self):
4✔
552
        super().add_widget_attrs()
×
553
        self.add_error_attrs()
×
554

555
    def append_to_field(self, html):
4✔
556
        return html
×
557

558
    def get_field_class(self):
4✔
559
        return self.field_class
×
560

561
    def get_label_class(self):
4✔
562
        return add_css_class(self.label_class, "sr-only")
×
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