From fa71875f9d46c752d4fa2036ebfd91579511431f Mon Sep 17 00:00:00 2001 From: Diego Date: Tue, 20 Oct 2026 16:23:23 -0300 Subject: [PATCH 1/2] Give format_output one column type per column A numeric column appended float and then, through the fall-through else, str as well, so every column after it carried the wrong type and the integer format skipped the ints that followed a numeric. Only the auto-vertical fallback with float_format or integer_format set was affected. --- pgcli/main.py | 32 ++++++++++++++++++++------------ tests/test_main.py | 15 +++++++++++++++ 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/pgcli/main.py b/pgcli/main.py index 994f116e5..2b3beff3e 100644 --- a/pgcli/main.py +++ b/pgcli/main.py @@ -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" @@ -2201,18 +2220,7 @@ def format_status(cur, status): 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 - 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) + column_types = column_types_of(cur) if hasattr(cur, "description") else None formatted = formatter.format_output(cur, headers, **output_kwargs) if isinstance(formatted, str): diff --git a/tests/test_main.py b/tests/test_main.py index decae97b1..3b33944fa 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -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 = [ From df8e7a19b1773c41b32aa39ddd68f4aa9692a817 Mon Sep 17 00:00:00 2001 From: Diego Date: Wed, 21 Oct 2026 11:36:19 -0300 Subject: [PATCH 2/2] Take the column types from the cursor, not from the values cli_helpers infers a column's type from its values when none is given, and its ranking files float and Decimal alike under Decimal, which neither format_numbers nor align_decimals treats as a number. So the float format of [data_formats] never applied, and float and numeric columns were never aligned on the decimal point; the types pgcli did compute came after list(cur) had dropped the description, so they were always None. The types now come from the cursor description before the rows are materialised, and go to both the table and the auto-vertical fallback. --- changelog.rst | 10 ++++++++++ pgcli/main.py | 8 ++++++-- tests/test_main.py | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/changelog.rst b/changelog.rst index 81fbefe78..1bad0b26f 100644 --- a/changelog.rst +++ b/changelog.rst @@ -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) ================== diff --git a/pgcli/main.py b/pgcli/main.py index 2b3beff3e..0215088ae 100644 --- a/pgcli/main.py +++ b/pgcli/main.py @@ -2218,11 +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 = column_types_of(cur) if hasattr(cur, "description") else None - 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) diff --git a/tests/test_main.py b/tests/test_main.py index 3b33944fa..e4391eb5d 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1102,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