@@ -39,6 +39,25 @@ def _import_yaml() -> Any:
3939 print ("ERROR: pyyaml is required — pip install pyyaml" , file = sys .stderr )
4040 sys .exit (2 )
4141
42+ def _import_console (no_color : bool = False , use_stderr : bool = False ):
43+ '''Lazy import for rich.console.Console.'''
44+ try :
45+ from rich .console import Console
46+
47+ return Console (stderr = use_stderr , no_color = no_color )
48+ except (ModuleNotFoundError , ImportError ):
49+ print ("WARNING: rich library not installed, using plain text output" , file = sys .stderr )
50+ return None
51+
52+ def _import_rich_text () -> Any :
53+ '''Lazy import for rich.text.Text.'''
54+ try :
55+ from rich .text import Text
56+
57+ return Text
58+ except ImportError as e :
59+ print (f"WARNING: { e } " , file = sys .stderr )
60+ return None
4261
4362def _load_file (path : Path ) -> dict [str , Any ]:
4463 """Load a YAML or JSON file and return the parsed dict."""
@@ -60,6 +79,57 @@ def _load_file(path: Path) -> dict[str, Any]:
6079 return data
6180
6281
82+ # ---------------------------------------------------------------------------
83+ # Colored output helpers — use rich when available, fall back to plain text.
84+ # ---------------------------------------------------------------------------
85+
86+
87+ def error (msg : str , no_color : bool = False ) -> None :
88+ """Print an error message in red to stderr."""
89+ console = _import_console (no_color , use_stderr = True )
90+ Text = _import_rich_text ()
91+ if console and Text :
92+ console .print (Text (msg ), style = "red" )
93+ else :
94+ print (msg , file = sys .stderr )
95+
96+ def success (msg : str , no_color : bool = False ) -> None :
97+ """Print a success message in green to stdout."""
98+ console = _import_console (no_color , use_stderr = False )
99+ Text = _import_rich_text ()
100+ if console and Text :
101+ console .print (Text (msg ), style = "green" )
102+ else :
103+ print (msg )
104+
105+ def warn (msg : str , no_color : bool = False ) -> None :
106+ """Print a warning message in yellow to stdout."""
107+ console = _import_console (no_color , use_stderr = False )
108+ Text = _import_rich_text ()
109+ if console and Text :
110+ console .print (Text (msg ), style = "yellow" )
111+ else :
112+ print (msg )
113+
114+ def policy_violation (msg : str , no_color : bool = False ) -> None :
115+ """Print a policy violation message in bold red to stdout."""
116+ console = _import_console (no_color , use_stderr = True )
117+ Text = _import_rich_text ()
118+ if console and Text :
119+ console .print (Text (msg ), style = "bold red" )
120+ else :
121+ print (msg )
122+
123+ def passed_check (msg : str , no_color : bool = False ) -> None :
124+ """Print a passed-check message with a green checkmark to stdout."""
125+ console = _import_console (no_color , use_stderr = False )
126+ Text = _import_rich_text ()
127+ if console and Text :
128+ console .print (Text (f"\u2714 { msg } " ), style = "green" )
129+ else :
130+ print (f"\u2714 { msg } " )
131+
132+
63133# ============================================================================
64134# validate
65135# ============================================================================
@@ -71,13 +141,13 @@ def cmd_validate(args: argparse.Namespace) -> int:
71141
72142 path = Path (args .path )
73143 if not path .exists ():
74- print (f"ERROR: file not found: { path } " , file = sys . stderr )
144+ error (f"ERROR: file not found: { path } " )
75145 return 2
76146
77147 try :
78148 data = _load_file (path )
79149 except Exception as exc :
80- print (f"ERROR: failed to parse { path } : { exc } " , file = sys . stderr )
150+ error (f"ERROR: failed to parse { path } : { exc } " )
81151 return 2
82152
83153 # --- Optional JSON-Schema validation (best-effort) --------------------
@@ -91,22 +161,22 @@ def cmd_validate(args: argparse.Namespace) -> int:
91161 except ImportError :
92162 pass # jsonschema not installed — skip, rely on Pydantic
93163 except jsonschema .ValidationError as ve :
94- print (f"FAIL: { path } " )
95- print (f" JSON-Schema error: { ve .message } " )
164+ policy_violation (f"FAIL: { path } " )
165+ policy_violation (f" JSON-Schema error: { ve .message } " )
96166 if ve .absolute_path :
97- print (f" Location: { ' -> ' .join (str (p ) for p in ve .absolute_path )} " )
167+ policy_violation (f" Location: { ' -> ' .join (str (p ) for p in ve .absolute_path )} " )
98168 return 1
99169
100170 # --- Pydantic validation (authoritative) ------------------------------
101171 try :
102172 PolicyDocument .model_validate (data )
103173 except Exception as exc :
104- print (f"FAIL: { path } " )
174+ policy_violation (f"FAIL: { path } " )
105175 for line in str (exc ).splitlines ():
106- print (f" { line } " )
176+ policy_violation (f" { line } " )
107177 return 1
108178
109- print (f"OK: { path } " )
179+ success (f"OK: { path } " )
110180 return 0
111181
112182
@@ -125,7 +195,7 @@ def cmd_test(args: argparse.Namespace) -> int:
125195
126196 for p in (policy_path , scenarios_path ):
127197 if not p .exists ():
128- print (f"ERROR: file not found: { p } " , file = sys . stderr )
198+ error (f"ERROR: file not found: { p } " )
129199 return 2
130200
131201 # Load the policy
@@ -135,19 +205,19 @@ def cmd_test(args: argparse.Namespace) -> int:
135205 try :
136206 doc = PolicyDocument .from_json (policy_path )
137207 except Exception :
138- print (f"ERROR: failed to load policy { policy_path } : { exc } " , file = sys . stderr )
208+ error (f"ERROR: failed to load policy { policy_path } : { exc } " )
139209 return 2
140210
141211 # Load scenarios
142212 try :
143213 scenarios_data = _load_file (scenarios_path )
144214 except Exception as exc :
145- print (f"ERROR: failed to parse scenarios { scenarios_path } : { exc } " , file = sys . stderr )
215+ error (f"ERROR: failed to parse scenarios { scenarios_path } : { exc } " )
146216 return 2
147217
148218 scenarios = scenarios_data .get ("scenarios" , [])
149219 if not scenarios :
150- print ("ERROR: no scenarios found in test file" , file = sys . stderr )
220+ error ("ERROR: no scenarios found in test file" )
151221 return 2
152222
153223 evaluator = PolicyEvaluator (policies = [doc ])
@@ -175,14 +245,19 @@ def cmd_test(args: argparse.Namespace) -> int:
175245
176246 if errors :
177247 failed += 1
178- print (f" FAIL: { name } " )
248+ policy_violation (f" FAIL: { name } " )
179249 for err in errors :
180- print (f" - { err } " )
250+ policy_violation (f" - { err } " )
181251 else :
182252 passed += 1
183- print (f" PASS: { name } " )
253+ passed_check (f" PASS: { name } " )
254+
255+ summary = f"\n { passed } /{ total } scenarios passed."
256+ if failed > 0 :
257+ warn (summary )
258+ else :
259+ success (summary )
184260
185- print (f"\n { passed } /{ total } scenarios passed." )
186261 return 1 if failed > 0 else 0
187262
188263
@@ -200,14 +275,14 @@ def cmd_diff(args: argparse.Namespace) -> int:
200275
201276 for p in (path1 , path2 ):
202277 if not p .exists ():
203- print (f"ERROR: file not found: { p } " , file = sys . stderr )
278+ error (f"ERROR: file not found: { p } " )
204279 return 2
205280
206281 try :
207282 doc1 = PolicyDocument .model_validate (_load_file (path1 ))
208283 doc2 = PolicyDocument .model_validate (_load_file (path2 ))
209284 except Exception as exc :
210- print (f"ERROR: failed to load policies: { exc } " , file = sys . stderr )
285+ error (f"ERROR: failed to load policies: { exc } " )
211286 return 2
212287
213288 differences : list [str ] = []
@@ -271,12 +346,12 @@ def cmd_diff(args: argparse.Namespace) -> int:
271346 differences .append (f" rule '{ name } ' message changed" )
272347
273348 if differences :
274- print (f"Differences between { path1 } and { path2 } :" )
349+ warn (f"Differences between { path1 } and { path2 } :" )
275350 for diff in differences :
276- print (diff )
351+ warn (diff )
277352 return 1
278353 else :
279- print (f"No differences between { path1 } and { path2 } ." )
354+ success (f"No differences between { path1 } and { path2 } ." )
280355 return 0
281356
282357
0 commit comments