Skip to content
Merged
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
63 changes: 0 additions & 63 deletions .github/copilot-instructions.md

This file was deleted.

2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,6 @@ repos:
- id: end-of-file-fixer
exclude: ^tests/snapshots/
- repo: https://github.com/tox-dev/pyproject-fmt
rev: "v2.5.0"
rev: "v2.26.0"
hooks:
- id: pyproject-fmt
66 changes: 54 additions & 12 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

## Project Overview

DataComPy is a Python library for comparing two DataFrames/tables across multiple backends: Pandas, Polars, Spark, and Snowflake. It originated as a replacement for SAS's `PROC COMPARE`. Currently at v1.0.0 beta (pre-release on the `develop` branch).
DataComPy is a Python library for comparing two DataFrames/tables across multiple backends: Pandas, Polars, Spark, and Snowflake. It originated as a replacement for SAS's `PROC COMPARE`. v1 is GA; the version lives in `datacompy/__version__` and `pyproject.toml` derives the distribution version from it.

This file is the single AI agent guide for the repository. `.github/copilot-instructions.md` used to duplicate it and was removed; put new guidance here rather than starting a second copy.

## Common Commands

Expand All @@ -16,13 +18,24 @@ pre-commit install

### Testing
```bash
pytest # all tests
pytest tests/test_pandas.py # single backend
pytest tests/test_pandas.py::TestPandasCompare::test_method # single test
pytest --cov=datacompy --cov-report=term-missing # with coverage
pytest # all tests
pytest tests/test_pandas.py # single backend
pytest tests/test_pandas.py::test_numeric_columns_equal_abs # single test
pytest -k "tolerance and not spark" # by expression
pytest --cov=datacompy --cov-report=term-missing # with coverage
pytest -c pytest-ansi.ini # Spark ANSI mode
```

Spark tests require Java 17 and `pyspark` installed. Snowflake tests require a live Snowflake session (or `--snowflake-session local` for local testing).
CI runs the suite twice, once with the default `pytest.ini` and once with `-c pytest-ansi.ini`, which only differs by `spark.sql.ansi.enabled`. A change touching Spark casting or null handling needs both.

**Coverage:** always target the top-level package (`--cov=datacompy`). Passing a dotted submodule such as `--cov=datacompy.cli` triggers a numpy double-import in some environments and fails ~100 otherwise-passing tests with a confusing `_NoValueType` `TypeError`.

**Spark** needs `pyspark` and **Java 17** (newer JDKs fail with `py4j.protocol` errors). If the JDK came from conda (`conda install openjdk=17`, as `[edgetest.envs.core]` does), it is at `$CONDA_PREFIX/lib/jvm` and `JAVA_HOME` must point there. Activating the env normally sets this; a non-interactive shell will not inherit it:
```bash
export JAVA_HOME=$CONDA_PREFIX/lib/jvm
```

**Snowflake** tests need a live session, or `--snowflake-session local` for Snowpark's local testing mode. Local mode is an emulator, not Snowflake: `eqNullSafe` returns `True` for every row and high-precision decimals are truncated on DataFrame creation. Tests that depend on either must request the `requires_live_snowflake_session` fixture (`tests/conftest.py`), which skips them in local mode.

### Linting & Formatting
```bash
Expand All @@ -33,6 +46,10 @@ ruff format # apply formatting
mypy . # type-check (strict mode)
```

**Use the `ruff` version pinned in `.pre-commit-config.yaml`.** The config uses recent selectors, and an older ruff fails to parse `pyproject.toml` at all rather than degrading gracefully. `pre-commit run --all-files` fetches the right version itself.

**`mypy .` has a large pre-existing error baseline** (~185, mostly in `snowflake.py`, `polars.py`, and `pandas.py`) and is enforced by neither CI nor pre-commit; CI lint runs only `ruff check` and `ruff format --check`. New code is still expected to be clean, so check that your diff introduces no *new* errors rather than that the run is empty, and do not refactor unrelated modules to chase the baseline. Missing-stub errors for `pyspark` and `snowflake.snowpark` mean those extras are not installed in the current environment, not a code defect.

### Documentation
```bash
make sphinx # build docs (runs in docs/ subdirectory)
Expand All @@ -44,15 +61,17 @@ make sphinx # build docs (runs in docs/ subdirectory)

The core design uses the **Strategy pattern** with two abstraction layers:

1. **`BaseCompare`** (`datacompy/base.py`) ABC defining the comparison interface. All backends implement: `_compare`, `_dataframe_merge`, `_intersect_compare`, `report`, `matches`, `subset`, `sample_mismatch`, `all_mismatch`, etc.
1. **`BaseCompare`** (`datacompy/base.py`), the ABC defining the comparison interface. All backends implement: `_compare`, `_dataframe_merge`, `_intersect_compare`, `report`, `matches`, `subset`, `sample_mismatch`, `all_mismatch`, etc.

2. **Backend implementations** — Each in its own module:
2. **Backend implementations**, each in its own module:
- `datacompy/pandas.py` → `PandasCompare`
- `datacompy/polars.py` → `PolarsCompare`
- `datacompy/spark.py` → `SparkSQLCompare`
- `datacompy/snowflake.py` → `SnowflakeCompare`

Spark and Snowflake are optional imports (try/except in `__init__.py`).
Spark and Snowflake are optional imports (try/except in `__init__.py`), so `datacompy.SparkSQLCompare` simply does not exist when the extra is missing. Because that try/except runs at package import time, importing *any* datacompy submodule pulls in pyspark when it is installed; there is no lazy path around it.

Beyond the report, each backend exposes `df1_unq_rows`, `df2_unq_rows`, and `intersect_rows` for programmatic analysis, and `build_report_data()` returns a typed `ReportData` (`datacompy/report.py`) with `render()`, `to_html()`, `save()`, and `to_dict()`. Prefer these over parsing the string report.

### Comparator Subpackage

Expand All @@ -73,15 +92,38 @@ Reports use Jinja2 templates from `datacompy/templates/report_template.j2`. The

Tolerances (`abs_tol`, `rel_tol`) can be a single float (applied globally) or a dict mapping column names to per-column values. Validated by `validate_tolerance_parameter()` in `base.py`.

### Command Line Interface

`datacompy/cli/` implements the `datacompy` console script (entry point `datacompy.cli:main`, also reachable as `python -m datacompy`).

- `parser.py` holds the `OPTIONS` tuple, the **single source of truth** for the argument surface. Each `Opt` row records the argparse flags *and* the `*Compare` constructor keyword it maps to, so `build_parser()` and `backends.compare_kwargs()` are both generated from it. **Adding a library kwarg to the CLI is one new row.** Do not hand-write it in two places. `tests/cli/test_parser.py` asserts every `Opt.kwarg` against the real constructor signature via `inspect.signature`, so drift fails the build.
- Options are registered with `default=argparse.SUPPRESS` and defaults live on `Opt.default`. That is what makes `Opt.was_given()` meaningful. `validate_arguments()` must run **before** `fill_defaults()`.
- `backends.py` holds the `CLIBackend` ABC plus one implementation per backend, mirroring the `BaseCompare` strategy pattern. A backend owns its session, its loaders, and its constructor call. `pyspark` and `snowflake.snowpark` imports stay inside methods.
- Backend applicability is data (`Opt.backends`), not an `if` chain. An option passed with a backend that does not accept it is rejected generically.
- With `--backend snowflake`, `--left` / `--right` are **always** table references. There is deliberately no file-versus-table heuristic.
- Exit codes are the contract: `0` match, `1` mismatch, `2` error, `130` interrupt. Expected failures raise `CLIError` subclasses; anything else propagates as a traceback.

## Code Conventions

- **Typing**: All code must be fully type-hinted and pass `mypy --strict`
- **Docstrings**: NumPy style
- **Imports**: Only absolute imports (relative imports banned via ruff TID252)
- **Pre-commit hooks**: ruff (lint + format), trailing whitespace, debug statements, end-of-file fixer, pyproject-fmt

## Testing Conventions

- Write plain pytest functions, not class-based suites. Use `def test_*()` at module level.
- Do not group tests into `class Test*` unless the file already does so.

## Documentation Conventions

- Do not use em dashes in documentation or docstrings; rewrite the sentence instead.
- Do not use emojis in documentation, docstrings, or commit messages.

## Branching

- `develop` is the active development branch for v1
- `main` is the release branch
- `support/0.19.x` maintained for v0 users (bug fixes only)
- `main` is the release branch and currently the most advanced one. Recent release commits land here, so branch from `main` unless told otherwise.
- `develop` predates the v1 GA and lags `main`. Do not assume it is the integration branch without checking `git log origin/main origin/develop`.
- `support/0.19.x` is maintained for v0 users (bug fixes only).

CI (`.github/workflows/test-package.yml`) runs on `develop`, `main`, `release/*`, `release-*`, and `support/*`.
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,28 @@ pip install datacompy[snowflake]
- Snowflake/Snowpark: ([See documentation](https://capitalone.github.io/datacompy/snowflake_usage.html))


## Command Line Interface

DataComPy ships a `datacompy` command, so ad hoc checks and CI pipelines do not
need a throw-away script ([see documentation](https://capitalone.github.io/datacompy/cli.html)):

```bash
# Compare two files and print a report
datacompy compare --left before.csv --right after.csv --on id

# Fail a build on any difference, with a JSON report saved as an artifact
datacompy compare \
--left before.parquet --right after.parquet \
--on account_id,as_of_date \
--abs-tol balance=0.01 \
--max-unequal-rows 0 \
--report-format json --output reports/diff.json --quiet
```

It exits `0` when the datasets match, `1` when they differ, and `2` on error.
CSV, Parquet, and JSON inputs are supported on the pandas, polars, and Spark
backends, and Snowflake tables can be compared in place.

## Programmatic Report Access

Every compare object exposes `build_report_data()` which returns a typed
Expand Down
21 changes: 21 additions & 0 deletions datacompy/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#
# Copyright 2026 Capital One Services, LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Allow ``python -m datacompy`` to invoke the CLI."""

from datacompy.cli import main

if __name__ == "__main__":
raise SystemExit(main())
85 changes: 85 additions & 0 deletions datacompy/cli/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
#
# Copyright 2026 Capital One Services, LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

r"""DataComPy command line interface.

Invoked as ``datacompy`` once installed, or as ``python -m datacompy``.

Examples
--------
Compare two CSV files with the polars backend, which is the default:

.. code-block:: bash

datacompy compare --left before.csv --right after.csv --on id

Emit a machine readable report for a CI pipeline and rely on the exit code:

.. code-block:: bash

datacompy compare --left a.parquet --right b.parquet --on id,date \\
--report-format json --max-unequal-rows 0

Write an HTML report to a file:

.. code-block:: bash

datacompy compare --left a.csv --right b.csv --on id \\
--report-format html --output report.html
"""

import argparse
from collections.abc import Callable, Sequence

from datacompy.cli.compare import run_compare
from datacompy.cli.errors import CLIError
from datacompy.cli.output import print_error
from datacompy.cli.parser import build_parser

#: Subcommand name to handler. Adding a command is additive.
COMMANDS: dict[str, Callable[[argparse.Namespace], int]] = {"compare": run_compare}

__all__ = ["COMMANDS", "build_parser", "main"]


def main(argv: Sequence[str] | None = None) -> int:
"""Parse *argv*, dispatch the subcommand, and return the exit code.

Parameters
----------
argv : sequence of str, optional
Argument list. When ``None``, argparse reads :data:`sys.argv`.

Returns
-------
int
``0`` on a match, ``1`` on a mismatch, ``2`` on an expected error, and
``130`` on interrupt. Argparse exits with ``2`` itself on a parse
failure, before this function returns.
"""
parser = build_parser()
args = parser.parse_args(list(argv) if argv is not None else None)
debug = getattr(args, "debug", False)
try:
return COMMANDS[args.command](args)
except CLIError as exc:
if debug:
raise
print_error(str(exc))
return exc.exit_code
except KeyboardInterrupt:
print_error("interrupted")
return 130
# Anything else is an unexpected bug and propagates as a traceback.
21 changes: 21 additions & 0 deletions datacompy/cli/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#
# Copyright 2026 Capital One Services, LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Allow ``python -m datacompy.cli`` to invoke the CLI."""

from datacompy.cli import main

if __name__ == "__main__":
raise SystemExit(main())
Loading
Loading