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 994f116e5..0215088ae 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" @@ -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) diff --git a/tests/test_main.py b/tests/test_main.py index decae97b1..e4391eb5d 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 = [ @@ -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