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

bramp / build-along / 20389851973

20 Dec 2025 05:31AM UTC coverage: 89.185% (+0.04%) from 89.145%
20389851973

push

github

bramp
Add support for `ty` to the pyproject.toml.

13384 of 15007 relevant lines covered (89.19%)

0.89 hits per line

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

21.66
/src/build_a_long/pdf_extract/cli/reporting.py
1
"""Reporting and output formatting for PDF extraction."""
2

3
import logging
1✔
4
from collections import defaultdict
1✔
5
from typing import Any
1✔
6

7
from pydantic import BaseModel, ConfigDict, Field
1✔
8

9
from build_a_long.pdf_extract.classifier import (
1✔
10
    Candidate,
11
    ClassificationResult,
12
)
13
from build_a_long.pdf_extract.classifier.text import FontSizeHints, TextHistogram
1✔
14
from build_a_long.pdf_extract.extractor import PageData
1✔
15
from build_a_long.pdf_extract.extractor.bbox import BBox
1✔
16
from build_a_long.pdf_extract.extractor.hierarchy import build_hierarchy_from_blocks
1✔
17
from build_a_long.pdf_extract.extractor.lego_page_elements import Page
1✔
18
from build_a_long.pdf_extract.extractor.page_blocks import Blocks
1✔
19

20
logger = logging.getLogger(__name__)
1✔
21

22
# ANSI color codes
23
GREY = "\033[90m"
1✔
24
RESET = "\033[0m"
1✔
25

26

27
class TreeNode(BaseModel):
1✔
28
    """Unified node for the classification debug tree.
29

30
    Represents either a Block with optional candidates, or a synthetic Candidate.
31
    """
32

33
    model_config = ConfigDict(frozen=True)
1✔
34

35
    bbox: BBox
36
    """Bounding box for this node"""
1✔
37

38
    block: Blocks | None = None
1✔
39
    """The source block, if this node represents a block"""
1✔
40

41
    candidates: list[Candidate] = Field(default_factory=list)
1✔
42
    """Candidates for this block (empty if synthetic or no candidates)"""
1✔
43

44
    synthetic_candidate: Candidate | None = None
1✔
45
    """If this is a synthetic candidate (no source blocks)"""
1✔
46

47

48
def print_summary(
1✔
49
    pages: list[PageData],
50
    results: list[ClassificationResult],
51
    *,
52
    detailed: bool = False,
53
) -> None:
54
    """Print a human-readable summary of classification results to stdout.
55

56
    Args:
57
        pages: List of PageData containing extracted elements
58
        results: List of ClassificationResult with labels
59
        detailed: If True, include additional details like missing page numbers
60
    """
61
    total_pages = len(pages)
1✔
62
    total_blocks = 0
1✔
63
    blocks_by_type: dict[str, int] = {}
1✔
64
    labeled_counts: dict[str, int] = {}
1✔
65

66
    pages_with_page_number = 0
1✔
67
    missing_page_numbers: list[int] = []
1✔
68

69
    for page, result in zip(pages, results, strict=True):
1✔
70
        total_blocks += len(page.blocks)
1✔
71
        # Tally block types and labels
72
        has_page_number = False
1✔
73
        for block in page.blocks:
1✔
74
            t = block.__class__.__name__.lower()
×
75
            blocks_by_type[t] = blocks_by_type.get(t, 0) + 1
×
76

77
            label = result.get_label(block)
×
78
            if label:
×
79
                labeled_counts[label] = labeled_counts.get(label, 0) + 1
×
80
                if label == "page_number":
×
81
                    has_page_number = True
×
82

83
        if has_page_number:
1✔
84
            pages_with_page_number += 1
×
85
        else:
86
            missing_page_numbers.append(page.page_number)
1✔
87

88
    # Human-friendly, single-shot summary
89
    print("=== Classification summary ===")
1✔
90
    print(f"    Pages processed: {total_pages}")
1✔
91
    print(f"    Total blocks: {total_blocks}")
1✔
92
    if blocks_by_type:
1✔
93
        parts = [f"{k}={v}" for k, v in sorted(blocks_by_type.items())]
×
94
        print("Elements by type: " + ", ".join(parts))
×
95
    if labeled_counts:
1✔
96
        parts = [f"{k}={v}" for k, v in sorted(labeled_counts.items())]
×
97
        print("    Labeled elements: " + ", ".join(parts))
×
98

99
    if detailed and missing_page_numbers:
1✔
100
        sample = ", ".join(str(n) for n in missing_page_numbers[:20])
×
101
        more = " ..." if len(missing_page_numbers) > 20 else ""
×
102
        print(f"Pages missing page number: {sample}{more}")
×
103

104

105
def _print_font_size_distribution(
1✔
106
    title: str,
107
    counter: Any,
108
    *,
109
    max_items: int = 10,
110
    empty_message: str = "(no data)",
111
    total_label: str = "Total text elements",
112
    unique_label: str = "Total unique sizes",
113
) -> None:
114
    """Print a font size distribution with bar chart.
115

116
    Args:
117
        title: Section title to display
118
        counter: Counter/dict mapping font sizes to counts
119
        max_items: Maximum number of items to display
120
        empty_message: Message to show when counter is empty
121
        total_label: Label for total count summary
122
        unique_label: Label for unique size count
123
    """
124
    print(title)
×
125
    print("-" * 60)
×
126

127
    total = sum(counter.values())
×
128

129
    if total > 0:
×
130
        print(f"{'Size':>8} | {'Count':>6} | Distribution")
×
131
        print("-" * 60)
×
132

133
        # Get most common items
134
        if hasattr(counter, "most_common"):
×
135
            items = counter.most_common(max_items)
×
136
        else:
137
            items = sorted(counter.items(), key=lambda x: x[1], reverse=True)[
×
138
                :max_items
139
            ]
140

141
        max_count = items[0][1] if items else 1
×
142
        for size, count in items:
×
143
            bar_length = int((count / max_count) * 30)
×
144
            bar = "█" * bar_length
×
145
            print(f"{size:8.1f} | {count:6d} | {bar}")
×
146

147
        print("-" * 60)
×
148
        print(f"{unique_label}: {len(counter)}")
×
149
        print(f"{total_label}: {total}")
×
150
    else:
151
        print(empty_message)
×
152
    print()
×
153

154

155
def print_histogram(histogram: TextHistogram) -> None:
1✔
156
    """Print the text histogram showing font size and name distributions.
157

158
    Args:
159
        histogram: TextHistogram containing font statistics across all pages
160
    """
161
    print("=== Text Histogram ===")
×
162
    print()
×
163

164
    # 1. Part counts (\dx pattern) - calculated first
165
    _print_font_size_distribution(
×
166
        "1. Part Count Font Sizes (\\dx pattern, e.g., '2x', '3x'):",
167
        histogram.part_count_font_sizes,
168
        empty_message="(no part count data)",
169
        total_label="Total part counts",
170
    )
171

172
    # 2. Page numbers (±1) - calculated second
173
    _print_font_size_distribution(
×
174
        "2. Page Number Font Sizes (digits ±1 from current page):",
175
        histogram.page_number_font_sizes,
176
        empty_message="(no page number data)",
177
        total_label="Total page numbers",
178
    )
179

180
    # 3. Element IDs (6-7 digit numbers) - calculated third
181
    _print_font_size_distribution(
×
182
        "3. Element ID Font Sizes (6-7 digit numbers):",
183
        histogram.element_id_font_sizes,
184
        empty_message="(no Element ID data)",
185
        total_label="Total Element IDs",
186
    )
187

188
    # 4. Other integer font sizes - calculated fourth
189
    _print_font_size_distribution(
×
190
        "4. Other Integer Font Sizes (integers not matching above patterns):",
191
        histogram.remaining_font_sizes,
192
        max_items=20,
193
        empty_message="(no other integer font size data)",
194
    )
195

196
    # 5. Font name distribution - calculated fifth
197
    print("5. Font Name Distribution:")
×
198
    print("-" * 60)
×
199

200
    font_name_total = sum(histogram.font_name_counts.values())
×
201

202
    if font_name_total > 0:
×
203
        print(f"{'Font Name':<30} | {'Count':>6} | Distribution")
×
204
        print("-" * 60)
×
205

206
        font_names = histogram.font_name_counts.most_common(20)
×
207
        max_count = font_names[0][1] if font_names else 1
×
208
        for font_name, count in font_names:
×
209
            bar_length = int((count / max_count) * 30)
×
210
            bar = "█" * bar_length
×
211
            name_display = font_name[:27] + "..." if len(font_name) > 30 else font_name
×
212
            print(f"{name_display:<30} | {count:6d} | {bar}")
×
213

214
        print("-" * 60)
×
215
        print(f"Total unique fonts:  {len(histogram.font_name_counts)}")
×
216
        print(f"Total text elements: {font_name_total}")
×
217
    else:
218
        print("(no font name data)")
×
219

220
    print()
×
221

222

223
def print_font_hints(hints: FontSizeHints) -> None:
1✔
224
    """Print font size hints extracted from the document.
225

226
    Args:
227
        hints: FontSizeHints containing identified font sizes for different elements
228
    """
229
    print("=== Font Size Hints ===")
×
230
    print()
×
231

232
    def format_size(size: float | None) -> str:
×
233
        """Format a font size for display."""
234
        return f"{size:.1f}pt" if size is not None else "N/A"
×
235

236
    print("Identified font sizes:")
×
237
    print(f"  Part count size:         {format_size(hints.part_count_size)}")
×
238
    print(f"  Catalog part count size: {format_size(hints.catalog_part_count_size)}")
×
239
    print(f"  Step number size:        {format_size(hints.step_number_size)}")
×
240
    print(f"  Step repeat size:        {format_size(hints.step_repeat_size)}")
×
241
    print(f"  Catalog element ID size: {format_size(hints.catalog_element_id_size)}")
×
242
    print(f"  Page number size:        {format_size(hints.page_number_size)}")
×
243

244
    print()
×
245
    print("Remaining font sizes after removing known patterns:")
×
246
    if hints.remaining_font_sizes:
×
247
        print(f"{'Size':>8} | {'Count':>6}")
×
248
        print("-" * 20)
×
249
        for size, count in hints.remaining_font_sizes:
×
250
            print(f"{size:8.1f} | {count:6d}")
×
251
        print(f"\nTotal unique sizes: {len(hints.remaining_font_sizes)}")
×
252
    else:
253
        print("  (no remaining font sizes)")
×
254
    print()
×
255

256

257
def print_classification_debug(
1✔
258
    page: PageData,
259
    result: ClassificationResult,
260
    *,
261
    show_candidates: bool = True,
262
    show_hierarchy: bool = True,
263
    label: str | None = None,
264
) -> None:
265
    """Print comprehensive classification debug information.
266

267
    Shows all classification details in one consolidated view with blocks
268
    and candidates intermixed in a unified tree.
269

270
    Args:
271
        page: PageData containing all elements
272
        result: ClassificationResult with classification information
273
        show_candidates: Include detailed candidate breakdown
274
        show_hierarchy: Include page hierarchy summary
275
        label: If provided, filter candidate analysis to this label only
276
    """
277
    print(f"\n{'=' * 80}")
×
278
    print(f"CLASSIFICATION DEBUG - Page {page.page_number}")
×
279
    print(f"{'=' * 80}\n")
×
280

281
    # Build mapping from blocks to their candidates
282
    block_to_candidates: dict[int, list[Candidate]] = {}
×
283
    all_candidates = result.get_all_candidates()
×
284

285
    for _label_name, candidates in all_candidates.items():
×
286
        for candidate in candidates:
×
287
            for source_block in candidate.source_blocks:
×
288
                if source_block.id not in block_to_candidates:
×
289
                    block_to_candidates[source_block.id] = []
×
290
                block_to_candidates[source_block.id].append(candidate)
×
291

292
    # Create TreeNode for each block
293
    block_nodes: list[TreeNode] = []
×
294
    block_node_map: dict[int, TreeNode] = {}  # block.id -> TreeNode
×
295

296
    for block in page.blocks:
×
297
        candidates_list = block_to_candidates.get(block.id, [])
×
298
        node = TreeNode(
×
299
            bbox=block.bbox,
300
            block=block,
301
            candidates=candidates_list,
302
        )
303
        block_nodes.append(node)
×
304
        block_node_map[block.id] = node
×
305

306
    # Create TreeNode for synthetic candidates
307
    synthetic_nodes: list[TreeNode] = []
×
308
    for _label_name, candidates in all_candidates.items():
×
309
        for candidate in candidates:
×
310
            if not candidate.source_blocks:
×
311
                node = TreeNode(
×
312
                    bbox=candidate.bbox,
313
                    synthetic_candidate=candidate,
314
                )
315
                synthetic_nodes.append(node)
×
316

317
    # Combine all nodes for hierarchy building
318
    all_nodes = block_nodes + synthetic_nodes
×
319

320
    # Build unified hierarchy
321
    tree = build_hierarchy_from_blocks(all_nodes)
×
322

323
    def print_node(node: TreeNode, depth: int, is_last: bool = True) -> None:
×
324
        """Recursively print a node and its children."""
325
        # Build tree characters
326
        if depth == 0:
×
327
            tree_prefix = ""
×
328
            indent = ""
×
329
        else:
330
            tree_char = "└─" if is_last else "├─"
×
331
            indent = "  " * (depth - 1)
×
332
            tree_prefix = f"{indent}{tree_char} "
×
333

334
        # Check if this is a removed block
335
        is_removed = node.block and result.is_removed(node.block)
×
336
        color = GREY if is_removed else ""
×
337
        reset = RESET if is_removed else ""
×
338

339
        line = f"{color}{tree_prefix}"
×
340

341
        if node.synthetic_candidate:
×
342
            # Synthetic candidate (Page, Step, OpenBag, etc.)
343
            candidate = node.synthetic_candidate
×
344
            elem_str = (
×
345
                str(candidate.constructed)
346
                if candidate.constructed
347
                else "NOT CONSTRUCTED"
348
            )
349
            line += f"[{candidate.label}] {elem_str} (score={candidate.score:.3f})"
×
350
        elif node.block:
×
351
            # Regular block
352
            block = node.block
×
353
            block_type = type(block).__name__
×
354
            line += f"{block.id:3d} ({block_type}) "
×
355

356
            if is_removed:
×
357
                reason = result.get_removal_reason(block)
×
358
                reason_text = reason.reason_type if reason else "unknown"
×
359
                line += f"* REMOVED: {reason_text}"
×
360
                if reason:
×
361
                    target = reason.target_block
×
362
                    if target:
×
363
                        line += f" by {target.id}"
×
364

365
                        target_best = result.get_best_candidate(target)
×
366
                        if target_best:
×
367
                            line += f" ({target_best.label})"
×
368
                line += f"* {str(block)}"
×
369
            elif node.candidates:
×
370
                # Show all candidates for this block
371
                sorted_candidates = sorted(
×
372
                    node.candidates, key=lambda c: c.score, reverse=True
373
                )
374
                best = sorted_candidates[0]
×
375

376
                # Show best candidate prominently
377
                elem_str = str(best.constructed) if best.constructed else str(block)
×
378
                line += f"[{best.label}] {elem_str} (score={best.score:.3f})"
×
379

380
                # If multiple candidates, show others on additional lines
381
                if len(sorted_candidates) > 1:
×
382
                    print(line + reset)
×
383
                    for other in sorted_candidates[1:]:
×
384
                        other_indent = indent + ("  " if is_last else "│ ")
×
385
                        elem_str = (
×
386
                            str(other.constructed) if other.constructed else str(block)
387
                        )
388
                        alt_line = (
×
389
                            f"{color}{other_indent}   alt [{other.label}] "
390
                            f"{elem_str} (score={other.score:.3f}){reset}"
391
                        )
392
                        print(alt_line)
×
393
                    line = None  # Already printed
×
394
            else:
395
                line += f"[no candidates] {str(block)}"
×
396

397
        if line:
×
398
            print(line + reset)
×
399

400
        # Print children
401
        children = tree.get_children(node)
×
402
        sorted_children = sorted(
×
403
            children, key=lambda n: (n.block.id if n.block else -1)
404
        )
405
        for i, child in enumerate(sorted_children):
×
406
            child_is_last = i == len(sorted_children) - 1
×
407
            print_node(child, depth + 1, child_is_last)
×
408

409
    # Print tree
410
    for root in tree.roots:
×
411
        print_node(root, 0)
×
412

413
    # Summary stats
414
    total = len(page.blocks)
×
415
    with_labels = sum(1 for b in page.blocks if result.get_label(b) is not None)
×
416
    removed = sum(1 for b in page.blocks if result.is_removed(b))
×
417
    no_candidates = total - with_labels - removed
×
418
    total_candidates = sum(len(candidates) for candidates in all_candidates.values())
×
419
    num_synthetic = len(synthetic_nodes)
×
420

421
    # Great a histogram of labels per block
422
    block_histogram = defaultdict(int)
×
423
    for block in page.blocks:
×
424
        labels = result.get_all_candidates_for_block(block)
×
425
        block_histogram[len(labels)] += 1
×
426

427
    print(f"\n{'─' * 80}")
×
428
    print(
×
429
        f"Blocks: {total} total | {with_labels} labeled | "
430
        f"{removed} removed | {no_candidates} no candidates"
431
        f" | Histogram: {dict(block_histogram)}"
432
    )
433
    print(f"Candidates: {total_candidates} total | {num_synthetic} synthetic")
×
434

435
    # Detailed candidate analysis
436
    if show_candidates:
×
437
        print(f"\n{'=' * 80}")
×
438
        print("CANDIDATES BY LABEL")
×
439
        print(f"{'=' * 80}")
×
440

441
        # Get all candidates
442
        all_candidates = result.get_all_candidates()
×
443

444
        # Filter to specific label if requested
445
        if label:
×
446
            labels_to_show = {label: all_candidates.get(label, [])}
×
447
        else:
448
            labels_to_show = all_candidates
×
449

450
        # Build set of elements that made it into the final Page hierarchy
451
        # These are the "winners" - candidates whose constructed elements
452
        # were actually used in the final output
453
        elements_in_page: set[int] = set()
×
454
        if result.page:
×
455
            for element in result.page.iter_elements():
×
456
                elements_in_page.add(id(element))
×
457

458
        # Summary table
459
        print(f"\n{'Label':<20} {'Total':<8} {'In Page':<8} {'Constructed':<12}")
×
460
        print(f"{'-' * 52}")
×
461
        for lbl in sorted(labels_to_show.keys()):
×
462
            candidates = labels_to_show[lbl]
×
463
            in_page = [
×
464
                c
465
                for c in candidates
466
                if c.constructed and id(c.constructed) in elements_in_page
467
            ]
468
            constructed = [c for c in candidates if c.constructed is not None]
×
469
            print(
×
470
                f"{lbl:<20} {len(candidates):<8} "
471
                f"{len(in_page):<8} {len(constructed):<12}"
472
            )
473

474
        # Detailed per-label breakdown
475
        for lbl in sorted(labels_to_show.keys()):
×
476
            candidates = labels_to_show[lbl]
×
477
            if not candidates:
×
478
                continue
×
479

480
            # Sort by score (highest first) for better readability
481
            sorted_candidates = sorted(candidates, key=lambda c: c.score, reverse=True)
×
482

483
            print(f"\n{lbl} ({len(candidates)} candidates):")
×
484
            for candidate in sorted_candidates:
×
485
                # TODO Use all source blocks, not just the first one.
486
                block = candidate.source_blocks[0] if candidate.source_blocks else None
×
487
                block_id_str = f"{block.id:3d}" if block else "  ?"
×
488

489
                # Determine if this candidate made it into the final Page
490
                in_page = (
×
491
                    candidate.constructed
492
                    and id(candidate.constructed) in elements_in_page
493
                )
494
                winner_mark = "✓ " if in_page else "  "
×
495

496
                if candidate.constructed:
×
497
                    constructed_str = str(candidate.constructed)
×
498
                else:
499
                    constructed_str = "<never constructed>"
×
500

501
                source_str = str(block) if block else "no source"
×
502
                print(
×
503
                    f"  {winner_mark}{block_id_str} [{lbl}] {constructed_str} | "
504
                    f"score={candidate.score:.3f} | {source_str}"
505
                )
506

507
    # Page hierarchy
508
    if show_hierarchy:
×
509
        page_obj = result.page
×
510
        if page_obj:
×
511
            print(f"\n{'=' * 80}")
×
512
            print("PAGE HIERARCHY")
×
513
            print(f"{'=' * 80}")
×
514
            page_num_str = (
×
515
                page_obj.page_number.value if page_obj.page_number else "None"
516
            )
517
            categories_str = (
×
518
                ", ".join(c.name for c in page_obj.categories)
519
                if page_obj.categories
520
                else "None"
521
            )
522
            print(f"Page number: {page_num_str}")
×
523
            print(f"Categories: {categories_str}")
×
524
            print(f"Progress bar: {'Yes' if page_obj.progress_bar else 'No'}")
×
525

526
            if page_obj.catalog:
×
527
                parts_count = len(page_obj.catalog.parts)
×
528
                total_items = sum(p.count.count for p in page_obj.catalog.parts)
×
529
                print(f"Catalog: {parts_count} parts ({total_items} total items)")
×
530

531
            steps = page_obj.instruction.steps if page_obj.instruction else []
×
532
            print(f"Steps: {len(steps)}")
×
533

534
            for i, step in enumerate(steps, 1):
×
535
                parts_count = len(step.parts_list.parts) if step.parts_list else 0
×
536
                print(f"  Step {i}: #{step.step_number.value} ({parts_count} parts)")
×
537

538
    print(f"\n{'=' * 80}\n")
×
539

540

541
def print_page_hierarchy(page_data: PageData, page: Page) -> None:
1✔
542
    """Print the structured LEGO page hierarchy.
543

544
    Args:
545
        page_data: PageData containing the raw page number
546
        page: Structured Page object with steps, parts lists, etc.
547
    """
548
    categories_str = (
1✔
549
        f" ([{', '.join(c.name for c in page.categories)}])" if page.categories else ""
550
    )
551
    print(f"Page {page_data.page_number}{categories_str}:")
1✔
552

553
    if page.page_number:
1✔
554
        print(f"  ✓ Page Number: {page.page_number.value}")
1✔
555

556
    if page.scale:
1✔
557
        print(f"  ✓ Scale: 1:1 reference for length {page.scale.length.value}")
×
558

559
    if page.instruction and page.instruction.open_bags:
1✔
560
        print(f"  ✓ Open Bags: {len(page.instruction.open_bags)}")
×
561
        for open_bag in page.instruction.open_bags:
×
562
            bag_label = (
×
563
                f"Bag {open_bag.number.value}" if open_bag.number else "Bag (all)"
564
            )
565
            print(f"    - {bag_label} at {open_bag.bbox}")
×
566

567
    if page.catalog:
1✔
568
        parts_count = len(page.catalog.parts)
×
569
        total_items = sum(p.count.count for p in page.catalog.parts)
×
570
        print(f"  ✓ Catalog: {parts_count} parts ({total_items} total items)")
×
571
        if page.catalog.parts:
×
572
            print("      Parts:")
×
573
            for part in page.catalog.parts:
×
574
                number_str = part.number.element_id if part.number else "no number"
×
575
                print(f"        • {part.count.count}x ({number_str})")
×
576

577
    if page.instruction and page.instruction.steps:
1✔
578
        print(f"  ✓ Steps: {len(page.instruction.steps)}")
1✔
579
        for step in page.instruction.steps:
1✔
580
            parts_count = len(step.parts_list.parts) if step.parts_list else 0
1✔
581
            print(f"    - Step {step.step_number.value} ({parts_count} parts)")
1✔
582
            # Print parts list details
583
            if step.parts_list and step.parts_list.parts:
1✔
584
                print("      Parts List:")
1✔
585
                for part in step.parts_list.parts:
1✔
586
                    number_str = part.number.element_id if part.number else "no number"
1✔
587
                    print(f"        • {part.count.count}x ({number_str})")
1✔
588
            else:
589
                print("      Parts List: (none)")
1✔
590

591
            if step.diagram:
1✔
592
                print(f"      Diagram: {step.diagram.bbox}")
1✔
593

594

595
def build_and_print_page_hierarchy(
1✔
596
    pages: list[PageData], results: list[ClassificationResult]
597
) -> None:
598
    """Build LEGO page hierarchy from classification results and print structure.
599

600
    Args:
601
        pages: List of PageData containing extracted elements
602
        results: List of ClassificationResult with labels and relationships
603
    """
604
    print("Building LEGO page hierarchy...")
×
605

606
    for page_data, result in zip(pages, results, strict=True):
×
607
        page = result.page
×
608
        if page:
×
609
            print_page_hierarchy(page_data, page)
×
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