88Commands
99--------
1010 arnio scan --input FILE [--format json|text]
11+ arnio profile --input FILE [--format text|json|markdown]
1112
1213Exit codes
1314----------
2425import argparse
2526import json
2627import 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 )
0 commit comments