Skip to content

Commit 163048a

Browse files
rosie lookup: improve output format
* Fix broken terminal width detection. * Change default format to a table
1 parent 467be08 commit 163048a

2 files changed

Lines changed: 244 additions & 21 deletions

File tree

metomi/rosie/cli_utils.py

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
# Copyright (C) British Crown (Met Office) & Contributors.
2+
# This file is part of Rose, a framework for meteorological suites.
3+
#
4+
# Rose is free software: you can redistribute it and/or modify
5+
# it under the terms of the GNU General Public License as published by
6+
# the Free Software Foundation, either version 3 of the License, or
7+
# (at your option) any later version.
8+
#
9+
# Rose is distributed in the hope that it will be useful,
10+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
# GNU General Public License for more details.
13+
#
14+
# You should have received a copy of the GNU General Public License
15+
# along with Rose. If not, see <http://www.gnu.org/licenses/>.
16+
# -----------------------------------------------------------------------------
17+
18+
from subprocess import Popen, PIPE
19+
import textwrap
20+
from typing import Optional, List
21+
22+
# NOTE: Box Drawing characters
23+
# t, b, l, r -> top, bottom, left, right
24+
# h, v -> horizontal, vertical
25+
# l, x -> line, cross (i.e. vertex)
26+
27+
# unicode (U_) chars
28+
U_HL = '─'
29+
U_VL = '│'
30+
U_TL = '┌'
31+
U_TR = '┐'
32+
U_BL = '└'
33+
U_BR = '┘'
34+
U_LX = '├'
35+
U_RX = '┤'
36+
U_TX = '┬'
37+
U_BX = '┴'
38+
U_XX = '┼'
39+
40+
# ASCII (A_) chars
41+
A_HL = '-'
42+
A_VL = '|'
43+
A_TL = '+'
44+
A_TR = '+'
45+
A_BL = '+'
46+
A_BR = '+'
47+
A_LX = '+'
48+
A_RX = '+'
49+
A_TX = '+'
50+
A_BX = '+'
51+
A_XX = '+'
52+
53+
54+
def get_terminal_cols(default=80):
55+
"""Return the width of the terminal in columns.
56+
57+
If the check fails, this will fallback to the default value.
58+
"""
59+
proc = Popen(['stty', 'size'], stdout=PIPE, stderr=PIPE, text=True)
60+
try:
61+
return int(proc.communicate()[0].split()[1]) or default
62+
except Exception:
63+
return default
64+
65+
66+
def table(
67+
rows: List[List[str]],
68+
header: Optional[List[str]] = None,
69+
max_width: Optional[int] = None,
70+
unicode: bool = True,
71+
) -> str:
72+
"""Format text into a table.
73+
74+
Args:
75+
rows: 2D table composed of lists.
76+
header: Optional 1D list to use as the table header.
77+
max_width: Maximum permitted width for the whole table including
78+
borders.
79+
unicode: Use unicode characters if True, else fallback to ascii.
80+
81+
Examples:
82+
# simple ASCII table:
83+
>>> print(table([['a', 'b', 'c']], unicode=False))
84+
+---+---+---+
85+
| a | b | c |
86+
+---+---+---+
87+
88+
# unicode table with headers and a max-width:
89+
>>> print(table(
90+
... [['a', 'b', 'c'], ['d', 'e', 'qwertyuiopasdfghjkl']],
91+
... header=['foo', 'bar', 'baz'],
92+
... max_width=27,
93+
... ))
94+
┌─────┬─────┬─────────────┐
95+
│ foo │ bar │ baz │
96+
├─────┼─────┼─────────────┤
97+
├─────┼─────┼─────────────┤
98+
│ a │ b │ c │
99+
├─────┼─────┼─────────────┤
100+
│ d │ e │ qwertyuiopa │
101+
│ │ │ sdfghjkl │
102+
└─────┴─────┴─────────────┘
103+
104+
# edge case: table doesn't fit within max_width
105+
# (columns will use width of 1 and table will exceed max_width)
106+
>>> print(table([["a", "long"]], max_width=6))
107+
┌───┬───┐
108+
│ a │ l │
109+
│ │ o │
110+
│ │ n │
111+
│ │ g │
112+
└───┴───┘
113+
114+
"""
115+
# determine character set
116+
if unicode:
117+
hl = U_HL
118+
vl = U_VL
119+
tl = U_TL
120+
tr = U_TR
121+
bl = U_BL
122+
br = U_BR
123+
lx = U_LX
124+
rx = U_RX
125+
tx = U_TX
126+
bx = U_BX
127+
xx = U_XX
128+
else:
129+
hl = A_HL
130+
vl = A_VL
131+
tl = A_TL
132+
tr = A_TR
133+
bl = A_BL
134+
br = A_BR
135+
lx = A_LX
136+
rx = A_RX
137+
tx = A_TX
138+
bx = A_BX
139+
xx = A_XX
140+
141+
# determine column widths
142+
widths = [0] * len(rows[0])
143+
for table_ in (rows, [header or []]):
144+
for row in (table_):
145+
for ind, col in enumerate(row):
146+
widths[ind] = max(widths[ind], len(col))
147+
148+
def calc_width():
149+
return (sum(widths) + (len(widths) * 3) + 1)
150+
151+
# resize cols to make them fix the max_width
152+
if max_width:
153+
overhang = max_width - calc_width()
154+
for _ in range(15): # limit to 15 itterations
155+
_max_width = 0
156+
_max_col = 0
157+
# find the longest column
158+
for ind, width in enumerate(widths):
159+
if width > _max_width:
160+
_max_width = width
161+
_max_col = ind
162+
# reduce the longest column by up to 50% until it fits
163+
widths[_max_col] = max(
164+
int(widths[_max_col] / 2),
165+
widths[_max_col] + overhang
166+
) or 1 # don't let column width go to 0
167+
overhang = max_width - calc_width()
168+
if overhang >= 0:
169+
break
170+
171+
# textwrap the table to fit the desired widths
172+
_rows = [
173+
[
174+
textwrap.wrap(col, width=widths[ind])
175+
for ind, col in enumerate(row)
176+
]
177+
for row in rows
178+
]
179+
180+
# Python f-string for formatting each row of the table
181+
row_format = vl + vl.join(
182+
f" {{{ind}:{width}}} " for ind, width in enumerate(widths)
183+
) + vl
184+
185+
# a divider row (i.e. a horizontal line)
186+
blank_line = lx + xx.join(hl * (width + 2) for width in widths) + rx
187+
188+
# top border of table
189+
ret = [tl + tx.join(hl * (width + 2) for width in widths) + tr]
190+
191+
# table header
192+
if header:
193+
ret.append(row_format.format(*header))
194+
ret.extend([blank_line, blank_line])
195+
196+
# table body
197+
for row_ind, row in enumerate(_rows):
198+
max_height = max(len(col) for col in row)
199+
for line_ind in range(max_height):
200+
ret.append(row_format.format(
201+
*[
202+
col[line_ind] if line_ind < len(col) else ''
203+
for col in row
204+
]
205+
))
206+
if row_ind != len(_rows) - 1:
207+
# don't add a divider row for the last item
208+
ret.append(blank_line)
209+
210+
# bottom border of table
211+
ret.append(bl + bx.join(hl * (width + 2) for width in widths) + br)
212+
213+
return '\n'.join(ret)

metomi/rosie/ws_client_cli.py

Lines changed: 31 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@
2323
import traceback
2424

2525
from metomi.rose.opt_parse import RoseOptionParser
26-
from metomi.rose.popen import RosePopenError
2726
from metomi.rose.reporter import Event, Reporter
27+
from metomi.rosie.cli_utils import get_terminal_cols, table
2828
from metomi.rosie.suite_id import SuiteId
2929
from metomi.rosie.ws_client import (
3030
RosieWSClient,
@@ -265,6 +265,9 @@ def lookup():
265265
266266
# list all suites in projects starting with "ocean" that are owned by "bob"
267267
$ rosie lookup -Q project like ocean% and owner eq bob
268+
269+
# turn off the table formatting
270+
$ rosie lookup --no-pretty
268271
''',
269272
).add_my_options(
270273
"address_mode",
@@ -277,6 +280,7 @@ def lookup():
277280
"reverse",
278281
"search_mode",
279282
"sort",
283+
"no_pretty_mode",
280284
)
281285
opts, args = opt_parser.parse_args()
282286
if not args:
@@ -346,13 +350,7 @@ def _display_maps(opts, ws_client, dict_rows, url=None):
346350
"""Display returned suite details."""
347351
report = ws_client.event_handler
348352

349-
try:
350-
terminal_cols = int(ws_client.popen("stty", "size")[0].split()[1])
351-
except (IndexError, RosePopenError, ValueError):
352-
terminal_cols = None
353-
354-
if terminal_cols == 0:
355-
terminal_cols = None
353+
terminal_cols = get_terminal_cols()
356354

357355
if opts.quietness and not opts.print_format:
358356
opts.print_format = PRINT_FORMAT_QUIET
@@ -399,16 +397,28 @@ def _display_maps(opts, ws_client, dict_rows, url=None):
399397

400398
dict_rows = _align(dict_rows, keylist)
401399

402-
for dict_row in dict_rows:
403-
out = opts.print_format
404-
for key, value in dict_row.items():
405-
if "%" + key in out:
406-
out = str(out).replace("%" + str(key), str(value), 1)
407-
out = str(out.replace("%%", "%").expandtabs().rstrip())
408-
409-
report(
410-
SuiteEvent(out.expandtabs() + "\n"), prefix="", clip=terminal_cols
411-
)
412-
report(SuiteInfo(dict_row), prefix="")
413-
if url is not None:
414-
report(URLEvent(url + "\n"), prefix="")
400+
if opts.no_pretty_mode:
401+
for dict_row in dict_rows:
402+
out = opts.print_format
403+
for key, value in dict_row.items():
404+
if "%" + key in out:
405+
out = str(out).replace("%" + str(key), str(value), 1)
406+
out = str(out.replace("%%", "%").expandtabs().rstrip())
407+
408+
report(
409+
SuiteEvent(out.expandtabs() + "\n"),
410+
prefix="",
411+
clip=terminal_cols,
412+
)
413+
report(SuiteInfo(dict_row), prefix="")
414+
if url is not None:
415+
report(URLEvent(url + "\n"), prefix="")
416+
else:
417+
cols = [x.replace('%', '') for x in opts.print_format.split()]
418+
_rows = [[_dict[col] for col in cols] for _dict in dict_rows[2:]]
419+
try:
420+
print(table(_rows, header=cols, max_width=terminal_cols))
421+
except UnicodeDecodeError:
422+
print(table(
423+
_rows, header=cols, max_width=terminal_cols, unicode=False
424+
))

0 commit comments

Comments
 (0)