Skip to content
Closed
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
30 changes: 29 additions & 1 deletion cli/utils/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -61,11 +62,38 @@ 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]]:
Expand Down
111 changes: 111 additions & 0 deletions tests/test_output.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
from __future__ import annotations

import io
import json
import sys
from unittest.mock import patch

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
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading