Skip to content

Commit 2fbcfa6

Browse files
new: Overhaul truncation system; move markdown output to Rich (#502)
## 📝 Description This PR makes a number of changes to the CLI's output system, including: - The removal of the legacy truncation system in favor of dynamic truncation through Rich - The removal of the legacy markdown output method in favor of Rich's markdown box style - The addition of a new `--column-width` field to specify the maximum width of each column in an output table ## ✔️ How to Test ``` make testunit ```
1 parent 972c068 commit 2fbcfa6

File tree

4 files changed

+58
-143
lines changed

4 files changed

+58
-143
lines changed

linodecli/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ def main(): # pylint: disable=too-many-branches,too-many-statements
100100

101101
cli.output_handler.suppress_warnings = parsed.suppress_warnings
102102
cli.output_handler.disable_truncation = parsed.no_truncation
103+
cli.output_handler.column_width = parsed.column_width
103104

104105
if parsed.as_user and not skip_config:
105106
cli.config.set_user(parsed.as_user)

linodecli/arg_helpers.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,13 @@ def register_args(parser):
126126
default=False,
127127
help="Prevent the truncation of long values in command outputs.",
128128
)
129+
parser.add_argument(
130+
"--column-width",
131+
type=int,
132+
default=None,
133+
help="Sets the maximum width of each column in outputted tables. "
134+
"By default, columns are dynamically sized to fit the terminal.",
135+
)
129136
parser.add_argument(
130137
"--version",
131138
"-v",

linodecli/output.py

Lines changed: 28 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@
22
Handles formatting the output of commands used in Linode CLI
33
"""
44
import json
5-
import sys
65
from enum import Enum
76
from sys import stdout
8-
from typing import IO, List, Optional, Union
7+
from typing import IO, List, Optional, Union, cast
98

109
from rich import box
1110
from rich import print as rprint
12-
from rich.table import Table
11+
from rich.console import OverflowMethod
12+
from rich.table import Column, Table
1313
from rich.text import Text
1414

1515
from linodecli.baked.response import OpenAPIResponse
@@ -40,18 +40,19 @@ def __init__( # pylint: disable=too-many-arguments
4040
pretty_json=False,
4141
columns=None,
4242
disable_truncation=False,
43-
truncation_length=64,
4443
suppress_warnings=False,
44+
column_width=None,
4545
):
4646
self.mode = mode
4747
self.delimiter = delimiter
4848
self.pretty_json = pretty_json
4949
self.headers = headers
5050
self.columns = columns
51-
self.disable_truncation = disable_truncation
52-
self.truncation_length = truncation_length
5351
self.suppress_warnings = suppress_warnings
5452

53+
self.disable_truncation = disable_truncation
54+
self.column_width = column_width
55+
5556
# Used to track whether a warning has already been printed
5657
self.has_warned = False
5758

@@ -82,14 +83,14 @@ def print(
8283
header, data, columns, title, to
8384
),
8485
OutputMode.ascii_table: lambda: self._table_output(
85-
header, data, columns, title, to, box.ASCII
86+
header, data, columns, title, to, box_style=box.ASCII
8687
),
8788
OutputMode.delimited: lambda: self._delimited_output(
8889
header, data, columns, to
8990
),
9091
OutputMode.json: lambda: self._json_output(header, data, to),
91-
OutputMode.markdown: lambda: self._markdown_output(
92-
header, data, columns, to
92+
OutputMode.markdown: lambda: self._table_output(
93+
header, data, columns, title, to, box_style=box.MARKDOWN
9394
),
9495
}
9596

@@ -143,13 +144,27 @@ def _table_output(
143144
content = self._build_output_content(
144145
data,
145146
columns,
146-
value_transform=lambda attr, v: self._attempt_truncate_value(
147-
attr.render_value(v)
148-
),
147+
value_transform=lambda attr, v: str(attr.render_value(v)),
148+
)
149+
150+
# Determine the rich overflow mode to use
151+
# for each column.
152+
overflow_mode = cast(
153+
OverflowMethod, "fold" if self.disable_truncation else "ellipsis"
149154
)
150155

156+
# Convert the headers into column objects
157+
# so we can override the overflow method.
158+
header_columns = [
159+
Column(v, overflow=overflow_mode, max_width=self.column_width)
160+
for v in header
161+
]
162+
151163
tab = Table(
152-
*header, header_style="", box=box_style, show_header=self.headers
164+
*header_columns,
165+
header_style="",
166+
box=box_style,
167+
show_header=self.headers,
153168
)
154169
for row in content:
155170
row = [Text.from_ansi(item) for item in row]
@@ -212,26 +227,6 @@ def _select_json_elements(keys, json_res):
212227
ret[k] = v
213228
return ret
214229

215-
def _markdown_output(self, header, data, columns, to):
216-
"""
217-
Pretty-prints data in a Markdown-formatted table. This uses github's
218-
flavor of Markdown
219-
"""
220-
content = self._build_output_content(
221-
data,
222-
columns,
223-
value_transform=lambda attr, v: self._attempt_truncate_value(
224-
attr.render_value(v, colorize=False)
225-
),
226-
)
227-
228-
if header:
229-
print("| " + " | ".join([str(c) for c in header]) + " |", file=to)
230-
print("|---" * len(header) + "|", file=to)
231-
232-
for row in content:
233-
print("| " + " | ".join([str(c) for c in row]) + " |", file=to)
234-
235230
def _build_output_content(
236231
self,
237232
data,
@@ -258,25 +253,3 @@ def _build_output_content(
258253
content.append([value_transform(attr, model) for attr in columns])
259254

260255
return content
261-
262-
def _attempt_truncate_value(self, value):
263-
if not isinstance(value, str):
264-
value = str(value)
265-
266-
if self.disable_truncation:
267-
return value
268-
269-
if len(value) < self.truncation_length:
270-
return value
271-
272-
if not self.suppress_warnings and not self.has_warned:
273-
print(
274-
"Certain values in this output have been truncated. "
275-
"To disable output truncation, use --no-truncation. "
276-
"Alternatively, use the --json or --text output modes, "
277-
"or disable warnings using --suppress-warnings.",
278-
file=sys.stderr,
279-
)
280-
self.has_warned = True
281-
282-
return f"{value[:self.truncation_length]}..."

tests/unit/test_output.py

Lines changed: 22 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -13,48 +13,6 @@ class TestOutputHandler:
1313
Unit tests for linodecli.output
1414
"""
1515

16-
def test_markdown_output_columns(self, mock_cli):
17-
output = io.StringIO()
18-
19-
output_handler = mock_cli.output_handler
20-
21-
output_handler._markdown_output(
22-
["very cool header", "wow"],
23-
[["foo", "bar"], ["oof", "rab"]],
24-
["1", "2"],
25-
output,
26-
)
27-
28-
assert (
29-
output.getvalue() == "| very cool header | wow |\n"
30-
"|---|---|\n"
31-
"| foo | bar |\n"
32-
"| oof | rab |\n"
33-
)
34-
35-
def test_markdown_output_models(
36-
self, mock_cli, list_operation_for_output_tests
37-
):
38-
output = io.StringIO()
39-
40-
output_handler = mock_cli.output_handler
41-
42-
attr = list_operation_for_output_tests.response_model.attrs[0]
43-
44-
output_handler._markdown_output(
45-
["very cool header"],
46-
[{"cool": "foo"}, {"cool": "bar"}],
47-
[attr],
48-
output,
49-
)
50-
51-
assert (
52-
output.getvalue() == "| very cool header |\n"
53-
"|---|\n"
54-
"| foo |\n"
55-
"| bar |\n"
56-
)
57-
5816
def test_json_output_delimited(self, mock_cli):
5917
output = io.StringIO()
6018
headers = ["foo", "bar"]
@@ -297,46 +255,13 @@ def test_print(self, mock_cli, list_operation_for_output_tests):
297255
in output.getvalue()
298256
)
299257

300-
def test_truncation(self, mock_cli):
301-
stderr_buf = io.StringIO()
302-
test_str = "x" * 80
303-
test_str_truncated = f"{'x' * 64}..."
304-
305-
with contextlib.redirect_stderr(stderr_buf):
306-
result = mock_cli.output_handler._attempt_truncate_value(test_str)
307-
308-
assert "truncation" in stderr_buf.getvalue()
309-
assert result == test_str_truncated
310-
311-
# --suppress-warnings
312-
# Faster than flushing apparently
313-
stderr_buf = io.StringIO()
314-
mock_cli.output_handler.suppress_warnings = True
315-
316-
with contextlib.redirect_stderr(stderr_buf):
317-
result = mock_cli.output_handler._attempt_truncate_value(test_str)
318-
319-
assert "truncation" not in stderr_buf
320-
assert result == test_str_truncated
321-
322-
# --no-truncation
323-
mock_cli.output_handler.disable_truncation = True
324-
325-
result = mock_cli.output_handler._attempt_truncate_value(test_str)
326-
327-
assert result == test_str
328-
329258
def test_truncated_table(self, mock_cli, list_operation_for_output_tests):
330-
# Ensure integers are properly converted
331-
result = mock_cli.output_handler._attempt_truncate_value(12345)
332-
333-
assert result == "12345"
334-
assert isinstance(result, str)
259+
mock_cli.output_handler.column_width = 2
335260

336261
output = io.StringIO()
337262

338263
test_str = "x" * 80
339-
test_str_truncated = f"{'x' * 64}..."
264+
test_str_truncated = "x…"
340265

341266
header = ["h1"]
342267
data = [
@@ -360,31 +285,40 @@ def test_truncated_table(self, mock_cli, list_operation_for_output_tests):
360285

361286
assert output.getvalue() == mock_table.getvalue()
362287

363-
def test_truncated_markdown(
288+
def test_nontruncated_table(
364289
self, mock_cli, list_operation_for_output_tests
365290
):
366-
test_str = "x" * 80
367-
test_str_truncated = f"{'x' * 64}..."
291+
mock_cli.output_handler.column_width = 2
292+
mock_cli.output_handler.disable_truncation = True
368293

369294
output = io.StringIO()
370295

371-
header = ["very cool header"]
296+
test_str = "x" * 80
297+
test_str_truncated = "x…"
298+
299+
header = ["h1"]
372300
data = [
373301
{
374302
"cool": test_str,
375303
},
376304
]
377305
columns = [list_operation_for_output_tests.response_model.attrs[0]]
378306

379-
output_handler = mock_cli.output_handler
307+
mock_cli.output_handler._table_output(
308+
header, data, columns, "cool table", output
309+
)
380310

381-
output_handler._markdown_output(header, data, columns, output)
311+
data[0]["cool"] = test_str_truncated
382312

383-
assert (
384-
output.getvalue() == "| very cool header |\n"
385-
"|---|\n"
386-
f"| {test_str_truncated} |\n"
387-
)
313+
mock_table = io.StringIO()
314+
tab = Table("h1", header_style="", box=box.SQUARE)
315+
tab.add_row(test_str_truncated)
316+
tab.title = "cool table"
317+
rprint(tab, file=mock_table)
318+
319+
print(output.getvalue())
320+
321+
assert output.getvalue() != mock_table.getvalue()
388322

389323
def test_warn_broken_output(self, mock_cli):
390324
stderr_buf = io.StringIO()

0 commit comments

Comments
 (0)