Skip to content

Commit 670c09a

Browse files
authored
Merge pull request #1938 from dbcli/RW/client-commands-test-coverage
Add test coverage for `client_commands.py`
2 parents 19d329f + f392eab commit 670c09a

2 files changed

Lines changed: 298 additions & 0 deletions

File tree

changelog.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
Upcoming (TBD)
2+
==============
3+
4+
Internal
5+
--------
6+
* Add test coverage for `client_commands.py`.
7+
8+
19
1.74.1 (2026/06/18)
210
==============
311

Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
from pathlib import Path
5+
from typing import Any
6+
7+
import pytest
8+
9+
from mycli import client_commands
10+
from mycli.client_commands import ClientCommandsMixin
11+
from mycli.packages.sqlresult import SQLResult
12+
13+
14+
class DummyClient(ClientCommandsMixin):
15+
def __init__(self) -> None:
16+
self.echo_calls: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
17+
18+
def echo(self, *args: Any, **kwargs: Any) -> None:
19+
self.echo_calls.append((args, kwargs))
20+
21+
def reconnect(self, database: str = '') -> bool:
22+
self.reconnect_database = database
23+
return True
24+
25+
def refresh_completions(self, reset: bool = False) -> list[SQLResult]:
26+
return [SQLResult(status=f'refresh {reset}')]
27+
28+
29+
class FakeFormatter:
30+
def __init__(self, *, supported_formats: list[str] | None = None, fail: bool = False) -> None:
31+
self.supported_formats = supported_formats or ['ascii', 'csv']
32+
self.fail = fail
33+
self.values: list[str] = []
34+
35+
@property
36+
def format_name(self) -> str:
37+
return self.values[-1]
38+
39+
@format_name.setter
40+
def format_name(self, value: str) -> None:
41+
if self.fail:
42+
raise ValueError
43+
self.values.append(value)
44+
45+
46+
class FakeSQLExecute:
47+
def __init__(self, *, dbname: str = 'old_db', user: str = 'alice') -> None:
48+
self.dbname = dbname
49+
self.user = user
50+
self.changed_to: list[str] = []
51+
self.runs: list[str] = []
52+
53+
def change_db(self, dbname: str) -> None:
54+
self.changed_to.append(dbname)
55+
self.dbname = dbname
56+
57+
def run(self, query: str) -> list[SQLResult]:
58+
self.runs.append(query)
59+
return [SQLResult(status=f'ran {query}')]
60+
61+
62+
@pytest.fixture(autouse=True)
63+
def patch_sql_execute(monkeypatch: pytest.MonkeyPatch) -> None:
64+
monkeypatch.setattr(client_commands, 'SQLExecute', FakeSQLExecute)
65+
66+
67+
def result_statuses(results: Any) -> list[str | None]:
68+
return [result.status for result in list(results)]
69+
70+
71+
def test_register_special_commands_registers_expected_commands(monkeypatch: pytest.MonkeyPatch) -> None:
72+
client = DummyClient()
73+
calls: list[tuple[Any, ...]] = []
74+
monkeypatch.setattr(client_commands.special, 'register_special_command', lambda *args, **kwargs: calls.append((*args, kwargs)))
75+
76+
client.register_special_commands()
77+
78+
assert [call[1] for call in calls] == ['use', 'connect', 'rehash', 'tableformat', 'redirectformat', 'source', 'prompt']
79+
assert calls[0][0] == client.change_db
80+
assert calls[1][0] == client.manual_reconnect
81+
assert calls[2][0] == client.refresh_completions
82+
assert calls[3][0] == client.change_table_format
83+
assert calls[4][0] == client.change_redirect_format
84+
assert calls[5][0] == client.execute_from_file
85+
assert calls[6][0] == client.change_prompt_format
86+
87+
88+
def test_manual_reconnect_reports_not_connected() -> None:
89+
client = DummyClient()
90+
91+
def fake_reconnect(database: str = '') -> bool:
92+
client.reconnect_database = database
93+
return False
94+
95+
client.reconnect = fake_reconnect # type: ignore[method-assign]
96+
97+
assert result_statuses(client.manual_reconnect('new_db')) == ['Not connected']
98+
assert client.reconnect_database == 'new_db'
99+
100+
101+
def test_manual_reconnect_without_database_returns_empty_result() -> None:
102+
client = DummyClient()
103+
104+
assert list(client.manual_reconnect()) == [SQLResult()]
105+
assert client.reconnect_database == ''
106+
107+
108+
def test_manual_reconnect_with_database_delegates_to_change_db(monkeypatch: pytest.MonkeyPatch) -> None:
109+
client = DummyClient()
110+
changed: list[str] = []
111+
112+
def fake_change_db(arg: str, **_: Any) -> Any:
113+
changed.append(arg)
114+
yield SQLResult(status='changed')
115+
116+
monkeypatch.setattr(client, 'change_db', fake_change_db)
117+
118+
assert result_statuses(client.manual_reconnect('new_db')) == ['changed']
119+
assert changed == ['new_db']
120+
121+
122+
def test_change_table_format_reports_supported_formats_on_error() -> None:
123+
client = DummyClient()
124+
client.main_formatter = FakeFormatter(supported_formats=['plain', 'csv'], fail=True)
125+
126+
assert result_statuses(client.change_table_format('bad')) == ['Table format bad not recognized. Allowed formats:\n\tplain\n\tcsv']
127+
128+
129+
def test_change_table_format_updates_formatter() -> None:
130+
client = DummyClient()
131+
client.main_formatter = FakeFormatter()
132+
133+
assert result_statuses(client.change_table_format('csv')) == ['Changed table format to csv']
134+
assert client.main_formatter.values == ['csv']
135+
136+
137+
def test_change_redirect_format_updates_formatter() -> None:
138+
client = DummyClient()
139+
client.redirect_formatter = FakeFormatter()
140+
141+
assert result_statuses(client.change_redirect_format('csv')) == ['Changed redirect format to csv']
142+
assert client.redirect_formatter.values == ['csv']
143+
144+
145+
def test_change_redirect_format_reports_supported_formats_on_error() -> None:
146+
client = DummyClient()
147+
client.redirect_formatter = FakeFormatter(supported_formats=['plain', 'json'], fail=True)
148+
149+
assert result_statuses(client.change_redirect_format('bad')) == [
150+
'Redirect format bad not recognized. Allowed formats:\n\tplain\n\tjson'
151+
]
152+
153+
154+
def test_change_db_unquotes_mysql_identifier(monkeypatch: pytest.MonkeyPatch) -> None:
155+
client = DummyClient()
156+
client.sqlexecute = FakeSQLExecute()
157+
title_calls: list[DummyClient] = []
158+
monkeypatch.setattr(client_commands, 'set_all_external_titles', lambda value: title_calls.append(value))
159+
160+
assert result_statuses(client.change_db('`new``db`')) == ['You are now connected to database "new`db" as user "alice"']
161+
assert client.sqlexecute.changed_to == ['new`db']
162+
assert title_calls == [client]
163+
164+
165+
def test_change_db_reports_when_database_is_already_selected(monkeypatch: pytest.MonkeyPatch) -> None:
166+
client = DummyClient()
167+
client.sqlexecute = FakeSQLExecute(dbname='same_db')
168+
title_calls: list[DummyClient] = []
169+
monkeypatch.setattr(client_commands, 'set_all_external_titles', lambda value: title_calls.append(value))
170+
171+
assert result_statuses(client.change_db('same_db')) == ['You are already connected to database "same_db" as user "alice"']
172+
assert client.sqlexecute.changed_to == []
173+
assert title_calls == [client]
174+
175+
176+
def test_change_db_without_argument_reports_error(monkeypatch: pytest.MonkeyPatch) -> None:
177+
client = DummyClient()
178+
secho_calls: list[tuple[str, dict[str, Any]]] = []
179+
monkeypatch.setattr(client_commands.click, 'secho', lambda message, **kwargs: secho_calls.append((message, kwargs)))
180+
181+
assert list(client.change_db('')) == []
182+
assert secho_calls == [('No database selected', {'err': True, 'fg': 'red'})]
183+
184+
185+
def test_execute_from_file_requires_filename() -> None:
186+
client = DummyClient()
187+
188+
assert list(client.execute_from_file('')) == [SQLResult(status='Missing required argument: filename.')]
189+
190+
191+
def test_execute_from_file_reports_open_errors() -> None:
192+
client = DummyClient()
193+
194+
result = list(client.execute_from_file('/does/not/exist.sql'))
195+
196+
assert len(result) == 1
197+
assert result[0].status is not None
198+
assert '/does/not/exist.sql' in result[0].status
199+
200+
201+
def test_execute_from_file_stops_when_destructive_query_is_rejected(
202+
monkeypatch: pytest.MonkeyPatch,
203+
tmp_path: Path,
204+
) -> None:
205+
client = DummyClient()
206+
sql_file = tmp_path / 'query.sql'
207+
sql_file.write_text('drop table users;', encoding='utf-8')
208+
client.destructive_warning = True
209+
client.destructive_keywords = {'drop'}
210+
monkeypatch.setattr(client_commands, 'confirm_destructive_query', lambda keywords, query: False)
211+
212+
assert list(client.execute_from_file(str(sql_file))) == [SQLResult(status='Wise choice. Command execution stopped.')]
213+
214+
215+
def test_execute_from_file_runs_file_query(tmp_path: Path) -> None:
216+
client = DummyClient()
217+
sql_file = tmp_path / 'query.sql'
218+
sql_file.write_text('select 1;', encoding='utf-8')
219+
client.destructive_warning = False
220+
client.destructive_keywords = set()
221+
client.sqlexecute = FakeSQLExecute()
222+
223+
assert list(client.execute_from_file(str(sql_file))) == [SQLResult(status='ran select 1;')]
224+
assert client.sqlexecute.runs == ['select 1;']
225+
226+
227+
def test_change_prompt_format_requires_argument() -> None:
228+
client = DummyClient()
229+
230+
assert client.change_prompt_format('') == [SQLResult(status='Missing required argument, format.')]
231+
232+
233+
def test_change_prompt_format_updates_prompt_format() -> None:
234+
client = DummyClient()
235+
236+
assert client.change_prompt_format('\\u> ') == [SQLResult(status='Changed prompt format to \\u> ')]
237+
assert client.prompt_format == '\\u> '
238+
239+
240+
def test_initialize_logging_uses_null_handler_for_none_level(monkeypatch: pytest.MonkeyPatch) -> None:
241+
client = DummyClient()
242+
client.config = {'main': {'log_file': '/unused/mycli.log', 'log_level': 'NONE'}}
243+
capture_warning_calls: list[bool] = []
244+
monkeypatch.setattr(client_commands.logging, 'captureWarnings', lambda value: capture_warning_calls.append(value))
245+
logger = logging.getLogger('mycli')
246+
original_handlers = list(logger.handlers)
247+
try:
248+
client.initialize_logging()
249+
250+
added_handlers = [handler for handler in logger.handlers if handler not in original_handlers]
251+
assert len(added_handlers) == 1
252+
assert isinstance(added_handlers[0], logging.NullHandler)
253+
assert logger.level == logging.CRITICAL
254+
assert capture_warning_calls == [True]
255+
finally:
256+
for handler in logger.handlers:
257+
if handler not in original_handlers:
258+
logger.removeHandler(handler)
259+
260+
261+
def test_initialize_logging_uses_file_handler(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
262+
log_file = tmp_path / 'mycli.log'
263+
client = DummyClient()
264+
client.config = {'main': {'log_file': str(log_file), 'log_level': 'DEBUG'}}
265+
capture_warning_calls: list[bool] = []
266+
monkeypatch.setattr(client_commands.logging, 'captureWarnings', lambda value: capture_warning_calls.append(value))
267+
logger = logging.getLogger('mycli')
268+
original_handlers = list(logger.handlers)
269+
try:
270+
client.initialize_logging()
271+
272+
added_handlers = [handler for handler in logger.handlers if handler not in original_handlers]
273+
assert len(added_handlers) == 1
274+
assert isinstance(added_handlers[0], logging.FileHandler)
275+
assert logger.level == logging.DEBUG
276+
assert capture_warning_calls == [True]
277+
finally:
278+
for handler in logger.handlers:
279+
if handler not in original_handlers:
280+
logger.removeHandler(handler)
281+
handler.close()
282+
283+
284+
def test_initialize_logging_reports_invalid_log_path() -> None:
285+
client = DummyClient()
286+
client.config = {'main': {'log_file': '/does/not/exist/mycli.log', 'log_level': 'INFO'}}
287+
288+
client.initialize_logging()
289+
290+
assert client.echo_calls == [(('Error: Unable to open the log file "/does/not/exist/mycli.log".',), {'err': True, 'fg': 'red'})]

0 commit comments

Comments
 (0)