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
46 changes: 46 additions & 0 deletions src/freshdata/_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,52 @@ def add_column(df: pd.DataFrame, name: object, values: object) -> None:
PANDAS_MAJOR: int = int(pd.__version__.split(".")[0])


def require_unique_labels(frame: pd.DataFrame, func: str) -> None:
"""Reject duplicate column labels, which make ``frame[col]`` a DataFrame."""
if not frame.columns.is_unique:
duplicated = sorted({str(c) for c in frame.columns[frame.columns.duplicated()]})
raise ValueError(f"{func} requires unique column labels; duplicated: {duplicated}")


def _same_label(left: Any, right: Any) -> bool:
"""Label equality that matches two missing labels."""
if left is right:
return True
try:
if pd.isna(left) and pd.isna(right):
return True
except (TypeError, ValueError):
pass
try:
return bool(left == right)
except Exception: # noqa: BLE001 - exotic labels compare however they like
return False


def duplicated_mask(
df: pd.DataFrame, subset: Any = None, keep: Any = "first"
) -> pd.Series[bool]:
"""``df.duplicated`` for frames whose column labels may be missing (#461).

``Index([0, None])`` coerces to float64 with NaN, and pandas then fails to
match that label against the frame's own columns, so duplicate detection
raised ``KeyError`` on frames every other step handles. Addressing the
columns by position sidesteps the lookup entirely.
"""
labels = df.columns
if not labels.isna().any():
return df.duplicated(subset=subset, keep=keep)
work = df.set_axis(pd.RangeIndex(len(labels)), axis=1)
if subset is None:
return work.duplicated(keep=keep)
wanted = [
position
for position, label in enumerate(labels)
if any(_same_label(label, s) for s in subset)
]
return work.duplicated(subset=wanted, keep=keep)


def json_scalar(value: Any) -> Any:
"""One value in a JSON-representable form (``repr`` as a last resort).

Expand Down
17 changes: 10 additions & 7 deletions src/freshdata/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

from ._csv_io import leading_zero_dtypes
from ._reportframe import ReportFrame
from ._util import sanitize_csv_formulas
from ._util import require_unique_labels, sanitize_csv_formulas
from .adapters.polars import from_pandas, to_pandas
from .cleaner import Cleaner, run_pipeline
from .config import CleanConfig, merge_options
Expand Down Expand Up @@ -1152,11 +1152,8 @@ def _engine_mode(cfg: CleanConfig) -> EngineMode:
return "balanced" if mode == "balanced" else "aggressive"


def _require_unique_labels(frame: pd.DataFrame, func: str) -> None:
"""Reject duplicate column labels, which make ``frame[col]`` a DataFrame."""
if not frame.columns.is_unique:
duplicated = sorted({str(c) for c in frame.columns[frame.columns.duplicated()]})
raise ValueError(f"{func} requires unique column labels; duplicated: {duplicated}")
#: Shared with the other entry points that index frames by label.
_require_unique_labels = require_unique_labels


def infer_roles(
Expand Down Expand Up @@ -1217,7 +1214,13 @@ def infer_roles(
),
}
)
return ReportFrame.wrap(pd.DataFrame(rows), "infer_roles")
out = pd.DataFrame(rows)
if rows:
# Collecting the labels into a Series coerces a mixed numeric/None set
# (0 and None become 0.0 and NaN), so frame[label] no longer round-trips
# (#462). Object dtype keeps every label exactly as it came in.
out["column"] = pd.Series([r["column"] for r in rows], dtype=object)
return ReportFrame.wrap(out, "infer_roles")


def profile(
Expand Down
4 changes: 2 additions & 2 deletions src/freshdata/engine/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
import pandas as pd
from pandas.api.types import is_bool_dtype, is_datetime64_any_dtype, is_numeric_dtype

from .._util import _is_stringlike_dtype
from .._util import _is_stringlike_dtype, duplicated_mask
from ..config import CleanConfig
from ..steps.outliers import safe_skew

Expand Down Expand Up @@ -323,7 +323,7 @@ def build_contexts(
duplicated_rows = None
if stats is None and len(df) and columns:
try:
mask = df.duplicated()
mask = duplicated_mask(df)
except (TypeError, NotImplementedError): # unhashable cells / nested Arrow
mask = None
if mask is not None and mask.any():
Expand Down
2 changes: 2 additions & 0 deletions src/freshdata/plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from pandas.api.types import is_bool_dtype, is_numeric_dtype

from ._reportframe import ReportFrame
from ._util import require_unique_labels
from .cleaner import run_pipeline
from .config import CleanConfig, merge_options
from .engine.context import build_contexts
Expand Down Expand Up @@ -341,6 +342,7 @@ def suggest_plan(
:func:`freshdata.clean` does — user options and policy always win, and
severe schema drift disables the fold entirely.
"""
require_unique_labels(df, "suggest_plan")
if context is not None:
options["context"] = context
if policy is not None:
Expand Down
10 changes: 8 additions & 2 deletions src/freshdata/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,13 @@
import pandas as pd
from pandas.api.types import infer_dtype, is_bool_dtype, is_numeric_dtype

from ._util import _is_stringlike_dtype, format_bytes, json_scalar, memory_bytes
from ._util import (
_is_stringlike_dtype,
duplicated_mask,
format_bytes,
json_scalar,
memory_bytes,
)
from .config import CleanConfig
from .render.mixins import HtmlReprMixin
from .steps.dtypes import suggest_conversion
Expand Down Expand Up @@ -253,7 +259,7 @@ def build_profile(
duplicate_rows: int | None = None
else:
try:
duplicate_rows = int(work.duplicated().sum())
duplicate_rows = int(duplicated_mask(work).sum())
except (TypeError, NotImplementedError):
# Unhashable cells: object lists/dicts raise TypeError, nested Arrow
# dtypes (list/struct/map) raise ArrowNotImplementedError, a
Expand Down
7 changes: 4 additions & 3 deletions src/freshdata/steps/duplicates.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import pandas as pd
from pandas.api.types import is_bool_dtype, is_numeric_dtype

from .._util import duplicated_mask
from ..config import CleanConfig
from ..report import CleanReport

Expand Down Expand Up @@ -178,7 +179,7 @@ def drop_duplicate_rows(df: pd.DataFrame, config: CleanConfig,
return df
subset = _validated_subset(df, config)
try:
dup_any = df.duplicated(subset=subset, keep="first")
dup_any = duplicated_mask(df, subset=subset, keep="first")
except (TypeError, NotImplementedError): # nested Arrow: ArrowNotImplementedError
report.add("drop_duplicates",
"skipped: column(s) contain unhashable values (e.g. lists)")
Expand Down Expand Up @@ -223,9 +224,9 @@ def drop_duplicate_rows(df: pd.DataFrame, config: CleanConfig,
df, subset, protected=hard_protected_columns(config, df.columns)
)
if keep in ("first", "last"):
df = _filter_rows(df, ~df.duplicated(subset=subset, keep=keep))
df = _filter_rows(df, ~duplicated_mask(df, subset=subset, keep=keep))
elif keep == "drop":
df = _filter_rows(df, ~df.duplicated(subset=subset, keep=False))
df = _filter_rows(df, ~duplicated_mask(df, subset=subset, keep=False))

n_removed = n_before - len(df)
verb = {"first": "dropped", "last": "dropped", "drop": "dropped",
Expand Down
3 changes: 2 additions & 1 deletion src/freshdata/textclean.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@

import pandas as pd

from ._util import _is_stringlike_dtype
from ._util import _is_stringlike_dtype, require_unique_labels

__all__ = [
"TextCleanConfig",
Expand Down Expand Up @@ -313,6 +313,7 @@ def clean_text(
restricted via :func:`config_for_field` so e.g. punctuation stripping
never runs on an amount or identifier column.
"""
require_unique_labels(df, "clean_text")
if columns is None:
cols = [c for c in df.columns if _is_stringlike_dtype(df[c].dtype)]
else:
Expand Down
3 changes: 2 additions & 1 deletion src/freshdata/textlint.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

import pandas as pd

from ._util import stringlike_columns
from ._util import require_unique_labels, stringlike_columns
from .render import html as H
from .render.mixins import SimpleHtmlReport

Expand Down Expand Up @@ -266,6 +266,7 @@ def lint_text_encoding(
TextLintReport
"""
hints = list(locale_hints or [])
require_unique_labels(df, "lint_text_encoding")
if columns is None:
cols = list(stringlike_columns(df))
else:
Expand Down
89 changes: 89 additions & 0 deletions tests/test_label_robustness.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""Column labels that pandas itself handles awkwardly: missing, coerced, repeated.

Regressions for #459 (suggest_plan on duplicate labels), #461 (numeric-or-None
labels), #462 (label identity in infer_roles) and #437 (the text entry points).
"""

from __future__ import annotations

import warnings

import pandas as pd
import pytest

import freshdata as fd

warnings.simplefilter("ignore")


# ── #461: a NaN column label must not break duplicate detection ────────────────


def _nan_label_frame() -> pd.DataFrame:
# pandas coerces Index([0, None]) to float64, so the second label is NaN and
# cannot be looked up by value — DataFrame.duplicated() raised KeyError.
return pd.DataFrame({0: [1, 2, 1], None: [3, 4, 3]})


@pytest.mark.parametrize("call", [
lambda df: fd.clean(df, verbose=False),
fd.profile,
fd.infer_roles,
fd.explain_clean,
])
def test_numeric_or_none_labels_are_accepted(call):
assert call(_nan_label_frame()) is not None


def test_duplicate_rows_are_still_detected_with_a_nan_label():
_, report = fd.clean(
_nan_label_frame(), drop_duplicates=True, return_report=True, verbose=False
)
assert any(a.step == "drop_duplicates" and a.count == 1 for a in report.actions)


def test_duplicate_subset_still_applies_with_a_nan_label():
df = pd.DataFrame({0: [1, 1, 2], None: [9, 8, 7]})
out = fd.clean(
df, drop_duplicates=True, duplicate_subset=[0.0], verbose=False
)
assert len(out) == 2 # deduplicated on the first column only


# ── #462: infer_roles reports the labels the frame actually has ────────────────


@pytest.mark.parametrize("labels", [[0, None], [-2, 0.78], ["a", 1]])
def test_infer_roles_keeps_label_identity(labels):
df = pd.DataFrame([[1, 2], [3, 4]])
df.columns = pd.Index(labels, dtype=object)
reported = fd.infer_roles(df)["column"].tolist()
assert reported == sorted(labels, key=str) # rows are ordered by label text
for label in reported:
assert df[label].shape == (2,) # the documented round-trip


# ── #459 / #437: duplicate labels raise the same error everywhere ──────────────


@pytest.mark.parametrize("call", [
fd.suggest_plan,
fd.plan,
fd.clean_text,
fd.lint_text_encoding,
fd.infer_roles,
])
def test_duplicate_labels_raise_value_error(call):
df = pd.DataFrame([[1, 2], [3, 4]], columns=["a", "a"])
with pytest.raises(ValueError, match="requires unique column labels"):
call(df)
assert df.columns.tolist() == ["a", "a"] # never modified


def test_unique_labels_still_work_on_those_entry_points():
df = pd.DataFrame({"a": [" x ", "y"], "n": [1, 2]})
assert fd.suggest_plan(df) is not None
assert fd.plan(df) is not None
cleaned, _ = fd.clean_text(df)
assert cleaned["a"].tolist() == ["x", "y"]
assert fd.lint_text_encoding(df) is not None
Loading