diff --git a/pyproject.toml b/pyproject.toml
index 98f123c..093bf43 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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]
diff --git a/src/checkup/materializers/__init__.py b/src/checkup/materializers/__init__.py
index 258ef7c..4408cb9 100644
--- a/src/checkup/materializers/__init__.py
+++ b/src/checkup/materializers/__init__.py
@@ -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",
diff --git a/src/checkup/materializers/markdown.py b/src/checkup/materializers/markdown.py
new file mode 100644
index 0000000..e47c16a
--- /dev/null
+++ b/src/checkup/materializers/markdown.py
@@ -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 `
`.
+ """
+
+ return str(value or "").replace("|", "\\|").replace("\n", "
").strip()
diff --git a/tests/test_materializers.py b/tests/test_materializers.py
index 65fe3ac..62b8eac 100644
--- a/tests/test_materializers.py
+++ b/tests/test_materializers.py
@@ -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):
@@ -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
@@ -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
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
@@ -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
@@ -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
@@ -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