Skip to content
Open
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
26 changes: 23 additions & 3 deletions csv_diff/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ def load_csv(fp, key=None, dialect=None):
# Oh well, we tried. Fallback to the default.
pass
fp = csv.reader(fp, dialect=(dialect or "excel"))
headings = next(fp)
try:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this fix handles an empty file (no header at all), but the same stopiteration crash also happens in compare() at line 64 when a file has a header row but zero data rows. that's a separate issue from this pr's stated goal of escaping quotes.

headings = next(fp)
except StopIteration:
raise ValueError("CSV input is empty (no header row found)")
rows = [dict(zip(headings, line)) for line in fp]
if key:
keyfn = lambda r: r[key]
Expand Down Expand Up @@ -149,7 +152,11 @@ def human_text(result, key=None, singular=None, plural=None, current=None, extra
block.append(" {}: {}".format(key, details["key"]))
for field, (prev_value, current_value) in details["changes"].items():
block.append(
' {}: "{}" => "{}"'.format(field, prev_value, current_value)
" {}: {} => {}".format(
field,
_format_quoted(prev_value),
_format_quoted(current_value),
)
)
if extras:
current_item = current[details["key"]]
Expand All @@ -160,7 +167,7 @@ def human_text(result, key=None, singular=None, plural=None, current=None, extra
block = []
block.append(" Unchanged:")
for field, value in details["unchanged"].items():
block.append(' {}: "{}"'.format(field, value))
block.append(" {}: {}".format(field, _format_quoted(value)))
block.append("")
change_blocks.append("\n".join(block))
summary.append("\n".join(change_blocks))
Expand Down Expand Up @@ -197,6 +204,19 @@ def human_text(result, key=None, singular=None, plural=None, current=None, extra
return (", ".join(title) + "\n\n" + ("\n".join(summary))).strip()


def _format_quoted(value):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this helper escapes backslash before double quote, which is the correct order. but it doesn't handle values with embedded newlines - if a csv/json value contains \n, the rendered output would have broken line formatting. not a regression (the old code had the same problem), but worth noting for a follow-up.

also, consider naming this _escape_and_quote_value for clarity.

"""Render value as a double-quoted string with internal quotes/backslashes escaped.

Values are rendered the same way for every path that prints them as
``"..."``: a backslash escapes any backslash, then any double quote, so the
output is unambiguous even when the value itself contains those characters.
Non-string values are stringified first so the helper is safe to call with
integers, floats, ``None``, or anything else produced by ``compare``.
"""
s = "" if value is None else str(value)
return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'


def human_row(row, prefix=""):
bits = []
for key, value in row.items():
Expand Down
41 changes: 41 additions & 0 deletions tests/test_human_text.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,3 +222,44 @@ def test_no_key():
).strip()
== human_text(diff)
)


def test_row_changed_escapes_inner_quotes_in_change_values():
# Regression: values containing double quotes used to render as
# name: "hello "world"" => "goodbye "cruel" world", where the
# internal quotes were indistinguishable from the wrapping quotes.
diff = compare(
load_csv(io.StringIO('id,name\na,"hello ""world"""\n'), key="id"),
load_csv(io.StringIO('id,name\na,"goodbye ""cruel"" world"\n'), key="id"),
)
expected = (
"1 row changed\n"
"\n"
" id: a\n"
' name: "hello \\"world\\"" => "goodbye \\"cruel\\" world"'
)
assert expected == human_text(diff, "id")



def test_row_changed_escapes_inner_quotes_in_unchanged_values():

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good tests. consider also adding a direct unit test for _format_quoted itself with edge cases like none, empty string, and strings with backslash+quote combos.

# Regression: unchanged rows with quote-containing values used to
# render as name: "has "quote"" which was ambiguous.
diff = compare(
load_csv(io.StringIO('id,tag,name\na,outer,"hello ""world"""\n'), key="id"),
load_csv(io.StringIO('id,tag,name\na,outer,"hello ""world"" v2"\n'), key="id"),
show_unchanged=True,
)
expected = (
"1 row changed\n"
"\n"
" id: a\n"
' name: "hello \\"world\\"" => "hello \\"world\\" v2"\n'
"\n"
" Unchanged:\n"
' tag: "outer"'
)
assert expected == human_text(diff, "id")