From 07d349b4b0ca0c7d4ee7b93f3c9ed31db09da3ad Mon Sep 17 00:00:00 2001 From: sylvester Date: Wed, 18 Mar 2026 02:59:46 +0000 Subject: [PATCH 1/3] feat(cli): flatten nested fields in table and CSV output Nested dicts are promoted to dot-notation columns (e.g. info.symbol). Lists are serialized as JSON strings. CSV values for any remaining nested types use json.dumps instead of Python repr (single quotes). Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/utils/output.py | 26 +++++++++- tests/test_output.py | 113 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 tests/test_output.py diff --git a/cli/utils/output.py b/cli/utils/output.py index c8a7096..8fb83a8 100644 --- a/cli/utils/output.py +++ b/cli/utils/output.py @@ -40,6 +40,7 @@ def _table(self, data: Any) -> None: self._console.print_json(json.dumps(data, default=str)) return + rows = _flatten_rows(rows) columns = list(rows[0].keys()) numeric_cols = _detect_numeric_columns(rows, columns) @@ -61,11 +62,34 @@ def _csv(self, data: Any) -> None: ) sys.exit(EXIT_ERROR) + rows = _flatten_rows(rows) columns = list(rows[0].keys()) writer = csv.DictWriter(sys.stdout, fieldnames=columns) writer.writeheader() for row in rows: - writer.writerow({k: str(v) for k, v in row.items()}) + writer.writerow({ + k: json.dumps(v, default=str) if isinstance(v, (dict, list)) else str(v) + for k, v in row.items() + }) + + +def _flatten_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Flatten nested dicts into dot-notation columns (one level deep).""" + if not rows: + return rows + flat: list[dict[str, Any]] = [] + for row in rows: + new_row: dict[str, Any] = {} + for key, value in row.items(): + if isinstance(value, dict): + for sub_key, sub_value in value.items(): + new_row[f"{key}.{sub_key}"] = sub_value + elif isinstance(value, list): + new_row[key] = json.dumps(value, default=str) + else: + new_row[key] = value + flat.append(new_row) + return flat def _extract_rows(data: Any) -> list[dict[str, Any]]: diff --git a/tests/test_output.py b/tests/test_output.py new file mode 100644 index 0000000..509926b --- /dev/null +++ b/tests/test_output.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import io +import json +import sys +from unittest.mock import patch + +import pytest + +from cli.types.enums import OutputFormat +from cli.utils.output import OutputRenderer, _flatten_rows + + +class TestFlattenRows: + def test_nested_dicts_produce_dot_notation_keys(self): + rows = [ + {"id": 1, "info": {"name": "Tether USD", "symbol": "USDT"}}, + {"id": 2, "info": {"name": "USD Coin", "symbol": "USDC"}}, + ] + result = _flatten_rows(rows) + assert result == [ + {"id": 1, "info.name": "Tether USD", "info.symbol": "USDT"}, + {"id": 2, "info.name": "USD Coin", "info.symbol": "USDC"}, + ] + + def test_lists_serialized_as_json(self): + rows = [{"id": 1, "tags": ["stablecoin", "erc20"]}] + result = _flatten_rows(rows) + assert result == [{"id": 1, "tags": '["stablecoin", "erc20"]'}] + # Verify it's valid JSON + assert json.loads(result[0]["tags"]) == ["stablecoin", "erc20"] + + def test_flat_data_passes_through_unchanged(self): + rows = [ + {"id": 1, "name": "Tether", "price": "1.00"}, + {"id": 2, "name": "Ethereum", "price": "3000.00"}, + ] + result = _flatten_rows(rows) + assert result == rows + + def test_empty_rows_returns_empty(self): + assert _flatten_rows([]) == [] + + def test_mixed_nested_and_flat_values(self): + rows = [ + { + "id": 1, + "name": "Token", + "info": {"symbol": "TKN"}, + "tags": ["defi"], + "price": "1.50", + } + ] + result = _flatten_rows(rows) + assert result == [ + { + "id": 1, + "name": "Token", + "info.symbol": "TKN", + "tags": '["defi"]', + "price": "1.50", + } + ] + + +class TestCsvOutput: + def test_csv_no_python_repr(self): + """CSV output should use JSON (double quotes) not Python repr (single quotes).""" + data = [ + { + "id": 1, + "info": {"name": "Tether USD", "symbol": "USDT"}, + "tags": ["stablecoin"], + } + ] + buf = io.StringIO() + renderer = OutputRenderer() + with patch.object(sys, "stdout", buf): + renderer.render(data, OutputFormat.CSV) + + output = buf.getvalue() + # Should not contain Python-style single-quoted dicts + assert "{'name'" not in output + assert "'symbol'" not in output + # Flattened dict keys should appear as columns + assert "info.name" in output + assert "info.symbol" in output + assert "Tether USD" in output + # Tags should be valid JSON + lines = output.strip().split("\n") + assert len(lines) == 2 # header + 1 row + + +class TestTableOutput: + def test_table_with_nested_data_shows_flattened_columns(self): + """Table output should show flattened dot-notation columns.""" + from rich.console import Console + + buf = io.StringIO() + console = Console(file=buf, width=200) + renderer = OutputRenderer(console=console) + + data = [ + {"id": 1, "info": {"name": "Tether", "symbol": "USDT"}}, + {"id": 2, "info": {"name": "Ethereum", "symbol": "ETH"}}, + ] + renderer.render(data, OutputFormat.TABLE) + + output = buf.getvalue() + assert "info.name" in output + assert "info.symbol" in output + assert "Tether" in output + assert "ETH" in output From 6e305eb848947f749d1776ec76c861639c61afc0 Mon Sep 17 00:00:00 2001 From: sylvester Date: Wed, 18 Mar 2026 10:15:20 +0000 Subject: [PATCH 2/3] fix lint --- cli/utils/output.py | 12 ++++++++---- tests/test_output.py | 2 -- uv.lock | 4 ++-- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/cli/utils/output.py b/cli/utils/output.py index 8fb83a8..3f3afa6 100644 --- a/cli/utils/output.py +++ b/cli/utils/output.py @@ -67,10 +67,14 @@ def _csv(self, data: Any) -> None: writer = csv.DictWriter(sys.stdout, fieldnames=columns) writer.writeheader() for row in rows: - writer.writerow({ - k: json.dumps(v, default=str) if isinstance(v, (dict, list)) else str(v) - for k, v in row.items() - }) + writer.writerow( + { + k: json.dumps(v, default=str) + if isinstance(v, (dict, list)) + else str(v) + for k, v in row.items() + } + ) def _flatten_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: diff --git a/tests/test_output.py b/tests/test_output.py index 509926b..26513b3 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -5,8 +5,6 @@ import sys from unittest.mock import patch -import pytest - from cli.types.enums import OutputFormat from cli.utils.output import OutputRenderer, _flatten_rows diff --git a/uv.lock b/uv.lock index fb19041..50f216e 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.12" [[package]] @@ -111,7 +111,7 @@ wheels = [ [[package]] name = "allium-cli" -version = "0.2.1" +version = "0.3.0" source = { editable = "." } dependencies = [ { name = "click" }, From aa1e373009d3f944bdadb2d780bcbf9884ea3efb Mon Sep 17 00:00:00 2001 From: Sylvester Chin Date: Wed, 18 Mar 2026 10:16:10 +0000 Subject: [PATCH 3/3] Apply suggestion from @slai11 --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 50f216e..18bd610 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.12" [[package]]