diff --git a/changelog.rst b/changelog.rst index 0fe57c725..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) ================== diff --git a/pgcli/main.py b/pgcli/main.py index 433c25357..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, ) @@ -221,6 +223,7 @@ def __init__( application_name="pgcli", single_connection=False, less_chatty=None, + tuples_only=None, prompt=None, prompt_dsn=None, auto_vertical_output=False, @@ -280,6 +283,13 @@ 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"] + # 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") @@ -1270,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 @@ -1536,6 +1547,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: no column headers, no status footer and no timing, like psql.", +) @click.option("--prompt", help='Prompt format (Default: "\\u@\\h:\\d> ").') @click.option( "--prompt-dsn", @@ -1599,6 +1618,7 @@ def cli( row_limit, application_name, less_chatty, + tuples_only, prompt, prompt_dsn, list_databases, @@ -1662,6 +1682,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, @@ -1954,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: @@ -2012,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 @@ -2050,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 new file mode 100644 index 000000000..fffc1a387 --- /dev/null +++ b/tests/test_tuples_only.py @@ -0,0 +1,69 @@ +from unittest.mock import patch + +from click.testing import CliRunner + +from pgcli.main import cli, format_output, OutputSettings, 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_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_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"]