Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ checkup = "checkup:main"
console = "checkup.materializers:ConsoleMaterializer"
csv = "checkup.materializers:CSVMaterializer"
html = "checkup.materializers:HTMLMaterializer"
markdown = "checkup.materializers:MarkdownMaterializer"
sqlalchemy = "checkup.materializers:SQLAlchemyMaterializer"

[build-system]
Expand Down
2 changes: 2 additions & 0 deletions src/checkup/materializers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@
from checkup.materializers.csv_file import CSVMaterializer
from checkup.materializers.database import SQLAlchemyMaterializer
from checkup.materializers.html_report import HTMLMaterializer
from checkup.materializers.markdown import MarkdownMaterializer

__all__ = [
"ConsoleMaterializer",
"CSVMaterializer",
"HTMLMaterializer",
"MarkdownMaterializer",
"Materializer",
"SQLAlchemyMaterializer",
"group_measurements_by_tags",
Expand Down
52 changes: 52 additions & 0 deletions src/checkup/materializers/markdown.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""Markdown materializer for Markdown table output."""

from checkup.materializers.base import Materializer
from checkup.measurement import Measurement

COLUMNS = ("Name", "Description", "Value", "Unit", "Diagnostics")
ALIGNMENTS = ("---", "---", "---:", "---", "---") # Right-align the Value column.


class MarkdownMaterializer(Materializer):
"""
Output measurements as a GitHub-flavoured Markdown table.
"""

def materialize(
self, measurements: list[Measurement], direct_metric_names: set[str]
) -> None:
"""
Print measurements as a Markdown table.
"""

filtered = self._filter_measurements(measurements, direct_metric_names)

rows = [self._row(COLUMNS), self._row(ALIGNMENTS)]
for measurement in filtered:
rows.append(
self._row(
(
measurement.metric.name,
measurement.metric.description,
str(measurement.value) if measurement.value is not None else "",
measurement.metric.unit,
measurement.diagnostic,
)
)
)
print("\n".join(rows))

@classmethod
def _row(cls, cells: tuple[str, ...]) -> str:
return "| " + " | ".join(cls._cell(cell) for cell in cells) + " |"

@staticmethod
def _cell(value: str) -> str:
"""
Escape a value for a Markdown table cell.

Cells cannot contain a raw pipe (column separator) or newline (row separator),
so escape pipes and turn newlines into `<br>`.
"""

return str(value or "").replace("|", "\\|").replace("\n", "<br>").strip()
118 changes: 65 additions & 53 deletions tests/test_materializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,26 @@
ConsoleMaterializer,
CSVMaterializer,
HTMLMaterializer,
MarkdownMaterializer,
Materializer,
SQLAlchemyMaterializer,
)


def _capture(materializer: Materializer, measurements, direct) -> str:
"""
Capture the stdout a materializer produces, restoring stdout afterwards.
"""

captured_output = StringIO()
sys.stdout = captured_output
try:
materializer.materialize(measurements, direct)
finally:
sys.stdout = sys.__stdout__
return captured_output.getvalue()


def test_materializer_is_abstract():
"""Test that Materializer cannot be instantiated."""
with pytest.raises(TypeError):
Expand All @@ -28,17 +43,10 @@ def test_console_materializer():
metric = DummyMetric(expected_value=42)
measurement = metric.measure(value=42)

# Capture stdout
captured_output = StringIO()
sys.stdout = captured_output

materializer = ConsoleMaterializer(group_tags=["domain", "project"])
materializer.materialize([measurement], {"dummy"})

# Reset stdout
sys.stdout = sys.__stdout__
output = _capture(
ConsoleMaterializer(group_tags=["domain", "project"]), [measurement], {"dummy"}
)

output = captured_output.getvalue()
assert "dummy" in output
assert "42" in output

Expand All @@ -48,33 +56,50 @@ def test_console_materializer_no_grouping():
metric = DummyMetric(expected_value=42)
measurement = metric.measure(value=42)

captured_output = StringIO()
sys.stdout = captured_output
output = _capture(ConsoleMaterializer(), [measurement], {"dummy"})

materializer = ConsoleMaterializer() # No group_tags
materializer.materialize([measurement], {"dummy"})

sys.stdout = sys.__stdout__

output = captured_output.getvalue()
assert "dummy" in output
assert "42" in output


def test_markdown_materializer_renders_table():
"""
Markdown materializer emits a markdown table with header, separator and rows.
"""

metric = DummyMetric(expected_value=42)
measurement = metric.measure(value=42)

output = _capture(MarkdownMaterializer(), [measurement], {"dummy"})

assert "| Name | Description | Value | Unit | Diagnostics |" in output
assert "| --- | --- | ---: | --- | --- |" in output # Value right-aligned
assert "| dummy | Test metric | 42 | count |" in output


def test_markdown_materializer_escapes_pipes_and_newlines():
"""
Cells cannot hold raw pipes or newlines; they must be escaped.
"""

metric = DummyMetric(expected_value=1)
measurement = metric.measure(value=1, diagnostic="a|b\nc")

output = _capture(MarkdownMaterializer(), [measurement], {"dummy"})

assert "a\\|b<br>c" in output
assert "a|b\nc" not in output


def test_console_materializer_single_grouping():
"""Test console materializer with single-level grouping."""
metric = DummyMetric(expected_value=42)
measurement = metric.measure(value=42, tags={"domain": "Analytics"})

captured_output = StringIO()
sys.stdout = captured_output

materializer = ConsoleMaterializer(group_tags=["domain"])
materializer.materialize([measurement], {"dummy"})

sys.stdout = sys.__stdout__
output = _capture(
ConsoleMaterializer(group_tags=["domain"]), [measurement], {"dummy"}
)

output = captured_output.getvalue()
assert "dummy" in output
assert "42" in output
assert "domain: Analytics" in output
Expand All @@ -87,15 +112,12 @@ def test_console_materializer_three_level_grouping():
value=42, tags={"domain": "Analytics", "project": "Core", "env": "prod"}
)

captured_output = StringIO()
sys.stdout = captured_output

materializer = ConsoleMaterializer(group_tags=["domain", "project", "env"])
materializer.materialize([measurement], {"dummy"})

sys.stdout = sys.__stdout__
output = _capture(
ConsoleMaterializer(group_tags=["domain", "project", "env"]),
[measurement],
{"dummy"},
)

output = captured_output.getvalue()
assert "dummy" in output
assert "42" in output
assert "domain: Analytics" in output
Expand Down Expand Up @@ -157,17 +179,13 @@ def test_materializer_filters_indirect_by_default():
indirect_metric = IndirectDummyMetric(expected_value=100)
indirect_measurement = indirect_metric.measure(value=100)

# Capture stdout
captured_output = StringIO()
sys.stdout = captured_output

materializer = ConsoleMaterializer(group_tags=["domain", "project"])
# Only "dummy" is direct, "indirect" is not
materializer.materialize([direct_measurement, indirect_measurement], {"dummy"})

sys.stdout = sys.__stdout__
output = _capture(
ConsoleMaterializer(group_tags=["domain", "project"]),
[direct_measurement, indirect_measurement],
{"dummy"},
)

output = captured_output.getvalue()
assert "dummy" in output # Direct metric included
assert "indirect" not in output # Indirect metric filtered out

Expand All @@ -182,18 +200,12 @@ def test_materializer_includes_indirect_when_configured():
indirect_metric = IndirectDummyMetric(expected_value=100)
indirect_measurement = indirect_metric.measure(value=100)

# Capture stdout
captured_output = StringIO()
sys.stdout = captured_output

materializer = ConsoleMaterializer(
include_indirect=True, group_tags=["domain", "project"]
output = _capture(
ConsoleMaterializer(include_indirect=True, group_tags=["domain", "project"]),
[direct_measurement, indirect_measurement],
{"dummy"},
)
materializer.materialize([direct_measurement, indirect_measurement], {"dummy"})

sys.stdout = sys.__stdout__

output = captured_output.getvalue()
assert "dummy" in output # Direct metric included
assert "indirect" in output # Indirect metric also included

Expand Down
Loading