Skip to content

Commit 86279db

Browse files
committed
Fix import errors and add comprehensive CLI tests v0.6.1
- Fixed ImportError in data_processor.py - extract_model_name now imported from model_name_strategies - Added __main__.py to enable running as module: python -m ll2cz - Created comprehensive test suite for imports and CLI commands - test_imports.py: Ensures all modules import correctly - test_cli_smoke.py: Smoke tests for all CLI commands - test_cli.py: Detailed CLI command tests - All CLI commands verified working: transmit, analyze, transform, cache, config - 37 tests passing (21 core + 16 new tests) CHANGELOG: 2025-01-28 - Fixed import errors and added CLI tests (Erik Peterson)
1 parent cfed18a commit 86279db

File tree

8 files changed

+533
-3
lines changed

8 files changed

+533
-3
lines changed

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.6.1] - 2025-01-28
11+
12+
### Fixed
13+
- Fixed import error in `data_processor.py` - `extract_model_name` now correctly imported from `model_name_strategies`
14+
- Added `__main__.py` to enable running package as module: `python -m ll2cz`
15+
16+
### Added
17+
- Comprehensive import tests (`test_imports.py`) to prevent future import errors
18+
- CLI smoke tests (`test_cli_smoke.py`) to ensure all commands work without crashing
19+
- Tests for all critical imports and circular dependency detection
20+
21+
### Testing
22+
- Verified all CLI commands work correctly: `transmit`, `analyze`, `transform`, `cache`, `config`
23+
- All 21 core tests passing
24+
- All 16 new import and smoke tests passing
25+
1026
## [0.6.0] - 2025-01-28
1127

1228
### Changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "ll2cz"
3-
version = "0.6.0"
3+
version = "0.6.1"
44
description = "Transform LiteLLM database data into CloudZero AnyCost CBF format"
55
readme = "README.md"
66
authors = [

src/ll2cz/__main__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# SPDX-FileCopyrightText: Copyright (c), CloudZero, Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Main entry point for ll2cz package when run as a module."""
5+
6+
from .cli import main
7+
8+
if __name__ == '__main__':
9+
main()

src/ll2cz/data_processor.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@
1414
import polars as pl
1515

1616
from .error_tracking import ConsolidatedErrorTracker
17+
from .model_name_strategies import extract_model_name
1718
from .transformations import (
18-
extract_model_name,
1919
generate_resource_id,
2020
get_field_mappings,
2121
normalize_component,

tests/test_cli.py

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
# SPDX-FileCopyrightText: Copyright (c), CloudZero, Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Tests for CLI commands to ensure all commands work correctly."""
5+
6+
import os
7+
import tempfile
8+
from pathlib import Path
9+
from unittest.mock import MagicMock, patch
10+
11+
import polars as pl
12+
import pytest
13+
14+
from ll2cz.cli import main
15+
16+
17+
class TestCLICommands:
18+
"""Test all CLI commands to prevent regressions."""
19+
20+
@pytest.fixture
21+
def mock_config(self):
22+
"""Mock configuration."""
23+
with patch('ll2cz.cli.Config') as mock:
24+
config_instance = MagicMock()
25+
config_instance.get.side_effect = lambda key, default=None: {
26+
'database_url': 'postgresql://test@localhost/test',
27+
'cz_api_key': 'test-api-key',
28+
'cz_connection_id': 'test-connection-id'
29+
}.get(key, default)
30+
mock.return_value = config_instance
31+
yield config_instance
32+
33+
@pytest.fixture
34+
def mock_database(self):
35+
"""Mock database with test data."""
36+
test_data = pl.DataFrame({
37+
'api_key': ['key1', 'key2'],
38+
'custom_llm_provider': ['openai', 'anthropic'],
39+
'model': ['gpt-4', 'claude-3'],
40+
'api_request': [1, 1],
41+
'cache_hits': [0, 0],
42+
'cache_read': [0, 0],
43+
'completion_tokens': [100, 200],
44+
'prompt_tokens': [50, 100],
45+
'response_cost_usd': [0.01, 0.02],
46+
'spend': [0.01, 0.02],
47+
'team_id': ['team1', 'team2'],
48+
'startTime': ['2025-01-01', '2025-01-02'],
49+
'request_id': ['req1', 'req2'],
50+
'user': ['user1', 'user2'],
51+
'team_alias': [None, None],
52+
'team': [None, None],
53+
'key_alias': [None, None],
54+
'user_email': [None, None],
55+
'user_alias': [None, None],
56+
'project_tag': [None, None],
57+
'org_tag': [None, None],
58+
'custom_tag_0': [None, None],
59+
'custom_tag_1': [None, None],
60+
'custom_tag_2': [None, None],
61+
'custom_tag_3': [None, None],
62+
'custom_tag_4': [None, None]
63+
})
64+
65+
with patch('ll2cz.cli.CachedLiteLLMDatabase') as mock_db, \
66+
patch('ll2cz.analysis.CachedLiteLLMDatabase') as mock_db_analysis:
67+
db_instance = MagicMock()
68+
db_instance.get_usage_data.return_value = test_data
69+
db_instance.get_individual_table_data.return_value = test_data
70+
db_instance.get_spend_analysis_data.return_value = test_data
71+
db_instance.is_server_available.return_value = True
72+
db_instance.get_cache_info.return_value = {
73+
'total_records': 2,
74+
'user_records': 2,
75+
'team_records': 0,
76+
'tag_records': 0,
77+
'last_updated': '2025-01-01T00:00:00'
78+
}
79+
mock_db.return_value = db_instance
80+
mock_db_analysis.return_value = db_instance
81+
yield db_instance
82+
83+
def test_help_command(self, capsys):
84+
"""Test that help command works."""
85+
with patch('sys.argv', ['ll2cz', '--help']):
86+
with pytest.raises(SystemExit) as exc_info:
87+
main()
88+
assert exc_info.value.code == 0
89+
captured = capsys.readouterr()
90+
assert 'Transform LiteLLM database data' in captured.out
91+
92+
def test_version_command(self, capsys):
93+
"""Test that version command works."""
94+
with patch('sys.argv', ['ll2cz', '--version']):
95+
with pytest.raises(SystemExit) as exc_info:
96+
main()
97+
assert exc_info.value.code == 0
98+
captured = capsys.readouterr()
99+
assert 'll2cz' in captured.out
100+
101+
def test_analyze_data_command(self, mock_config, mock_database, capsys):
102+
"""Test analyze data command."""
103+
with patch('sys.argv', ['ll2cz', 'analyze', 'data', '--limit', '5']):
104+
main()
105+
106+
captured = capsys.readouterr()
107+
assert 'Comprehensive Data Analysis' in captured.out
108+
assert 'Database Overview' in captured.out
109+
110+
def test_analyze_spend_command(self, mock_config, mock_database, capsys):
111+
"""Test analyze spend command."""
112+
with patch('sys.argv', ['ll2cz', 'analyze', 'spend']):
113+
main()
114+
115+
captured = capsys.readouterr()
116+
assert 'Spend Analysis' in captured.out or 'Analysis' in captured.out
117+
118+
def test_analyze_schema_command(self, mock_config, mock_database, capsys):
119+
"""Test analyze schema command."""
120+
# Mock database schema
121+
mock_database.get_schema_info.return_value = {
122+
'test_table': [
123+
{'column_name': 'id', 'data_type': 'integer', 'is_nullable': 'NO', 'column_default': None}
124+
]
125+
}
126+
127+
with patch('sys.argv', ['ll2cz', 'analyze', 'schema']):
128+
main()
129+
130+
captured = capsys.readouterr()
131+
assert 'Schema' in captured.out or 'test_table' in captured.out
132+
133+
def test_transform_command(self, mock_config, mock_database):
134+
"""Test transform command."""
135+
with tempfile.NamedTemporaryFile(mode='w', suffix='.csv', delete=False) as f:
136+
output_file = f.name
137+
138+
try:
139+
with patch('sys.argv', ['ll2cz', 'transform', '--output', output_file, '--limit', '5']):
140+
main()
141+
142+
# Verify output file was created
143+
assert Path(output_file).exists()
144+
145+
# Verify content
146+
content = Path(output_file).read_text()
147+
assert content # Should not be empty
148+
149+
finally:
150+
if Path(output_file).exists():
151+
os.unlink(output_file)
152+
153+
def test_transmit_test_mode(self, mock_config, mock_database, capsys):
154+
"""Test transmit command in test mode."""
155+
with patch('sys.argv', ['ll2cz', 'transmit', '--mode', 'all', '--test']):
156+
main()
157+
158+
captured = capsys.readouterr()
159+
assert 'TEST MODE' in captured.out
160+
assert 'Sample payload' in captured.out
161+
162+
def test_transmit_day_mode(self, mock_config, mock_database, capsys):
163+
"""Test transmit command for specific day."""
164+
with patch('sys.argv', ['ll2cz', 'transmit', '--mode', 'day', '--date', '01-01-2025', '--test']):
165+
main()
166+
167+
captured = capsys.readouterr()
168+
assert 'TEST MODE' in captured.out or 'Day: 2025-01-01' in captured.out
169+
170+
def test_transmit_month_mode(self, mock_config, mock_database, capsys):
171+
"""Test transmit command for specific month."""
172+
with patch('sys.argv', ['ll2cz', 'transmit', '--mode', 'month', '--date', '01-2025', '--test']):
173+
main()
174+
175+
captured = capsys.readouterr()
176+
assert 'TEST MODE' in captured.out or 'Month: January 2025' in captured.out
177+
178+
def test_cache_status_command(self, mock_config, mock_database, capsys):
179+
"""Test cache status command."""
180+
with patch('sys.argv', ['ll2cz', 'cache', 'status']):
181+
main()
182+
183+
captured = capsys.readouterr()
184+
assert 'Cache Status' in captured.out
185+
assert 'Records cached' in captured.out
186+
187+
def test_cache_clear_command(self, mock_config, mock_database, capsys):
188+
"""Test cache clear command."""
189+
mock_database.clear_cache.return_value = None
190+
191+
with patch('sys.argv', ['ll2cz', 'cache', 'clear']):
192+
main()
193+
194+
captured = capsys.readouterr()
195+
assert 'Cache cleared' in captured.out
196+
197+
def test_cache_refresh_command(self, mock_config, mock_database, capsys):
198+
"""Test cache refresh command."""
199+
mock_database.refresh_cache.return_value = {
200+
'user': {'added': 10, 'updated': 5, 'unchanged': 100},
201+
'team': {'added': 0, 'updated': 0, 'unchanged': 0},
202+
'tag': {'added': 0, 'updated': 0, 'unchanged': 0}
203+
}
204+
205+
with patch('sys.argv', ['ll2cz', 'cache', 'refresh']):
206+
main()
207+
208+
captured = capsys.readouterr()
209+
assert 'Refresh Results' in captured.out or 'Cache refreshed' in captured.out
210+
211+
def test_config_show_command(self, mock_config, capsys):
212+
"""Test config show command."""
213+
with patch('sys.argv', ['ll2cz', 'config', 'show']):
214+
main()
215+
216+
captured = capsys.readouterr()
217+
assert 'Configuration' in captured.out or 'Configured' in captured.out
218+
219+
def test_config_example_command(self, mock_config):
220+
"""Test config example command."""
221+
with tempfile.TemporaryDirectory() as tmpdir:
222+
config_file = Path(tmpdir) / 'config.yml'
223+
224+
with patch('ll2cz.cli.Path.home', return_value=Path(tmpdir)):
225+
with patch('sys.argv', ['ll2cz', 'config', 'example']):
226+
main()
227+
228+
# Verify config file was created
229+
expected_path = Path(tmpdir) / '.ll2cz' / 'config.yml'
230+
assert expected_path.exists()
231+
232+
def test_invalid_command(self, capsys):
233+
"""Test that invalid commands show error."""
234+
with patch('sys.argv', ['ll2cz', 'invalid-command']):
235+
with pytest.raises(SystemExit) as exc_info:
236+
main()
237+
assert exc_info.value.code != 0
238+
239+
captured = capsys.readouterr()
240+
assert 'invalid choice' in captured.err or 'error' in captured.err.lower()
241+
242+
def test_transform_with_source_logs(self, mock_config, mock_database):
243+
"""Test transform command with logs source."""
244+
with tempfile.NamedTemporaryFile(mode='w', suffix='.csv', delete=False) as f:
245+
output_file = f.name
246+
247+
try:
248+
# Mock spend logs data
249+
mock_database.get_spend_logs_data.return_value = mock_database.get_usage_data()
250+
251+
with patch('sys.argv', ['ll2cz', 'transform', '--source', 'logs', '--output', output_file]):
252+
main()
253+
254+
assert Path(output_file).exists()
255+
256+
finally:
257+
if Path(output_file).exists():
258+
os.unlink(output_file)
259+
260+
def test_transmit_with_append_mode(self, mock_config, mock_database, capsys):
261+
"""Test transmit command with append mode."""
262+
with patch('sys.argv', ['ll2cz', 'transmit', '--mode', 'all', '--append', '--test']):
263+
main()
264+
265+
captured = capsys.readouterr()
266+
assert 'TEST MODE' in captured.out
267+
# In append mode, operation should be 'sum' instead of 'replace_hourly'
268+
assert 'sum' in captured.out or 'append' in captured.out.lower()

0 commit comments

Comments
 (0)