Skip to content

Commit fa72b85

Browse files
committed
Merge remote-tracking branch 'original/main' into upstream/list-keeps-connection-string
# Conflicts: # tests/test_main.py
2 parents d3648de + 71b5db4 commit fa72b85

9 files changed

Lines changed: 175 additions & 17 deletions

File tree

AUTHORS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ Contributors:
153153
* Tommi Kyntölä (kynde)
154154
* Diego
155155
* Chris (ChrisJr404)
156+
* Pieter Ouwerkerk (pouwerkerk)
156157

157158
Creator:
158159
--------

changelog.rst

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
Upcoming (TBD)
2-
==============
1+
Upcoming
2+
========
33

44
Bug fixes:
55
----------
@@ -9,13 +9,38 @@ Bug fixes:
99
``sslmode``, everything) and silently fell back to a local socket connection
1010
as the OS user. Only a plain database name is discarded now; a connection
1111
string that names no database gets ``postgres`` for the listing.
12+
13+
4.6.0 (2026-08-26)
14+
==================
15+
16+
Internal:
17+
---------
18+
* Make the external-editor behave scenario less flaky: the ``expect_exact``
19+
timeouts in ``tests/features/steps/iocommands.py`` were as low as 1-2
20+
seconds, which intermittently expired on loaded CI runners and reported
21+
``Scenario: edit sql in file with external editor`` as an error. Raised to 10
22+
seconds; passing runs are unaffected because pexpect returns as soon as the
23+
expected text appears.
24+
25+
Bug fixes:
26+
----------
1227
* Restore cursor shape behaviour for Emacs mode
1328
* Fix ``TypeError: cannot use a string pattern on a bytes-like object`` when
1429
completion metadata comes back as bytes (e.g. ``SQL_ASCII`` client encoding).
1530
* Suggest columns, not datatypes, after a column literally named ``type`` in a ``SELECT`` list.
31+
* Allow ``sqlparse`` 0.6.x. sqlparse 0.6.0 fixes several denial-of-service
32+
issues (CVE-2026-59893, CVE-2026-54284, CVE-2026-71491) and a string-escaping
33+
bug (CVE-2026-59894); the previous ``<0.6`` cap prevented users from
34+
installing the fixed release.
1635

1736
Features:
1837
---------
38+
* Add a ``--timeout`` command line option and a ``connect_timeout`` config value
39+
(default 30 seconds) for the connection timeout. Precedence, highest first:
40+
``--timeout``, then a ``connect_timeout`` in the connection string, then
41+
``$PGCONNECT_TIMEOUT``, then the config value. libpq's own default is 0,
42+
which waits until the operating system gives up on the TCP connection, so an
43+
unreachable host used to hang for minutes.
1944
* Honor the ``PSQL_EDITOR`` environment variable when opening the external
2045
editor (``\\e``, ``\\ev``, ``\\ef``, ``\\ne``), matching psql's precedence of
2146
``PSQL_EDITOR``, then ``EDITOR``, then ``VISUAL`` ([issue 1398](https://github.com/dbcli/pgcli/issues/1398)).

pgcli/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "4.5.0"
1+
__version__ = "4.6.0"

pgcli/main.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,35 @@ def get_editor():
157157
return os.environ.get("PSQL_EDITOR") or os.environ.get("EDITOR") or os.environ.get("VISUAL") or None
158158

159159

160+
def get_connect_timeout(explicit, dsn, kwargs, default):
161+
"""Pick the connection timeout to apply, in seconds.
162+
163+
Precedence, highest first:
164+
165+
1. ``explicit``, i.e. ``--timeout`` on the command line
166+
2. ``connect_timeout`` in the connection string, or in ``kwargs``
167+
3. ``$PGCONNECT_TIMEOUT``
168+
4. ``default``, the ``connect_timeout`` config value
169+
170+
Returns ``None`` when the user already stated a timeout by one of the
171+
means we must not override, in which case the caller leaves the
172+
connection parameters alone and libpq reads it from where it already is.
173+
174+
A default matters because libpq's own is 0, which waits until the
175+
operating system gives up on the TCP connection, so an unreachable host
176+
hangs for minutes.
177+
"""
178+
if explicit is not None:
179+
return explicit
180+
if "connect_timeout" in kwargs:
181+
return None
182+
if dsn and "connect_timeout" in conninfo_to_dict(dsn):
183+
return None
184+
if os.environ.get("PGCONNECT_TIMEOUT"):
185+
return None
186+
return default
187+
188+
160189
class PGCli:
161190
default_prompt = "\\u@\\h:\\d> "
162191
max_len_prompt = 30
@@ -197,6 +226,7 @@ def __init__(
197226
auto_vertical_output=False,
198227
warn=None,
199228
ssh_tunnel_url: str | None = None,
229+
connect_timeout: int | None = None,
200230
log_file: str | None = None,
201231
):
202232
self.force_passwd_prompt = force_passwd_prompt
@@ -263,6 +293,9 @@ def __init__(
263293
self.prompt_format = prompt if prompt is not None else c["main"].get("prompt", self.default_prompt)
264294
self.prompt_dsn_format = prompt_dsn
265295
self.on_error = c["main"]["on_error"].upper()
296+
# Connection timeout, in seconds. See connect() for the precedence.
297+
self.connect_timeout = connect_timeout
298+
self.default_connect_timeout = c["main"].as_int("connect_timeout")
266299
self.decimal_format = c["data_formats"]["decimal"]
267300
self.float_format = c["data_formats"]["float"]
268301
self.column_date_formats = c["column_date_formats"]
@@ -676,6 +709,12 @@ def connect(self, database="", host="", user="", port="", passwd="", dsn="", **k
676709

677710
kwargs.setdefault("application_name", self.application_name)
678711

712+
# The resolved value is passed as a connection parameter rather than
713+
# merged into the dsn, leaving the user's connection string untouched.
714+
timeout = get_connect_timeout(self.connect_timeout, dsn, kwargs, self.default_connect_timeout)
715+
if timeout is not None:
716+
kwargs["connect_timeout"] = str(timeout)
717+
679718
# If password prompt is not forced but no password is provided, try
680719
# getting it from environment variable.
681720
if not self.force_passwd_prompt and not passwd:
@@ -1425,6 +1464,13 @@ def echo_via_pager(self, text, color=None):
14251464
help="Username to connect to the postgres database.",
14261465
)
14271466
@click.option("-u", "--user", "username_opt", help="Username to connect to the postgres database.")
1467+
@click.option(
1468+
"--timeout",
1469+
"connect_timeout",
1470+
type=click.INT,
1471+
default=None,
1472+
help="Seconds to wait for a connection before giving up (0 waits forever). Overrides the connection string and $PGCONNECT_TIMEOUT.",
1473+
)
14281474
@click.option(
14291475
"-W",
14301476
"--password",
@@ -1563,6 +1609,7 @@ def cli(
15631609
ssh_tunnel: str,
15641610
init_command: str,
15651611
log_file: str,
1612+
connect_timeout: int | None,
15661613
):
15671614
if version:
15681615
print("Version:", __version__)
@@ -1621,6 +1668,7 @@ def cli(
16211668
warn=warn,
16221669
ssh_tunnel_url=ssh_tunnel,
16231670
log_file=log_file,
1671+
connect_timeout=connect_timeout,
16241672
)
16251673

16261674
# Choose which ever one has a valid value.

pgcli/pgclirc

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,13 @@ syntax_style = default
146146
# for end are available in the REPL.
147147
vi = False
148148

149+
# Seconds to wait for a connection before giving up. Only applies when nothing
150+
# else specifies a timeout: an explicit --timeout on the command line wins, then
151+
# a connect_timeout in the connection string, then $PGCONNECT_TIMEOUT. Use 0 to
152+
# wait forever, which is libpq's own default (the OS then gives up on the TCP
153+
# connection after a few minutes).
154+
connect_timeout = 30
155+
149156
# Error handling
150157
# When one of multiple SQL statements causes an error, choose to either
151158
# continue executing the remaining statements, or stopping

pgcli/pgexecute.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -215,8 +215,10 @@ def connect(
215215
new_params.update(kwargs)
216216

217217
if new_params["dsn"]:
218-
# When using DSN, only keep dsn, password, and hostaddr (for SSH tunnels)
219-
new_params = {k: v for k, v in new_params.items() if k in ("dsn", "password", "hostaddr")}
218+
# When using a DSN, the connection details all live in the dsn
219+
# itself. Only keep the parameters that have to stay outside it:
220+
# the password, hostaddr (for SSH tunnels) and connect_timeout.
221+
new_params = {k: v for k, v in new_params.items() if k in ("dsn", "password", "hostaddr", "connect_timeout")}
220222

221223
if new_params["password"]:
222224
new_params["dsn"] = make_conninfo(new_params["dsn"], password=new_params.pop("password"))

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ dependencies = [
3232
"prompt_toolkit>=2.0.6,<4.0.0",
3333
"psycopg >= 3.0.14; sys_platform != 'win32'",
3434
"psycopg-binary >= 3.0.14; sys_platform == 'win32'",
35-
"sqlparse >=0.3.0,<0.6",
35+
"sqlparse >=0.3.0,<0.7",
3636
"configobj >= 5.0.6",
3737
"cli_helpers[styles] >= 2.4.0",
3838
# setproctitle is used to mask the password when running `ps` in command line.

tests/features/steps/iocommands.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,28 +12,28 @@ def step_edit_file(context):
1212
if os.path.exists(context.editor_file_name):
1313
os.remove(context.editor_file_name)
1414
context.cli.sendline(r"\e {}".format(os.path.basename(context.editor_file_name)))
15-
wrappers.expect_exact(context, 'Entering Ex mode. Type "visual" to go to Normal mode.', timeout=2)
16-
wrappers.expect_exact(context, ":", timeout=2)
15+
wrappers.expect_exact(context, 'Entering Ex mode. Type "visual" to go to Normal mode.', timeout=10)
16+
wrappers.expect_exact(context, ":", timeout=10)
1717

1818

1919
@when("we type sql in the editor")
2020
def step_edit_type_sql(context):
2121
context.cli.sendline("i")
2222
context.cli.sendline("select * from abc")
2323
context.cli.sendline(".")
24-
wrappers.expect_exact(context, ":", timeout=2)
24+
wrappers.expect_exact(context, ":", timeout=10)
2525

2626

2727
@when("we exit the editor")
2828
def step_edit_quit(context):
2929
context.cli.sendline("x")
30-
wrappers.expect_exact(context, "written", timeout=2)
30+
wrappers.expect_exact(context, "written", timeout=10)
3131

3232

3333
@then("we see the sql in prompt")
3434
def step_edit_done_sql(context):
3535
for match in "select * from abc".split(" "):
36-
wrappers.expect_exact(context, match, timeout=1)
36+
wrappers.expect_exact(context, match, timeout=10)
3737
# Cleanup the command line.
3838
context.cli.sendcontrol("c")
3939
# Cleanup the edited file.
@@ -48,10 +48,10 @@ def step_tee_ouptut(context):
4848
if os.path.exists(context.tee_file_name):
4949
os.remove(context.tee_file_name)
5050
context.cli.sendline(r"\o {}".format(os.path.basename(context.tee_file_name)))
51-
wrappers.expect_exact(context, context.conf["pager_boundary"] + "\r\n", timeout=5)
52-
wrappers.expect_exact(context, "Writing to file", timeout=5)
53-
wrappers.expect_exact(context, context.conf["pager_boundary"] + "\r\n", timeout=5)
54-
wrappers.expect_exact(context, "Time", timeout=5)
51+
wrappers.expect_exact(context, context.conf["pager_boundary"] + "\r\n", timeout=10)
52+
wrappers.expect_exact(context, "Writing to file", timeout=10)
53+
wrappers.expect_exact(context, context.conf["pager_boundary"] + "\r\n", timeout=10)
54+
wrappers.expect_exact(context, "Time", timeout=10)
5555

5656

5757
@when('we query "select 123456"')
@@ -62,7 +62,7 @@ def step_query_select_123456(context):
6262
@when("we stop teeing output")
6363
def step_notee_output(context):
6464
context.cli.sendline(r"\o")
65-
wrappers.expect_exact(context, "Time", timeout=5)
65+
wrappers.expect_exact(context, "Time", timeout=10)
6666

6767

6868
@then("we see 123456 in tee output")

tests/test_main.py

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
obfuscate_process_password,
1919
duration_in_words,
2020
format_output,
21+
get_connect_timeout,
2122
get_editor,
2223
notify_callback,
2324
PGCli,
@@ -503,6 +504,7 @@ def test_pg_service_file(tmpdir):
503504
"",
504505
notify_callback,
505506
application_name="pgcli",
507+
connect_timeout="30",
506508
)
507509
del os.environ["PGPASSWORD"]
508510
del os.environ["PGSERVICEFILE"]
@@ -551,7 +553,7 @@ def test_application_name_db_uri(tmpdir):
551553
mock_pgexecute.return_value = None
552554
cli = PGCli(pgclirc_file=str(tmpdir.join("rcfile")))
553555
cli.connect_uri("postgres://bar@baz.com/?application_name=cow")
554-
mock_pgexecute.assert_called_with("bar", "bar", "", "baz.com", "", "", notify_callback, application_name="cow")
556+
mock_pgexecute.assert_called_with("bar", "bar", "", "baz.com", "", "", notify_callback, application_name="cow", connect_timeout="30")
555557

556558

557559
@pytest.mark.parametrize(
@@ -766,3 +768,76 @@ def test_list_databases_discards_plain_dbname(tmpdir):
766768
path, call = _cli_conn_target(["mydb", "-l"], tmpdir)
767769
assert path == "plain"
768770
assert call.args[0] == "postgres"
771+
772+
773+
def _effective_connect_timeout(tmpdir, cli_timeout=None, dsn_timeout=None, env=None, cfgval=None):
774+
"""The connect_timeout that actually reaches the connection."""
775+
rc = str(tmpdir.join("rcfile"))
776+
with open(rc, "w") as f:
777+
f.write("[main]\n" + (f"connect_timeout = {cfgval}\n" if cfgval else ""))
778+
environ = {k: v for k, v in os.environ.items() if k != "PGCONNECT_TIMEOUT"}
779+
if env:
780+
environ["PGCONNECT_TIMEOUT"] = env
781+
with mock.patch.dict(os.environ, environ, clear=True):
782+
cli_obj = PGCli(pgclirc_file=rc, connect_timeout=cli_timeout)
783+
dsn = "postgresql://u@h:5432/db" + (f"?connect_timeout={dsn_timeout}" if dsn_timeout else "")
784+
captured = {}
785+
786+
def fake(*a, **k):
787+
captured["dsn"] = k.get("dsn") or (a[5] if len(a) > 5 else None)
788+
captured["kwargs"] = k
789+
raise RuntimeError("stop")
790+
791+
# connect() turns a failed connection into sys.exit(1); let it.
792+
with mock.patch("pgcli.main.PGExecute", side_effect=fake), pytest.raises(SystemExit):
793+
cli_obj.connect(dsn=dsn, host="h", port="5432", user="u", database="db")
794+
from_kwargs = captured.get("kwargs", {}).get("connect_timeout")
795+
return from_kwargs or conninfo_to_dict(captured.get("dsn") or "").get("connect_timeout")
796+
797+
798+
DSN_WITH_TIMEOUT = "postgresql://u@h:5432/db?connect_timeout=15"
799+
DSN_PLAIN = "postgresql://u@h:5432/db"
800+
801+
802+
@pytest.mark.parametrize(
803+
"explicit, dsn, kwargs, env, expected, why",
804+
[
805+
(None, DSN_PLAIN, {}, None, 30, "nothing else set, so the config default applies"),
806+
(None, DSN_WITH_TIMEOUT, {}, None, None, "the connection string already says so"),
807+
(None, DSN_PLAIN, {"connect_timeout": "9"}, None, None, "the caller already says so"),
808+
(None, DSN_PLAIN, {}, "7", None, "libpq reads $PGCONNECT_TIMEOUT itself"),
809+
(None, DSN_WITH_TIMEOUT, {}, "7", None, "the connection string beats the environment"),
810+
(3, DSN_WITH_TIMEOUT, {}, "7", 3, "--timeout beats everything"),
811+
(0, DSN_WITH_TIMEOUT, {}, None, 0, "--timeout 0 is meaningful, not unset"),
812+
(None, None, {}, None, 30, "no dsn at all"),
813+
],
814+
)
815+
def test_get_connect_timeout(explicit, dsn, kwargs, env, expected, why):
816+
environ = {k: v for k, v in os.environ.items() if k != "PGCONNECT_TIMEOUT"}
817+
if env:
818+
environ["PGCONNECT_TIMEOUT"] = env
819+
with mock.patch.dict(os.environ, environ, clear=True):
820+
assert get_connect_timeout(explicit, dsn, kwargs, 30) == expected, why
821+
822+
823+
def test_connect_timeout_config_default_reaches_the_connection(tmpdir):
824+
"""The helper is actually wired into connect(): libpq's own default of 0
825+
waits until the OS gives up, which takes minutes."""
826+
assert _effective_connect_timeout(tmpdir) == "30"
827+
828+
829+
def test_connect_timeout_config_value_used(tmpdir):
830+
assert _effective_connect_timeout(tmpdir, cfgval=45) == "45"
831+
832+
833+
def test_connect_timeout_cli_reaches_the_connection(tmpdir):
834+
assert _effective_connect_timeout(tmpdir, cli_timeout=3, dsn_timeout=15, env="7") == "3"
835+
836+
837+
def test_connect_timeout_config_value_must_be_a_number(tmpdir):
838+
"""A typo in the config is reported instead of being silently ignored."""
839+
rc = str(tmpdir.join("rcfile"))
840+
with open(rc, "w") as f:
841+
f.write("[main]\nconnect_timeout = soon\n")
842+
with pytest.raises(ValueError):
843+
PGCli(pgclirc_file=rc)

0 commit comments

Comments
 (0)