From 0df8bef749647d4d31d3a27024269382d2cf2725 Mon Sep 17 00:00:00 2001 From: cnYui Date: Tue, 15 Sep 2026 06:25:02 +0900 Subject: [PATCH] fix(enterprise): escape pipes and newlines in Markdown Actions table QualityReport.to_markdown() escaped only the description cell, so a column name containing "|" added an extra cell and shifted the Actions row, and newlines in any cell split the row. Escape every cell inside _md_table_row instead: "|" becomes "\|" and line breaks become "
". The manual per-description escape is removed so values are no longer double-escaped. Fixes #338 --- CHANGELOG.md | 3 +++ src/freshdata/enterprise/metrics.py | 19 ++++++++++++++-- tests/test_enterprise_metrics.py | 35 ++++++++++++++++++++++++++++- 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cc50e0d..f39bc2cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,9 @@ adheres to [Semantic Versioning](https://semver.org/). that difference (#201). ### Fixed +- `QualityReport.to_markdown()` now escapes every cell in the Actions table, so + a column name (or description) containing `|` or a newline no longer adds or + splits table columns. Pipes become `\|` and line breaks become `
` (#338). - The minimum supported numpy is now 1.22. The numpy 1.21.6 wheel bundles an OpenBLAS that segfaults on BLAS-backed matrix multiplies on current Apple Silicon Macs regardless of `OPENBLAS_NUM_THREADS`, so installs at the old diff --git a/src/freshdata/enterprise/metrics.py b/src/freshdata/enterprise/metrics.py index 0bd358b7..f4ea419c 100644 --- a/src/freshdata/enterprise/metrics.py +++ b/src/freshdata/enterprise/metrics.py @@ -133,8 +133,23 @@ def __repr__(self) -> str: return f"" +def _md_escape_cell(cell: str) -> str: + """Neutralise Markdown table delimiters inside a single cell. + + A literal ``|`` starts a new column and a newline ends the row, so a + column name or description containing either would shift or split the + row. Pipes are backslash-escaped and line breaks become ``
``. + """ + return ( + cell.replace("|", "\\|") + .replace("\r\n", "
") + .replace("\r", "
") + .replace("\n", "
") + ) + + def _md_table_row(cells: tuple[str, ...]) -> str: - return "| " + " | ".join(cells) + " |" + return "| " + " | ".join(_md_escape_cell(c) for c in cells) + " |" def _column_validity( @@ -362,7 +377,7 @@ def to_markdown(self) -> str: _md_table_row(("---", "---", "---", "---:"))] lines += [ _md_table_row((a.step, a.column or "—", - a.description.replace("|", "\\|"), f"{a.count:,}")) + a.description, f"{a.count:,}")) for a in rep.actions ] return "\n".join(lines) diff --git a/tests/test_enterprise_metrics.py b/tests/test_enterprise_metrics.py index 1fbf511e..a41f4ce7 100644 --- a/tests/test_enterprise_metrics.py +++ b/tests/test_enterprise_metrics.py @@ -15,7 +15,7 @@ clean_enterprise, compute_trust_score, ) -from freshdata.enterprise.metrics import ColumnTrust +from freshdata.enterprise.metrics import ColumnTrust, _md_table_row def test_clean_frame_scores_high(already_clean): @@ -139,6 +139,39 @@ def test_quality_report_without_actions_omits_action_table(already_clean): assert "## Actions" not in quality.to_markdown() +def test_md_table_row_escapes_pipe_and_newline(): + row = _md_table_row(("a|b", "one\ntwo", "c")) + # only the structural delimiters remain unescaped: 3 cells -> 4 pipes + assert row.count("|") - row.count("\\|") == 4 + assert "a\\|b" in row + assert "one
two" in row + assert "\n" not in row + + # carriage returns (bare and CRLF) are neutralised too + row2 = _md_table_row(("x\r\ny", "z\rw")) + assert "\r" not in row2 and "\n" not in row2 + assert "x
y" in row2 and "z
w" in row2 + + +def test_quality_report_actions_table_survives_pipe_in_column_name(): + # A column named "a|b" used to add an extra cell to its Actions row, + # shifting the table (issue #338). + df = pd.DataFrame({"a|b": [" x", "y ", "z", "w"], "k": [1, 2, 3, 4]}) + cleaned, report = fd.clean(df, return_report=True, verbose=False, column_names=False) + md = build_quality_report(df, cleaned, report).to_markdown() + lines = md.splitlines() + header = next(line for line in lines if line.startswith("| Step |")) + header_delims = header.count("|") + action_rows = [ + line for line in lines if line.startswith("| ") and "strip_whitespace" in line + ] + assert action_rows, "expected a strip_whitespace action row" + for row in action_rows: + # escaped pipes must not be counted as column delimiters + assert row.count("|") - row.count("\\|") == header_delims + assert "a\\|b" in row + + def test_quality_report_construct_directly_sets_generated_at(): score = compute_trust_score(pd.DataFrame({"a": [1, 2]})) _, report = fd.clean(pd.DataFrame({"a": [1, 2]}), return_report=True, verbose=False)