Skip to content

Commit 6dcdbd1

Browse files
adding function to print colored terminal output (#554)
* adding function to print colored terminal output * agent-governance-toolkit/packages/agent-os/tests * keeping the same file format as the main branch * adding function to print colored terminal output * add rich as runtime dependency and improve pytest coverage * Add MIT license header to test_cli_output.py * Updated to print messages using Rich Console when available * improve error handling and redirect policy violations to stderr * Sanitize CLI output and document rich fallback * robust fallback for rich Text and safer output handling * improve improve error handling * Fix test_policy_violation to capture stderr instead of stdout * Fix test_policy_cli to capture stderr and clean lint issues --------- Co-authored-by: Imran Siddique <45405841+imran-siddique@users.noreply.github.com>
1 parent 584a2db commit 6dcdbd1

File tree

4 files changed

+136
-27
lines changed

4 files changed

+136
-27
lines changed

packages/agent-os/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ classifiers = [
4444
# Core dependencies (minimal - just what's needed for kernel)
4545
dependencies = [
4646
"pydantic>=2.4.0",
47+
"rich>=13.0.0"
4748
]
4849

4950
[project.urls]

packages/agent-os/src/agent_os/policies/cli.py

Lines changed: 96 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -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

4362
def _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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
from agent_os.policies.cli import success,error,warn,policy_violation,passed_check
5+
6+
def test_success(capsys):
7+
success("This is a success message")
8+
captured = capsys.readouterr()
9+
assert "success message" in captured.out.lower()
10+
11+
12+
def test_error(capsys):
13+
error("This is an error message")
14+
captured = capsys.readouterr()
15+
assert "error message" in captured.err.lower()
16+
17+
18+
def test_warn(capsys):
19+
warn("This is a warning message")
20+
captured = capsys.readouterr()
21+
assert "warning message" in captured.out.lower()
22+
23+
24+
def test_policy_violation_output(capsys):
25+
policy_violation("FAIL: invalid policy")
26+
captured = capsys.readouterr()
27+
assert "fail" in captured.err.lower()
28+
29+
30+
def test_passed_check(capsys):
31+
passed_check("Test passed successfully!")
32+
captured = capsys.readouterr()
33+
assert "test passed" in captured.out.lower()

packages/agent-os/tests/test_policy_cli.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,9 @@
77
import json
88
from pathlib import Path
99

10-
import pytest
1110
import yaml
1211

13-
from agent_os.policies.cli import cmd_diff, cmd_test, cmd_validate, main
12+
from agent_os.policies.cli import main
1413
from agent_os.policies.schema import (
1514
PolicyAction,
1615
PolicyCondition,
@@ -146,7 +145,7 @@ def test_validate_invalid_action(self, tmp_path, capsys):
146145
policy_file = _write_yaml(tmp_path / "bad.yaml", data)
147146
rc = main(["validate", str(policy_file)])
148147
assert rc == 1
149-
assert "FAIL" in capsys.readouterr().out
148+
assert "FAIL" in capsys.readouterr().err
150149

151150
def test_validate_missing_required_field(self, tmp_path, capsys):
152151
data = _valid_policy_dict()
@@ -228,9 +227,10 @@ def test_failing_scenario(self, tmp_path, capsys):
228227
scenarios_file = _write_yaml(tmp_path / "scenarios.yaml", scenarios)
229228
rc = main(["test", str(policy_file), str(scenarios_file)])
230229
assert rc == 1
231-
out = capsys.readouterr().out
232-
assert "FAIL" in out
233-
assert "0/1 scenarios passed" in out
230+
captured = capsys.readouterr()
231+
assert "FAIL" in captured.err
232+
assert "0/1 scenarios passed" in captured.out
233+
234234

235235
def test_missing_policy_file(self, tmp_path, capsys):
236236
scenarios_file = _write_yaml(tmp_path / "scenarios.yaml", {"scenarios": [{"name": "x"}]})

0 commit comments

Comments
 (0)