Skip to content

Commit b0e2962

Browse files
committed
Synced from main
2 parents 3462f0f + 015b491 commit b0e2962

9 files changed

Lines changed: 208 additions & 41 deletions

File tree

README.md

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,22 @@ sudo dnf install mycli
105105

106106
### Windows
107107

108+
#### Option 1: Native Windows
109+
110+
Install the `less` pager, for example by `scoop install less`.
111+
108112
Follow the instructions on this blogpost: http://web.archive.org/web/20221006045208/https://www.codewall.co.uk/installing-using-mycli-on-windows/
109113

114+
**Mycli is not tested on Windows**, but the libraries used in the app are Windows-compatible.
115+
This means it should work without any modifications, but isn't supported.
116+
117+
PRs to add native Windows testing to Mycli CI would be welcome!
118+
119+
#### Option 2: WSL
120+
121+
Everything should work as expected in WSL. This is a good option for using
122+
Mycli on Windows.
123+
110124

111125
### Thanks:
112126

@@ -128,9 +142,6 @@ Thanks to [PyMysql](https://github.com/PyMySQL/PyMySQL) for a pure python adapte
128142

129143
Mycli is tested on macOS and Linux, and requires Python 3.10 or better.
130144

131-
**Mycli is not tested on Windows**, but the libraries used in this app are Windows-compatible.
132-
This means it should work without any modifications. If you're unable to run it
133-
on Windows, please [file a bug](https://github.com/dbcli/mycli/issues/new).
134145

135146
### Configuration and Usage
136147

changelog.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,22 @@ Features
55
--------
66
* Update query processing functions to allow automatic show_warnings to work for more code paths like DDL.
77
* Update the default SSL value to connect securely by default. Add a --no-ssl option to disable it.
8+
* Rework reconnect logic to actually reconnect or create a new connection instead of simply changing the database (#746).
9+
810

911
Bug Fixes
1012
--------
1113
* Update the prompt display logic to handle an edge case where a socket is used without
1214
a host being parsed from any other method (#707).
1315

1416

17+
Internal
18+
--------
19+
* Refine documentation for Windows.
20+
* Target Python 3.10 for linting.
21+
* Use fully-qualified pymysql exception classes.
22+
23+
1524
1.42.0 (2025/12/20)
1625
==============
1726

mycli/main.py

Lines changed: 86 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@
3737
from prompt_toolkit.layout.processors import ConditionalProcessor, HighlightMatchingBracketProcessor
3838
from prompt_toolkit.lexers import PygmentsLexer
3939
from prompt_toolkit.shortcuts import CompleteStyle, PromptSession
40-
from pymysql import OperationalError, err
41-
from pymysql.constants.ER import HANDSHAKE_ERROR
40+
import pymysql
41+
from pymysql.constants.ER import ERROR_CODE_ACCESS_DENIED, HANDSHAKE_ERROR
4242
from pymysql.cursors import Cursor
4343
import sqlglot
4444
import sqlparse
@@ -214,7 +214,7 @@ def close(self) -> None:
214214
def register_special_commands(self) -> None:
215215
special.register_special_command(self.change_db, "use", "\\u", "Change to a new database.", aliases=["\\u"])
216216
special.register_special_command(
217-
self.change_db,
217+
self.manual_reconnect,
218218
"connect",
219219
"\\r",
220220
"Reconnect to the database. Optional database argument.",
@@ -261,6 +261,18 @@ def register_special_commands(self) -> None:
261261
self.change_prompt_format, "prompt", "\\R", "Change prompt format.", aliases=["\\R"], case_sensitive=True
262262
)
263263

264+
def manual_reconnect(self, arg: str = "", **_) -> Generator[tuple, None, None]:
265+
"""
266+
Interactive method to use for the \r command, so that the utility method
267+
may be cleanly used elsewhere.
268+
"""
269+
if not self.reconnect(database=arg):
270+
yield (None, None, None, "Not connected")
271+
elif not arg or arg == '``':
272+
yield (None, None, None, None)
273+
else:
274+
yield self.change_db(arg).send(None)
275+
264276
def enable_show_warnings(self, **_) -> Generator[tuple, None, None]:
265277
self.show_warnings = True
266278
msg = "Show warnings enabled."
@@ -301,13 +313,18 @@ def change_db(self, arg: str, **_) -> Generator[tuple, None, None]:
301313
return
302314

303315
assert isinstance(self.sqlexecute, SQLExecute)
304-
self.sqlexecute.change_db(arg)
316+
317+
if self.sqlexecute.dbname == arg:
318+
msg = f'You are already connected to database "{self.sqlexecute.dbname}" as user "{self.sqlexecute.user}"'
319+
else:
320+
self.sqlexecute.change_db(arg)
321+
msg = f'You are now connected to database "{self.sqlexecute.dbname}" as user "{self.sqlexecute.user}"'
305322

306323
yield (
307324
None,
308325
None,
309326
None,
310-
f'You are now connected to database "{self.sqlexecute.dbname}" as user "{self.sqlexecute.user}"',
327+
msg,
311328
)
312329

313330
def execute_from_file(self, arg: str, **_) -> Iterable[tuple]:
@@ -526,7 +543,7 @@ def _connect() -> None:
526543
}
527544
try:
528545
self.sqlexecute = SQLExecute(**conn_config)
529-
except OperationalError as e:
546+
except pymysql.OperationalError as e:
530547
if e.args[0] == ERROR_CODE_ACCESS_DENIED:
531548
if password_from_file is not None:
532549
conn_config["password"] = password_from_file
@@ -547,7 +564,7 @@ def _connect() -> None:
547564
self.echo(f"Connecting to socket {socket}, owned by user {socket_owner}", err=True)
548565
try:
549566
_connect()
550-
except OperationalError as e:
567+
except pymysql.OperationalError as e:
551568
# These are "Can't open socket" and 2x "Can't connect"
552569
if [code for code in (2001, 2002, 2003) if code == e.args[0]]:
553570
self.logger.debug("Database connection failed: %r.", e)
@@ -900,19 +917,12 @@ def one_iteration(text: str | None = None) -> None:
900917
output_res(res, start)
901918
special.unset_once_if_written(self.post_redirect_command)
902919
special.flush_pipe_once_if_written(self.post_redirect_command)
903-
except err.InterfaceError:
904-
logger.debug("Attempting to reconnect.")
905-
self.echo("Reconnecting...", fg="yellow")
906-
try:
907-
sqlexecute.connect()
908-
logger.debug("Reconnected successfully.")
909-
one_iteration(text)
910-
return # OK to just return, cuz the recursion call runs to the end.
911-
except OperationalError as e2:
912-
logger.debug("Reconnect failed. e: %r", e2)
913-
self.echo(str(e2), err=True, fg="red")
914-
# If reconnection failed, don't proceed further.
920+
except pymysql.err.InterfaceError:
921+
# attempt to reconnect
922+
if not self.reconnect():
915923
return
924+
one_iteration(text)
925+
return # OK to just return, cuz the recursion call runs to the end.
916926
except EOFError as e:
917927
raise e
918928
except KeyboardInterrupt:
@@ -943,21 +953,14 @@ def one_iteration(text: str | None = None) -> None:
943953
self.echo("Did not get a connection id, skip cancelling query", err=True, fg="red")
944954
except NotImplementedError:
945955
self.echo("Not Yet Implemented.", fg="yellow")
946-
except OperationalError as e1:
956+
except pymysql.OperationalError as e1:
947957
logger.debug("Exception: %r", e1)
948958
if e1.args[0] in (2003, 2006, 2013):
949-
logger.debug("Attempting to reconnect.")
950-
self.echo("Reconnecting...", fg="yellow")
951-
try:
952-
sqlexecute.connect()
953-
logger.debug("Reconnected successfully.")
954-
one_iteration(text)
955-
return # OK to just return, cuz the recursion call runs to the end.
956-
except OperationalError as e2:
957-
logger.debug("Reconnect failed. e: %r", e2)
958-
self.echo(str(e2), err=True, fg="red")
959-
# If reconnection failed, don't proceed further.
959+
# attempt to reconnect
960+
if not self.reconnect():
960961
return
962+
one_iteration(text)
963+
return # OK to just return, cuz the recursion call runs to the end.
961964
else:
962965
logger.error("sql: %r, error: %r", text, e1)
963966
logger.error("traceback: %r", traceback.format_exc())
@@ -1029,6 +1032,58 @@ def one_iteration(text: str | None = None) -> None:
10291032
if not self.less_chatty:
10301033
self.echo("Goodbye!")
10311034

1035+
def reconnect(self, database: str = "") -> bool:
1036+
"""
1037+
Attempt to reconnect to the server. Return True if successful,
1038+
False if unsuccessful.
1039+
1040+
The "database" argument is used only to improve messages.
1041+
"""
1042+
assert self.sqlexecute is not None
1043+
assert self.sqlexecute.conn is not None
1044+
1045+
# First pass with ping(reconnect=False) and minimal feedback levels. This definitely
1046+
# works as expected, and is a good idea especially when "connect" was used as a
1047+
# synonym for "use".
1048+
try:
1049+
self.sqlexecute.conn.ping(reconnect=False)
1050+
if not database:
1051+
self.echo("Already connected.", fg="yellow")
1052+
return True
1053+
except pymysql.err.Error:
1054+
pass
1055+
1056+
# Second pass with ping(reconnect=True). It is not demonstrated that this pass ever
1057+
# gives the benefit it is looking for, _ie_ preserves session state. We need to test
1058+
# this with connection pooling.
1059+
try:
1060+
old_connection_id = self.sqlexecute.connection_id
1061+
self.logger.debug("Attempting to reconnect.")
1062+
self.echo("Reconnecting...", fg="yellow")
1063+
self.sqlexecute.conn.ping(reconnect=True)
1064+
self.logger.debug("Reconnected successfully.")
1065+
self.echo("Reconnected successfully.", fg="yellow")
1066+
self.sqlexecute.reset_connection_id()
1067+
if old_connection_id != self.sqlexecute.connection_id:
1068+
self.echo("Any session state was reset.", fg="red")
1069+
return True
1070+
except pymysql.err.Error:
1071+
pass
1072+
1073+
# Third pass with sqlexecute.connect() should always work, but always resets session state.
1074+
try:
1075+
self.logger.debug("Creating new connection")
1076+
self.echo("Creating new connection...", fg="yellow")
1077+
self.sqlexecute.connect()
1078+
self.logger.debug("New connection created successfully.")
1079+
self.echo("New connection created successfully.", fg="yellow")
1080+
self.echo("Any session state was reset.", fg="red")
1081+
return True
1082+
except pymysql.OperationalError as e:
1083+
self.logger.debug("Reconnect failed. e: %r", e)
1084+
self.echo(str(e), err=True, fg="red")
1085+
return False
1086+
10321087
def log_output(self, output: str) -> None:
10331088
"""Log the output in the audit log, if it's enabled."""
10341089
if isinstance(self.logfile, TextIOWrapper):

mycli/packages/special/llm.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,7 @@ def sql_using_llm(
304304
row = cur.fetchone()
305305
if row is None:
306306
continue
307-
sample_data[table_name] = list(zip(cols, row))
307+
sample_data[table_name] = list(zip(cols, row, strict=True))
308308
args = [
309309
"--template",
310310
LLM_TEMPLATE_NAME,

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ dev = [
5555
"llm>=0.19.0",
5656
"setuptools", # Required by llm commands to install models
5757
"pip",
58-
"ruff>=0.14.6",
58+
"ruff~=0.14.10",
5959
]
6060

6161
[project.scripts]
@@ -68,7 +68,7 @@ mycli = ["myclirc", "AUTHORS", "SPONSORS"]
6868
include = ["mycli*"]
6969

7070
[tool.ruff]
71-
target-version = 'py39'
71+
target-version = 'py310'
7272
line-length = 140
7373

7474
[tool.ruff.lint]

test/features/fixture_data/help_commands.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
| help | \? | Show this help. |
2020
| nopager | \n | Disable pager, print to stdout. |
2121
| notee | notee | Stop writing results to an output file. |
22+
| nowarnings | \w | Disable automatic warnings display. |
2223
| pager | \P [command] | Set PAGER. Print the query results via PAGER. |
2324
| prompt | \R | Change prompt format. |
2425
| quit | \q | Quit. |
@@ -30,5 +31,6 @@
3031
| tableformat | \T | Change the table format used to output results. |
3132
| tee | tee [-o] filename | Append all results to an output file (overwrite using -o). |
3233
| use | \u | Change to a new database. |
34+
| warnings | \W | Enable automatic warnings display. |
3335
| watch | watch [seconds] [-c] query | Executes the query every [seconds] seconds (by default 5). |
3436
+----------------+----------------------------+------------------------------------------------------------+

test/features/steps/crud_database.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,6 @@ def step_see_db_dropped_no_default(context):
108108
@then("we see database connected")
109109
def step_see_db_connected(context):
110110
"""Wait to see drop database output."""
111-
wrappers.expect_exact(context, 'You are now connected to database "', timeout=2)
111+
wrappers.expect_exact(context, 'connected to database "', timeout=2)
112112
wrappers.expect_exact(context, '"', timeout=2)
113113
wrappers.expect_exact(context, f' as user "{context.conf["user"]}"', timeout=2)

test/test_main.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from click.testing import CliRunner
1111

1212
from mycli.main import MyCli, cli, thanks_picker
13+
import mycli.packages.special
1314
from mycli.packages.special.main import COMMANDS as SPECIAL_COMMANDS
1415
from mycli.sqlexecute import ServerInfo, SQLExecute
1516
from test.utils import HOST, PASSWORD, PORT, USER, dbtest, run
@@ -37,6 +38,95 @@
3738
]
3839

3940

41+
@dbtest
42+
def test_reconnect_no_database(executor, capsys):
43+
m = MyCli()
44+
m.register_special_commands()
45+
m.sqlexecute = SQLExecute(
46+
None,
47+
USER,
48+
PASSWORD,
49+
HOST,
50+
PORT,
51+
None,
52+
None,
53+
None,
54+
None,
55+
None,
56+
None,
57+
None,
58+
None,
59+
None,
60+
None,
61+
)
62+
sql = "\\r"
63+
result = next(mycli.packages.special.execute(executor, sql))
64+
stdout, _stderr = capsys.readouterr()
65+
assert result[-1] is None
66+
assert "Already connected" in stdout
67+
68+
69+
@dbtest
70+
def test_reconnect_with_different_database(executor):
71+
m = MyCli()
72+
m.register_special_commands()
73+
m.sqlexecute = SQLExecute(
74+
None,
75+
USER,
76+
PASSWORD,
77+
HOST,
78+
PORT,
79+
None,
80+
None,
81+
None,
82+
None,
83+
None,
84+
None,
85+
None,
86+
None,
87+
None,
88+
None,
89+
)
90+
database_1 = "mycli_test_db"
91+
database_2 = "mysql"
92+
sql_1 = f"use {database_1}"
93+
sql_2 = f"\\r {database_2}"
94+
_result_1 = next(mycli.packages.special.execute(executor, sql_1))
95+
result_2 = next(mycli.packages.special.execute(executor, sql_2))
96+
expected = f'You are now connected to database "{database_2}" as user "{USER}"'
97+
assert expected in result_2[-1]
98+
99+
100+
@dbtest
101+
def test_reconnect_with_same_database(executor):
102+
m = MyCli()
103+
m.register_special_commands()
104+
m.sqlexecute = SQLExecute(
105+
None,
106+
USER,
107+
PASSWORD,
108+
HOST,
109+
PORT,
110+
None,
111+
None,
112+
None,
113+
None,
114+
None,
115+
None,
116+
None,
117+
None,
118+
None,
119+
None,
120+
)
121+
database = "mysql"
122+
sql = f"\\u {database}"
123+
result = next(mycli.packages.special.execute(executor, sql))
124+
sql = f"\\r {database}"
125+
result = next(mycli.packages.special.execute(executor, sql))
126+
expected = f'You are already connected to database "{database}" as user "{USER}"'
127+
assert expected in result[-1]
128+
129+
40130
@dbtest
41131
def test_prompt_no_host_only_socket(executor):
42132
mycli = MyCli()

0 commit comments

Comments
 (0)