From e3ce9de900406d8af0c5a2e33a4597475715963d Mon Sep 17 00:00:00 2001 From: "Chris (ChrisJr404)" <11917633+ChrisJr404@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:09:49 -0400 Subject: [PATCH] feat: add md_table helper for Markdown tables in templates --- CHANGES.rst | 1 + .../plugin_development/messaging.rst | 9 ++++ errbot/templating.py | 35 +++++++++++++++ tests/templating_test.py | 43 +++++++++++++++++++ 4 files changed, 88 insertions(+) create mode 100644 tests/templating_test.py diff --git a/CHANGES.rst b/CHANGES.rst index ad91a9c07..52dbc84ca 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -9,6 +9,7 @@ v9.9.9 (unreleased) - chore: bump actions/setup-python version (#1773, #1775) - refactor: switch to pyproject.toml (#1753) - chore: bump dependencies (#1774, #1777) +- feat: add md_table helper to render Markdown tables in templates (#1780) v6.2.1 (2026-06-06) diff --git a/docs/user_guide/plugin_development/messaging.rst b/docs/user_guide/plugin_development/messaging.rst index 0b4733119..8cfc6bad9 100644 --- a/docs/user_guide/plugin_development/messaging.rst +++ b/docs/user_guide/plugin_development/messaging.rst @@ -131,6 +131,15 @@ using `self.send_templated()`: response = tenv().get_template('Hello/hello.md').render(name=args) self.send(msg.frm, response) +Building tables by hand in a Jinja2 template is fiddly, so a ``md_table`` +helper is available in every template. Give it the rows (an iterable of +iterables) and, optionally, a list of headers, and it returns a Markdown table. +Pipe and newline characters in the cells are escaped for you so the table stays +valid: + +.. code-block:: jinja + + {{ md_table(rows, headers=['Name', 'Score']) }} Cards ----- diff --git a/errbot/templating.py b/errbot/templating.py index b2f988714..7d3577e2d 100644 --- a/errbot/templating.py +++ b/errbot/templating.py @@ -2,6 +2,7 @@ from pathlib import Path from jinja2 import ChoiceLoader, Environment, FileSystemLoader, PrefixLoader +from markupsafe import Markup from errbot.plugin_info import PluginInfo @@ -12,6 +13,39 @@ def make_templates_path(root: Path) -> Path: return root / "templates" +def _md_table_cell(value) -> str: + text = str(value) + # Newlines and pipes would break a Markdown table, so neutralize them. + text = text.replace("\r\n", " ").replace("\n", " ").replace("\r", " ") + return text.replace("|", "\\|") + + +def md_table(rows, headers=None) -> Markup: + """Render an iterable of rows as a GitHub flavored Markdown table. + + ``rows`` is an iterable of iterables, one per table row. ``headers`` is an + optional list used for the header row. Cells are turned into strings and any + pipe or newline characters in them are escaped so the table stays valid. + + The result is a :class:`~markupsafe.Markup` string so it can be dropped + straight into a Markdown template with ``{{ md_table(rows, headers=[...]) }}``. + """ + rows = [list(row) for row in rows] + header_cells = list(headers) if headers is not None else [] + ncols = max([len(header_cells)] + [len(row) for row in rows]) + if ncols == 0: + return Markup("") + + def line(cells): + cells = [_md_table_cell(c) for c in cells] + cells += [""] * (ncols - len(cells)) + return "| " + " | ".join(cells) + " |" + + lines = [line(header_cells), "| " + " | ".join(["---"] * ncols) + " |"] + lines += [line(row) for row in rows] + return Markup("\n".join(lines)) + + system_templates_path = str(make_templates_path(Path(__file__).parent)) template_path = [system_templates_path] plugin_templates = {} # plugin_name -> FileSystemLoader @@ -30,6 +64,7 @@ def _recreate_env(): keep_trailing_newline=False, autoescape=True, ) + env.globals["md_table"] = md_table _recreate_env() diff --git a/tests/templating_test.py b/tests/templating_test.py new file mode 100644 index 000000000..e7f2d39ce --- /dev/null +++ b/tests/templating_test.py @@ -0,0 +1,43 @@ +from markupsafe import Markup + +from errbot.templating import md_table, tenv + + +def test_md_table_with_headers(): + result = md_table([[1, 2], [3, 4]], headers=["a", "b"]) + assert result == "| a | b |\n| --- | --- |\n| 1 | 2 |\n| 3 | 4 |" + + +def test_md_table_without_headers_uses_blank_header(): + lines = str(md_table([["x", "y"]])).splitlines() + assert lines[0] == "| | |" + assert lines[1] == "| --- | --- |" + assert lines[2] == "| x | y |" + + +def test_md_table_pads_short_rows(): + lines = str(md_table([["only"]], headers=["a", "b"])).splitlines() + assert lines[2] == "| only | |" + + +def test_md_table_escapes_pipes_and_newlines(): + result = md_table([["a|b", "c\nd"]], headers=["h1", "h2"]) + assert "| a\\|b | c d |" in result + + +def test_md_table_empty(): + assert md_table([]) == "" + + +def test_md_table_returns_markup(): + assert isinstance(md_table([["x"]]), Markup) + + +def test_md_table_available_in_template_env(): + assert "md_table" in tenv().globals + rendered = ( + tenv() + .from_string("{{ md_table(rows, headers=cols) }}") + .render(rows=[["a", "b"]], cols=["c1", "c2"]) + ) + assert rendered == "| c1 | c2 |\n| --- | --- |\n| a | b |"