Skip to content

Commit badba82

Browse files
fix: report revert, outlier NA mask, pyarrow error message
- CleanReport.revert() wrote reverted values into the caller's frame: the shallow copy's object columns still share memory with the input, and the .loc assignment landed in it. Revert now works on its own column copy. - detect_outliers() returned a mask with <NA> on nullable numeric columns and remove_outliers() then dropped rows that were only missing. Missing cells are now "not an outlier" and the mask is plain bool. - remove_outliers()/resolve_duplicates() with inplace=True dropped by label, so a duplicated index also removed unflagged rows; they now raise a clear ValueError instead of over-dropping (the copy path is unchanged). - require_pyarrow() hard-coded "Reading Parquet metadata" even for output_format="arrow"; it now names the feature. from_parquet_path() also relied on pyarrow.parquet having been imported elsewhere and raised AttributeError in a fresh process; it imports the submodule itself. Closes #207 Closes #211 Closes #215
1 parent 55a8044 commit badba82

8 files changed

Lines changed: 155 additions & 10 deletions

File tree

‎src/freshdata/execution/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ def _convert_output(frame: Any, output_format: str) -> Any:
124124
if output_format == "arrow":
125125
from ._lazy import require_pyarrow
126126

127-
require_pyarrow()
127+
require_pyarrow("Arrow output (output_format='arrow')")
128128
import pyarrow as pa
129129

130130
if isinstance(frame, pa.Table):

‎src/freshdata/execution/_lazy.py‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,17 @@ def require_duckdb() -> Any:
3535
return duckdb
3636

3737

38-
def require_pyarrow() -> Any:
39-
"""Return the imported :mod:`pyarrow` module or raise a helpful error."""
38+
def require_pyarrow(purpose: str = "This feature") -> Any:
39+
"""Return the imported :mod:`pyarrow` module or raise a helpful error.
40+
41+
*purpose* names what needs pyarrow (e.g. ``"Arrow output"``) so the error
42+
points at the feature the caller actually used.
43+
"""
4044
try:
4145
import pyarrow # noqa: F401
42-
except ImportError as exc: # pragma: no cover - exercised via message
46+
except ImportError as exc:
4347
raise ImportError(
44-
"Reading Parquet metadata requires pyarrow. "
48+
f"{purpose} requires pyarrow. "
4549
"Install it with: pip install 'freshdata-cleaner[pyarrow]'"
4650
) from exc
4751
return pyarrow

‎src/freshdata/execution/_metadata.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,12 @@ def from_parquet_path(path: str) -> list[ColumnMetadata]:
174174
The footer gives the row count for free (no data scan); DuckDB streams the
175175
file for null/value statistics without loading it into Python.
176176
"""
177-
n_rows = require_pyarrow().parquet.read_metadata(path).num_rows
177+
require_pyarrow("Reading Parquet metadata")
178+
# ``pyarrow.parquet`` is a submodule: importing ``pyarrow`` alone does
179+
# not expose it as an attribute.
180+
import pyarrow.parquet as pq
181+
182+
n_rows = pq.read_metadata(path).num_rows
178183
duckdb = require_duckdb()
179184
escaped = path.replace("'", "''")
180185
conn = duckdb.connect()

‎src/freshdata/report.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -446,7 +446,9 @@ def revert(
446446
for column, column_entries in touched.items():
447447
if column not in out.columns:
448448
continue
449-
series = out[column]
449+
# Own copy: an object column of a shallow copy still shares memory
450+
# with the caller's frame, and ``.loc`` below would write into it.
451+
series = out[column].copy()
450452
if series.dtype != object:
451453
series = series.astype(object)
452454
for entry in column_entries:

‎src/freshdata/simple.py‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,10 +110,29 @@ def _outlier_mask(
110110
lo, hi = mean - factor * std, mean + factor * std
111111
if pd.isna(lo) or pd.isna(hi) or lo == hi:
112112
continue # all-null / constant column -- nothing to flag
113-
mask |= (s < lo) | (s > hi)
113+
# Nullable dtypes compare to <NA> on missing cells; missing is not an outlier.
114+
mask |= ((s < lo) | (s > hi)).fillna(False).astype(bool)
114115
return mask
115116

116117

118+
def _drop_rows_inplace(df: pd.DataFrame, mask: pd.Series, func: str) -> None:
119+
"""Drop the rows flagged by *mask* from *df* in place.
120+
121+
``DataFrame.drop`` works by label, so on a non-unique index it would also
122+
remove unflagged rows sharing a label with a flagged one. Refuse instead of
123+
silently over-dropping.
124+
"""
125+
if not mask.any():
126+
return
127+
if not df.index.is_unique:
128+
raise ValueError(
129+
f"{func}(inplace=True) requires a unique index; dropping by label would "
130+
"also remove unflagged rows that share a label. Use inplace=False or "
131+
"reset the index first."
132+
)
133+
df.drop(index=df.index[mask.to_numpy()], inplace=True)
134+
135+
117136
def fill_missing(
118137
df: pd.DataFrame,
119138
columns: str | Sequence[str] | None = None,
@@ -216,7 +235,7 @@ def remove_outliers(
216235
cols = _resolve_columns(df, columns, numeric_only=True)
217236
mask = _outlier_mask(df, cols, method, threshold)
218237
if inplace:
219-
df.drop(index=df.index[mask], inplace=True)
238+
_drop_rows_inplace(df, mask, "remove_outliers")
220239
result = df
221240
else:
222241
result = df.loc[~mask]
@@ -246,7 +265,7 @@ def resolve_duplicates(
246265
keep: str | bool = False if method == "drop" else method
247266
drop_mask = df.duplicated(subset=subset, keep=keep)
248267
if inplace:
249-
df.drop(index=df.index[drop_mask], inplace=True)
268+
_drop_rows_inplace(df, drop_mask, "resolve_duplicates")
250269
result = df
251270
else:
252271
result = df.loc[~drop_mask]
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""pyarrow requirement errors name the feature that needs it (#215)."""
2+
3+
from __future__ import annotations
4+
5+
import subprocess
6+
import sys
7+
8+
import pandas as pd
9+
import pytest
10+
11+
import freshdata as fd
12+
from freshdata.execution._lazy import require_pyarrow
13+
from freshdata.execution._metadata import MetadataScanner
14+
15+
16+
@pytest.fixture
17+
def no_pyarrow(monkeypatch):
18+
# A ``None`` entry makes ``import pyarrow`` (and its submodules) raise ImportError.
19+
monkeypatch.setitem(sys.modules, "pyarrow", None)
20+
21+
22+
def test_arrow_output_error_names_arrow_output(no_pyarrow):
23+
with pytest.raises(ImportError) as exc:
24+
fd.clean(pd.DataFrame({"a": [1.0, None]}), output_format="arrow", verbose=False)
25+
message = str(exc.value)
26+
assert "Arrow output" in message
27+
assert "Parquet" not in message
28+
assert "freshdata-cleaner[pyarrow]" in message
29+
30+
31+
def test_parquet_metadata_error_names_parquet(no_pyarrow):
32+
with pytest.raises(ImportError, match="Reading Parquet metadata requires pyarrow"):
33+
MetadataScanner.from_parquet_path("missing.parquet")
34+
35+
36+
def test_default_purpose(no_pyarrow):
37+
with pytest.raises(ImportError, match="This feature requires pyarrow"):
38+
require_pyarrow()
39+
40+
41+
def test_parquet_metadata_in_fresh_process(tmp_path):
42+
# ``pyarrow.parquet`` is not an attribute of a bare ``import pyarrow``; the
43+
# scanner must import the submodule itself rather than rely on a caller.
44+
pytest.importorskip("pyarrow")
45+
pytest.importorskip("duckdb")
46+
path = tmp_path / "x.parquet"
47+
pd.DataFrame({"a": [1.0, None, 3.0]}).to_parquet(path)
48+
code = (
49+
"from freshdata.execution._metadata import MetadataScanner as M;"
50+
f"m = M.from_parquet_path({str(path)!r})[0];"
51+
"print(m.row_count, m.non_null_count)"
52+
)
53+
result = subprocess.run(
54+
[sys.executable, "-c", code], capture_output=True, text=True, check=False
55+
)
56+
assert result.returncode == 0, result.stderr
57+
assert result.stdout.split() == ["3", "2"]

‎tests/test_report.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,33 @@ def test_revert_restores_values_and_skips_missing_columns():
241241
assert "missing_column" not in restored.columns
242242

243243

244+
def test_revert_does_not_mutate_input_object_column():
245+
report = fd.CleanReport()
246+
report.undo_log = {
247+
"entries": [{"action_id": "a1", "column": "email", "index": [0], "value": " A@X.COM"}],
248+
"column_dtypes": {"email": "object"},
249+
}
250+
df = pd.DataFrame({"email": ["a@x.com", "b@y.org"]})
251+
snapshot = df.copy(deep=True)
252+
253+
restored = report.revert(df)
254+
255+
pd.testing.assert_frame_equal(df, snapshot)
256+
assert restored["email"].tolist() == [" A@X.COM", "b@y.org"]
257+
258+
259+
def test_revert_after_apply_plan_leaves_cleaned_frame_untouched():
260+
df = pd.DataFrame({"email": [" A@X.COM", "b@y.org", "C@Z.COM "], "n": [1, 2, 3]})
261+
plan = fd.suggest_plan(df, context="email must be a valid email.")
262+
cleaned, report = fd.apply_plan(df, plan, keep_undo=True)
263+
snapshot = cleaned.copy(deep=True)
264+
265+
restored = report.revert(cleaned)
266+
267+
pd.testing.assert_frame_equal(cleaned, snapshot)
268+
assert restored["email"].tolist() == df["email"].tolist()
269+
270+
244271
def test_action_str_format():
245272
action = fd.Action(step="impute", column="age", description="filled 2", count=2)
246273
assert str(action) == "[impute] 'age': filled 2"

‎tests/test_simple.py‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,37 @@ def test_remove_outliers_inplace():
156156
assert len(df) == 5
157157

158158

159+
def _nullable_with_gap():
160+
return pd.DataFrame(
161+
{"x": pd.array([10, 11, 12, 13, 12, 11, 10, 1000, None], dtype="Int64")}
162+
)
163+
164+
165+
def test_detect_outliers_nullable_mask_is_plain_bool():
166+
mask = fd.detect_outliers(_nullable_with_gap())
167+
assert mask.dtype == bool
168+
assert mask.tolist()[-2:] == [True, False] # missing is not an outlier
169+
170+
171+
@pytest.mark.parametrize("inplace", [False, True])
172+
def test_remove_outliers_keeps_missing_rows_in_nullable_columns(inplace):
173+
df = _nullable_with_gap()
174+
expected = fd.remove_outliers(df.astype("float64")).index.tolist()
175+
out = fd.remove_outliers(df, inplace=inplace)
176+
assert out.index.tolist() == expected == [0, 1, 2, 3, 4, 5, 6, 8]
177+
178+
179+
def test_remove_outliers_inplace_rejects_duplicate_index():
180+
df = pd.DataFrame({"x": [1, 2, 3, 4, 5, 1000]}, index=[0, 1, 2, 3, 4, 0])
181+
with pytest.raises(ValueError, match="unique index"):
182+
fd.remove_outliers(df, inplace=True)
183+
assert len(df) == 6 # untouched
184+
assert fd.remove_outliers(df).index.tolist() == [0, 1, 2, 3, 4]
185+
# nothing flagged -> nothing to drop, so a duplicate index is fine
186+
calm = pd.DataFrame({"x": [1, 2, 3]}, index=[0, 0, 1])
187+
assert fd.remove_outliers(calm, inplace=True) is calm
188+
189+
159190
def test_remove_outliers_bad_method_raises():
160191
with pytest.raises(ValueError, match="method must be one of"):
161192
fd.remove_outliers(pd.DataFrame({"x": [1]}), method="nope")

0 commit comments

Comments
 (0)