Skip to content

Commit 407a253

Browse files
vvvitosweco
authored andcommitted
feat: add markdown as output format for CoverageDiff
This changes the output format argument from cov_format to output_format.
1 parent feb89bd commit 407a253

File tree

2 files changed

+140
-66
lines changed

2 files changed

+140
-66
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

77
## [Unreleased]
8+
### Added
9+
- Support for markdown output format. [#90] _Thanks to @vvvito!_
10+
11+
### Changed
12+
- The output format argument from cov_format to output_format. [#90]
813

914
## [0.4.0] - 2025-10-20
1015
### Added

dbt_coverage/__init__.py

Lines changed: 135 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import io
44
import json
55
import logging
6+
import textwrap
67
from dataclasses import dataclass, field, replace
78
from enum import Enum
89
from pathlib import Path
@@ -31,7 +32,7 @@ class CoverageType(Enum):
3132
UNIT_TEST = "unit-test"
3233

3334

34-
class CoverageFormat(str, Enum):
35+
class OutputFormat(str, Enum):
3536
STRING_TABLE = "string"
3637
MARKDOWN_TABLE = "markdown"
3738

@@ -632,89 +633,135 @@ def find_new_misses(self):
632633

633634
return res
634635

635-
def summary(self):
636+
def summary(self, output_format: OutputFormat):
636637
buf = io.StringIO()
637638

638639
if self.after.entity_type != CoverageReport.EntityType.CATALOG:
639640
raise TypeError(
640641
f"Unsupported report_type for summary method: " f"{self.after.entity_type}"
641642
)
642643

643-
buf.write(f"{'':10}{'before':>10}{'after':>10}{'+/-':>15}\n")
644-
buf.write("=" * 45 + "\n")
645-
646644
before_cov = self.before.coverage if self.before.coverage is not None else 0.0
647645
after_cov = self.after.coverage if self.after.coverage is not None else 0.0
648-
buf.write(
649-
f"{'Coverage':10}{before_cov:10.2%}{after_cov:10.2%}"
650-
f"{(after_cov - before_cov):+15.2%}\n"
651-
)
652-
buf.write("=" * 45 + "\n")
653-
654-
add_del = (
646+
total_diff = after_cov - before_cov
647+
tables_add_del = (
655648
f"{len(set(self.after.subentities) - set(self.before.subentities)):+d}/"
656649
f"{-len(set(self.before.subentities) - set(self.after.subentities)):+d}"
657650
)
658-
buf.write(
659-
f"{'Tables':10}{len(self.before.subentities):10d}"
660-
f"{len(self.after.subentities):10d}"
661-
f"{add_del:>15}\n"
662-
)
663-
664-
add_del = (
651+
columns_add_del = (
665652
f"{len(self.after.total - self.before.total):+d}/"
666653
f"{-len(self.before.total - self.after.total):+d}"
667654
)
668-
buf.write(
669-
f"{'Columns':10}{len(self.before.total):10d}{len(self.after.total):10d}"
670-
f"{add_del:>15}\n"
671-
)
672-
buf.write("=" * 45 + "\n")
673-
674-
add_del = (
655+
hits_add_del = (
675656
f"{len(self.after.covered - self.before.covered):+d}/"
676657
f"{-len(self.before.covered - self.after.covered):+d}"
677658
)
678-
buf.write(
679-
f"{'Hits':10}{len(self.before.covered):10d}{len(self.after.covered):10d}"
680-
f"{add_del:>15}\n"
681-
)
682-
683-
add_del = (
659+
misses_add_del = (
684660
f"{len(self.after.misses - self.before.misses):+d}/"
685661
f"{-len(self.before.misses - self.after.misses):+d}"
686662
)
687-
buf.write(
688-
f"{'Misses':10}{len(self.before.misses):10d}{len(self.after.misses):10d}"
689-
f"{add_del:>15}\n"
690-
)
691663

692-
buf.write("=" * 45 + "\n")
664+
if output_format == OutputFormat.MARKDOWN_TABLE:
665+
template = textwrap.dedent(
666+
"""\
667+
| | before | after | +/- |
668+
|:----------|:-----------------:|:----------------:|:-----------------:|
669+
| Coverage | {before_coverage} | {after_coverage} | {total_diff} |
670+
| Tables | {before_tables} | {after_tables} | {tables_add_del} |
671+
| Columns | {before_columns} | {after_columns} | {columns_add_del} |
672+
| Hits | {before_hits} | {after_hits} | {hits_add_del} |
673+
| Misses | {before_misses} | {after_misses} | {misses_add_del} |
674+
"""
675+
)
676+
677+
formatted = template.format(
678+
before_coverage=f"{before_cov:.2%}",
679+
after_coverage=f"{after_cov:.2%}",
680+
total_diff=f"{total_diff:.2%}",
681+
before_tables=len(self.before.subentities),
682+
after_tables=len(self.after.subentities),
683+
tables_add_del=tables_add_del,
684+
before_columns=len(self.before.total),
685+
after_columns=len(self.after.total),
686+
columns_add_del=columns_add_del,
687+
before_hits=len(self.before.covered),
688+
after_hits=len(self.after.covered),
689+
hits_add_del=hits_add_del,
690+
before_misses=len(self.before.misses),
691+
after_misses=len(self.after.misses),
692+
misses_add_del=misses_add_del,
693+
)
694+
695+
buf.write(formatted)
696+
elif output_format == OutputFormat.STRING_TABLE:
697+
buf.write(f"{'':10}{'before':>10}{'after':>10}{'+/-':>15}\n")
698+
buf.write("=" * 45 + "\n")
699+
700+
buf.write(
701+
f"{'Coverage':10}{before_cov:10.2%}{after_cov:10.2%}" f"{total_diff:+15.2%}\n"
702+
)
703+
buf.write("=" * 45 + "\n")
704+
705+
buf.write(
706+
f"{'Tables':10}{len(self.before.subentities):10d}"
707+
f"{len(self.after.subentities):10d}"
708+
f"{tables_add_del:>15}\n"
709+
)
710+
711+
buf.write(
712+
f"{'Columns':10}{len(self.before.total):10d}{len(self.after.total):10d}"
713+
f"{columns_add_del:>15}\n"
714+
)
715+
buf.write("=" * 45 + "\n")
716+
717+
buf.write(
718+
f"{'Hits':10}{len(self.before.covered):10d}{len(self.after.covered):10d}"
719+
f"{hits_add_del:>15}\n"
720+
)
721+
722+
buf.write(
723+
f"{'Misses':10}{len(self.before.misses):10d}{len(self.after.misses):10d}"
724+
f"{misses_add_del:>15}\n"
725+
)
726+
727+
buf.write("=" * 45 + "\n")
728+
else:
729+
raise ValueError(f"Unsupported output_format: {output_format}")
693730

694731
return buf.getvalue()
695732

696-
def new_misses_summary(self):
733+
def new_misses_summary(self, output_format: OutputFormat, last: bool = False):
697734
if self.after.entity_type == CoverageReport.EntityType.COLUMN:
698-
return self._new_miss_summary_row()
735+
return self._new_miss_summary_row(output_format, last)
699736

700737
elif self.after.entity_type == CoverageReport.EntityType.TABLE:
701738
buf = io.StringIO()
702739

703-
buf.write(self._new_miss_summary_row())
740+
buf.write(self._new_miss_summary_row(output_format))
704741
if self.after.cov_type != CoverageType.UNIT_TEST: # Unit tests work on the table level
705-
for col in self.new_misses.values():
706-
buf.write(col.new_misses_summary())
742+
for i, col in enumerate(self.new_misses.values()):
743+
last = i == len(self.new_misses) - 1
744+
buf.write(col.new_misses_summary(output_format, last=last))
707745

708746
return buf.getvalue()
709747

710748
elif self.after.entity_type == CoverageReport.EntityType.CATALOG:
711749
buf = io.StringIO()
712-
buf.write("=" * 94 + "\n")
713-
buf.write(self._new_miss_summary_row())
714-
buf.write("=" * 94 + "\n")
715-
for table in self.new_misses.values():
716-
buf.write(table.new_misses_summary())
750+
if output_format == OutputFormat.MARKDOWN_TABLE:
751+
buf.write("| | before (%) | after (%) |\n")
752+
buf.write("|:----|:----------:|:---------:|\n")
753+
buf.write(self._new_miss_summary_row(output_format))
754+
for table in self.new_misses.values():
755+
buf.write(table.new_misses_summary(output_format))
756+
elif output_format == OutputFormat.STRING_TABLE:
757+
buf.write("=" * 94 + "\n")
758+
buf.write(self._new_miss_summary_row(output_format))
717759
buf.write("=" * 94 + "\n")
760+
for table in self.new_misses.values():
761+
buf.write(table.new_misses_summary(output_format))
762+
buf.write("=" * 94 + "\n")
763+
else:
764+
raise ValueError(f"Unsupported output_format: {output_format}")
718765

719766
return buf.getvalue()
720767

@@ -724,13 +771,13 @@ def new_misses_summary(self):
724771
f"{self.after.entity_type}"
725772
)
726773

727-
def _new_miss_summary_row(self):
774+
def _new_miss_summary_row(self, output_format: OutputFormat, last: bool = False):
728775
if self.after.entity_type == CoverageReport.EntityType.CATALOG:
729776
title_prefix = ""
730777
elif self.after.entity_type == CoverageReport.EntityType.TABLE:
731-
title_prefix = "- "
778+
title_prefix = " "
732779
elif self.after.entity_type == CoverageReport.EntityType.COLUMN:
733-
title_prefix = "-- "
780+
title_prefix = "└── " if last else "├── "
734781
else:
735782
raise TypeError(
736783
f"Unsupported report_type for _new_miss_summary_row method: "
@@ -758,10 +805,23 @@ def _new_miss_summary_row(self):
758805
)
759806

760807
buf = io.StringIO()
761-
buf.write(f"{title:50}")
762-
buf.write(f"{before_covered:>5}/{before_total:<5}{before_coverage:^9}")
763-
buf.write(" -> ")
764-
buf.write(f"{after_covered:>5}/{after_total:<5}{after_coverage:^9}\n")
808+
if output_format == OutputFormat.MARKDOWN_TABLE:
809+
title = (
810+
f"`{title}`"
811+
if self.after.entity_type == CoverageReport.EntityType.CATALOG
812+
else title
813+
)
814+
buf.write(
815+
f"| {title} | {before_covered}/{before_total} {before_coverage} "
816+
f"| {after_covered}/{after_total} {after_coverage} |\n"
817+
)
818+
elif output_format == OutputFormat.STRING_TABLE:
819+
buf.write(f"{title:50}")
820+
buf.write(f"{before_covered:>5}/{before_total:<5}{before_coverage:^9}")
821+
buf.write(" -> ")
822+
buf.write(f"{after_covered:>5}/{after_total:<5}{after_coverage:^9}\n")
823+
else:
824+
raise ValueError(f"Unsupported output_format: {output_format}")
765825

766826
return buf.getvalue()
767827

@@ -878,11 +938,13 @@ def compute_coverage(catalog: Catalog, cov_type: CoverageType):
878938
return coverage_report
879939

880940

881-
def compare_reports(report: CoverageReport, compare_report: CoverageReport) -> CoverageDiff:
882-
diff = CoverageDiff(compare_report, report)
941+
def compare_reports(
942+
report: CoverageReport, compare_report: CoverageReport, output_format: OutputFormat
943+
) -> CoverageDiff:
883944

884-
print(diff.summary())
885-
print(diff.new_misses_summary())
945+
diff = CoverageDiff(compare_report, report)
946+
print(diff.summary(output_format))
947+
print(diff.new_misses_summary(output_format))
886948

887949
return diff
888950

@@ -930,7 +992,7 @@ def do_compute(
930992
cov_fail_compare: Path = None,
931993
model_path_filter: Optional[List[str]] = None,
932994
model_path_exclusion_filter: Optional[List[str]] = None,
933-
cov_format: CoverageFormat = CoverageFormat.STRING_TABLE,
995+
output_format: OutputFormat = OutputFormat.STRING_TABLE,
934996
):
935997
"""
936998
Computes coverage for a dbt project.
@@ -949,7 +1011,7 @@ def do_compute(
9491011

9501012
coverage_report = compute_coverage(catalog, cov_type)
9511013

952-
if cov_format == CoverageFormat.MARKDOWN_TABLE:
1014+
if output_format == OutputFormat.MARKDOWN_TABLE:
9531015
print(coverage_report.to_markdown_table())
9541016
else:
9551017
print(coverage_report.to_formatted_string())
@@ -965,7 +1027,9 @@ def do_compute(
9651027
return coverage_report
9661028

9671029

968-
def do_compare(report: Path, compare_report: Path) -> CoverageDiff:
1030+
def do_compare(
1031+
report: Path, compare_report: Path, output_format: OutputFormat = OutputFormat.STRING_TABLE
1032+
) -> CoverageDiff:
9691033
"""
9701034
Compares two coverage reports generated by the ``compute`` command.
9711035
@@ -974,6 +1038,7 @@ def do_compare(report: Path, compare_report: Path) -> CoverageDiff:
9741038
Args:
9751039
report: ``Path`` to the current report - the after state.
9761040
compare_report: ``Path`` to the report to compare against - the before state.
1041+
output_format: The OutputFormat to print, either `string` or `markdown`
9771042
9781043
Returns:
9791044
The ``CoverageDiff`` between the two coverage reports.
@@ -982,7 +1047,7 @@ def do_compare(report: Path, compare_report: Path) -> CoverageDiff:
9821047
report = read_coverage_report(report)
9831048
compare_report = read_coverage_report(compare_report)
9841049

985-
diff = compare_reports(report, compare_report)
1050+
diff = compare_reports(report, compare_report, output_format)
9861051

9871052
return diff
9881053

@@ -1009,8 +1074,8 @@ def compute(
10091074
model_path_exclusion_filter: Optional[List[str]] = typer.Option(
10101075
None, help="The model_path string(s) to filter tables on excluding tables that match."
10111076
),
1012-
cov_format: CoverageFormat = typer.Option(
1013-
CoverageFormat.STRING_TABLE,
1077+
output_format: OutputFormat = typer.Option(
1078+
OutputFormat.STRING_TABLE,
10141079
help="The output format to print, either `string` or `markdown`",
10151080
),
10161081
):
@@ -1025,7 +1090,7 @@ def compute(
10251090
cov_fail_compare,
10261091
model_path_filter,
10271092
model_path_exclusion_filter,
1028-
cov_format,
1093+
output_format,
10291094
)
10301095

10311096

@@ -1035,10 +1100,14 @@ def compare(
10351100
compare_report: Path = typer.Argument(
10361101
..., help="Path to another coverage report to compare with - the before state."
10371102
),
1103+
output_format: OutputFormat = typer.Option(
1104+
OutputFormat.STRING_TABLE,
1105+
help="The output format to print, either `string` or `markdown`",
1106+
),
10381107
):
10391108
"""Compare two coverage reports generated by the compute command."""
10401109

1041-
return do_compare(report, compare_report)
1110+
return do_compare(report, compare_report, output_format)
10421111

10431112

10441113
@app.callback()

0 commit comments

Comments
 (0)