diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8cc50e0..f39bc2c 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 0bd358b..f4ea419 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 1fbf511..a41f4ce 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)