Skip to content

Commit 7238e46

Browse files
committed
feat(cli): add --stats flag for token comparison
1 parent 9c4f0c0 commit 7238e46

2 files changed

Lines changed: 121 additions & 0 deletions

File tree

src/toon_format/cli.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from . import decode, encode
1616
from .types import DecodeOptions, EncodeOptions
17+
from .utils import compare_formats
1718

1819

1920
def main() -> int:
@@ -77,6 +78,12 @@ def main() -> int:
7778
help="Disable strict validation when decoding",
7879
)
7980

81+
parser.add_argument(
82+
"--stats",
83+
action="store_true",
84+
help="Show token count estimates and savings (encode only)",
85+
)
86+
8087
args = parser.parse_args()
8188

8289
# Read input
@@ -125,6 +132,11 @@ def main() -> int:
125132
except json.JSONDecodeError:
126133
mode = "decode"
127134

135+
# Handle --stats with decode mode
136+
if args.stats and mode == "decode":
137+
print("Warning: --stats is only available in encode mode", file=sys.stderr)
138+
args.stats = False
139+
128140
# Process
129141
try:
130142
if mode == "encode":
@@ -134,6 +146,15 @@ def main() -> int:
134146
indent=args.indent,
135147
length_marker=args.length_marker,
136148
)
149+
150+
# Show stats if requested
151+
if args.stats:
152+
try:
153+
data = json.loads(input_text)
154+
print("\n" + compare_formats(data))
155+
except RuntimeError as e:
156+
# tiktoken not installed
157+
print(f"\n {e}", file=sys.stderr)
137158
else:
138159
output_text = decode_toon_to_json(
139160
input_text,

tests/test_cli.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,106 @@ def test_decode_lenient_mode(self):
7878
assert data["name"] == "Alice"
7979

8080

81+
class TestStatsFlag:
82+
"""Tests for the --stats CLI flag."""
83+
84+
def test_stats_flag_in_help(self, tmp_path):
85+
"""Test that --stats appears in help text."""
86+
with patch("sys.argv", ["toon", "--help"]):
87+
with pytest.raises(SystemExit):
88+
main()
89+
90+
def test_stats_with_file_input(self, tmp_path):
91+
"""Test --stats with file input."""
92+
input_file = tmp_path / "test.json"
93+
input_file.write_text('{"users": [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]}')
94+
95+
with patch("sys.stdout", new_callable=StringIO) as mock_stdout:
96+
with patch("sys.argv", ["toon", str(input_file), "--stats"]):
97+
result = main()
98+
assert result == 0
99+
output = mock_stdout.getvalue()
100+
assert "users[2" in output
101+
assert "Format Comparison" in output or "Savings" in output
102+
103+
def test_stats_with_stdin(self):
104+
"""Test --stats with stdin input."""
105+
input_data = '{"items": ["a", "b", "c"]}'
106+
107+
with patch("sys.stdin", StringIO(input_data)):
108+
with patch("sys.stdout", new_callable=StringIO) as mock_stdout:
109+
with patch("sys.argv", ["toon", "-", "--stats"]):
110+
result = main()
111+
assert result == 0
112+
output = mock_stdout.getvalue()
113+
assert "items[3" in output
114+
115+
def test_stats_ignored_in_decode_mode(self, tmp_path):
116+
"""Test that --stats is ignored when decoding."""
117+
input_file = tmp_path / "test.toon"
118+
input_file.write_text("items[2]: a,b")
119+
120+
with patch("sys.stdout", new_callable=StringIO) as mock_stdout:
121+
with patch("sys.stderr", new_callable=StringIO) as mock_stderr:
122+
with patch("sys.argv", ["toon", str(input_file), "--decode", "--stats"]):
123+
result = main()
124+
assert result == 0
125+
output = mock_stdout.getvalue()
126+
assert '"items"' in output
127+
if mock_stderr.getvalue():
128+
assert "warning" in mock_stderr.getvalue().lower()
129+
130+
def test_stats_with_different_delimiters(self, tmp_path):
131+
"""Test that --stats works with alternative delimiters."""
132+
input_file = tmp_path / "test.json"
133+
input_file.write_text('{"data": [{"a": 1, "b": 2}]}')
134+
135+
# Test with tab delimiter
136+
with patch("sys.stdout", new_callable=StringIO):
137+
with patch("sys.argv", ["toon", str(input_file), "--delimiter", "\t", "--stats"]):
138+
result = main()
139+
assert result == 0
140+
141+
# Test with pipe delimiter
142+
with patch("sys.stdout", new_callable=StringIO):
143+
with patch("sys.argv", ["toon", str(input_file), "--delimiter", "|", "--stats"]):
144+
result = main()
145+
assert result == 0
146+
147+
def test_stats_without_tiktoken(self, tmp_path, monkeypatch):
148+
"""Test graceful handling when tiktoken is not available."""
149+
input_file = tmp_path / "test.json"
150+
input_file.write_text('{"test": 123}')
151+
152+
# Mock compare_formats to raise RuntimeError (simulating missing tiktoken)
153+
def mock_compare_formats(data):
154+
raise RuntimeError("tiktoken is required")
155+
156+
with patch("toon_format.cli.compare_formats", side_effect=mock_compare_formats):
157+
with patch("sys.stdout", new_callable=StringIO):
158+
with patch("sys.stderr", new_callable=StringIO) as mock_stderr:
159+
with patch("sys.argv", ["toon", str(input_file), "--stats"]):
160+
result = main()
161+
assert result == 0
162+
assert "tiktoken" in mock_stderr.getvalue()
163+
164+
def test_stats_with_output_file(self, tmp_path):
165+
"""Test --stats with -o output option."""
166+
input_file = tmp_path / "test.json"
167+
input_file.write_text('{"test": 123}')
168+
output_file = tmp_path / "output.toon"
169+
170+
with patch("sys.stdout", new_callable=StringIO) as mock_stdout:
171+
with patch("sys.argv", ["toon", str(input_file), "-o", str(output_file), "--stats"]):
172+
result = main()
173+
assert result == 0
174+
assert output_file.exists()
175+
assert (
176+
"Format Comparison" in mock_stdout.getvalue()
177+
or "Savings" in mock_stdout.getvalue()
178+
)
179+
180+
81181
class TestCLIMain:
82182
"""Integration tests for the main CLI function."""
83183

0 commit comments

Comments
 (0)