Skip to content

Commit 9d81904

Browse files
fix(enterprise): escape pipes and newlines in Markdown Actions table (#404)
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 "<br>". The manual per-description escape is removed so values are no longer double-escaped. Fixes #338 Co-authored-by: cnYui <xiaobianfuai@gmail.com>
1 parent c86251a commit 9d81904

3 files changed

Lines changed: 54 additions & 3 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,9 @@ adheres to [Semantic Versioning](https://semver.org/).
237237
### Fixed
238238
- Trust-gate integrations now validate `on_low_score` policies at configuration
239239
boundaries, rejecting typos instead of silently skipping failure handling (#345).
240+
- `QualityReport.to_markdown()` now escapes every cell in the Actions table, so
241+
a column name (or description) containing `|` or a newline no longer adds or
242+
splits table columns. Pipes become `\|` and line breaks become `<br>` (#338).
240243
- The minimum supported numpy is now 1.22. The numpy 1.21.6 wheel bundles an
241244
OpenBLAS that segfaults on BLAS-backed matrix multiplies on current Apple
242245
Silicon Macs regardless of `OPENBLAS_NUM_THREADS`, so installs at the old

‎src/freshdata/enterprise/metrics.py‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,8 +133,23 @@ def __repr__(self) -> str:
133133
return f"<TrustScore {self.overall:.1f}/100 grade={self.grade}>"
134134

135135

136+
def _md_escape_cell(cell: str) -> str:
137+
"""Neutralise Markdown table delimiters inside a single cell.
138+
139+
A literal ``|`` starts a new column and a newline ends the row, so a
140+
column name or description containing either would shift or split the
141+
row. Pipes are backslash-escaped and line breaks become ``<br>``.
142+
"""
143+
return (
144+
cell.replace("|", "\\|")
145+
.replace("\r\n", "<br>")
146+
.replace("\r", "<br>")
147+
.replace("\n", "<br>")
148+
)
149+
150+
136151
def _md_table_row(cells: tuple[str, ...]) -> str:
137-
return "| " + " | ".join(cells) + " |"
152+
return "| " + " | ".join(_md_escape_cell(c) for c in cells) + " |"
138153

139154

140155
def _column_validity(
@@ -362,7 +377,7 @@ def to_markdown(self) -> str:
362377
_md_table_row(("---", "---", "---", "---:"))]
363378
lines += [
364379
_md_table_row((a.step, a.column or "—",
365-
a.description.replace("|", "\\|"), f"{a.count:,}"))
380+
a.description, f"{a.count:,}"))
366381
for a in rep.actions
367382
]
368383
return "\n".join(lines)

‎tests/test_enterprise_metrics.py‎

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
clean_enterprise,
1616
compute_trust_score,
1717
)
18-
from freshdata.enterprise.metrics import ColumnTrust
18+
from freshdata.enterprise.metrics import ColumnTrust, _md_table_row
1919

2020

2121
def test_clean_frame_scores_high(already_clean):
@@ -139,6 +139,39 @@ def test_quality_report_without_actions_omits_action_table(already_clean):
139139
assert "## Actions" not in quality.to_markdown()
140140

141141

142+
def test_md_table_row_escapes_pipe_and_newline():
143+
row = _md_table_row(("a|b", "one\ntwo", "c"))
144+
# only the structural delimiters remain unescaped: 3 cells -> 4 pipes
145+
assert row.count("|") - row.count("\\|") == 4
146+
assert "a\\|b" in row
147+
assert "one<br>two" in row
148+
assert "\n" not in row
149+
150+
# carriage returns (bare and CRLF) are neutralised too
151+
row2 = _md_table_row(("x\r\ny", "z\rw"))
152+
assert "\r" not in row2 and "\n" not in row2
153+
assert "x<br>y" in row2 and "z<br>w" in row2
154+
155+
156+
def test_quality_report_actions_table_survives_pipe_in_column_name():
157+
# A column named "a|b" used to add an extra cell to its Actions row,
158+
# shifting the table (issue #338).
159+
df = pd.DataFrame({"a|b": [" x", "y ", "z", "w"], "k": [1, 2, 3, 4]})
160+
cleaned, report = fd.clean(df, return_report=True, verbose=False, column_names=False)
161+
md = build_quality_report(df, cleaned, report).to_markdown()
162+
lines = md.splitlines()
163+
header = next(line for line in lines if line.startswith("| Step |"))
164+
header_delims = header.count("|")
165+
action_rows = [
166+
line for line in lines if line.startswith("| ") and "strip_whitespace" in line
167+
]
168+
assert action_rows, "expected a strip_whitespace action row"
169+
for row in action_rows:
170+
# escaped pipes must not be counted as column delimiters
171+
assert row.count("|") - row.count("\\|") == header_delims
172+
assert "a\\|b" in row
173+
174+
142175
def test_quality_report_construct_directly_sets_generated_at():
143176
score = compute_trust_score(pd.DataFrame({"a": [1, 2]}))
144177
_, report = fd.clean(pd.DataFrame({"a": [1, 2]}), return_report=True, verbose=False)

0 commit comments

Comments
 (0)