Mercurial > repos > guerler > springsuite
comparison planemo/lib/python3.7/site-packages/humanfriendly/tables.py @ 1:56ad4e20f292 draft
"planemo upload commit 6eee67778febed82ddd413c3ca40b3183a3898f1"
| author | guerler |
|---|---|
| date | Fri, 31 Jul 2020 00:32:28 -0400 |
| parents | |
| children |
comparison
equal
deleted
inserted
replaced
| 0:d30785e31577 | 1:56ad4e20f292 |
|---|---|
| 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) |
