Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions changelog.rst
Original file line number Diff line number Diff line change
@@ -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)
==================

Expand Down
42 changes: 36 additions & 6 deletions pgcli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -132,6 +133,7 @@
lambda x: x,
None,
DEFAULT_MAX_FIELD_WIDTH,
False,
)


Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -1599,6 +1618,7 @@ def cli(
row_limit,
application_name,
less_chatty,
tuples_only,
prompt,
prompt_dsn,
list_databases,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
69 changes: 69 additions & 0 deletions tests/test_tuples_only.py
Original file line number Diff line number Diff line change
@@ -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"]
Loading