smartclean-df 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- smartclean_df/__init__.py +6 -0
- smartclean_df/_io.py +90 -0
- smartclean_df/cli.py +99 -0
- smartclean_df/core.py +1083 -0
- smartclean_df/parsing.py +184 -0
- smartclean_df/result.py +102 -0
- smartclean_df-0.1.0.dist-info/METADATA +151 -0
- smartclean_df-0.1.0.dist-info/RECORD +11 -0
- smartclean_df-0.1.0.dist-info/WHEEL +4 -0
- smartclean_df-0.1.0.dist-info/entry_points.txt +2 -0
- smartclean_df-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""smartclean-df: turn a messy table into a tidy one in one call, and say what changed."""
|
|
2
|
+
from .core import FORWARD_FILL, Cleaner, clean
|
|
3
|
+
from .result import Action, CleanResult
|
|
4
|
+
|
|
5
|
+
__version__ = "0.1.0"
|
|
6
|
+
__all__ = ["clean", "Cleaner", "CleanResult", "Action", "FORWARD_FILL", "__version__"]
|
smartclean_df/_io.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Accept a DataFrame or a path to a tabular file."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Union
|
|
7
|
+
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
__all__ = ["load_frame", "write_frame", "FrameLike"]
|
|
11
|
+
|
|
12
|
+
FrameLike = Union[pd.DataFrame, str, "os.PathLike[str]"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _inference_is_lossless(text: pd.Series) -> bool:
|
|
16
|
+
"""True when letting pandas type-infer this column of text changes nothing.
|
|
17
|
+
|
|
18
|
+
``"123"`` becomes ``123`` and renders back as ``"123"``, so inferring it is
|
|
19
|
+
free. ``"00123"`` becomes ``123`` and renders back as ``"123"`` -- a
|
|
20
|
+
different identifier -- so that column must stay text and be handed to the
|
|
21
|
+
cleaning pipeline, which knows to leave zero-padded codes alone.
|
|
22
|
+
"""
|
|
23
|
+
values = text.dropna()
|
|
24
|
+
if values.empty:
|
|
25
|
+
return True
|
|
26
|
+
try:
|
|
27
|
+
numbers = pd.to_numeric(values)
|
|
28
|
+
except (ValueError, TypeError, OverflowError):
|
|
29
|
+
# pandas will not turn this column into numbers either; nothing to lose
|
|
30
|
+
return True
|
|
31
|
+
try:
|
|
32
|
+
rendered = numbers.astype(str).to_numpy()
|
|
33
|
+
except (ValueError, TypeError):
|
|
34
|
+
return False
|
|
35
|
+
return bool((rendered == values.to_numpy()).all())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _read_text_table(path: Path, *, sep: str) -> pd.DataFrame:
|
|
39
|
+
"""Read a delimited text file without letting pandas destroy any values.
|
|
40
|
+
|
|
41
|
+
Reading a file must never be a lossy, unlogged transformation. Columns
|
|
42
|
+
pandas can type-infer without changing a single value are inferred as
|
|
43
|
+
usual; every other column is read as text, so that the cleaning pipeline
|
|
44
|
+
decides what becomes a number, a date or a boolean -- and logs an Action
|
|
45
|
+
for it -- instead of pandas deciding silently at read time.
|
|
46
|
+
"""
|
|
47
|
+
text = pd.read_csv(path, sep=sep, dtype=str, keep_default_na=True)
|
|
48
|
+
as_text = {col: str for col in text.columns if not _inference_is_lossless(text[col])}
|
|
49
|
+
if not as_text:
|
|
50
|
+
return pd.read_csv(path, sep=sep)
|
|
51
|
+
return pd.read_csv(path, sep=sep, dtype=as_text)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def load_frame(df_or_path: FrameLike) -> pd.DataFrame:
|
|
55
|
+
"""Return a deep copy of a DataFrame, or read a .csv/.tsv/.parquet file."""
|
|
56
|
+
if isinstance(df_or_path, pd.DataFrame):
|
|
57
|
+
return df_or_path.copy(deep=True)
|
|
58
|
+
if isinstance(df_or_path, (str, os.PathLike)):
|
|
59
|
+
path = Path(df_or_path)
|
|
60
|
+
suffix = path.suffix.lower()
|
|
61
|
+
if suffix in (".csv", ".txt"):
|
|
62
|
+
return _read_text_table(path, sep=",")
|
|
63
|
+
if suffix == ".tsv":
|
|
64
|
+
return _read_text_table(path, sep="\t")
|
|
65
|
+
if suffix in (".parquet", ".pq"):
|
|
66
|
+
return pd.read_parquet(path)
|
|
67
|
+
raise ValueError(
|
|
68
|
+
f"Unsupported file type {suffix!r} for {path}; expected .csv, .tsv or .parquet"
|
|
69
|
+
)
|
|
70
|
+
raise TypeError(
|
|
71
|
+
"expected a pandas DataFrame or a path to a .csv/.tsv/.parquet file, "
|
|
72
|
+
f"got {type(df_or_path).__name__}"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def write_frame(df: pd.DataFrame, path: "Union[str, os.PathLike[str]]") -> Path:
|
|
77
|
+
"""Write ``df`` to ``path`` by extension (.csv, .tsv or .parquet)."""
|
|
78
|
+
target = Path(path)
|
|
79
|
+
suffix = target.suffix.lower()
|
|
80
|
+
if suffix in (".csv", ".txt"):
|
|
81
|
+
df.to_csv(target, index=False)
|
|
82
|
+
elif suffix == ".tsv":
|
|
83
|
+
df.to_csv(target, index=False, sep="\t")
|
|
84
|
+
elif suffix in (".parquet", ".pq"):
|
|
85
|
+
df.to_parquet(target, index=False)
|
|
86
|
+
else:
|
|
87
|
+
raise ValueError(
|
|
88
|
+
f"Unsupported output type {suffix!r} for {target}; expected .csv, .tsv or .parquet"
|
|
89
|
+
)
|
|
90
|
+
return target
|
smartclean_df/cli.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Command line entry point: ``smartclean-df data.csv --output clean.csv``."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from typing import List, Optional
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from ._io import write_frame
|
|
11
|
+
from .core import MISSING_MODES, OUTLIER_MODES, clean
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
15
|
+
parser = argparse.ArgumentParser(
|
|
16
|
+
prog="smartclean-df",
|
|
17
|
+
description=(
|
|
18
|
+
"Detect and fix missing values, duplicate rows, outliers, dirty column names and "
|
|
19
|
+
"numbers/dates/booleans stored as text in a table, and report every change."
|
|
20
|
+
),
|
|
21
|
+
)
|
|
22
|
+
parser.add_argument("path", help="input table (.csv, .tsv or .parquet)")
|
|
23
|
+
parser.add_argument(
|
|
24
|
+
"--missing",
|
|
25
|
+
choices=MISSING_MODES,
|
|
26
|
+
default="auto",
|
|
27
|
+
help="auto: fill with median / mode / forward-fill; drop: drop rows with missing values; "
|
|
28
|
+
"none: leave them (default auto)",
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"--outliers",
|
|
32
|
+
choices=OUTLIER_MODES,
|
|
33
|
+
default="clip",
|
|
34
|
+
help="clip: winsorize to the IQR bounds; flag: only report; drop: drop the rows; "
|
|
35
|
+
"none: skip (default clip)",
|
|
36
|
+
)
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
"--iqr-factor",
|
|
39
|
+
type=float,
|
|
40
|
+
default=3.0,
|
|
41
|
+
metavar="K",
|
|
42
|
+
help="outlier bounds are Q1 - K*IQR and Q3 + K*IQR (default 3.0)",
|
|
43
|
+
)
|
|
44
|
+
parser.add_argument("--keep-duplicates", action="store_true", help="do not drop exact duplicate rows")
|
|
45
|
+
parser.add_argument("--keep-column-names", action="store_true", help="do not normalize column names")
|
|
46
|
+
parser.add_argument("--no-parse-numbers", action="store_true", help="leave numbers stored as text alone")
|
|
47
|
+
parser.add_argument("--no-parse-dates", action="store_true", help="leave dates stored as text alone")
|
|
48
|
+
parser.add_argument("--no-parse-booleans", action="store_true", help="leave booleans stored as text alone")
|
|
49
|
+
parser.add_argument(
|
|
50
|
+
"--dry-run",
|
|
51
|
+
action="store_true",
|
|
52
|
+
help="report what would change without changing anything (cannot be combined with --output)",
|
|
53
|
+
)
|
|
54
|
+
parser.add_argument("--json", action="store_true", help="print the result as JSON instead of the text summary")
|
|
55
|
+
parser.add_argument("--output", metavar="FILE", help="write the cleaned table here (.csv, .tsv or .parquet)")
|
|
56
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
57
|
+
return parser
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
61
|
+
"""Run the command line; returns the process exit status."""
|
|
62
|
+
for stream in (sys.stdout, sys.stderr):
|
|
63
|
+
if hasattr(stream, "reconfigure"):
|
|
64
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
65
|
+
parser = build_parser()
|
|
66
|
+
args = parser.parse_args(argv)
|
|
67
|
+
if args.dry_run and args.output:
|
|
68
|
+
parser.error("--dry-run cannot be combined with --output")
|
|
69
|
+
try:
|
|
70
|
+
result = clean(
|
|
71
|
+
args.path,
|
|
72
|
+
missing=args.missing,
|
|
73
|
+
outliers=args.outliers,
|
|
74
|
+
iqr_factor=args.iqr_factor,
|
|
75
|
+
duplicates=not args.keep_duplicates,
|
|
76
|
+
normalize_columns=not args.keep_column_names,
|
|
77
|
+
parse_numbers=not args.no_parse_numbers,
|
|
78
|
+
parse_dates=not args.no_parse_dates,
|
|
79
|
+
parse_booleans=not args.no_parse_booleans,
|
|
80
|
+
dry_run=args.dry_run,
|
|
81
|
+
)
|
|
82
|
+
written = write_frame(result.df, args.output) if args.output else None
|
|
83
|
+
except (ValueError, TypeError, ImportError, OSError) as exc:
|
|
84
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
85
|
+
return 2
|
|
86
|
+
if args.json:
|
|
87
|
+
payload = result.to_dict()
|
|
88
|
+
if written is not None:
|
|
89
|
+
payload["output"] = str(written)
|
|
90
|
+
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
91
|
+
else:
|
|
92
|
+
print(result.summary())
|
|
93
|
+
if written is not None:
|
|
94
|
+
print(f"cleaned table written to {written}")
|
|
95
|
+
return 0
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
if __name__ == "__main__": # pragma: no cover
|
|
99
|
+
sys.exit(main())
|