comparison env/lib/python3.7/site-packages/humanfriendly/tables.py @ 0:26e78fe6e8c4 draft

"planemo upload commit c699937486c35866861690329de38ec1a5d9f783"
author shellac
date Sat, 02 May 2020 07:14:21 -0400
parents
children
comparison
equal deleted inserted replaced
-1:000000000000 0:26e78fe6e8c4
1 # Human friendly input/output in Python.
2 #
3 # Author: Peter Odding <peter@peterodding.com>
4 # Last Change: February 16, 2020
5 # URL: https://humanfriendly.readthedocs.io
6
7 """
8 Functions that render ASCII tables.
9
10 Some generic notes about the table formatting functions in this module:
11
12 - These functions were not written with performance in mind (*at all*) because
13 they're intended to format tabular data to be presented on a terminal. If
14 someone were to run into a performance problem using these functions, they'd
15 be printing so much tabular data to the terminal that a human wouldn't be
16 able to digest the tabular data anyway, so the point is moot :-).
17
18 - These functions ignore ANSI escape sequences (at least the ones generated by
19 the :mod:`~humanfriendly.terminal` module) in the calculation of columns
20 widths. On reason for this is that column names are highlighted in color when
21 connected to a terminal. It also means that you can use ANSI escape sequences
22 to highlight certain column's values if you feel like it (for example to
23 highlight deviations from the norm in an overview of calculated values).
24 """
25
26 # Standard library modules.
27 import collections
28 import re
29
30 # Modules included in our package.
31 from humanfriendly.compat import coerce_string
32 from humanfriendly.terminal import (
33 ansi_strip,
34 ansi_width,
35 ansi_wrap,
36 terminal_supports_colors,
37 find_terminal_size,
38 HIGHLIGHT_COLOR,
39 )
40
41 # Public identifiers that require documentation.
42 __all__ = (
43 'format_pretty_table',
44 'format_robust_table',
45 'format_rst_table',
46 'format_smart_table',
47 )
48
49 # Compiled regular expression pattern to recognize table columns containing
50 # numeric data (integer and/or floating point numbers). Used to right-align the
51 # contents of such columns.
52 #
53 # Pre-emptive snarky comment: This pattern doesn't match every possible
54 # floating point number notation!?!1!1
55 #
56 # Response: I know, that's intentional. The use of this regular expression
57 # pattern has a very high DWIM level and weird floating point notations do not
58 # fall under the DWIM umbrella :-).
59 NUMERIC_DATA_PATTERN = re.compile(r'^\d+(\.\d+)?$')
60
61
62 def format_smart_table(data, column_names):
63 """
64 Render tabular data using the most appropriate representation.
65
66 :param data: An iterable (e.g. a :func:`tuple` or :class:`list`)
67 containing the rows of the table, where each row is an
68 iterable containing the columns of the table (strings).
69 :param column_names: An iterable of column names (strings).
70 :returns: The rendered table (a string).
71
72 If you want an easy way to render tabular data on a terminal in a human
73 friendly format then this function is for you! It works as follows:
74
75 - If the input data doesn't contain any line breaks the function
76 :func:`format_pretty_table()` is used to render a pretty table. If the
77 resulting table fits in the terminal without wrapping the rendered pretty
78 table is returned.
79
80 - If the input data does contain line breaks or if a pretty table would
81 wrap (given the width of the terminal) then the function
82 :func:`format_robust_table()` is used to render a more robust table that
83 can deal with data containing line breaks and long text.
84 """
85 # Normalize the input in case we fall back from a pretty table to a robust
86 # table (in which case we'll definitely iterate the input more than once).
87 data = [normalize_columns(r) for r in data]
88 column_names = normalize_columns(column_names)
89 # Make sure the input data doesn't contain any line breaks (because pretty
90 # tables break horribly when a column's text contains a line break :-).
91 if not any(any('\n' in c for c in r) for r in data):
92 # Render a pretty table.
93 pretty_table = format_pretty_table(data, column_names)
94 # Check if the pretty table fits in the terminal.
95 table_width = max(map(ansi_width, pretty_table.splitlines()))
96 num_rows, num_columns = find_terminal_size()
97 if table_width <= num_columns:
98 # The pretty table fits in the terminal without wrapping!
99 return pretty_table
100 # Fall back to a robust table when a pretty table won't work.
101 return format_robust_table(data, column_names)
102
103
104 def format_pretty_table(data, column_names=None, horizontal_bar='-', vertical_bar='|'):
105 """
106 Render a table using characters like dashes and vertical bars to emulate borders.
107
108 :param data: An iterable (e.g. a :func:`tuple` or :class:`list`)
109 containing the rows of the table, where each row is an
110 iterable containing the columns of the table (strings).
111 :param column_names: An iterable of column names (strings).
112 :param horizontal_bar: The character used to represent a horizontal bar (a
113 string).
114 :param vertical_bar: The character used to represent a vertical bar (a
115 string).
116 :returns: The rendered table (a string).
117
118 Here's an example:
119
120 >>> from humanfriendly.tables import format_pretty_table
121 >>> column_names = ['Version', 'Uploaded on', 'Downloads']
122 >>> humanfriendly_releases = [
123 ... ['1.23', '2015-05-25', '218'],
124 ... ['1.23.1', '2015-05-26', '1354'],
125 ... ['1.24', '2015-05-26', '223'],
126 ... ['1.25', '2015-05-26', '4319'],
127 ... ['1.25.1', '2015-06-02', '197'],
128 ... ]
129 >>> print(format_pretty_table(humanfriendly_releases, column_names))
130 -------------------------------------
131 | Version | Uploaded on | Downloads |
132 -------------------------------------
133 | 1.23 | 2015-05-25 | 218 |
134 | 1.23.1 | 2015-05-26 | 1354 |
135 | 1.24 | 2015-05-26 | 223 |
136 | 1.25 | 2015-05-26 | 4319 |
137 | 1.25.1 | 2015-06-02 | 197 |
138 -------------------------------------
139
140 Notes about the resulting table:
141
142 - If a column contains numeric data (integer and/or floating point
143 numbers) in all rows (ignoring column names of course) then the content
144 of that column is right-aligned, as can be seen in the example above. The
145 idea here is to make it easier to compare the numbers in different
146 columns to each other.
147
148 - The column names are highlighted in color so they stand out a bit more
149 (see also :data:`.HIGHLIGHT_COLOR`). The following screen shot shows what
150 that looks like (my terminals are always set to white text on a black
151 background):
152
153 .. image:: images/pretty-table.png
154 """
155 # Normalize the input because we'll have to iterate it more than once.
156 data = [normalize_columns(r, expandtabs=True) for r in data]
157 if column_names is not None:
158 column_names = normalize_columns(column_names)
159 if column_names:
160 if terminal_supports_colors():
161 column_names = [highlight_column_name(n) for n in column_names]
162 data.insert(0, column_names)
163 # Calculate the maximum width of each column.
164 widths = collections.defaultdict(int)
165 numeric_data = collections.defaultdict(list)
166 for row_index, row in enumerate(data):
167 for column_index, column in enumerate(row):
168 widths[column_index] = max(widths[column_index], ansi_width(column))
169 if not (column_names and row_index == 0):
170 numeric_data[column_index].append(bool(NUMERIC_DATA_PATTERN.match(ansi_strip(column))))
171 # Create a horizontal bar of dashes as a delimiter.
172 line_delimiter = horizontal_bar * (sum(widths.values()) + len(widths) * 3 + 1)
173 # Start the table with a vertical bar.
174 lines = [line_delimiter]
175 # Format the rows and columns.
176 for row_index, row in enumerate(data):
177 line = [vertical_bar]
178 for column_index, column in enumerate(row):
179 padding = ' ' * (widths[column_index] - ansi_width(column))
180 if all(numeric_data[column_index]):
181 line.append(' ' + padding + column + ' ')
182 else:
183 line.append(' ' + column + padding + ' ')
184 line.append(vertical_bar)
185 lines.append(u''.join(line))
186 if column_names and row_index == 0:
187 lines.append(line_delimiter)
188 # End the table with a vertical bar.
189 lines.append(line_delimiter)
190 # Join the lines, returning a single string.
191 return u'\n'.join(lines)
192
193
194 def format_robust_table(data, column_names):
195 """
196 Render tabular data with one column per line (allowing columns with line breaks).
197
198 :param data: An iterable (e.g. a :func:`tuple` or :class:`list`)
199 containing the rows of the table, where each row is an
200 iterable containing the columns of the table (strings).
201 :param column_names: An iterable of column names (strings).
202 :returns: The rendered table (a string).
203
204 Here's an example:
205
206 >>> from humanfriendly.tables import format_robust_table
207 >>> column_names = ['Version', 'Uploaded on', 'Downloads']
208 >>> humanfriendly_releases = [
209 ... ['1.23', '2015-05-25', '218'],
210 ... ['1.23.1', '2015-05-26', '1354'],
211 ... ['1.24', '2015-05-26', '223'],
212 ... ['1.25', '2015-05-26', '4319'],
213 ... ['1.25.1', '2015-06-02', '197'],
214 ... ]
215 >>> print(format_robust_table(humanfriendly_releases, column_names))
216 -----------------------
217 Version: 1.23
218 Uploaded on: 2015-05-25
219 Downloads: 218
220 -----------------------
221 Version: 1.23.1
222 Uploaded on: 2015-05-26
223 Downloads: 1354
224 -----------------------
225 Version: 1.24
226 Uploaded on: 2015-05-26
227 Downloads: 223
228 -----------------------
229 Version: 1.25
230 Uploaded on: 2015-05-26
231 Downloads: 4319
232 -----------------------
233 Version: 1.25.1
234 Uploaded on: 2015-06-02
235 Downloads: 197
236 -----------------------
237
238 The column names are highlighted in bold font and color so they stand out a
239 bit more (see :data:`.HIGHLIGHT_COLOR`).
240 """
241 blocks = []
242 column_names = ["%s:" % n for n in normalize_columns(column_names)]
243 if terminal_supports_colors():
244 column_names = [highlight_column_name(n) for n in column_names]
245 # Convert each row into one or more `name: value' lines (one per column)
246 # and group each `row of lines' into a block (i.e. rows become blocks).
247 for row in data:
248 lines = []
249 for column_index, column_text in enumerate(normalize_columns(row)):
250 stripped_column = column_text.strip()
251 if '\n' not in stripped_column:
252 # Columns without line breaks are formatted inline.
253 lines.append("%s %s" % (column_names[column_index], stripped_column))
254 else:
255 # Columns with line breaks could very well contain indented
256 # lines, so we'll put the column name on a separate line. This
257 # way any indentation remains intact, and it's easier to
258 # copy/paste the text.
259 lines.append(column_names[column_index])
260 lines.extend(column_text.rstrip().splitlines())
261 blocks.append(lines)
262 # Calculate the width of the row delimiter.
263 num_rows, num_columns = find_terminal_size()
264 longest_line = max(max(map(ansi_width, lines)) for lines in blocks)
265 delimiter = u"\n%s\n" % ('-' * min(longest_line, num_columns))
266 # Force a delimiter at the start and end of the table.
267 blocks.insert(0, "")
268 blocks.append("")
269 # Embed the row delimiter between every two blocks.
270 return delimiter.join(u"\n".join(b) for b in blocks).strip()
271
272
273 def format_rst_table(data, column_names=None):
274 """
275 Render a table in reStructuredText_ format.
276
277 :param data: An iterable (e.g. a :func:`tuple` or :class:`list`)
278 containing the rows of the table, where each row is an
279 iterable containing the columns of the table (strings).
280 :param column_names: An iterable of column names (strings).
281 :returns: The rendered table (a string).
282
283 Here's an example:
284
285 >>> from humanfriendly.tables import format_rst_table
286 >>> column_names = ['Version', 'Uploaded on', 'Downloads']
287 >>> humanfriendly_releases = [
288 ... ['1.23', '2015-05-25', '218'],
289 ... ['1.23.1', '2015-05-26', '1354'],
290 ... ['1.24', '2015-05-26', '223'],
291 ... ['1.25', '2015-05-26', '4319'],
292 ... ['1.25.1', '2015-06-02', '197'],
293 ... ]
294 >>> print(format_rst_table(humanfriendly_releases, column_names))
295 ======= =========== =========
296 Version Uploaded on Downloads
297 ======= =========== =========
298 1.23 2015-05-25 218
299 1.23.1 2015-05-26 1354
300 1.24 2015-05-26 223
301 1.25 2015-05-26 4319
302 1.25.1 2015-06-02 197
303 ======= =========== =========
304
305 .. _reStructuredText: https://en.wikipedia.org/wiki/ReStructuredText
306 """
307 data = [normalize_columns(r) for r in data]
308 if column_names:
309 data.insert(0, normalize_columns(column_names))
310 # Calculate the maximum width of each column.
311 widths = collections.defaultdict(int)
312 for row in data:
313 for index, column in enumerate(row):
314 widths[index] = max(widths[index], len(column))
315 # Pad the columns using whitespace.
316 for row in data:
317 for index, column in enumerate(row):
318 if index < (len(row) - 1):
319 row[index] = column.ljust(widths[index])
320 # Add table markers.
321 delimiter = ['=' * w for i, w in sorted(widths.items())]
322 if column_names:
323 data.insert(1, delimiter)
324 data.insert(0, delimiter)
325 data.append(delimiter)
326 # Join the lines and columns together.
327 return '\n'.join(' '.join(r) for r in data)
328
329
330 def normalize_columns(row, expandtabs=False):
331 results = []
332 for value in row:
333 text = coerce_string(value)
334 if expandtabs:
335 text = text.expandtabs()
336 results.append(text)
337 return results
338
339
340 def highlight_column_name(name):
341 return ansi_wrap(name, bold=True, color=HIGHLIGHT_COLOR)