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
Expand Up @@ -6,6 +6,16 @@ Features:
* Add a ``\service`` prompt token for connections made using ``service=...``
or ``PGSERVICE``. For example, set ``prompt = '\service> '``.

Bug fixes:
----------
* The ``float`` format of ``[data_formats]`` applies at last, and the numbers of
float and numeric columns line up on their decimal point: the formatter was
left to infer the column types from the values, and cli_helpers files float
and Decimal alike under Decimal, which neither its number formatting nor its
decimal alignment treats as a number. The types now come from the cursor
description (``float4``, ``float8`` and ``numeric`` as float, ``int2`` to
``int8`` as int), in the table and in the auto-vertical fallback.

4.7.1 (2026-09-20)
==================

Expand Down
38 changes: 25 additions & 13 deletions pgcli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2124,6 +2124,25 @@ def exception_formatter(e, verbose_errors: bool = False):
return click.style(s, fg="red")


def column_types_of(cur):
"""One Python type per column of ``cur``, for cli_helpers' number formats.

A numeric column used to get two entries (float, then str from the
fall-through), which shifted the type of every column after it.
"""
column_types = []
for d in cur.description:
col_type = cur.adapters.types.get(d.type_code)
type_name = col_type.name if col_type else None
if type_name in ("numeric", "float4", "float8"):
column_types.append(float)
elif type_name in ("int2", "int4", "int8"):
column_types.append(int)
else:
column_types.append(str)
return column_types


def format_output(title, cur, headers, status, settings, explain_mode=False):
output = []
expanded = settings.expanded or settings.table_format == "vertical"
Expand Down Expand Up @@ -2199,22 +2218,15 @@ def format_status(cur, status):

if cur:
headers = [] if settings.tuples_only else [case_function(x) for x in headers]
# The types come from the cursor, which list(cur) below turns into plain
# rows. Left to infer them from the values, cli_helpers files float and
# Decimal alike under Decimal, which neither format_numbers nor
# align_decimals treats as a number: the float format never applied.
column_types = column_types_of(cur) if hasattr(cur, "description") else None
if max_width is not None:
cur = list(cur)
column_types = None
if hasattr(cur, "description"):
column_types = []
for d in cur.description:
col_type = cur.adapters.types.get(d.type_code)
type_name = col_type.name if col_type else None
if type_name in ("numeric", "float4", "float8"):
column_types.append(float)
if type_name in ("int2", "int4", "int8"):
column_types.append(int)
else:
column_types.append(str)

formatted = formatter.format_output(cur, headers, **output_kwargs)
formatted = formatter.format_output(cur, headers, column_types=column_types, **output_kwargs)
if isinstance(formatted, str):
formatted = iter(formatted.splitlines())
first_line = next(formatted)
Expand Down
51 changes: 51 additions & 0 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,21 @@ def test_format_output_auto_expand():
assert "\n".join(expanded_results) == "\n".join(expanded)


def test_column_types_of_keeps_one_entry_per_column():
# A numeric column used to add two entries (float, then str from the
# fall-through), shifting the type of every column after it; the integer
# format then skipped the ints that followed a numeric.
from types import SimpleNamespace

from pgcli.main import column_types_of

names = {1700: "numeric", 23: "int4", 25: "text", 701: "float8"}
cur = mock.Mock()
cur.description = [SimpleNamespace(type_code=code) for code in (1700, 23, 25, 701, 9999)]
cur.adapters.types.get.side_effect = lambda code: SimpleNamespace(name=names[code]) if code in names else None
assert column_types_of(cur) == [float, int, str, float, str]


termsize = namedtuple("termsize", ["rows", "columns"])
test_line = "-" * 10
test_data = [
Expand Down Expand Up @@ -1087,3 +1102,39 @@ 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_format_output_applies_the_float_format_to_float_and_numeric_columns(executor):
# Left to infer the types from the values, cli_helpers files float and
# Decimal alike under Decimal, which format_numbers skips: the float format
# of [data_formats] never applied. The types now come from the cursor.
with executor.conn.cursor() as cur:
cur.execute("select 1234567::int4 as i, 123456.78::float8 as f, 123456.78::numeric as n")
settings = OutputSettings(table_format="psql", dcmlfmt=",", floatfmt=",.2f")
output = "\n".join(format_output(None, cur, ["i", "f", "n"], "SELECT 1", settings))
assert "1,234,567" in output
assert output.count("123,456.78") == 2


@dbtest
def test_format_output_formats_numbers_in_the_auto_vertical_fallback_too(executor):
with executor.conn.cursor() as cur:
cur.execute("select 1234567::int4 as i, 123456.78::float8 as f")
settings = OutputSettings(table_format="psql", dcmlfmt=",", floatfmt=",.2f", max_width=5)
output = "\n".join(format_output(None, cur, ["i", "f"], "SELECT 1", settings))
assert "RECORD 1" in output and "1,234,567" in output and "123,456.78" in output


@dbtest
def test_format_output_aligns_float_and_numeric_columns_on_the_decimal_point(executor):
# With no float format, align_decimals pads the numbers of a float column
# so their decimal points line up; it needs the column typed as float.
with executor.conn.cursor() as cur:
cur.execute("select n, n::float8 as f from (values (1.5), (123.456)) as t(n) order by 1")
lines = list(format_output(None, cur, ["n", "f"], "SELECT 2", OutputSettings(table_format="psql")))
values = [line for line in lines if "1.5" in line or "123.456" in line]
assert len(values) == 2
for column in (0, 1):
points = {line.split("|")[column + 1].index(".") for line in values}
assert len(points) == 1, values
Loading