# Copyright Kevin Deldycke <kevin@deldycke.com> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
from __future__ import annotations
import csv
import json
from pathlib import PurePosixPath
import hjson
import pytest
import tabulate
import tomlkit
import xmltodict
import yaml
from boltons.strutils import strip_ansi
from extra_platforms import is_windows
from click_extra import (
Color,
command,
echo,
pass_context,
style,
table_format_option,
)
from click_extra.pytest import command_decorators
from click_extra.table import (
SERIALIZATION_FORMATS,
SortByOption,
TableFormat,
_apply_default,
_column_sort_key,
_strip_none,
print_data,
print_sorted_table,
print_table,
render_table,
serialize_data,
)
aligned_table = """\
Day Temperature
1 42.9
2
Friday Hot 🥵
"""
asciidoc_table = (
'[cols="<8,<13",options="header"]\n'
"|====\n"
"| Day | Temperature \n"
"| 1 | 42.9 \n"
"| 2 | \n"
"| Friday | Hot 🥵 \n"
"|====\n"
)
colon_grid_table = """\
+--------+-------------+
| Day | Temperature |
+:=======+:============+
| 1 | 42.9 |
+--------+-------------+
| 2 | |
+--------+-------------+
| Friday | Hot 🥵 |
+--------+-------------+
"""
csv_table = """\
Day,Temperature\r
1,42.9\r
2,\r
Friday,Hot 🥵\r
"""
csv_excel_table = csv_table
csv_excel_tab_table = """\
Day\tTemperature\r
1\t42.9\r
2\t\r
Friday\tHot 🥵\r
"""
csv_unix_table = """\
"Day","Temperature"
"1","42.9"
"2",""
"Friday","Hot 🥵"
"""
double_grid_table = """\
╔════════╦═════════════╗
║ Day ║ Temperature ║
╠════════╬═════════════╣
║ 1 ║ 42.9 ║
╠════════╬═════════════╣
║ 2 ║ ║
╠════════╬═════════════╣
║ Friday ║ Hot 🥵 ║
╚════════╩═════════════╝
"""
double_outline_table = """\
╔════════╦═════════════╗
║ Day ║ Temperature ║
╠════════╬═════════════╣
║ 1 ║ 42.9 ║
║ 2 ║ ║
║ Friday ║ Hot 🥵 ║
╚════════╩═════════════╝
"""
fancy_grid_table = """\
╒════════╤═════════════╕
│ Day │ Temperature │
╞════════╪═════════════╡
│ 1 │ 42.9 │
├────────┼─────────────┤
│ 2 │ │
├────────┼─────────────┤
│ Friday │ Hot 🥵 │
╘════════╧═════════════╛
"""
fancy_outline_table = """\
╒════════╤═════════════╕
│ Day │ Temperature │
╞════════╪═════════════╡
│ 1 │ 42.9 │
│ 2 │ │
│ Friday │ Hot 🥵 │
╘════════╧═════════════╛
"""
github_table = """\
| Day | Temperature |
| :----- | :---------- |
| 1 | 42.9 |
| 2 | |
| Friday | Hot 🥵 |
"""
grid_table = """\
+--------+-------------+
| Day | Temperature |
+========+=============+
| 1 | 42.9 |
+--------+-------------+
| 2 | |
+--------+-------------+
| Friday | Hot 🥵 |
+--------+-------------+
"""
heavy_grid_table = """\
┏━━━━━━━━┳━━━━━━━━━━━━━┓
┃ Day ┃ Temperature ┃
┣━━━━━━━━╋━━━━━━━━━━━━━┫
┃ 1 ┃ 42.9 ┃
┣━━━━━━━━╋━━━━━━━━━━━━━┫
┃ 2 ┃ ┃
┣━━━━━━━━╋━━━━━━━━━━━━━┫
┃ Friday ┃ Hot 🥵 ┃
┗━━━━━━━━┻━━━━━━━━━━━━━┛
"""
heavy_outline_table = """\
┏━━━━━━━━┳━━━━━━━━━━━━━┓
┃ Day ┃ Temperature ┃
┣━━━━━━━━╋━━━━━━━━━━━━━┫
┃ 1 ┃ 42.9 ┃
┃ 2 ┃ ┃
┃ Friday ┃ Hot 🥵 ┃
┗━━━━━━━━┻━━━━━━━━━━━━━┛
"""
hjson_table = """\
[
{
Day: 1
Temperature: 42.9
}
{
Day: 2
Temperature: null
}
{
Day: Friday
Temperature: Hot 🥵
}
]
"""
html_table = """\
<table>
<thead>
<tr><th>Day </th><th>Temperature</th></tr>
</thead>
<tbody>
<tr><td>1 </td><td>42.9 </td></tr>
<tr><td>2 </td><td> </td></tr>
<tr><td>Friday</td><td>Hot 🥵 </td></tr>
</tbody>
</table>
"""
json_table = """\
[
{
"Day": 1,
"Temperature": 42.9
},
{
"Day": 2,
"Temperature": null
},
{
"Day": "Friday",
"Temperature": "Hot 🥵"
}
]
"""
json5_table = json_table
jsonc_table = json_table
jira_table = """\
|| Day || Temperature ||
| 1 | 42.9 |
| 2 | |
| Friday | Hot 🥵 |
"""
latex_table = """\
\\begin{tabular}{ll}
\\hline
Day & Temperature \\\\
\\hline
1 & 42.9 \\\\
2 & \\\\
Friday & Hot 🥵 \\\\
\\hline
\\end{tabular}
"""
latex_booktabs_table = """\
\\begin{tabular}{ll}
\\toprule
Day & Temperature \\\\
\\midrule
1 & 42.9 \\\\
2 & \\\\
Friday & Hot 🥵 \\\\
\\bottomrule
\\end{tabular}
"""
latex_longtable_table = """\
\\begin{longtable}{ll}
\\hline
Day & Temperature \\\\
\\hline
\\endhead
1 & 42.9 \\\\
2 & \\\\
Friday & Hot 🥵 \\\\
\\hline
\\end{longtable}
"""
latex_raw_table = """\
\\begin{tabular}{ll}
\\hline
Day & Temperature \\\\
\\hline
1 & 42.9 \\\\
2 & \\\\
Friday & Hot 🥵 \\\\
\\hline
\\end{tabular}
"""
mediawiki_table = """\
{| class="wikitable" style="text-align: left;"
|+ <!-- caption -->
|-
! Day !! Temperature
|-
| 1 || 42.9
|-
| 2 ||
|-
| Friday || Hot 🥵
|}
"""
mixed_grid_table = """\
┍━━━━━━━━┯━━━━━━━━━━━━━┑
│ Day │ Temperature │
┝━━━━━━━━┿━━━━━━━━━━━━━┥
│ 1 │ 42.9 │
├────────┼─────────────┤
│ 2 │ │
├────────┼─────────────┤
│ Friday │ Hot 🥵 │
┕━━━━━━━━┷━━━━━━━━━━━━━┙
"""
mixed_outline_table = """\
┍━━━━━━━━┯━━━━━━━━━━━━━┑
│ Day │ Temperature │
┝━━━━━━━━┿━━━━━━━━━━━━━┥
│ 1 │ 42.9 │
│ 2 │ │
│ Friday │ Hot 🥵 │
┕━━━━━━━━┷━━━━━━━━━━━━━┙
"""
moinmoin_table = """\
|| ''' Day ''' || ''' Temperature ''' ||
|| 1 || 42.9 ||
|| 2 || ||
|| Friday || Hot 🥵 ||
"""
orgtbl_table = """\
| Day | Temperature |
|--------+-------------|
| 1 | 42.9 |
| 2 | |
| Friday | Hot 🥵 |
"""
outline_table = """\
+--------+-------------+
| Day | Temperature |
+========+=============+
| 1 | 42.9 |
| 2 | |
| Friday | Hot 🥵 |
+--------+-------------+
"""
pipe_table = """\
| Day | Temperature |
| :----- | :---------- |
| 1 | 42.9 |
| 2 | |
| Friday | Hot 🥵 |
"""
plain_table = """\
Day Temperature
1 42.9
2
Friday Hot 🥵
"""
presto_table = """\
Day | Temperature
--------+-------------
1 | 42.9
2 |
Friday | Hot 🥵
"""
pretty_table = """\
+--------+-------------+
| Day | Temperature |
+--------+-------------+
| 1 | 42.9 |
| 2 | |
| Friday | Hot 🥵 |
+--------+-------------+
"""
psql_table = """\
+--------+-------------+
| Day | Temperature |
|--------+-------------|
| 1 | 42.9 |
| 2 | |
| Friday | Hot 🥵 |
+--------+-------------+
"""
rounded_grid_table = """\
╭────────┬─────────────╮
│ Day │ Temperature │
├────────┼─────────────┤
│ 1 │ 42.9 │
├────────┼─────────────┤
│ 2 │ │
├────────┼─────────────┤
│ Friday │ Hot 🥵 │
╰────────┴─────────────╯
"""
rounded_outline_table = """\
╭────────┬─────────────╮
│ Day │ Temperature │
├────────┼─────────────┤
│ 1 │ 42.9 │
│ 2 │ │
│ Friday │ Hot 🥵 │
╰────────┴─────────────╯
"""
rst_table = """\
====== ===========
Day Temperature
====== ===========
1 42.9
2
Friday Hot 🥵
====== ===========
"""
simple_table = """\
Day Temperature
------ -----------
1 42.9
2
Friday Hot 🥵
"""
simple_grid_table = """\
┌────────┬─────────────┐
│ Day │ Temperature │
├────────┼─────────────┤
│ 1 │ 42.9 │
├────────┼─────────────┤
│ 2 │ │
├────────┼─────────────┤
│ Friday │ Hot 🥵 │
└────────┴─────────────┘
"""
simple_outline_table = """\
┌────────┬─────────────┐
│ Day │ Temperature │
├────────┼─────────────┤
│ 1 │ 42.9 │
│ 2 │ │
│ Friday │ Hot 🥵 │
└────────┴─────────────┘
"""
textile_table = """\
|_. Day |_. Temperature |
|<. 1 |<. 42.9 |
|<. 2 |<. |
|<. Friday |<. Hot 🥵 |
"""
toml_table = """\
[[record]]
Day = 1
Temperature = 42.9
[[record]]
Day = 2
[[record]]
Day = "Friday"
Temperature = "Hot 🥵"
"""
tsv_table = "Day \tTemperature\n1 \t42.9\n2\nFriday\tHot 🥵\n"
unsafehtml_table = """\
<table>
<thead>
<tr><th>Day </th><th>Temperature</th></tr>
</thead>
<tbody>
<tr><td>1 </td><td>42.9 </td></tr>
<tr><td>2 </td><td> </td></tr>
<tr><td>Friday</td><td>Hot 🥵 </td></tr>
</tbody>
</table>
"""
vertical_table = (
"***************************[ 1. row ]***************************\n"
"Day | 1\n"
"Temperature | 42.9\n"
"***************************[ 2. row ]***************************\n"
"Day | 2\n"
"Temperature | \n"
"***************************[ 3. row ]***************************\n"
"Day | Friday\n"
"Temperature | Hot 🥵\n"
)
xml_table = """\
<records>
\t<record>
\t\t<Day>1</Day>
\t\t<Temperature>42.9</Temperature>
\t</record>
\t<record>
\t\t<Day>2</Day>
\t</record>
\t<record>
\t\t<Day>Friday</Day>
\t\t<Temperature>Hot 🥵</Temperature>
\t</record>
</records>
"""
yaml_table = """\
- Day: 1
Temperature: 42.9
- Day: 2
Temperature: null
- Day: Friday
Temperature: Hot 🥵
"""
youtrack_table = """\
|| Day || Temperature ||
| 1 | 42.9 |
| 2 | |
| Friday | Hot 🥵 |
"""
expected_renderings = {
TableFormat.ALIGNED: aligned_table,
TableFormat.ASCIIDOC: asciidoc_table,
TableFormat.COLON_GRID: colon_grid_table,
TableFormat.CSV: csv_table,
TableFormat.CSV_EXCEL: csv_excel_table,
TableFormat.CSV_EXCEL_TAB: csv_excel_tab_table,
TableFormat.CSV_UNIX: csv_unix_table,
TableFormat.DOUBLE_GRID: double_grid_table,
TableFormat.DOUBLE_OUTLINE: double_outline_table,
TableFormat.FANCY_GRID: fancy_grid_table,
TableFormat.FANCY_OUTLINE: fancy_outline_table,
TableFormat.GITHUB: github_table,
TableFormat.GRID: grid_table,
TableFormat.HEAVY_GRID: heavy_grid_table,
TableFormat.HEAVY_OUTLINE: heavy_outline_table,
TableFormat.HJSON: hjson_table,
TableFormat.HTML: html_table,
TableFormat.JIRA: jira_table,
TableFormat.JSON: json_table,
TableFormat.JSON5: json5_table,
TableFormat.JSONC: jsonc_table,
TableFormat.LATEX: latex_table,
TableFormat.LATEX_BOOKTABS: latex_booktabs_table,
TableFormat.LATEX_LONGTABLE: latex_longtable_table,
TableFormat.LATEX_RAW: latex_raw_table,
TableFormat.MEDIAWIKI: mediawiki_table,
TableFormat.MIXED_GRID: mixed_grid_table,
TableFormat.MIXED_OUTLINE: mixed_outline_table,
TableFormat.MOINMOIN: moinmoin_table,
TableFormat.ORGTBL: orgtbl_table,
TableFormat.OUTLINE: outline_table,
TableFormat.PIPE: pipe_table,
TableFormat.PLAIN: plain_table,
TableFormat.PRESTO: presto_table,
TableFormat.PRETTY: pretty_table,
TableFormat.PSQL: psql_table,
TableFormat.ROUNDED_GRID: rounded_grid_table,
TableFormat.ROUNDED_OUTLINE: rounded_outline_table,
TableFormat.RST: rst_table,
TableFormat.SIMPLE: simple_table,
TableFormat.SIMPLE_GRID: simple_grid_table,
TableFormat.SIMPLE_OUTLINE: simple_outline_table,
TableFormat.TEXTILE: textile_table,
TableFormat.TOML: toml_table,
TableFormat.TSV: tsv_table,
TableFormat.UNSAFEHTML: unsafehtml_table,
TableFormat.VERTICAL: vertical_table,
TableFormat.XML: xml_table,
TableFormat.YAML: yaml_table,
TableFormat.YOUTRACK: youtrack_table,
}
[docs]
@pytest.mark.parametrize("cmd_decorator", command_decorators(no_groups=True))
@pytest.mark.parametrize(
"option_decorator", (table_format_option, table_format_option())
)
@pytest.mark.parametrize(
("format_name", "expected"),
(pytest.param(k, v, id=str(k)) for k, v in expected_renderings.items()),
)
def test_all_table_rendering(
invoke, cmd_decorator, option_decorator, format_name, expected
):
@cmd_decorator
@option_decorator
@pass_context
def table_cli(ctx):
format_id = ctx.meta["click_extra.table_format"]
echo(f"Table format: {format_id}")
headers = ("Day", "Temperature")
data = (
(1, 42.9),
(2, None),
(style("Friday", fg=Color.blue), style("Hot 🥵", fg=Color.red, bold=True)),
)
ctx.print_table(data, headers)
result = invoke(table_cli, "--table-format", format_name, color=False)
if not is_windows():
expected = expected.replace("\r\n", "\n")
assert result.stdout == f"Table format: {format_name}\n{expected}"
assert not result.stderr
assert result.exit_code == 0
[docs]
@pytest.mark.parametrize(
"format_id",
(pytest.param(f, id=str(f)) for f in TableFormat if f.is_markup),
)
def test_markup_strips_ansi_by_default(invoke, format_id):
"""Markup formats strip ANSI codes when ``--color`` is not forced."""
@command
@table_format_option
@pass_context
def table_cli(ctx):
data = ((style("hello", fg=Color.red),),)
ctx.print_table(data, headers=("greeting",))
# color=True tells the CliRunner to preserve ANSI in captured output so we
# can inspect what print_table actually wrote.
result = invoke(table_cli, "--table-format", format_id, color=True)
assert result.exit_code == 0
assert result.stdout == strip_ansi(result.stdout)
[docs]
@pytest.mark.parametrize(
"format_id",
(
pytest.param(f, id=str(f))
for f in TableFormat
if f.is_markup and f not in SERIALIZATION_FORMATS
),
)
def test_markup_preserves_ansi_with_color_flag(invoke, format_id):
"""``--color`` overrides ANSI stripping for markup formats."""
@command
@table_format_option
@pass_context
def table_cli(ctx):
data = ((style("hello", fg=Color.red),),)
ctx.print_table(data, headers=("greeting",))
result = invoke(table_cli, "--color", "--table-format", format_id, color=True)
assert result.exit_code == 0
assert result.stdout != strip_ansi(result.stdout)
[docs]
@pytest.mark.parametrize(
("table_format", "data"),
(
pytest.param(TableFormat.JSON, {"a": {"b": [1, 2, 3]}}, id="json"),
pytest.param(TableFormat.JSON5, {"key": "value"}, id="json5"),
pytest.param(TableFormat.JSONC, [1, 2, 3], id="jsonc"),
),
)
def test_serialize_json_compatible(table_format, data):
result = serialize_data(data, table_format)
assert json.loads(result) == data
[docs]
@pytest.mark.parametrize(
("table_format", "data", "loader"),
(
pytest.param(
TableFormat.HJSON,
{"name": "test", "count": 42},
hjson.loads,
id="hjson",
),
pytest.param(
TableFormat.TOML,
{"section": {"key": "value"}},
tomlkit.loads,
id="toml",
),
pytest.param(
TableFormat.YAML,
{"managers": {"brew": {"version": "4.0"}}},
yaml.safe_load,
id="yaml",
),
),
)
def test_serialize_roundtrip(table_format, data, loader):
result = serialize_data(data, table_format)
assert loader(result) == data
[docs]
def test_serialize_toml_list_wrapping():
"""Top-level lists are wrapped under a ``record`` key for TOML."""
data = [{"id": "a"}, {"id": "b"}]
result = serialize_data(data, TableFormat.TOML)
parsed = tomlkit.loads(result)
assert parsed == {"record": data}
[docs]
@pytest.mark.parametrize(
"table_format",
(
pytest.param(TableFormat.TOML, id="toml"),
pytest.param(TableFormat.XML, id="xml"),
),
)
def test_serialize_strips_none(table_format):
"""TOML and XML have no null type. ``None`` values are omitted."""
data = {"present": "yes", "absent": None}
result = serialize_data(data, table_format)
assert "absent" not in result
[docs]
def test_serialize_xml():
data = {"item": {"name": "test"}}
result = serialize_data(data, TableFormat.XML)
parsed = xmltodict.parse(f"<root>{result}</root>")
assert parsed["root"]["records"]["item"]["name"] == "test"
[docs]
def test_serialize_xml_list_wrapping():
"""Top-level lists are wrapped under a ``record`` key for XML."""
data = [{"id": "a"}, {"id": "b"}]
result = serialize_data(data, TableFormat.XML)
parsed = xmltodict.parse(f"<root>{result}</root>")
records = parsed["root"]["records"]["record"]
assert len(records) == 2
assert records[0]["id"] == "a"
[docs]
def test_serialize_xml_custom_root_element():
data = {"key": "value"}
result = serialize_data(data, TableFormat.XML, root_element="mpm")
assert "<mpm>" in result
[docs]
def test_serialize_default_callback():
"""Custom types are converted via the ``default`` callback."""
data = {"path": PurePosixPath("/usr/bin"), "name": "test"}
result = serialize_data(
data,
TableFormat.JSON,
default=lambda obj: str(obj) if isinstance(obj, PurePosixPath) else obj,
)
parsed = json.loads(result)
assert parsed == {"path": "/usr/bin", "name": "test"}
[docs]
@pytest.mark.parametrize(
("data", "expected"),
(
pytest.param({"a": 1, "b": None}, {"a": 1}, id="flat-dict"),
pytest.param(
{"a": {"b": None, "c": 2}, "d": [{"e": None, "f": 3}]},
{"a": {"c": 2}, "d": [{"f": 3}]},
id="nested",
),
pytest.param("hello", "hello", id="passthrough-string"),
pytest.param(42, 42, id="passthrough-int"),
),
)
def test_strip_none(data, expected):
assert _strip_none(data) == expected
[docs]
def test_apply_default_native_types_unchanged():
data = {"s": "a", "i": 1, "f": 1.5, "b": True, "n": None}
assert _apply_default(data, lambda x: x) == data
[docs]
@pytest.mark.parametrize(
("data", "expected"),
(
pytest.param({"p": PurePosixPath("/tmp")}, {"p": "/tmp"}, id="dict"),
pytest.param(
[PurePosixPath("/a"), [PurePosixPath("/b")]],
["/a", ["/b"]],
id="nested-lists",
),
),
)
def test_apply_default_custom_type_converted(data, expected):
result = _apply_default(data, lambda obj: str(obj))
assert result == expected
[docs]
@pytest.mark.parametrize(
("func", "args", "kwargs", "match"),
(
pytest.param(
print_data,
({"a": 1}, TableFormat.YAML),
{},
"pip install click-extra",
id="print-data-default-package",
),
pytest.param(
print_data,
({"a": 1}, TableFormat.YAML),
{"package": "my-project"},
"pip install my-project",
id="print-data-custom-package",
),
pytest.param(
print_table,
([["a"]], ["col"], TableFormat.YAML),
{},
"pip install click-extra",
id="print-table",
),
),
)
def test_missing_dependency_clean_error(monkeypatch, func, args, kwargs, match):
"""Missing optional dependency produces a clean error, no traceback."""
monkeypatch.setitem(__import__("sys").modules, "yaml", None)
with pytest.raises(SystemExit, match=match):
func(*args, **kwargs)
[docs]
@pytest.mark.parametrize(
("data", "headers", "sort_key", "expected_fruits"),
(
pytest.param(
[["banana", "3"], ["apple", "1"], ["cherry", "2"]],
["Fruit", "Count"],
lambda row: row[0],
["apple", "banana", "cherry"],
id="with-sort-key",
),
pytest.param(
[["banana"], ["apple"]],
["Fruit"],
None,
["banana", "apple"],
id="preserves-original-order",
),
),
)
def test_render_table_sort(data, headers, sort_key, expected_fruits):
result = render_table(
data,
headers=headers,
table_format=TableFormat.JSON,
sort_key=sort_key,
)
parsed = json.loads(result)
assert [r["Fruit"] for r in parsed] == expected_fruits
[docs]
@pytest.mark.parametrize(
("header_defs", "rows", "sort_columns", "cell_key", "expected_first_col"),
(
pytest.param(
[("Name", "name"), ("Age", "age"), ("City", None)],
[["Bob", "30", "NYC"], ["Alice", "25", "LA"], ["Charlie", "25", "SF"]],
("age",),
None,
["Alice", "Charlie", "Bob"],
id="primary-sort",
),
pytest.param(
[("Name", "name"), ("Age", "age")],
[["Bob", "25"], ["Alice", "30"]],
None,
None,
["Alice", "Bob"],
id="default-order",
),
pytest.param(
[("Name", "name"), ("Count", "count")],
[["a", "10"], ["b", "2"], ["c", "1"]],
("count",),
lambda v: (0, int(v)) if v and v.isdigit() else (1, v or ""),
["c", "b", "a"],
id="custom-cell-key",
),
pytest.param(
[("First", "name"), ("Last", "name")],
[["Bob", "Smith"], ["Alice", "Jones"]],
("name",),
None,
["Alice", "Bob"],
id="duplicate-keys-last-index-wins",
),
pytest.param(
[("Name", ""), ("Age", "age")],
[["Bob", "25"], ["Alice", "30"]],
("",),
None,
["Alice", "Bob"],
id="empty-key-falls-back-to-default",
),
pytest.param(
[("Name", "name")],
[["\x1b[31mBob\x1b[0m"], ["\x1b[32mAlice\x1b[0m"]],
("name",),
None,
["\x1b[32mAlice\x1b[0m", "\x1b[31mBob\x1b[0m"],
id="ansi-stripped-for-sort",
),
pytest.param(
[("Fruit", "fruit")],
[["🍒 cherry"], ["🍌 banana"], ["🍎 apple"]],
("fruit",),
None,
["🍌 banana", "🍎 apple", "🍒 cherry"],
id="emoji-in-cells",
),
pytest.param(
[("City", "city"), ("Name", "name"), ("Age", "age")],
[
["NYC", "Alice", "30"],
["LA", "Bob", "25"],
["SF", "Alice", "25"],
],
("name", "age"),
None,
["SF", "NYC", "LA"],
id="multi-column-priority",
),
),
)
def test_column_sort_key(header_defs, rows, sort_columns, cell_key, expected_first_col):
key = _column_sort_key(header_defs, sort_columns, cell_key)
result = sorted(rows, key=key)
assert [r[0] for r in result] == expected_first_col
[docs]
def test_print_sorted_table_empty_rows(capsys):
"""Empty table produces no output."""
print_sorted_table(
header_defs=[("Name", "name")],
table_data=[],
sort_columns=("name",),
table_format=TableFormat.PLAIN,
)
assert capsys.readouterr().out == ""
[docs]
@pytest.mark.parametrize(
("header_defs", "expected_choices", "expected_default"),
(
pytest.param(
(("Name", "name"), ("Age", "age"), ("Notes", None)),
["name", "age"],
("name",),
id="none-key-excluded",
),
pytest.param(
(("ID", "id"), ("Label", "label")),
["id", "label"],
("id",),
id="first-sortable-as-default",
),
pytest.param(
(("First", "name"), ("Last", "name"), ("Age", "age")),
["name", "name", "age"],
("name",),
id="duplicate-keys",
),
pytest.param(
(("Notes", ""), ("Name", "name")),
["name"],
("name",),
id="empty-key-excluded",
),
),
)
def test_sort_by_option_choices_and_default(
header_defs, expected_choices, expected_default
):
"""SortByOption choices and default are derived from column definitions."""
opt = SortByOption(*header_defs)
assert list(opt.type.choices) == expected_choices # type: ignore[attr-defined]
assert opt.default == expected_default
[docs]
def test_sort_by_option_wires_context(invoke):
"""SortByOption replaces ctx.print_table with the sorted variant."""
sort_opt = SortByOption(("Fruit", "fruit"), ("Count", "count"))
@command(params=[sort_opt])
@table_format_option
@pass_context
def cli(ctx):
header_defs = (("Fruit", "fruit"), ("Count", "count"))
data = [["banana", "3"], ["apple", "1"], ["cherry", "2"]]
ctx.print_table(header_defs, data)
result = invoke(cli, "--table-format", "json", "--sort-by", "fruit", color=False)
assert result.exit_code == 0
parsed = json.loads(result.stdout)
assert [r["Fruit"] for r in parsed] == ["apple", "banana", "cherry"]
[docs]
def test_sort_by_option_multi_column(invoke):
"""Multiple --sort-by options define sort priority."""
sort_opt = SortByOption(
("City", "city"),
("Name", "name"),
("Age", "age"),
)
@command(params=[sort_opt])
@table_format_option
@pass_context
def cli(ctx):
header_defs = (("City", "city"), ("Name", "name"), ("Age", "age"))
data = [
["NYC", "Alice", "30"],
["LA", "Bob", "25"],
["SF", "Alice", "25"],
]
ctx.print_table(header_defs, data)
result = invoke(
cli,
"--table-format",
"json",
"--sort-by",
"name",
"--sort-by",
"age",
color=False,
)
assert result.exit_code == 0
parsed = json.loads(result.stdout)
assert [r["City"] for r in parsed] == ["SF", "NYC", "LA"]