From d63d975ec0597cdf8d008305b5acf0d0e8fdea6f Mon Sep 17 00:00:00 2001 From: CasperTeirlinck Date: Fri, 10 Jul 2026 16:59:43 +0200 Subject: [PATCH 1/2] update --- src/checkup/materializers/__init__.py | 2 + src/checkup/materializers/markdown.py | 52 ++++++++++++ tests/test_materializers.py | 118 ++++++++++++++------------ 3 files changed, 119 insertions(+), 53 deletions(-) create mode 100644 src/checkup/materializers/markdown.py 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 From 8d92f9dd9bc95919d10b92e9d4a76cc9d604d0ec Mon Sep 17 00:00:00 2001 From: CasperTeirlinck Date: Fri, 10 Jul 2026 17:01:14 +0200 Subject: [PATCH 2/2] update --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) 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]