Skip to content

Commit b54a185

Browse files
committed
Merge pull request #3272 from LuisFigueroaG/feat-profile-cli-command
feat: add profile CLI command
2 parents 5fc60ab + af44a84 commit b54a185

3 files changed

Lines changed: 210 additions & 7 deletions

File tree

CLI_REFERENCE.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,32 @@ make lint
1919
make benchmark
2020
```
2121

22+
## Available CLI Commands
23+
24+
### `arnio scan`
25+
26+
Infer CSV column names and types without loading the full dataset into memory.
27+
28+
```bash
29+
arnio scan --input data.csv
30+
arnio scan --input data.csv --format json
31+
```
32+
33+
### `arnio profile`
34+
35+
Generate a data quality report for a CSV file, including row and column counts,
36+
null counts, duplicate rows, a quality score, and cleaning suggestions.
37+
38+
```bash
39+
arnio profile --input data.csv
40+
arnio profile --input data.csv --format json
41+
arnio profile --input data.csv --format markdown
42+
```
43+
44+
The `text` format is the default and prints a compact terminal-friendly summary.
45+
The `json` format emits `DataQualityReport.to_dict()` output. The `markdown`
46+
format emits `DataQualityReport.to_markdown()` output.
47+
2248
## Common Python Workflow Examples
2349

2450
```python

arnio/cli.py

Lines changed: 117 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
Commands
99
--------
1010
arnio scan --input FILE [--format json|text]
11+
arnio profile --input FILE [--format text|json|markdown]
1112
1213
Exit codes
1314
----------
@@ -24,7 +25,7 @@
2425
import argparse
2526
import json
2627
import sys
27-
from typing import NoReturn
28+
from typing import Any, NoReturn
2829

2930
# ---------------------------------------------------------------------------
3031
# Helpers
@@ -37,6 +38,16 @@ def _exit_error(message: str, code: int = 1) -> NoReturn:
3738
sys.exit(code)
3839

3940

41+
def _validate_input_file(path: str) -> None:
42+
"""Validate that *path* points to a readable input file."""
43+
import os
44+
45+
if not os.path.exists(path):
46+
_exit_error(f"input file not found: {path!r}")
47+
if not os.path.isfile(path):
48+
_exit_error(f"input path is not a file: {path!r}")
49+
50+
4051
# ---------------------------------------------------------------------------
4152
# scan
4253
# ---------------------------------------------------------------------------
@@ -64,13 +75,8 @@ def _cmd_scan(args: argparse.Namespace) -> int:
6475
name string
6576
score float64
6677
"""
67-
import os
68-
6978
path = args.input
70-
if not os.path.exists(path):
71-
_exit_error(f"input file not found: {path!r}")
72-
if not os.path.isfile(path):
73-
_exit_error(f"input path is not a file: {path!r}")
79+
_validate_input_file(path)
7480

7581
try:
7682
import arnio as ar # lazy import keeps --help fast
@@ -102,6 +108,90 @@ def _cmd_scan(args: argparse.Namespace) -> int:
102108
return 0
103109

104110

111+
# ---------------------------------------------------------------------------
112+
# profile
113+
# ---------------------------------------------------------------------------
114+
115+
116+
def _format_suggestion(suggestion: Any) -> str:
117+
"""Format a cleaning suggestion for compact CLI text output."""
118+
if hasattr(suggestion, "step"):
119+
step = suggestion.step
120+
kwargs = suggestion.kwargs
121+
else:
122+
step = suggestion[0]
123+
kwargs = suggestion[1]
124+
confidence = getattr(suggestion, "confidence_score", None)
125+
126+
suffix = ""
127+
if confidence is not None:
128+
suffix = f" (confidence {confidence:.2f})"
129+
130+
return f"{step}{suffix}: {json.dumps(kwargs, sort_keys=True, default=str)}"
131+
132+
133+
def _format_profile_text(path: str, report: Any, *, max_suggestions: int = 5) -> str:
134+
"""Return a readable text summary for ``arnio profile``."""
135+
lines = [
136+
f"Profile: {path}",
137+
f"Quality score: {report.quality_score:.2f}",
138+
f"Rows: {report.row_count}",
139+
f"Columns: {report.column_count}",
140+
f"Duplicate rows: {report.duplicate_rows} ({report.duplicate_ratio:.2%})",
141+
"",
142+
"Null counts:",
143+
]
144+
145+
if report.columns:
146+
name_width = max(len(str(name)) for name in report.columns) + 2
147+
header = f"{'column':<{name_width}}nulls null_ratio"
148+
lines.append(header)
149+
lines.append("-" * len(header))
150+
for name in sorted(report.columns):
151+
column = report.columns[name]
152+
lines.append(
153+
f"{name:<{name_width}}{column.null_count:<7}{column.null_ratio:.2%}"
154+
)
155+
else:
156+
lines.append(" (no columns found)")
157+
158+
lines.extend(["", "Top suggestions:"])
159+
suggestions = list(report.suggestions[:max_suggestions])
160+
if suggestions:
161+
for index, suggestion in enumerate(suggestions, start=1):
162+
lines.append(f"{index}. {_format_suggestion(suggestion)}")
163+
else:
164+
lines.append(" (none)")
165+
166+
return "\n".join(lines)
167+
168+
169+
def _cmd_profile(args: argparse.Namespace) -> int:
170+
"""Run ``arnio profile`` and print a data-quality report."""
171+
path = args.input
172+
_validate_input_file(path)
173+
174+
try:
175+
import arnio as ar # lazy import keeps --help fast
176+
except ImportError as exc: # pragma: no cover
177+
_exit_error(f"arnio package not importable: {exc}")
178+
179+
try:
180+
frame = ar.read_csv(path)
181+
report = ar.profile(frame)
182+
except Exception as exc:
183+
_exit_error(f"profile failed for {path!r}: {exc}")
184+
185+
if args.format == "json":
186+
print(json.dumps(report.to_dict(), indent=2))
187+
elif args.format == "markdown":
188+
print(report.to_markdown())
189+
else:
190+
print(_format_profile_text(path, report))
191+
192+
return 0
193+
194+
105195
# ---------------------------------------------------------------------------
106196
# Parser construction
107197
# ---------------------------------------------------------------------------
@@ -140,6 +230,25 @@ def _build_parser() -> argparse.ArgumentParser:
140230
help="output format (default: text)",
141231
)
142232

233+
# ---- profile ------------------------------------------------------------
234+
p_profile = sub.add_parser(
235+
"profile",
236+
help="generate a data quality report for a CSV file",
237+
description=(
238+
"Load a CSV file and print a data quality report with row counts, "
239+
"null counts, quality score, duplicates, and cleaning suggestions."
240+
),
241+
)
242+
p_profile.add_argument(
243+
"--input", required=True, metavar="FILE", help="path to input CSV file"
244+
)
245+
p_profile.add_argument(
246+
"--format",
247+
choices=["text", "json", "markdown"],
248+
default="text",
249+
help="output format (default: text)",
250+
)
251+
143252
return parser
144253

145254

@@ -168,6 +277,7 @@ def main(argv: list[str] | None = None) -> None:
168277

169278
_HANDLERS = {
170279
"scan": _cmd_scan,
280+
"profile": _cmd_profile,
171281
}
172282

173283
handler = _HANDLERS.get(args.command)

tests/test_cli.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
* ``arnio scan`` – JSON keys correct, dtype values match, column ordering,
1212
text format, default format, missing-file exit 1,
1313
missing --input flag exit nonzero
14+
* ``arnio profile`` – text, JSON, Markdown, default format, missing-file exit 1
1415
* ``arnio --version`` – returns a version string
1516
* ``arnio --help`` – exits 0 and contains command names
1617
* ``arnio`` (no args) – exits 0 and prints help
@@ -45,6 +46,13 @@ def _simple_csv(tmp_path: Path, *, name: str = "data.csv") -> Path:
4546
return p
4647

4748

49+
def _quality_csv(tmp_path: Path, *, name: str = "quality.csv") -> Path:
50+
"""Write a CSV fixture with nulls, duplicates, and whitespace."""
51+
p = tmp_path / name
52+
p.write_text("name,age,status\nAlice,30, active\nBob,,inactive\nBob,,inactive\n")
53+
return p
54+
55+
4856
# ---------------------------------------------------------------------------
4957
# scan
5058
# ---------------------------------------------------------------------------
@@ -134,6 +142,61 @@ def test_scan_single_column_csv(self, tmp_path: Path):
134142
assert cols["value"] == "int64"
135143

136144

145+
# ---------------------------------------------------------------------------
146+
# profile
147+
# ---------------------------------------------------------------------------
148+
149+
150+
class TestProfile:
151+
def test_profile_text_contains_quality_summary(self, tmp_path: Path):
152+
csv = _quality_csv(tmp_path)
153+
result = _run(["profile", "--input", str(csv), "--format", "text"])
154+
155+
assert result.returncode == 0, result.stderr
156+
assert "Profile:" in result.stdout
157+
assert "Quality score:" in result.stdout
158+
assert "Rows: 3" in result.stdout
159+
assert "Columns: 3" in result.stdout
160+
assert "Null counts:" in result.stdout
161+
assert "age" in result.stdout
162+
assert "Top suggestions:" in result.stdout
163+
assert "drop_duplicates" in result.stdout
164+
165+
def test_profile_text_default_format(self, tmp_path: Path):
166+
csv = _quality_csv(tmp_path)
167+
result = _run(["profile", "--input", str(csv)])
168+
169+
assert result.returncode == 0, result.stderr
170+
assert "Quality score:" in result.stdout
171+
172+
def test_profile_json_emits_report_dict(self, tmp_path: Path):
173+
csv = _quality_csv(tmp_path)
174+
result = _run(["profile", "--input", str(csv), "--format", "json"])
175+
176+
assert result.returncode == 0, result.stderr
177+
data = json.loads(result.stdout)
178+
assert data["row_count"] == 3
179+
assert data["column_count"] == 3
180+
assert "quality_score" in data
181+
assert set(data["columns"]) == {"name", "age", "status"}
182+
assert isinstance(data["suggestions"], list)
183+
184+
def test_profile_markdown_emits_report_markdown(self, tmp_path: Path):
185+
csv = _quality_csv(tmp_path)
186+
result = _run(["profile", "--input", str(csv), "--format", "markdown"])
187+
188+
assert result.returncode == 0, result.stderr
189+
assert "# Data Quality Report" in result.stdout
190+
assert "## Overview" in result.stdout
191+
assert "- Rows: 3" in result.stdout
192+
193+
def test_profile_missing_file_exits_1(self, tmp_path: Path):
194+
result = _run(["profile", "--input", str(tmp_path / "missing.csv")])
195+
196+
assert result.returncode == 1
197+
assert "error" in result.stderr.lower()
198+
199+
137200
# ---------------------------------------------------------------------------
138201
# misc / top-level
139202
# ---------------------------------------------------------------------------
@@ -153,6 +216,10 @@ def test_help_mentions_scan(self):
153216
result = _run(["--help"])
154217
assert "scan" in result.stdout
155218

219+
def test_help_mentions_profile(self):
220+
result = _run(["--help"])
221+
assert "profile" in result.stdout
222+
156223
def test_no_args_exits_0_and_prints_help(self):
157224
result = _run([])
158225
assert result.returncode == 0

0 commit comments

Comments
 (0)