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

bramp / build-along / 20379241271

19 Dec 2025 06:37PM UTC coverage: 89.145% (+0.008%) from 89.137%
20379241271

push

github

bramp
fix(drawing): prevent text clipping for bounding box labels at bottom of page

When a bounding box is near the bottom of the page, the label text drawn below it could be clipped. This change moves the text inside the bounding box (aligned to the bottom) if it would otherwise be drawn off-screen.

20 of 20 new or added lines in 2 files covered. (100.0%)

89 existing lines in 7 files now uncovered.

13033 of 14620 relevant lines covered (89.15%)

0.89 hits per line

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

21.47
/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 []
×
UNCOV
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
×
UNCOV
536
                print(f"  Step {i}: #{step.step_number.value} ({parts_count} parts)")
×
537

UNCOV
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.instruction and page.instruction.open_bags:
1✔
557
        print(f"  ✓ Open Bags: {len(page.instruction.open_bags)}")
×
558
        for open_bag in page.instruction.open_bags:
×
UNCOV
559
            bag_label = (
×
560
                f"Bag {open_bag.number.value}" if open_bag.number else "Bag (all)"
561
            )
UNCOV
562
            print(f"    - {bag_label} at {open_bag.bbox}")
×
563

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

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

588
            if step.diagram:
1✔
589
                print(f"      Diagram: {step.diagram.bbox}")
1✔
590

591

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

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

603
    for page_data, result in zip(pages, results, strict=True):
×
604
        page = result.page
×
605
        if page:
×
UNCOV
606
            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