Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
31 changes: 23 additions & 8 deletions datacompy/cli/backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,20 +48,19 @@

#: File extension to canonical format name.
#:
#: ``.tsv`` is deliberately absent. Mapping it to ``csv`` would pick the right
#: reader but not the right delimiter, which stays comma unless
#: ``--csv-delimiter`` says otherwise, so a ``.tsv`` file would be recognised
#: and then misparsed. Until the delimiter is inferred per file, a tab
#: separated file is read with an explicit ``--input-format csv``.
_EXTENSION_FORMATS = {
".csv": "csv",
".tab": "csv",
".tsv": "csv",
".parquet": "parquet",
".pq": "parquet",
".json": "json",
".jsonl": "json",
".ndjson": "json",
}

_DELIMITER_EXTENSIONS = {".tab": "\t", ".tsv": "\t"}

_NDJSON_EXTENSIONS = frozenset({".jsonl", ".ndjson"})

#: A two or three part dotted Snowflake identifier, e.g. ``DB.SCHEMA.TABLE``.
Expand Down Expand Up @@ -100,6 +99,18 @@ def infer_format(ref: str, override: str | None) -> str:
) from None


def infer_delimiter(ref: str, override: str | None) -> str:
"""Return the field delimiter for *ref*.

An explicit ``--csv-delimiter`` takes precedence over extension based
inference. CSV files and paths with no recognised delimiter extension use
a comma.
"""
if override is not None:
return override
return _DELIMITER_EXTENSIONS.get(Path(ref).suffix.lower(), ",")


def _is_ndjson(ref: str) -> bool:
"""Return ``True`` when *ref* looks like newline delimited JSON."""
return Path(ref).suffix.lower() in _NDJSON_EXTENSIONS
Expand Down Expand Up @@ -203,7 +214,9 @@ def load(
fmt = infer_format(ref, namespace.input_format)
try:
if fmt == "csv":
return pd.read_csv(ref, sep=namespace.csv_delimiter)
return pd.read_csv(
ref, sep=infer_delimiter(ref, namespace.csv_delimiter)
)
if fmt == "parquet":
return pd.read_parquet(ref)
return pd.read_json(ref, lines=_is_ndjson(ref))
Expand All @@ -227,7 +240,9 @@ def load(
fmt = infer_format(ref, namespace.input_format)
try:
if fmt == "csv":
return pl.read_csv(ref, separator=namespace.csv_delimiter)
return pl.read_csv(
ref, separator=infer_delimiter(ref, namespace.csv_delimiter)
)
if fmt == "parquet":
return pl.read_parquet(ref)
if _is_ndjson(ref):
Expand Down Expand Up @@ -296,7 +311,7 @@ def load(self, session: Any, ref: str, namespace: argparse.Namespace) -> Any:
ref,
header=True,
inferSchema=True,
sep=namespace.csv_delimiter,
sep=infer_delimiter(ref, namespace.csv_delimiter),
)
if fmt == "parquet":
return session.read.parquet(ref)
Expand Down
1 change: 0 additions & 1 deletion datacompy/cli/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,6 @@ def _resolve_dataset_name(
),
group=GROUP_INPUT,
backends=FILE_BACKENDS,
default=",",
options={"type": single_char, "metavar": "CHAR"},
),
Opt(
Expand Down
16 changes: 5 additions & 11 deletions docs/source/cli.rst
Original file line number Diff line number Diff line change
Expand Up @@ -65,23 +65,17 @@ any extra flags:

datacompy compare --left snapshot.csv --right snapshot.parquet --on id

The extensions recognised are ``.csv``, ``.parquet``, ``.json``, ``.jsonl``,
and ``.ndjson``. Use ``--input-format`` when the extension is missing or
unusual, and ``--csv-delimiter`` for anything other than a comma:
The extensions recognised are ``.csv``, ``.tsv``, ``.tab``, ``.parquet``,
``.json``, ``.jsonl``, and ``.ndjson``. Tab separated files are detected from
their extension, including when compared with a comma separated CSV. Use
``--input-format`` when the extension is missing or unusual, and
``--csv-delimiter`` to override delimiter inference for both inputs:

.. code-block:: bash

datacompy compare --left extract.dat --right extract2.dat --on id \
--input-format csv --csv-delimiter '\t'

.. note::

``--csv-delimiter`` applies to both datasets, and ``.tsv`` is not inferred
as a format for that reason. Read tab separated files with an explicit
``--input-format csv --csv-delimiter '\t'``, which requires both sides to
use the same delimiter. Comparing a comma separated file against a tab
separated one is not currently supported.

Cloud URIs such as ``s3://``, ``gs://``, and ``abfs://`` are handed straight to
the underlying reader, so they work once the matching filesystem library
(``s3fs``, ``gcsfs``, ``adlfs``) is installed.
Expand Down
65 changes: 41 additions & 24 deletions tests/cli/test_compare.py
Original file line number Diff line number Diff line change
Expand Up @@ -586,37 +586,54 @@ def test_custom_csv_delimiter(tmp_path, left_frame, backend, capsys):
)


def test_tsv_extension_is_not_inferred(cli, tmp_path, left_frame, backend, capsys):
"""``.tsv`` is not in the extension table, so it asks for an explicit format.

Inferring it as CSV would pick the right reader and the wrong delimiter,
since ``--csv-delimiter`` applies to both sides at once and defaults to a
comma. Failing with a message that names the flag beats parsing the file
into a single mangled column.
"""
left = tmp_path / "left.tsv"
right = tmp_path / "right.tsv"
@pytest.mark.parametrize("suffix", ["tsv", "tab"])
def test_tab_separated_extensions_are_inferred(
tmp_path, left_frame, backend, capsys, suffix
):
left = tmp_path / f"left.{suffix}"
right = tmp_path / f"right.{suffix}"
left_frame.to_csv(left, index=False, sep="\t")
left_frame.to_csv(right, index=False, sep="\t")

assert (
cli("--left", str(left), "--right", str(right), "--backend", backend) == ERROR
main(
[
"compare",
"--left",
str(left),
"--right",
str(right),
"--on",
"id",
"--backend",
backend,
]
)
== MATCH
)
assert "--input-format" in capsys.readouterr().err

# Forcing both the format and the delimiter still works.

def test_mixed_csv_and_tsv_delimiters_are_inferred_per_file(
tmp_path, left_frame, backend, capsys
):
left = tmp_path / "left.csv"
right = tmp_path / "right.tsv"
left_frame.to_csv(left, index=False)
left_frame.to_csv(right, index=False, sep="\t")

assert (
cli(
"--left",
str(left),
"--right",
str(right),
"--input-format",
"csv",
"--csv-delimiter",
r"\t",
"--backend",
backend,
main(
[
"compare",
"--left",
str(left),
"--right",
str(right),
"--on",
"id",
"--backend",
backend,
]
)
== MATCH
)
Expand Down
23 changes: 23 additions & 0 deletions tests/cli/test_spark.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,29 @@ def test_spark_matches_identical_files(left_csv, tmp_path, left_frame, capsys):
assert exit_code == MATCH


def test_spark_infers_mixed_csv_and_tsv_delimiters(tmp_path, left_frame, capsys):
csv_path = tmp_path / "left.csv"
tsv_path = tmp_path / "right.tsv"
left_frame.to_csv(csv_path, index=False)
left_frame.to_csv(tsv_path, index=False, sep="\t")

exit_code = main(
[
"compare",
"--left",
str(csv_path),
"--right",
str(tsv_path),
"--on",
"id",
"--backend",
"spark",
]
)

assert exit_code == MATCH


def test_spark_session_is_stopped_even_when_loading_fails(
no_borrowed_session, tmp_path, capsys
):
Expand Down
Loading