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.
@@ -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())