From 8756bfe256d2585d05fab709130bb8c635ecedbb Mon Sep 17 00:00:00 2001 From: DiegoDAF Date: Thu, 19 Feb 2026 16:43:48 -0300 Subject: [PATCH 1/3] Rework -t/--tuples-only: set table format only, no header/status suppression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simplified per reviewer feedback (j-bennet): - `-t` is now a pure boolean flag (no format argument) - Sets table_format to csv-noheader at startup (like `\T csv-noheader`) - Does NOT suppress timing or status messages - Removed tuples_only from OutputSettings namedtuple - For custom formats, use `\T FORMAT` interactively Made with ❤️ and 🤖 Claude --- changelog.rst | 4 ++++ pgcli/main.py | 16 +++++++++++++- pgcli/pgexecute.py | 3 +-- tests/test_tuples_only.py | 44 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 tests/test_tuples_only.py diff --git a/changelog.rst b/changelog.rst index a6469b973..9f5f6925a 100644 --- a/changelog.rst +++ b/changelog.rst @@ -8,6 +8,10 @@ Features: reflects the current editing mode: beam in INSERT, block in NORMAL, underline in REPLACE. Uses prompt_toolkit's ``ModalCursorShapeConfig``. * Add support of Python 3.14. +* Add ``-t``/``--tuples-only`` CLI option to set table format at startup. + * Sets table format to ``csv-noheader`` (rows only, no headers) + * CLI shortcut equivalent to ``\T csv-noheader`` + * Does not suppress timing or status messages (use ``\pset`` for that) Bug fixes: ---------- diff --git a/pgcli/main.py b/pgcli/main.py index 433a6bd89..4ad82bb4d 100644 --- a/pgcli/main.py +++ b/pgcli/main.py @@ -180,6 +180,7 @@ def __init__( application_name="pgcli", single_connection=False, less_chatty=None, + tuples_only=None, prompt=None, prompt_dsn=None, auto_vertical_output=False, @@ -237,7 +238,10 @@ def __init__( self.min_num_menu_lines = c["main"].as_int("min_num_menu_lines") self.multiline_continuation_char = c["main"]["multiline_continuation_char"] - self.table_format = c["main"]["table_format"] + if tuples_only: + self.table_format = "csv-noheader" + else: + self.table_format = c["main"]["table_format"] self.syntax_style = c["main"]["syntax_style"] self.cli_style = c["colors"] self.wider_completion_menu = c["main"].as_bool("wider_completion_menu") @@ -1440,6 +1444,14 @@ def echo_via_pager(self, text, color=None): default=False, help="Skip intro on startup and goodbye on exit.", ) +@click.option( + "-t", + "--tuples-only", + "tuples_only", + is_flag=True, + default=False, + help="Print rows only, using csv-noheader format. Same as \\T csv-noheader.", +) @click.option("--prompt", help='Prompt format (Default: "\\u@\\h:\\d> ").') @click.option( "--prompt-dsn", @@ -1503,6 +1515,7 @@ def cli( row_limit, application_name, less_chatty, + tuples_only, prompt, prompt_dsn, list_databases, @@ -1565,6 +1578,7 @@ def cli( application_name=application_name, single_connection=single_connection, less_chatty=less_chatty, + tuples_only=tuples_only, prompt=prompt, prompt_dsn=prompt_dsn, auto_vertical_output=auto_vertical_output, diff --git a/pgcli/pgexecute.py b/pgcli/pgexecute.py index 2864c8645..31d478b6e 100644 --- a/pgcli/pgexecute.py +++ b/pgcli/pgexecute.py @@ -505,8 +505,7 @@ def view_definition(self, spec): else: template = "CREATE OR REPLACE VIEW {name} AS \n{stmt}" return ( - psycopg.sql - .SQL(template) + psycopg.sql.SQL(template) .format( name=psycopg.sql.Identifier(result.nspname, result.relname), stmt=psycopg.sql.SQL(result.viewdef), diff --git a/tests/test_tuples_only.py b/tests/test_tuples_only.py new file mode 100644 index 000000000..24e6bfd32 --- /dev/null +++ b/tests/test_tuples_only.py @@ -0,0 +1,44 @@ +from unittest.mock import patch + +from click.testing import CliRunner + +from pgcli.main import cli, PGCli + + +def test_tuples_only_flag_passed_to_pgcli(): + """Test that -t passes tuples_only=True to PGCli.""" + runner = CliRunner() + with patch.object(PGCli, "__init__", autospec=True, return_value=None) as mock_pgcli: + runner.invoke(cli, ["-t", "mydb"]) + call_kwargs = mock_pgcli.call_args[1] + assert call_kwargs["tuples_only"] is True + + +def test_tuples_only_long_form(): + """Test that --tuples-only passes tuples_only=True to PGCli.""" + runner = CliRunner() + with patch.object(PGCli, "__init__", autospec=True, return_value=None) as mock_pgcli: + runner.invoke(cli, ["--tuples-only", "mydb"]) + call_kwargs = mock_pgcli.call_args[1] + assert call_kwargs["tuples_only"] is True + + +def test_tuples_only_not_set_by_default(): + """Test that tuples_only is False when -t is not used.""" + runner = CliRunner() + with patch.object(PGCli, "__init__", autospec=True, return_value=None) as mock_pgcli: + runner.invoke(cli, ["mydb"]) + call_kwargs = mock_pgcli.call_args[1] + assert call_kwargs["tuples_only"] is False + + +def test_tuples_only_sets_csv_noheader_format(): + """Test that tuples_only=True sets table_format to csv-noheader.""" + pgcli = PGCli(tuples_only=True) + assert pgcli.table_format == "csv-noheader" + + +def test_default_table_format_without_tuples_only(): + """Test that table_format uses config default when tuples_only is False.""" + pgcli = PGCli() + assert pgcli.table_format != "csv-noheader" # Uses config default From c24a71bbbb277b158f8994a7e74dbebbee782caf Mon Sep 17 00:00:00 2001 From: Diego Date: Mon, 11 May 2026 10:48:05 -0300 Subject: [PATCH 2/3] Fix ruff format on pgexecute.py Spurious reformat introduced during rebase; ruff 0.15.11 with preview=true keeps the original split form. Restores upstream layout. --- pgcli/pgexecute.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pgcli/pgexecute.py b/pgcli/pgexecute.py index 31d478b6e..2864c8645 100644 --- a/pgcli/pgexecute.py +++ b/pgcli/pgexecute.py @@ -505,7 +505,8 @@ def view_definition(self, spec): else: template = "CREATE OR REPLACE VIEW {name} AS \n{stmt}" return ( - psycopg.sql.SQL(template) + psycopg.sql + .SQL(template) .format( name=psycopg.sql.Identifier(result.nspname, result.relname), stmt=psycopg.sql.SQL(result.viewdef), From 89117884b0393086c188242da4142aa4906ada5d Mon Sep 17 00:00:00 2001 From: Diego Date: Thu, 27 Aug 2026 09:20:12 -0300 Subject: [PATCH 3/3] Align -t/--tuples-only with psql: print the rows and nothing else Restores the header, title, status and timing suppression, as requested in the review. The suppression now happens at output time instead of forcing the table format to csv-noheader at startup. That keeps two things working: \T still reports (and can change) the configured format mid-session, and the rows come out in an unadorned but aligned layout, the way psql -t does, rather than as CSV. -t wins over expanded output: with the headers suppressed the vertical formatter has no label column left to lay out, and would fail. --- changelog.rst | 14 +++++++++---- pgcli/main.py | 38 ++++++++++++++++++++++++---------- tests/test_tuples_only.py | 43 +++++++++++++++++++++++++++++++-------- 3 files changed, 71 insertions(+), 24 deletions(-) diff --git a/changelog.rst b/changelog.rst index dcaff077a..02a028a4a 100644 --- a/changelog.rst +++ b/changelog.rst @@ -1,3 +1,13 @@ +Upcoming +======== + +Features: +--------- +* Add a ``-t``/``--tuples-only`` command line option that prints the rows and + nothing else, matching psql: no column headers, no title, no status footer + and no timing line. The configured table format is left untouched, so ``\T`` + still reports it and can still change it mid-session. + 4.6.0 (2026-08-26) ================== @@ -53,10 +63,6 @@ Features: * Add the option to force-quit pgcli when a transaction is in progress. * Add support of Python 3.14. * Drop support of Python 3.9. -* Add ``-t``/``--tuples-only`` CLI option to set table format at startup. - * Sets table format to ``csv-noheader`` (rows only, no headers) - * CLI shortcut equivalent to ``\T csv-noheader`` - * Does not suppress timing or status messages (use ``\pset`` for that) Bug fixes: ---------- diff --git a/pgcli/main.py b/pgcli/main.py index 6deba6ff6..a7837b2ff 100644 --- a/pgcli/main.py +++ b/pgcli/main.py @@ -119,7 +119,8 @@ OutputSettings = namedtuple( "OutputSettings", - "table_format dcmlfmt floatfmt column_date_formats missingval expanded max_width case_function style_output max_field_width", + "table_format dcmlfmt floatfmt column_date_formats missingval expanded max_width case_function style_output " + "max_field_width tuples_only", ) OutputSettings.__new__.__defaults__ = ( None, @@ -132,6 +133,7 @@ lambda x: x, None, DEFAULT_MAX_FIELD_WIDTH, + False, ) @@ -280,10 +282,14 @@ def __init__( self.min_num_menu_lines = c["main"].as_int("min_num_menu_lines") self.multiline_continuation_char = c["main"]["multiline_continuation_char"] - if tuples_only: - self.table_format = "csv-noheader" - else: - self.table_format = c["main"]["table_format"] + self.table_format = c["main"]["table_format"] + # psql's -t prints the rows and nothing else: no column headers, no + # title, no status footer and no timing line. The table format is left + # alone here and switched to an unadorned one at output time, so \T + # still reports (and can change) the configured format. + self.tuples_only = bool(tuples_only) + if self.tuples_only: + self.pgspecial.timing_enabled = False self.syntax_style = c["main"]["syntax_style"] self.cli_style = c["colors"] self.wider_completion_menu = c["main"].as_bool("wider_completion_menu") @@ -1274,6 +1280,7 @@ def _evaluate_command(self, text): case_function=(self.completer.case if self.settings["case_column_headers"] else lambda x: x), style_output=self.style_output, max_field_width=self.max_field_width, + tuples_only=self.tuples_only, ) # Hide query text for named queries in quiet mode @@ -1546,7 +1553,7 @@ def echo_via_pager(self, text, color=None): "tuples_only", is_flag=True, default=False, - help="Print rows only, using csv-noheader format. Same as \\T csv-noheader.", + help="Print rows only: no column headers, no status footer and no timing, like psql.", ) @click.option("--prompt", help='Prompt format (Default: "\\u@\\h:\\d> ").') @click.option( @@ -1968,7 +1975,15 @@ def exception_formatter(e, verbose_errors: bool = False): def format_output(title, cur, headers, status, settings, explain_mode=False): output = [] expanded = settings.expanded or settings.table_format == "vertical" - table_format = "vertical" if settings.expanded else settings.table_format + if settings.tuples_only: + # Rows and nothing else, so an unadorned format. This wins over + # expanded output: with the headers suppressed there is no label + # column left for the vertical formatter to lay out. + table_format = "plain" + elif settings.expanded: + table_format = "vertical" + else: + table_format = settings.table_format max_width = settings.max_width case_function = settings.case_function if explain_mode: @@ -2026,11 +2041,12 @@ def format_status(cur, status): dialect = "excel" if platform.system() == "Windows" else "unix" output_kwargs["dialect"] = dialect - if title: # Only print the title if it's not None. + # The title is printed unless there is none, or -t asked for rows only. + if title and not settings.tuples_only: output.append(title) if cur: - headers = [case_function(x) for x in headers] + headers = [] if settings.tuples_only else [case_function(x) for x in headers] if max_width is not None: cur = list(cur) column_types = None @@ -2064,8 +2080,8 @@ def format_status(cur, status): output = itertools.chain(output, formatted) - # Only print the status if it's not None - if status: + # Likewise the status footer. + if status and not settings.tuples_only: output = itertools.chain(output, [format_status(cur, status)]) return output diff --git a/tests/test_tuples_only.py b/tests/test_tuples_only.py index 24e6bfd32..fffc1a387 100644 --- a/tests/test_tuples_only.py +++ b/tests/test_tuples_only.py @@ -2,7 +2,7 @@ from click.testing import CliRunner -from pgcli.main import cli, PGCli +from pgcli.main import cli, format_output, OutputSettings, PGCli def test_tuples_only_flag_passed_to_pgcli(): @@ -32,13 +32,38 @@ def test_tuples_only_not_set_by_default(): assert call_kwargs["tuples_only"] is False -def test_tuples_only_sets_csv_noheader_format(): - """Test that tuples_only=True sets table_format to csv-noheader.""" - pgcli = PGCli(tuples_only=True) - assert pgcli.table_format == "csv-noheader" +def test_tuples_only_leaves_the_configured_table_format_alone(): + """-t must not hijack the table format: \\T still reports what is configured.""" + assert PGCli(tuples_only=True).table_format == PGCli().table_format -def test_default_table_format_without_tuples_only(): - """Test that table_format uses config default when tuples_only is False.""" - pgcli = PGCli() - assert pgcli.table_format != "csv-noheader" # Uses config default +def test_tuples_only_turns_off_timing(): + """psql's -t prints no timing line.""" + assert PGCli(tuples_only=True).pgspecial.timing_enabled is False + + +def test_tuples_only_prints_rows_only(): + """No title, no column headers, no status footer, no table borders.""" + settings = OutputSettings(table_format="psql", tuples_only=True) + output = list(format_output("Title", [(1, "one"), (2, "two")], ["a", "b"], "SELECT 2", settings)) + + assert output == ["1 one", "2 two"] + + +def test_without_tuples_only_everything_is_printed(): + """The counterpart of the test above: by default nothing is suppressed.""" + settings = OutputSettings(table_format="psql", tuples_only=False) + output = "\n".join(format_output("Title", [(1, "one")], ["a", "b"], "SELECT 1", settings)) + + assert "Title" in output + assert "a" in output and "b" in output + assert "SELECT 1" in output + + +def test_tuples_only_wins_over_expanded_output(): + """With the headers gone the vertical formatter has no label column left, + so -t falls back to the unadorned format rather than failing.""" + settings = OutputSettings(table_format="psql", expanded=True, tuples_only=True) + output = list(format_output("Title", [(1, "one")], ["a", "b"], "SELECT 1", settings)) + + assert output == ["1 one"]