-
Notifications
You must be signed in to change notification settings - Fork 54
human_text: escape inner double quotes and backslashes in change and unchanged values #48
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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: | ||
| 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] | ||
|
|
@@ -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"]] | ||
|
|
@@ -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)) | ||
|
|
@@ -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): | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 also, consider naming this |
||
| """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(): | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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(): | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. good tests. consider also adding a direct unit test for |
||
| # 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") | ||
|
|
||
|
|
||
|
|
||
There was a problem hiding this comment.
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.