Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<br>` (#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
Expand Down
19 changes: 17 additions & 2 deletions src/freshdata/enterprise/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,23 @@ def __repr__(self) -> str:
return f"<TrustScore {self.overall:.1f}/100 grade={self.grade}>"


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 ``<br>``.
"""
return (
cell.replace("|", "\\|")
.replace("\r\n", "<br>")
.replace("\r", "<br>")
.replace("\n", "<br>")
)


def _md_table_row(cells: tuple[str, ...]) -> str:
return "| " + " | ".join(cells) + " |"
return "| " + " | ".join(_md_escape_cell(c) for c in cells) + " |"


def _column_validity(
Expand Down Expand Up @@ -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)
Expand Down
35 changes: 34 additions & 1 deletion tests/test_enterprise_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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<br>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<br>y" in row2 and "z<br>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)
Expand Down
Loading