From 98ef37ad13591c6cc054c95283954c09c1cbb828 Mon Sep 17 00:00:00 2001 From: Diego Date: Wed, 14 Oct 2026 11:05:02 -0300 Subject: [PATCH] Keep SQL errors visible under -t/--tuples-only The error of a failed statement is printed in the status footer's place, and -t suppresses that footer, so with -t a broken query printed nothing at all. For example `pgcli -t -c "select nope"` gives no output and exits 0, where `psql -t -c "select nope"` prints the error on stderr. Under -t the error now goes to stderr, so stdout still carries only rows, as with psql -t. Output without -t is unchanged. Tested against a live server: a pytest that fails without the change (the error reaches stderr and not the rows), a guard that a good query under -t writes nothing to stderr, and a behave scenario that runs pgcli -t -c with stdout and stderr captured apart. --- changelog.rst | 7 ++++++ pgcli/main.py | 8 +++++++ tests/features/command_option.feature | 4 ++++ tests/features/steps/command_option.py | 31 ++++++++++++++++++++++++++ tests/test_main.py | 20 +++++++++++++++++ 5 files changed, 70 insertions(+) diff --git a/changelog.rst b/changelog.rst index 83e0ac390..815e22242 100644 --- a/changelog.rst +++ b/changelog.rst @@ -1,6 +1,13 @@ Upcoming ======== +Bug fixes: +---------- +* Fix `-t`/`--tuples-only` hiding SQL errors. The error of a failed + statement was printed in place of the status footer that `-t` suppresses, + so a broken query printed nothing at all. Errors now go to stderr under + `-t`, as with `psql -t`, so stdout still carries only rows. + 4.7.1 (2026-09-20) ================== diff --git a/pgcli/main.py b/pgcli/main.py index 3ff820ce2..28bb7c80d 100644 --- a/pgcli/main.py +++ b/pgcli/main.py @@ -1383,6 +1383,14 @@ def _evaluate_command(self, text): ): title = None + if not success and status and self.tuples_only: + # For a failed statement `status` is the error message, not + # the "SELECT 3" footer that -t drops, so -t used to hide the + # error completely. With -t stdout carries only rows, so the + # error goes to stderr, as psql does. + click.echo(status, err=True) + status = None + execution = time() - start formatted = format_output(title, cur, headers, status, settings, self.explain_mode) diff --git a/tests/features/command_option.feature b/tests/features/command_option.feature index 38f5fef51..0f9403c92 100644 --- a/tests/features/command_option.feature +++ b/tests/features/command_option.feature @@ -22,6 +22,10 @@ Feature: run the cli with -c/--command option, then we see an error message and pgcli exits successfully + Scenario: run pgcli with -t and an invalid query + When we run pgcli with -t -c "SELECT invalid_column FROM nonexistent_table" + then we see the error on stderr and nothing on stdout + Scenario: run pgcli with -c and multiple statements When we run pgcli with -c "SELECT 1; SELECT 2" then we see both query results diff --git a/tests/features/steps/command_option.py b/tests/features/steps/command_option.py index b783dc7a7..3ffe67e98 100644 --- a/tests/features/steps/command_option.py +++ b/tests/features/steps/command_option.py @@ -142,3 +142,34 @@ def step_see_all_command_outputs(context): # For the 3-command test, also check for third if "third" in output or "result" in output: assert "third" in output, f"Expected 'third' in output for 3-command test, but got: {output}" + + +@when('we run pgcli with -t -c "{command}"') +def step_run_pgcli_with_t_and_c(context, command): + """Run pgcli with -t and -c, keeping stdout and stderr apart.""" + cmd = [ + "pgcli", + "-h", + context.conf["host"], + "-p", + str(context.conf["port"]), + "-U", + context.conf["user"], + "-d", + context.conf["dbname"], + "-t", + "-c", + command, + ] + result = subprocess.run(cmd, cwd=context.package_root, capture_output=True, timeout=5) + context.cmd_output = result.stdout + context.cmd_stderr = result.stderr.decode() + context.exit_code = result.returncode + + +@then("we see the error on stderr and nothing on stdout") +def step_error_on_stderr_only(context): + """With -t, stdout carries only rows, so the error must be on stderr.""" + stdout = context.cmd_output.decode() + assert "does not exist" in context.cmd_stderr, f"Expected the error on stderr, got: {context.cmd_stderr!r}" + assert "does not exist" not in stdout, f"Expected no error on stdout, got: {stdout!r}" diff --git a/tests/test_main.py b/tests/test_main.py index be29393cc..c91da2351 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1059,3 +1059,23 @@ def test_connect_timeout_config_value_must_be_a_number(tmpdir): f.write("[main]\nconnect_timeout = soon\n") with pytest.raises(ValueError): PGCli(pgclirc_file=rc) + + +@dbtest +def test_tuples_only_sends_errors_to_stderr(executor, capsys): + # The error of a failed statement travels in `status`, the footer -t + # drops, so -t used to print nothing at all for a broken query. + cli = PGCli(pgexecute=executor, tuples_only=True) + output, query = cli._evaluate_command("select nope") + assert not query.successful + assert 'column "nope" does not exist' in capsys.readouterr().err + assert not any("nope" in line for line in output) + + +@dbtest +def test_tuples_only_rows_stay_on_stdout(executor, capsys): + cli = PGCli(pgexecute=executor, tuples_only=True) + output, query = cli._evaluate_command("select 42") + assert query.successful + assert any("42" in line for line in output) + assert capsys.readouterr().err == ""