OpenHands / tests /unit /test_cli_utils.py
Backup-bdg's picture
Upload 964 files
51ff9e5 verified
raw
history blame
17.3 kB
from pathlib import Path
from unittest.mock import MagicMock, PropertyMock, mock_open, patch
import toml
from openhands.cli.tui import UsageMetrics
from openhands.cli.utils import (
add_local_config_trusted_dir,
extract_model_and_provider,
get_local_config_trusted_dirs,
is_number,
organize_models_and_providers,
read_file,
split_is_actually_version,
update_usage_metrics,
write_to_file,
)
from openhands.events.event import Event
from openhands.llm.metrics import Metrics, TokenUsage
class TestGetLocalConfigTrustedDirs:
@patch('openhands.cli.utils._LOCAL_CONFIG_FILE_PATH')
def test_config_file_does_not_exist(self, mock_config_path):
mock_config_path.exists.return_value = False
result = get_local_config_trusted_dirs()
assert result == []
mock_config_path.exists.assert_called_once()
@patch('openhands.cli.utils._LOCAL_CONFIG_FILE_PATH')
@patch('builtins.open', new_callable=mock_open, read_data='invalid toml')
@patch(
'openhands.cli.utils.toml.load',
side_effect=toml.TomlDecodeError('error', 'doc', 0),
)
def test_config_file_invalid_toml(
self, mock_toml_load, mock_open_file, mock_config_path
):
mock_config_path.exists.return_value = True
result = get_local_config_trusted_dirs()
assert result == []
mock_config_path.exists.assert_called_once()
mock_open_file.assert_called_once_with(mock_config_path, 'r')
mock_toml_load.assert_called_once()
@patch('openhands.cli.utils._LOCAL_CONFIG_FILE_PATH')
@patch(
'builtins.open',
new_callable=mock_open,
read_data=toml.dumps({'sandbox': {'trusted_dirs': ['/path/one']}}),
)
@patch('openhands.cli.utils.toml.load')
def test_config_file_valid(self, mock_toml_load, mock_open_file, mock_config_path):
mock_config_path.exists.return_value = True
mock_toml_load.return_value = {'sandbox': {'trusted_dirs': ['/path/one']}}
result = get_local_config_trusted_dirs()
assert result == ['/path/one']
mock_config_path.exists.assert_called_once()
mock_open_file.assert_called_once_with(mock_config_path, 'r')
mock_toml_load.assert_called_once()
@patch('openhands.cli.utils._LOCAL_CONFIG_FILE_PATH')
@patch(
'builtins.open',
new_callable=mock_open,
read_data=toml.dumps({'other_section': {}}),
)
@patch('openhands.cli.utils.toml.load')
def test_config_file_missing_sandbox(
self, mock_toml_load, mock_open_file, mock_config_path
):
mock_config_path.exists.return_value = True
mock_toml_load.return_value = {'other_section': {}}
result = get_local_config_trusted_dirs()
assert result == []
mock_config_path.exists.assert_called_once()
mock_open_file.assert_called_once_with(mock_config_path, 'r')
mock_toml_load.assert_called_once()
@patch('openhands.cli.utils._LOCAL_CONFIG_FILE_PATH')
@patch(
'builtins.open',
new_callable=mock_open,
read_data=toml.dumps({'sandbox': {'other_key': []}}),
)
@patch('openhands.cli.utils.toml.load')
def test_config_file_missing_trusted_dirs(
self, mock_toml_load, mock_open_file, mock_config_path
):
mock_config_path.exists.return_value = True
mock_toml_load.return_value = {'sandbox': {'other_key': []}}
result = get_local_config_trusted_dirs()
assert result == []
mock_config_path.exists.assert_called_once()
mock_open_file.assert_called_once_with(mock_config_path, 'r')
mock_toml_load.assert_called_once()
class TestAddLocalConfigTrustedDir:
@patch('openhands.cli.utils._LOCAL_CONFIG_FILE_PATH')
@patch('builtins.open', new_callable=mock_open)
@patch('openhands.cli.utils.toml.dump')
@patch('openhands.cli.utils.toml.load')
def test_add_to_non_existent_file(
self, mock_toml_load, mock_toml_dump, mock_open_file, mock_config_path
):
mock_config_path.exists.return_value = False
mock_parent = MagicMock(spec=Path)
mock_config_path.parent = mock_parent
add_local_config_trusted_dir('/new/path')
mock_config_path.exists.assert_called_once()
mock_parent.mkdir.assert_called_once_with(parents=True, exist_ok=True)
mock_open_file.assert_called_once_with(mock_config_path, 'w')
expected_config = {'sandbox': {'trusted_dirs': ['/new/path']}}
mock_toml_dump.assert_called_once_with(expected_config, mock_open_file())
mock_toml_load.assert_not_called()
@patch('openhands.cli.utils._LOCAL_CONFIG_FILE_PATH')
@patch(
'builtins.open',
new_callable=mock_open,
read_data=toml.dumps({'sandbox': {'trusted_dirs': ['/old/path']}}),
)
@patch('openhands.cli.utils.toml.dump')
@patch('openhands.cli.utils.toml.load')
def test_add_to_existing_file(
self, mock_toml_load, mock_toml_dump, mock_open_file, mock_config_path
):
mock_config_path.exists.return_value = True
mock_toml_load.return_value = {'sandbox': {'trusted_dirs': ['/old/path']}}
add_local_config_trusted_dir('/new/path')
mock_config_path.exists.assert_called_once()
assert mock_open_file.call_count == 2 # Once for read, once for write
mock_open_file.assert_any_call(mock_config_path, 'r')
mock_open_file.assert_any_call(mock_config_path, 'w')
mock_toml_load.assert_called_once()
expected_config = {'sandbox': {'trusted_dirs': ['/old/path', '/new/path']}}
mock_toml_dump.assert_called_once_with(expected_config, mock_open_file())
@patch('openhands.cli.utils._LOCAL_CONFIG_FILE_PATH')
@patch(
'builtins.open',
new_callable=mock_open,
read_data=toml.dumps({'sandbox': {'trusted_dirs': ['/old/path']}}),
)
@patch('openhands.cli.utils.toml.dump')
@patch('openhands.cli.utils.toml.load')
def test_add_existing_dir(
self, mock_toml_load, mock_toml_dump, mock_open_file, mock_config_path
):
mock_config_path.exists.return_value = True
mock_toml_load.return_value = {'sandbox': {'trusted_dirs': ['/old/path']}}
add_local_config_trusted_dir('/old/path')
mock_config_path.exists.assert_called_once()
mock_toml_load.assert_called_once()
expected_config = {
'sandbox': {'trusted_dirs': ['/old/path']}
} # Should not change
mock_toml_dump.assert_called_once_with(expected_config, mock_open_file())
@patch('openhands.cli.utils._LOCAL_CONFIG_FILE_PATH')
@patch('builtins.open', new_callable=mock_open, read_data='invalid toml')
@patch('openhands.cli.utils.toml.dump')
@patch(
'openhands.cli.utils.toml.load',
side_effect=toml.TomlDecodeError('error', 'doc', 0),
)
def test_add_to_invalid_toml(
self, mock_toml_load, mock_toml_dump, mock_open_file, mock_config_path
):
mock_config_path.exists.return_value = True
add_local_config_trusted_dir('/new/path')
mock_config_path.exists.assert_called_once()
mock_toml_load.assert_called_once()
expected_config = {
'sandbox': {'trusted_dirs': ['/new/path']}
} # Should reset to default + new path
mock_toml_dump.assert_called_once_with(expected_config, mock_open_file())
@patch('openhands.cli.utils._LOCAL_CONFIG_FILE_PATH')
@patch(
'builtins.open',
new_callable=mock_open,
read_data=toml.dumps({'other_section': {}}),
)
@patch('openhands.cli.utils.toml.dump')
@patch('openhands.cli.utils.toml.load')
def test_add_to_missing_sandbox(
self, mock_toml_load, mock_toml_dump, mock_open_file, mock_config_path
):
mock_config_path.exists.return_value = True
mock_toml_load.return_value = {'other_section': {}}
add_local_config_trusted_dir('/new/path')
mock_config_path.exists.assert_called_once()
mock_toml_load.assert_called_once()
expected_config = {
'other_section': {},
'sandbox': {'trusted_dirs': ['/new/path']},
}
mock_toml_dump.assert_called_once_with(expected_config, mock_open_file())
@patch('openhands.cli.utils._LOCAL_CONFIG_FILE_PATH')
@patch(
'builtins.open',
new_callable=mock_open,
read_data=toml.dumps({'sandbox': {'other_key': []}}),
)
@patch('openhands.cli.utils.toml.dump')
@patch('openhands.cli.utils.toml.load')
def test_add_to_missing_trusted_dirs(
self, mock_toml_load, mock_toml_dump, mock_open_file, mock_config_path
):
mock_config_path.exists.return_value = True
mock_toml_load.return_value = {'sandbox': {'other_key': []}}
add_local_config_trusted_dir('/new/path')
mock_config_path.exists.assert_called_once()
mock_toml_load.assert_called_once()
expected_config = {'sandbox': {'other_key': [], 'trusted_dirs': ['/new/path']}}
mock_toml_dump.assert_called_once_with(expected_config, mock_open_file())
class TestUpdateUsageMetrics:
def test_update_usage_metrics_no_llm_metrics(self):
event = Event()
usage_metrics = UsageMetrics()
# Store original metrics object for comparison
original_metrics = usage_metrics.metrics
update_usage_metrics(event, usage_metrics)
# Metrics should remain unchanged
assert usage_metrics.metrics is original_metrics # Same object reference
assert usage_metrics.metrics.accumulated_cost == 0.0 # Default value
def test_update_usage_metrics_with_cost(self):
event = Event()
# Create a mock Metrics object
metrics = MagicMock(spec=Metrics)
# Mock the accumulated_cost property
type(metrics).accumulated_cost = PropertyMock(return_value=1.25)
event.llm_metrics = metrics
usage_metrics = UsageMetrics()
update_usage_metrics(event, usage_metrics)
# Test that the metrics object was updated to the one from the event
assert usage_metrics.metrics is metrics # Should be the same object reference
# Test that we can access the accumulated_cost through the metrics property
assert usage_metrics.metrics.accumulated_cost == 1.25
def test_update_usage_metrics_with_tokens(self):
event = Event()
# Create mock token usage
token_usage = MagicMock(spec=TokenUsage)
token_usage.prompt_tokens = 100
token_usage.completion_tokens = 50
token_usage.cache_read_tokens = 20
token_usage.cache_write_tokens = 30
# Create mock metrics
metrics = MagicMock(spec=Metrics)
# Set the mock properties
type(metrics).accumulated_cost = PropertyMock(return_value=1.5)
type(metrics).accumulated_token_usage = PropertyMock(return_value=token_usage)
event.llm_metrics = metrics
usage_metrics = UsageMetrics()
update_usage_metrics(event, usage_metrics)
# Test that the metrics object was updated to the one from the event
assert usage_metrics.metrics is metrics # Should be the same object reference
# Test we can access metrics values through the metrics property
assert usage_metrics.metrics.accumulated_cost == 1.5
assert usage_metrics.metrics.accumulated_token_usage is token_usage
assert usage_metrics.metrics.accumulated_token_usage.prompt_tokens == 100
assert usage_metrics.metrics.accumulated_token_usage.completion_tokens == 50
assert usage_metrics.metrics.accumulated_token_usage.cache_read_tokens == 20
assert usage_metrics.metrics.accumulated_token_usage.cache_write_tokens == 30
def test_update_usage_metrics_with_invalid_types(self):
event = Event()
# Create mock token usage with invalid types
token_usage = MagicMock(spec=TokenUsage)
token_usage.prompt_tokens = 'not an int'
token_usage.completion_tokens = 'not an int'
token_usage.cache_read_tokens = 'not an int'
token_usage.cache_write_tokens = 'not an int'
# Create mock metrics
metrics = MagicMock(spec=Metrics)
# Set the mock properties
type(metrics).accumulated_cost = PropertyMock(return_value='not a float')
type(metrics).accumulated_token_usage = PropertyMock(return_value=token_usage)
event.llm_metrics = metrics
usage_metrics = UsageMetrics()
update_usage_metrics(event, usage_metrics)
# Test that the metrics object was still updated to the one from the event
# Even though the values are invalid types, the metrics object reference should be updated
assert usage_metrics.metrics is metrics # Should be the same object reference
# We can verify that we can access the properties through the metrics object
# The invalid types are preserved since our update_usage_metrics function
# simply assigns the metrics object without validation
assert usage_metrics.metrics.accumulated_cost == 'not a float'
assert usage_metrics.metrics.accumulated_token_usage is token_usage
class TestModelAndProviderFunctions:
def test_extract_model_and_provider_slash_format(self):
model = 'openai/gpt-4o'
result = extract_model_and_provider(model)
assert result['provider'] == 'openai'
assert result['model'] == 'gpt-4o'
assert result['separator'] == '/'
def test_extract_model_and_provider_dot_format(self):
model = 'anthropic.claude-3-7'
result = extract_model_and_provider(model)
assert result['provider'] == 'anthropic'
assert result['model'] == 'claude-3-7'
assert result['separator'] == '.'
def test_extract_model_and_provider_openai_implicit(self):
model = 'gpt-4o'
result = extract_model_and_provider(model)
assert result['provider'] == 'openai'
assert result['model'] == 'gpt-4o'
assert result['separator'] == '/'
def test_extract_model_and_provider_anthropic_implicit(self):
model = 'claude-sonnet-4-20250514'
result = extract_model_and_provider(model)
assert result['provider'] == 'anthropic'
assert result['model'] == 'claude-sonnet-4-20250514'
assert result['separator'] == '/'
def test_extract_model_and_provider_versioned(self):
model = 'deepseek.deepseek-coder-1.3b'
result = extract_model_and_provider(model)
assert result['provider'] == 'deepseek'
assert result['model'] == 'deepseek-coder-1.3b'
assert result['separator'] == '.'
def test_extract_model_and_provider_unknown(self):
model = 'unknown-model'
result = extract_model_and_provider(model)
assert result['provider'] == ''
assert result['model'] == 'unknown-model'
assert result['separator'] == ''
def test_organize_models_and_providers(self):
models = [
'openai/gpt-4o',
'anthropic/claude-sonnet-4-20250514',
'o3-mini',
'anthropic.claude-3-5', # Should be ignored as it uses dot separator for anthropic
'unknown-model',
]
result = organize_models_and_providers(models)
assert 'openai' in result
assert 'anthropic' in result
assert 'other' in result
assert len(result['openai']['models']) == 2
assert 'gpt-4o' in result['openai']['models']
assert 'o3-mini' in result['openai']['models']
assert len(result['anthropic']['models']) == 1
assert 'claude-sonnet-4-20250514' in result['anthropic']['models']
assert len(result['other']['models']) == 1
assert 'unknown-model' in result['other']['models']
class TestUtilityFunctions:
def test_is_number_with_digit(self):
assert is_number('1') is True
assert is_number('9') is True
def test_is_number_with_letter(self):
assert is_number('a') is False
assert is_number('Z') is False
def test_is_number_with_special_char(self):
assert is_number('.') is False
assert is_number('-') is False
def test_split_is_actually_version_true(self):
split = ['model', '1.0']
assert split_is_actually_version(split) is True
def test_split_is_actually_version_false(self):
split = ['model', 'version']
assert split_is_actually_version(split) is False
def test_split_is_actually_version_single_item(self):
split = ['model']
assert split_is_actually_version(split) is False
class TestFileOperations:
def test_read_file(self):
mock_content = 'test file content'
with patch('builtins.open', mock_open(read_data=mock_content)):
result = read_file('test.txt')
assert result == mock_content
def test_write_to_file(self):
mock_content = 'test file content'
mock_file = mock_open()
with patch('builtins.open', mock_file):
write_to_file('test.txt', mock_content)
mock_file.assert_called_once_with('test.txt', 'w')
handle = mock_file()
handle.write.assert_called_once_with(mock_content)