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

bramp / build-along / 19751674072

28 Nov 2025 01:40AM UTC coverage: 89.023% (-0.8%) from 89.847%
19751674072

push

github

bramp
refactor(classifier): reorganize text modules and break circular dependencies

Major Changes:
- Created text/ subdirectory for text-related classifier modules
- Moved text_histogram, text_extractors, font_size_hints to text/
- Created constants.py to resolve circular dependency issue

Module Organization:
- classifier/text/__init__.py: Package exports for text modules
- classifier/text/text_histogram.py: TextHistogram class
- classifier/text/text_extractors.py: Text extraction functions
- classifier/text/font_size_hints.py: FontSizeHints class

Circular Dependency Resolution:
- Created classifier/constants.py with CATALOG_ELEMENT_ID_THRESHOLD
- Removed ClassVar from PageHintCollection
- Updated font_size_hints.py and page_hint_collection.py to import from constants
- Fixed package-level circular import by importing TextHistogram directly from module
- Added TODO to consider moving constant to ClassifierConfig

Bug Fixes:
- Fixed DrawableItem frozen model issue in drawing.py
- Create new instances with depth instead of mutating frozen objects

Import Updates:
- Updated all imports across ~15 files to use new module paths
- Updated classifier/__init__.py to re-export text module classes

Tests:
- All tests passing (42/42 test files)
- Type checking passes
- Code formatted with ruff

32 of 32 new or added lines in 23 files covered. (100.0%)

180 existing lines in 19 files now uncovered.

7429 of 8345 relevant lines covered (89.02%)

0.89 hits per line

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

90.8
/src/build_a_long/pdf_extract/classifier/classification_result.py
1
"""ClassificationResult class for single page classification."""
2

3
from __future__ import annotations
1✔
4

5
from dataclasses import dataclass
1✔
6
from typing import TYPE_CHECKING, cast
1✔
7

8
from pydantic import BaseModel, Field, PrivateAttr, model_validator
1✔
9

10
from build_a_long.pdf_extract.classifier.candidate import Candidate
1✔
11
from build_a_long.pdf_extract.classifier.removal_reason import RemovalReason
1✔
12
from build_a_long.pdf_extract.extractor.extractor import PageData
1✔
13
from build_a_long.pdf_extract.extractor.lego_page_elements import (
1✔
14
    LegoPageElements,
15
    Page,
16
)
17
from build_a_long.pdf_extract.extractor.page_blocks import Blocks
1✔
18

19
if TYPE_CHECKING:
20
    from build_a_long.pdf_extract.classifier.label_classifier import LabelClassifier
21

22
# Score key can be either a single Block or a tuple of Blocks (for pairings)
23
ScoreKey = Blocks | tuple[Blocks, ...]
1✔
24

25

26
@dataclass(frozen=True)
1✔
27
class _BuildSnapshot:
1✔
28
    """Snapshot of candidate and consumed block state for rollback.
29

30
    This is used to implement transactional semantics in build():
31
    if a classifier build fails, we can restore the state as if
32
    the build never started.
33
    """
34

35
    # Map candidate id -> (constructed value, failure_reason)
36
    candidate_states: dict[int, tuple[LegoPageElements | None, str | None]]
1✔
37
    # Set of consumed block IDs
38
    consumed_blocks: set[int]
1✔
39

40

41
class ClassificationResult(BaseModel):
1✔
42
    """Result of classifying a single page.
43

44
    This class stores both the results and intermediate artifacts for a page
45
    classification. It provides structured access to:
46
    - Labels assigned to blocks
47
    - LegoPageElements constructed from blocks
48
    - Removal reasons for filtered blocks
49
    - All candidates considered (including rejected ones)
50

51
    The use of dictionaries keyed by block IDs (int) instead of Block objects
52
    ensures JSON serializability and consistent equality semantics.
53

54
    # TODO: Consider refactoring to separate DAO (Data Access Object) representation
55
    # from the business logic. The public fields below are used for serialization
56
    # but external code should prefer using the accessor methods to maintain
57
    # encapsulation and allow future refactoring.
58

59
    External code should use the accessor methods rather than accessing these
60
    fields directly to maintain encapsulation.
61
    """
62

63
    page_data: PageData
1✔
64
    """The original page data being classified"""
1✔
65

66
    # TODO Do we need this field? Can we remove it?
67
    warnings: list[str] = Field(default_factory=list)
1✔
68
    """Warning messages generated during classification.
1✔
69
    
70
    Public for serialization. Prefer using add_warning() and get_warnings() methods.
71
    """
72

73
    removal_reasons: dict[int, RemovalReason] = Field(default_factory=dict)
1✔
74
    """Maps block IDs (block.id, not id(block)) to the reason they were removed.
1✔
75
    
76
    Keys are block IDs (int) instead of Block objects to ensure JSON serializability
77
    and consistency with constructed_elements.
78
    
79
    Public for serialization. Prefer using accessor methods.
80
    """
81

82
    candidates: dict[str, list[Candidate]] = Field(default_factory=dict)
1✔
83
    """Maps label names to lists of all candidates considered for that label.
1✔
84
    
85
    Each candidate includes:
86
    - The source element
87
    - Its score and score details
88
    - The constructed LegoPageElement (if successful)
89
    - Failure reason (if construction failed)
90
    
91
    This enables:
92
    - Re-evaluation with hints (exclude specific candidates)
93
    - Debugging (see why each candidate won/lost)
94
    - UI support (show users alternatives)
95
    
96
    Public for serialization. Prefer using get_* accessor methods.
97
    """
98

99
    _classifiers: dict[str, LabelClassifier] = PrivateAttr(default_factory=dict)
1✔
100
    _consumed_blocks: set[int] = PrivateAttr(default_factory=set)
1✔
101

102
    @model_validator(mode="after")
1✔
103
    def validate_unique_block_ids(self) -> ClassificationResult:
1✔
104
        """Validate that all block IDs in page_data are unique.
105

106
        Blocks must have unique IDs.
107
        Note: Blocks with IDs can be tracked in removal_reasons
108
        (which require block.id as keys for JSON serializability).
109
        """
110
        # Validate unique IDs
111
        block_ids = [b.id for b in self.page_data.blocks]
1✔
112
        if len(block_ids) != len(set(block_ids)):
1✔
113
            duplicates = [id_ for id_ in block_ids if block_ids.count(id_) > 1]
1✔
114
            raise ValueError(
1✔
115
                f"PageData blocks must have unique IDs. "
116
                f"Found duplicates: {set(duplicates)}"
117
            )
118
        return self
1✔
119

120
    def _register_classifier(self, label: str, classifier: LabelClassifier) -> None:
1✔
121
        """Register a classifier for a specific label.
122

123
        This is called automatically by LabelClassifier.score() and should not
124
        be called directly by external code.
125
        """
126
        self._classifiers[label] = classifier
1✔
127

128
    def build(self, candidate: Candidate) -> LegoPageElements:
1✔
129
        """Construct a candidate using the registered classifier.
130

131
        This is the entry point for top-down construction. If the build fails,
132
        all changes to candidate states and consumed blocks are automatically
133
        rolled back, ensuring transactional semantics.
134
        """
135
        if candidate.constructed:
1✔
136
            return candidate.constructed
1✔
137

138
        if candidate.failure_reason:
1✔
139
            raise ValueError(f"Candidate failed: {candidate.failure_reason}")
1✔
140

141
        # Check if any source block is already consumed
142
        # TODO Do we need the following? As _fail_conflicting_candidates should
143
        # be setting failure reasons already.
144
        for block in candidate.source_blocks:
1✔
145
            if block.id in self._consumed_blocks:
1✔
146
                # Find who consumed it (for better error message)
147
                # This is expensive but only happens on failure
UNCOV
148
                winner_label = "unknown"
×
UNCOV
149
                for _label, cat_candidates in self.candidates.items():
×
UNCOV
150
                    for c in cat_candidates:
×
UNCOV
151
                        if c.constructed and any(
×
152
                            b.id == block.id for b in c.source_blocks
153
                        ):
UNCOV
154
                            winner_label = _label
×
UNCOV
155
                            break
×
156

UNCOV
157
                failure_msg = f"Block {block.id} already consumed by '{winner_label}'"
×
UNCOV
158
                candidate.failure_reason = failure_msg
×
UNCOV
159
                raise ValueError(failure_msg)
×
160

161
        classifier = self._classifiers.get(candidate.label)
1✔
162
        if not classifier:
1✔
UNCOV
163
            raise ValueError(f"No classifier registered for label '{candidate.label}'")
×
164

165
        # Take snapshot before building for automatic rollback on failure
166
        snapshot = self._take_snapshot()
1✔
167

168
        try:
1✔
169
            element = classifier.build(candidate, self)
1✔
170
            candidate.constructed = element
1✔
171

172
            # Mark blocks as consumed
173
            for block in candidate.source_blocks:
1✔
174
                self._consumed_blocks.add(block.id)
1✔
175

176
            # Fail other candidates that use these blocks
177
            self._fail_conflicting_candidates(candidate)
1✔
178

179
            return element
1✔
180
        except Exception:
1✔
181
            # Rollback all changes made during this build
182
            self._restore_snapshot(snapshot)
1✔
183
            raise
1✔
184

185
    def _take_snapshot(self) -> _BuildSnapshot:
1✔
186
        """Take a snapshot of all candidate states and consumed blocks."""
187
        candidate_states = {}
1✔
188
        for candidates in self.candidates.values():
1✔
189
            for c in candidates:
1✔
190
                candidate_states[id(c)] = (c.constructed, c.failure_reason)
1✔
191

192
        return _BuildSnapshot(
1✔
193
            candidate_states=candidate_states,
194
            consumed_blocks=self._consumed_blocks.copy(),
195
        )
196

197
    def _restore_snapshot(self, snapshot: _BuildSnapshot) -> None:
1✔
198
        """Restore candidate states and consumed blocks from a snapshot."""
199
        # Restore candidate states
200
        for candidates in self.candidates.values():
1✔
201
            for c in candidates:
1✔
202
                cid = id(c)
1✔
203
                if cid in snapshot.candidate_states:
1✔
204
                    c.constructed, c.failure_reason = snapshot.candidate_states[cid]
1✔
205

206
        # Restore consumed blocks
207
        self._consumed_blocks = snapshot.consumed_blocks.copy()
1✔
208

209
    def _fail_conflicting_candidates(self, winner: Candidate) -> None:
1✔
210
        """Mark other candidates sharing blocks with winner as failed."""
211
        winner_block_ids = {b.id for b in winner.source_blocks}
1✔
212

213
        for _label, candidates in self.candidates.items():
1✔
214
            for candidate in candidates:
1✔
215
                if candidate is winner:
1✔
216
                    continue
1✔
217
                if candidate.failure_reason:
1✔
218
                    continue
1✔
219

220
                # Check for overlap
221
                for block in candidate.source_blocks:
1✔
222
                    if block.id in winner_block_ids:
1✔
223
                        candidate.failure_reason = (
1✔
224
                            f"Lost conflict to '{winner.label}' "
225
                            f"(score={winner.score:.3f})"
226
                        )
227
                        break
1✔
228

229
    def _validate_block_in_page_data(
1✔
230
        self, block: Blocks | None, param_name: str = "block"
231
    ) -> None:
232
        """Validate that a block is in PageData.
233

234
        Args:
235
            block: The block to validate (None is allowed and skips validation)
236
            param_name: Name of the parameter being validated (for error messages)
237

238
        Raises:
239
            ValueError: If block is not None and not in PageData.blocks
240
        """
241
        if block is not None and block not in self.page_data.blocks:
1✔
242
            raise ValueError(f"{param_name} must be in PageData.blocks. Block: {block}")
1✔
243

244
    @property
1✔
245
    def blocks(self) -> list[Blocks]:
1✔
246
        """Get the blocks from the page data.
247

248
        Returns:
249
            List of blocks from the page data
250
        """
251
        return self.page_data.blocks
1✔
252

253
    @property
1✔
254
    def page(self) -> Page | None:
1✔
255
        """Returns the Page object built from this classification result."""
256
        page_candidates = self.get_scored_candidates("page", valid_only=True)
1✔
257
        if page_candidates:
1✔
258
            page = page_candidates[0].constructed
1✔
259
            assert isinstance(page, Page)
1✔
260
            return page
1✔
261
        return None
1✔
262

263
    def add_warning(self, warning: str) -> None:
1✔
264
        """Add a warning message to the classification result.
265

266
        Args:
267
            warning: The warning message to add
268
        """
269
        self.warnings.append(warning)
1✔
270

271
    def get_warnings(self) -> list[str]:
1✔
272
        """Get all warnings generated during classification.
273

274
        Returns:
275
            List of warning messages
276
        """
277
        return self.warnings.copy()
1✔
278

279
    # TODO Reconsider the methods below - some may be redundant.
280

281
    def get_candidates(self, label: str) -> list[Candidate]:
1✔
282
        """Get all candidates for a specific label.
283

284
        Args:
285
            label: The label to get candidates for
286

287
        Returns:
288
            List of candidates for that label (returns copy to prevent
289
            external modification)
290
        """
291
        return self.candidates.get(label, []).copy()
1✔
292

293
    def get_scored_candidates(
1✔
294
        self,
295
        label: str,
296
        min_score: float = 0.0,
297
        valid_only: bool = True,
298
        exclude_failed: bool = False,
299
    ) -> list[Candidate]:
300
        """Get candidates for a label that have been scored.
301

302
        **Use this method in score() when working with dependency classifiers.**
303

304
        This enforces the pattern of working with candidates (not constructed
305
        elements or raw blocks) when one classifier depends on another. The
306
        returned candidates are sorted by score (highest first).
307

308
        During score(), you should:
309
        1. Get parent candidates using this method
310
        2. Store references to parent candidates in your score_details
311
        3. In construct(), validate parent candidates before using their elements
312

313
        Example:
314
            # In PartsClassifier.score()
315
            part_count_candidates = result.get_scored_candidates("part_count")
316
            for pc_cand in part_count_candidates:
317
                # Store the CANDIDATE reference in score details
318
                score_details = _PartPairScore(
319
                    part_count_candidate=pc_cand,  # Not pc_cand.constructed!
320
                    image=img,
321
                )
322

323
            # Later in _construct_single()
324
            def _construct_single(self, candidate, result):
325
                pc_cand = candidate.score_details.part_count_candidate
326

327
                # Validate parent candidate is still valid
328
                if not pc_cand.is_valid:
329
                    raise ValueError(
330
                        f"Parent invalid: {pc_cand.failure_reason or 'not constructed'}"
331
                    )
332

333
                # Now safe to use the constructed element
334
                assert isinstance(pc_cand.constructed, PartCount)
335
                return Part(count=pc_cand.constructed, ...)
336

337
        Args:
338
            label: The label to get candidates for
339
            min_score: Optional minimum score threshold (default: 0.0)
340
            valid_only: If True (default), only return valid candidates
341
                (constructed and no failure). Set to False to get all scored
342
                candidates regardless of construction status.
343
            exclude_failed: If True, filter out candidates with failure_reason,
344
                even if valid_only is False. (default: False)
345

346
        Returns:
347
            List of scored candidates sorted by score (highest first).
348
            By default, only includes valid candidates (is_valid=True).
349
        """
350
        candidates = self.get_candidates(label)
1✔
351

352
        # Filter to candidates that have been scored
353
        scored = [c for c in candidates if c.score_details is not None]
1✔
354

355
        # Apply score threshold if specified
356
        if min_score > 0:
1✔
357
            scored = [c for c in scored if c.score >= min_score]
1✔
358

359
        # Filter to valid candidates if requested (default)
360
        if valid_only:
1✔
361
            scored = [c for c in scored if c.is_valid]
1✔
362
        elif exclude_failed:
1✔
363
            scored = [c for c in scored if c.failure_reason is None]
1✔
364

365
        # Sort by score descending
366
        # TODO add a tie breaker for determinism.
367
        scored.sort(key=lambda c: -c.score)
1✔
368

369
        return scored
1✔
370

371
    def get_winners_by_score[T: LegoPageElements](
1✔
372
        self, label: str, element_type: type[T], max_count: int | None = None
373
    ) -> list[T]:
374
        """Get the best candidates for a specific label by score.
375

376
        **DEPRECATED for use in score() methods.**
377

378
        This method returns constructed LegoPageElements, which encourages the
379
        anti-pattern of looking at constructed elements during the score() phase.
380

381
        - **In score()**: Use get_scored_candidates() instead to work with candidates
382
        - **In construct()**: It's OK to use this method when you need fully
383
          constructed dependency elements
384

385
        Prefer get_scored_candidates() in score() to maintain proper separation
386
        between the scoring and construction phases.
387

388
        Selects candidates by:
389
        - Successfully constructed (constructed is not None)
390
        - Match the specified element type
391
        - Sorted by score (highest first)
392

393
        Invariant: Each source block should have at most one successfully
394
        constructed candidate per label. This method validates that invariant.
395

396
        Args:
397
            label: The label to get winners for (e.g., "page_number", "step")
398
            element_type: The type of element to filter for (e.g., PageNumber)
399
            max_count: Maximum number of winners to return (None = all valid)
400

401
        Returns:
402
            List of constructed elements of the specified type, sorted by score
403
            (highest first)
404

405
        Raises:
406
            AssertionError: If element_type doesn't match the actual constructed type,
407
                or if multiple candidates exist for the same source block
408
        """
409
        # Get all candidates and filter for successful construction
410
        valid_candidates = [
1✔
411
            c for c in self.get_candidates(label) if c.constructed is not None
412
        ]
413

414
        # Validate that each source block has at most one candidate for this label
415
        # (candidates without source blocks are synthetic and can have duplicates)
416
        seen_blocks: set[int] = set()
1✔
417
        for candidate in valid_candidates:
1✔
418
            assert isinstance(candidate.constructed, element_type), (
1✔
419
                f"Type mismatch for label '{label}': requested "
420
                f"{element_type.__name__} but got "
421
                f"{type(candidate.constructed).__name__}. "
422
                f"This indicates a programming error in the caller."
423
            )
424

425
            for source_block in candidate.source_blocks:
1✔
426
                block_id = id(source_block)
1✔
427
                assert block_id not in seen_blocks, (
1✔
428
                    f"Multiple successfully constructed candidates found for "
429
                    f"label '{label}' with the same source block id:{block_id}. "
430
                    f"This indicates a programming error in the classifier. "
431
                    f"Source block: {source_block}"
432
                )
433
                seen_blocks.add(block_id)
1✔
434

435
        # Sort by score (highest first), then by source block ID for determinism
436
        # when scores are equal
437
        valid_candidates.sort(
1✔
438
            key=lambda c: (
439
                -c.score,  # Negative for descending order
440
                # TODO Fix this, so it's deterministic.
441
                c.source_blocks[0].id if c.source_blocks else 0,  # Tie-breaker
442
            )
443
        )
444

445
        # Apply max_count if specified
446
        if max_count is not None:
1✔
UNCOV
447
            valid_candidates = valid_candidates[:max_count]
×
448

449
        # Extract constructed elements
450
        return [cast(T, c.constructed) for c in valid_candidates]
1✔
451

452
    def get_all_candidates(self) -> dict[str, list[Candidate]]:
1✔
453
        """Get all candidates across all labels.
454

455
        Returns:
456
            Dictionary mapping labels to their candidates (returns copy to
457
            prevent external modification)
458
        """
459
        return {label: cands for label, cands in self.candidates.items()}
1✔
460

461
    def count_successful_candidates(self, label: str) -> int:
1✔
462
        """Count how many candidates were successfully constructed for a label.
463

464
        Test helper method that counts candidates where construction succeeded.
465

466
        Args:
467
            label: The label to count successful candidates for
468

469
        Returns:
470
            Count of successfully constructed candidates
471
        """
472
        return sum(1 for c in self.get_candidates(label) if c.constructed is not None)
1✔
473

474
    def get_all_candidates_for_block(self, block: Blocks) -> list[Candidate]:
1✔
475
        """Get all candidates for a block across all labels.
476

477
        Searches across all labels to find candidates that used the given block
478
        as their source. For finding a candidate with a specific label, use
479
        get_candidate_for_block() instead.
480

481
        Args:
482
            block: The block to find candidates for
483

484
        Returns:
485
            List of all candidates across all labels with this block in source_blocks
486
        """
487
        results = []
1✔
488
        for candidates in self.candidates.values():
1✔
UNCOV
489
            for candidate in candidates:
×
UNCOV
490
                if block in candidate.source_blocks:
×
UNCOV
491
                    results.append(candidate)
×
492
        return results
1✔
493

494
    def get_candidate_for_block(self, block: Blocks, label: str) -> Candidate | None:
1✔
495
        """Get the candidate for a specific block with a specific label.
496

497
        Helper method for testing - returns the single candidate for the given
498
        block and label combination. Returns None if no such candidate exists.
499

500
        Args:
501
            block: The block to find the candidate for
502
            label: The label to search within
503

504
        Returns:
505
            The candidate if found, None otherwise
506

507
        Raises:
508
            ValueError: If multiple candidates exist for this block/label pair
509
        """
510
        candidates = [c for c in self.get_candidates(label) if block in c.source_blocks]
1✔
511

512
        if len(candidates) == 0:
1✔
513
            return None
1✔
514

515
        if len(candidates) == 1:
1✔
516
            return candidates[0]
1✔
517

UNCOV
518
        raise ValueError(
×
519
            f"Multiple candidates found for block {block.id} "
520
            f"with label '{label}'. Expected at most one."
521
        )
522

523
    def get_best_candidate(self, block: Blocks) -> Candidate | None:
1✔
524
        """Get the highest-scoring successfully constructed candidate for a block.
525

526
        When a block has candidates for multiple labels, this returns the one
527
        with the highest score. This is the "winning" candidate for reporting
528
        and output purposes.
529

530
        Args:
531
            block: The block to get the best candidate for
532

533
        Returns:
534
            The highest-scoring successfully constructed candidate, or None
535
            if no successfully constructed candidate exists
536
        """
537
        candidates = self.get_all_candidates_for_block(block)
1✔
538
        valid_candidates = [c for c in candidates if c.constructed is not None]
1✔
539

540
        if not valid_candidates:
1✔
541
            return None
1✔
542

543
        # Return the highest-scoring candidate
UNCOV
544
        return max(valid_candidates, key=lambda c: c.score)
×
545

546
    # TODO I think this API is broken - there can be multiple labels per block,
547
    # but we only return one here.
548
    def get_label(self, block: Blocks) -> str | None:
1✔
549
        """Get the label for a block from its highest-scoring constructed candidate.
550

551
        Returns the label of the successfully constructed candidate with the
552
        highest score for the given block, or None if no successfully
553
        constructed candidate exists.
554

555
        This is a convenience method equivalent to:
556
            candidate = result.get_best_candidate(block)
557
            return candidate.label if candidate else None
558

559
        Args:
560
            block: The block to get the label for
561

562
        Returns:
563
            The label string of the highest-scoring constructed candidate,
564
            None otherwise
565
        """
566
        best_candidate = self.get_best_candidate(block)
1✔
567
        return best_candidate.label if best_candidate else None
1✔
568

569
    def add_candidate(self, candidate: Candidate) -> None:
1✔
570
        """Add a single candidate.
571

572
        The label is extracted from candidate.label.
573

574
        Args:
575
            candidate: The candidate to add
576

577
        Raises:
578
            ValueError: If candidate has source_blocks that are not in PageData
579
        """
580
        for source_block in candidate.source_blocks:
1✔
581
            self._validate_block_in_page_data(source_block, "candidate.source_blocks")
1✔
582

583
        label = candidate.label
1✔
584
        if label not in self.candidates:
1✔
585
            self.candidates[label] = []
1✔
586
        self.candidates[label].append(candidate)
1✔
587

588
    def mark_removed(self, block: Blocks, reason: RemovalReason) -> None:
1✔
589
        """Mark a block as removed with the given reason.
590

591
        Args:
592
            block: The block to mark as removed
593
            reason: The reason for removal
594

595
        Raises:
596
            ValueError: If block is not in PageData
597
        """
598
        self._validate_block_in_page_data(block, "block")
1✔
599
        self.removal_reasons[block.id] = reason
1✔
600

601
    def is_removed(self, block: Blocks) -> bool:
1✔
602
        """Check if a block has been marked for removal.
603

604
        Args:
605
            block: The block to check
606

607
        Returns:
608
            True if the block is marked for removal, False otherwise
609
        """
610
        return block.id in self.removal_reasons
1✔
611

612
    def get_removal_reason(self, block: Blocks) -> RemovalReason | None:
1✔
613
        """Get the reason why a block was removed.
614

615
        Args:
616
            block: The block to get the removal reason for
617

618
        Returns:
619
            The RemovalReason if the block was removed, None otherwise
620
        """
621
        return self.removal_reasons.get(block.id)
1✔
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