Coverage for tests/test_cli.py: 98.37%
237 statements
« prev ^ index » next coverage.py v7.9.2, created at 2026-09-27 04:08 +0000
« prev ^ index » next coverage.py v7.9.2, created at 2026-09-27 04:08 +0000
1from __future__ import annotations
3import dataclasses
4import io
5import subprocess
6import sys
7from collections.abc import Callable
8from datetime import datetime, timezone
9from pathlib import Path
11import pytest
12from dirty_equals import IsStr
13from inline_snapshot import snapshot
14from rich.console import Console
16import voice_prices._cli as cli_module
17from voice_prices import update_prices
18from voice_prices._cli import (
19 _parse_cli,
20 _render_calc_error,
21 _should_split_model_price_columns,
22 _suggest_models,
23 cli_logic,
24)
25from voice_prices.data import providers
26from voice_prices.types import ClauseEquals, ModelInfo, ModelPrice, Provider, TieredPrices
29def _find_model_ref(predicate: Callable[[ModelPrice], bool], *, exclude: set[str] | None = None) -> str:
30 exclude = exclude or set()
31 now = datetime.now(timezone.utc)
32 for provider in providers: 32 ↛ 40line 32 didn't jump to line 40 because the loop on line 32 didn't complete
33 for model in provider.models:
34 prices = model.get_prices(now)
35 if predicate(prices):
36 model_ref = f'{provider.id}:{model.id}'
37 if model_ref in exclude: 37 ↛ 38line 37 didn't jump to line 38 because the condition on line 37 was never true
38 continue
39 return model_ref
40 raise AssertionError('No matching model found')
43def _has_tiered_prices(model_price: ModelPrice) -> bool:
44 return any(isinstance(getattr(model_price, field.name), TieredPrices) for field in dataclasses.fields(model_price))
47def test_version(capsys: pytest.CaptureFixture[str]):
48 assert cli_logic(['--version']) == 0
49 out, err = capsys.readouterr()
50 assert out == IsStr(regex=r'voice-prices .*\n')
51 assert err == ''
54def test_version_plain(capsys: pytest.CaptureFixture[str]):
55 assert cli_logic(['--plain', '--version']) == 0
56 out, err = capsys.readouterr()
57 assert out == IsStr(regex=r'voice-prices .*\n')
58 assert err == ''
61def test_cli_unknown_flag(capsys: pytest.CaptureFixture[str]):
62 assert cli_logic(['--unknown-flag']) == 2
63 out, err = capsys.readouterr()
64 assert out == ''
65 assert 'unrecognized arguments: --unknown-flag' in err
68def test_cli_no_subcommand_help(capsys: pytest.CaptureFixture[str]):
69 assert cli_logic([]) == 1
70 out, err = capsys.readouterr()
71 assert 'usage:' in out.lower()
72 assert 'calc' in out
73 assert 'list' in out
74 assert err == ''
77def test_parse_cli_none(monkeypatch: pytest.MonkeyPatch):
78 monkeypatch.setattr(cli_module.sys, 'argv', [cli_module.PROGRAM_NAME, '--version'])
79 cli = _parse_cli(None)
80 assert cli.version is True
83def test_cli_import_exits_for_missing_optional_dependency():
84 package_path = Path(__file__).parents[1] / 'packages' / 'python'
85 script_path = Path(__file__).with_name('cli_missing_dependency_import.py')
87 result = subprocess.run(
88 [sys.executable, str(script_path), str(package_path)], capture_output=True, text=True, check=False
89 )
90 assert result.returncode == 1
91 assert result.stdout == ''
92 assert result.stderr == (
93 'Optional CLI dependency \'rich\' is not installed. Install CLI extras with: pip install "voice-prices[cli]"\n'
94 )
97def test_render_calc_error_escapes_rich_markup_in_message():
98 stream = io.StringIO()
99 console = Console(file=stream, force_terminal=False, color_system=None)
101 _render_calc_error(
102 console,
103 message='bad [red]oops[/] message',
104 model_ref='gpt-4o',
105 provider_id=None,
106 providers=[],
107 plain=False,
108 use_color=True,
109 )
111 assert '[red]oops[/]' in stream.getvalue()
114def test_calc(capsys: pytest.CaptureFixture[str]):
115 assert cli_logic(['--plain', 'calc', '--input-tokens', '1000', '--output-tokens', '100', 'gpt-4o']) == 0
116 out, err = capsys.readouterr()
117 assert out == snapshot("""\
118 Provider: OpenAI
119 Model: gpt 4o
120 Model Prices: $2.5/input MTok, $1.25/cache read MTok, $10/output MTok
121Context Window: 128,000
122 Input Price: $0.0025
123 Output Price: $0.001
124 Total Price: $0.0035
126""")
127 assert err == ''
130def test_calc_with_provider(capsys: pytest.CaptureFixture[str]):
131 assert (
132 cli_logic(
133 [
134 '--plain',
135 'calc',
136 '--input-tokens',
137 '1000',
138 '--output-tokens',
139 '100',
140 'azure:gpt-3.5-turbo-16k-0613',
141 ]
142 )
143 == 0
144 )
145 out, err = capsys.readouterr()
146 assert out == snapshot("""\
147 Provider: Microsoft Azure
148 Model: GPT-3.5 Turbo 16k
149 Model Prices: $3/input MTok, $4/output MTok
150Context Window: 16,385
151 Input Price: $0.003
152 Output Price: $0.0004
153 Total Price: $0.0034
155""")
156 assert err == ''
159def test_calc_timestamp(capsys: pytest.CaptureFixture[str]):
160 assert cli_logic(['--plain', 'calc', '--input-tokens', '10000', 'o3']) == 0
161 out, err = capsys.readouterr()
162 assert out == snapshot("""\
163 Provider: OpenAI
164 Model: o3
165 Model Prices: $2/input MTok, $0.5/cache read MTok, $8/output MTok
166 Input Price: $0.02
167 Output Price: $0
168 Total Price: $0.02
170""")
171 assert err == ''
173 assert cli_logic(['--plain', 'calc', '--input-tokens', '10000', '--timestamp', '2025-06-01', 'o3']) == 0
174 out, err = capsys.readouterr()
175 assert out == snapshot("""\
176 Provider: OpenAI
177 Model: o3
178 Model Prices: $10/input MTok, $0.5/cache read MTok, $40/output MTok
179 Input Price: $0.1
180 Output Price: $0
181 Total Price: $0.1
183""")
184 assert err == ''
187def test_list(capsys: pytest.CaptureFixture[str]):
188 assert cli_logic(['--plain', 'list']) == 0
189 out, err = capsys.readouterr()
190 assert out.count('\n') > 100
191 assert err == ''
194def test_list_provider(capsys: pytest.CaptureFixture[str]):
195 assert cli_logic(['--plain', 'list', 'deepseek']) == 0
196 out, err = capsys.readouterr()
197 assert out == snapshot("""\
198Deepseek: (4 models)
199 deepseek:deepseek-chat: DeepSeek Chat
200 deepseek:deepseek-reasoner: Deepseek R1
201 deepseek:deepseek-v4-flash: DeepSeek V4 Flash
202 deepseek:deepseek-v4-pro: DeepSeek V4 Pro
203""")
204 assert err == ''
207def test_list_provider_wrong(capsys: pytest.CaptureFixture[str]):
208 assert cli_logic(['--plain', 'list', 'foobar']) == 1
209 out, err = capsys.readouterr()
210 assert out == ''
211 assert err == IsStr(regex="^Error: provider 'foobar' not found in .*\n")
214def test_list_rich(capsys: pytest.CaptureFixture[str]):
215 assert cli_logic(['list']) == 0
216 out, err = capsys.readouterr()
217 assert 'models' in out
218 assert err == ''
221def test_list_provider_rich(capsys: pytest.CaptureFixture[str]):
222 assert cli_logic(['list', 'deepseek']) == 0
223 out, err = capsys.readouterr()
224 assert 'deepseek' in out
225 assert err == ''
228def test_list_provider_wrong_rich(capsys: pytest.CaptureFixture[str]):
229 assert cli_logic(['list', 'foobar']) == 1
230 out, err = capsys.readouterr()
231 assert out == ''
232 assert 'Error:' in err
235def test_calc_table_compact(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]):
236 monkeypatch.setenv('COLUMNS', '80')
237 assert (
238 cli_logic(['calc', '--no-color', '--table', '--input-tokens', '1000', '--output-tokens', '100', 'gpt-4o']) == 0
239 )
240 out, err = capsys.readouterr()
241 assert 'Prices' in out
242 assert 'Input/MTok' not in out
243 assert err == ''
246def test_calc_table_split_columns(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]):
247 monkeypatch.setenv('COLUMNS', '200')
248 assert (
249 cli_logic(
250 [
251 'calc',
252 '--no-color',
253 '--table',
254 '--input-tokens',
255 '1000',
256 '--output-tokens',
257 '100',
258 'gpt-4o',
259 'azure:gpt-3.5-turbo-16k-0613',
260 ]
261 )
262 == 0
263 )
264 out, err = capsys.readouterr()
265 assert 'Model Prices' not in out
266 assert 'Input/MTok' in out
267 assert 'Cache Read/MTok' in out
268 assert 'Cache Write/MTok' not in out
269 assert 'Input Audio/MTok' not in out
270 assert err == ''
273def test_calc_rich_columns(capsys: pytest.CaptureFixture[str]):
274 assert cli_logic(['calc', '--input-tokens', '1000', '--output-tokens', '100', 'gpt-4o', 'o3']) == 0
275 out, err = capsys.readouterr()
276 assert out.count('Provider') >= 2
277 assert err == ''
280def test_calc_no_color_vertical_table(capsys: pytest.CaptureFixture[str]):
281 assert cli_logic(['calc', '--no-color', '--input-tokens', '1000', '--output-tokens', '100', 'gpt-4o']) == 0
282 out, err = capsys.readouterr()
283 assert 'Provider' in out
284 assert 'Model Prices' in out
285 assert err == ''
288def test_calc_keep_going(capsys: pytest.CaptureFixture[str]):
289 assert (
290 cli_logic(
291 [
292 'calc',
293 '--keep-going',
294 '--no-color',
295 '--input-tokens',
296 '1000',
297 '--output-tokens',
298 '100',
299 'gpt-4o',
300 'does-not-exist',
301 ]
302 )
303 == 1
304 )
305 out, err = capsys.readouterr()
306 assert 'gpt 4o' in out
307 assert 'Error:' in err
310def test_calc_keep_going_all_invalid(capsys: pytest.CaptureFixture[str]):
311 assert cli_logic(['calc', '--keep-going', '--input-tokens', '1000', 'does-not-exist']) == 1
312 out, err = capsys.readouterr()
313 assert out == ''
314 assert 'Error:' in err
317def test_calc_table_error_renders_summary(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]):
318 monkeypatch.setenv('COLUMNS', '200')
319 assert cli_logic(['calc', '--table', '--input-tokens', '1000', 'gpt-4o', 'does-not-exist']) == 1
320 out, err = capsys.readouterr()
321 assert 'Provider' in out
322 assert 'Error:' in err
325def test_calc_table_empty_results(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]):
326 monkeypatch.setenv('COLUMNS', '200')
327 assert cli_logic(['calc', '--table', '--keep-going', '--input-tokens', '1000', 'does-not-exist']) == 1
328 out, err = capsys.readouterr()
329 assert out == ''
330 assert 'Error:' in err
333def test_calc_provider_suggestions(capsys: pytest.CaptureFixture[str]):
334 assert cli_logic(['--plain', 'calc', '--input-tokens', '1000', 'opnai:gpt-4o']) == 1
335 out, err = capsys.readouterr()
336 assert out == ''
337 assert 'Did you mean provider: openai' in err
340def test_calc_model_suggestions(capsys: pytest.CaptureFixture[str]):
341 assert cli_logic(['--plain', 'calc', '--input-tokens', '1000', 'openai:gpt-4o0']) == 1
342 out, err = capsys.readouterr()
343 assert out == ''
344 assert 'Did you mean: openai:gpt-4o' in err
347def test_calc_provider_suggestions_rich_color(capsys: pytest.CaptureFixture[str]):
348 assert cli_logic(['calc', '--input-tokens', '1000', 'opnai:gpt-4o']) == 1
349 out, err = capsys.readouterr()
350 assert out == ''
351 assert 'Did you mean provider:' in err
354def test_calc_provider_suggestions_rich_no_color(capsys: pytest.CaptureFixture[str]):
355 assert cli_logic(['calc', '--no-color', '--input-tokens', '1000', 'opnai:gpt-4o']) == 1
356 out, err = capsys.readouterr()
357 assert out == ''
358 assert 'Did you mean provider: openai' in err
361def test_calc_model_suggestions_rich_color(capsys: pytest.CaptureFixture[str]):
362 assert cli_logic(['calc', '--input-tokens', '1000', 'openai:gpt-4o0']) == 1
363 out, err = capsys.readouterr()
364 assert out == ''
365 assert 'Did you mean:' in err
368def test_calc_model_suggestions_rich_no_color(capsys: pytest.CaptureFixture[str]):
369 assert cli_logic(['calc', '--no-color', '--input-tokens', '1000', 'openai:gpt-4o0']) == 1
370 out, err = capsys.readouterr()
371 assert out == ''
372 assert 'Did you mean: openai:gpt-4o' in err
375def test_calc_requests_kcount_rich(capsys: pytest.CaptureFixture[str]):
376 model_ref = _find_model_ref(lambda price: price.requests_kcount is not None)
377 assert cli_logic(['calc', '--input-tokens', '1000', model_ref]) == 0
378 out, err = capsys.readouterr()
379 assert 'K requests' in out
380 assert err == ''
383def test_calc_table_split_columns_rich_prices(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]):
384 tiered_ref = _find_model_ref(_has_tiered_prices)
385 requests_ref = _find_model_ref(
386 lambda price: price.requests_kcount is not None,
387 exclude={tiered_ref},
388 )
389 monkeypatch.setenv('COLUMNS', '200')
390 assert cli_logic(['calc', '--table', '--input-tokens', '1000', tiered_ref, requests_ref]) == 0
391 out, err = capsys.readouterr()
392 assert '(+tiers)' in out
393 assert 'Requests/K' in out
394 assert err == ''
397def test_split_model_price_columns_no_fields():
398 console = Console(width=60)
399 assert _should_split_model_price_columns(console, []) is False
402def test_suggest_models_missing_provider():
403 assert _suggest_models('gpt-4o', 'missing', providers) == []
406def test_suggest_models_case_insensitive_provider_path():
407 model_id = 'AaAaAaAaAaAaAaAaAaAa'
408 fake_providers = [
409 Provider(
410 id='provider',
411 name='Provider',
412 api_pattern='https://example.com',
413 models=[ModelInfo(id=model_id, match=ClauseEquals(model_id))],
414 )
415 ]
416 assert _suggest_models(model_id.lower(), 'provider', fake_providers) == [f'provider:{model_id}']
419def test_suggest_models_case_insensitive_global_path():
420 model_id = 'AaAaAaAaAaAaAaAaAaAa'
421 fake_providers = [
422 Provider(
423 id='provider',
424 name='Provider',
425 api_pattern='https://example.com',
426 models=[ModelInfo(id=model_id, match=ClauseEquals(model_id))],
427 )
428 ]
429 assert _suggest_models(f'provider:{model_id.lower()}', None, fake_providers) == [f'provider:{model_id}']
432def test_calc_update_prices(monkeypatch: pytest.MonkeyPatch):
433 calls: dict[str, int | bool] = {'instances': 0, 'starts': 0}
435 class DummyUpdatePrices:
436 def __init__(self) -> None:
437 calls['instances'] += 1
439 def start(self, *, wait: bool = False) -> None:
440 calls['starts'] += 1
441 calls['wait'] = wait
443 monkeypatch.setattr(update_prices, 'UpdatePrices', DummyUpdatePrices)
444 assert cli_logic(['--plain', 'calc', '--update-prices', '--input-tokens', '1000', 'gpt-4o', 'gpt-4o']) == 0
445 assert calls == {'instances': 1, 'starts': 1, 'wait': True}