kdanylov aka koder | 150b219 | 2017-04-01 16:53:01 +0300 | [diff] [blame] | 1 | # texttable - module for creating simple ASCII tables |
| 2 | # Copyright (C) 2003-2015 Gerome Fournier <jef(at)foutaise.org> |
| 3 | # |
| 4 | # This library is free software; you can redistribute it and/or |
| 5 | # modify it under the terms of the GNU Lesser General Public |
| 6 | # License as published by the Free Software Foundation; either |
| 7 | # version 2.1 of the License, or (at your option) any later version. |
| 8 | # |
| 9 | # This library 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 GNU |
| 12 | # Lesser General Public License for more details. |
| 13 | # |
| 14 | # You should have received a copy of the GNU Lesser General Public |
| 15 | # License along with this library; if not, write to the Free Software |
| 16 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA |
| 17 | |
| 18 | """module for creating simple ASCII tables |
| 19 | |
| 20 | |
| 21 | Example: |
| 22 | |
| 23 | table = Texttable() |
| 24 | table.set_cols_align(["l", "r", "c"]) |
| 25 | table.set_cols_valign(["t", "m", "b"]) |
| 26 | table.add_rows([["Name", "Age", "Nickname"], |
| 27 | ["Mr\\nXavier\\nHuon", 32, "Xav'"], |
| 28 | ["Mr\\nBaptiste\\nClement", 1, "Baby"], |
| 29 | ["Mme\\nLouise\\nBourgeau", 28, "Lou\\n\\nLoue"]]) |
| 30 | print table.draw() + "\\n" |
| 31 | |
| 32 | table = Texttable() |
| 33 | table.set_deco(Texttable.HEADER) |
| 34 | table.set_cols_dtype(['t', # text |
| 35 | 'f', # float (decimal) |
| 36 | 'e', # float (exponent) |
| 37 | 'i', # integer |
| 38 | 'a']) # automatic |
| 39 | table.set_cols_align(["l", "r", "r", "r", "l"]) |
| 40 | table.add_rows([["text", "float", "exp", "int", "auto"], |
| 41 | ["abcd", "67", 654, 89, 128.001], |
| 42 | ["efghijk", 67.5434, .654, 89.6, 12800000000000000000000.00023], |
| 43 | ["lmn", 5e-78, 5e-78, 89.4, .000000000000128], |
| 44 | ["opqrstu", .023, 5e+78, 92., 12800000000000000000000]]) |
| 45 | print table.draw() |
| 46 | |
| 47 | Result: |
| 48 | |
| 49 | +----------+-----+----------+ |
| 50 | | Name | Age | Nickname | |
| 51 | +==========+=====+==========+ |
| 52 | | Mr | | | |
| 53 | | Xavier | 32 | | |
| 54 | | Huon | | Xav' | |
| 55 | +----------+-----+----------+ |
| 56 | | Mr | | | |
| 57 | | Baptiste | 1 | | |
| 58 | | Clement | | Baby | |
| 59 | +----------+-----+----------+ |
| 60 | | Mme | | Lou | |
| 61 | | Louise | 28 | | |
| 62 | | Bourgeau | | Loue | |
| 63 | +----------+-----+----------+ |
| 64 | |
| 65 | text float exp int auto |
| 66 | =========================================== |
| 67 | abcd 67.000 6.540e+02 89 128.001 |
| 68 | efgh 67.543 6.540e-01 90 1.280e+22 |
| 69 | ijkl 0.000 5.000e-78 89 0.000 |
| 70 | mnop 0.023 5.000e+78 92 1.280e+22 |
| 71 | """ |
| 72 | |
| 73 | from __future__ import division |
| 74 | |
| 75 | __all__ = ["Texttable", "ArraySizeError"] |
| 76 | |
| 77 | __author__ = 'Gerome Fournier <jef(at)foutaise.org>' |
| 78 | __license__ = 'LGPL' |
| 79 | __version__ = '0.8.8' |
| 80 | __credits__ = """\ |
| 81 | Jeff Kowalczyk: |
| 82 | - textwrap improved import |
| 83 | - comment concerning header output |
| 84 | |
| 85 | Anonymous: |
| 86 | - add_rows method, for adding rows in one go |
| 87 | |
| 88 | Sergey Simonenko: |
| 89 | - redefined len() function to deal with non-ASCII characters |
| 90 | |
| 91 | Roger Lew: |
| 92 | - columns datatype specifications |
| 93 | |
| 94 | Brian Peterson: |
| 95 | - better handling of unicode errors |
| 96 | |
| 97 | Frank Sachsenheim: |
| 98 | - add Python 2/3-compatibility |
| 99 | |
| 100 | Maximilian Hils: |
| 101 | - fix minor bug for Python 3 compatibility |
| 102 | |
| 103 | frinkelpi: |
| 104 | - preserve empty lines |
| 105 | """ |
| 106 | |
| 107 | import sys |
| 108 | import string |
| 109 | import unicodedata |
| 110 | |
| 111 | try: |
| 112 | if sys.version >= '2.3': |
| 113 | import textwrap |
| 114 | elif sys.version >= '2.2': |
| 115 | from optparse import textwrap |
| 116 | else: |
| 117 | from optik import textwrap |
| 118 | except ImportError: |
| 119 | sys.stderr.write("Can't import textwrap module!\n") |
| 120 | raise |
| 121 | |
| 122 | if sys.version >= '2.7': |
| 123 | from functools import reduce |
| 124 | |
| 125 | if sys.version >= '3.0': |
| 126 | unicode_type = str |
| 127 | bytes_type = bytes |
| 128 | else: |
| 129 | unicode_type = unicode |
| 130 | bytes_type = str |
| 131 | |
| 132 | |
| 133 | def obj2unicode(obj): |
| 134 | """Return a unicode representation of a python object |
| 135 | """ |
| 136 | if isinstance(obj, unicode_type): |
| 137 | return obj |
| 138 | elif isinstance(obj, bytes_type): |
| 139 | try: |
| 140 | return unicode_type(obj, 'utf-8') |
| 141 | except UnicodeDecodeError as strerror: |
| 142 | sys.stderr.write("UnicodeDecodeError exception for string '%s': %s\n" % (obj, strerror)) |
| 143 | return unicode_type(obj, 'utf-8', 'replace') |
| 144 | else: |
| 145 | return unicode_type(obj) |
| 146 | |
| 147 | |
| 148 | def len(iterable): |
| 149 | """Redefining len here so it will be able to work with non-ASCII characters |
| 150 | """ |
| 151 | if isinstance(iterable, bytes_type) or isinstance(iterable, unicode_type): |
| 152 | unicode_data = obj2unicode(iterable) |
| 153 | if hasattr(unicodedata, 'east_asian_width'): |
| 154 | w = unicodedata.east_asian_width |
| 155 | return sum([w(c) in 'WF' and 2 or 1 for c in unicode_data]) |
| 156 | else: |
| 157 | return unicode_data.__len__() |
| 158 | else: |
| 159 | return iterable.__len__() |
| 160 | |
| 161 | |
| 162 | class ArraySizeError(Exception): |
| 163 | """Exception raised when specified rows don't fit the required size |
| 164 | """ |
| 165 | |
| 166 | def __init__(self, msg): |
| 167 | self.msg = msg |
| 168 | Exception.__init__(self, msg, '') |
| 169 | |
| 170 | def __str__(self): |
| 171 | return self.msg |
| 172 | |
| 173 | |
| 174 | class Texttable: |
| 175 | |
| 176 | BORDER = 1 |
| 177 | HEADER = 1 << 1 |
| 178 | HLINES = 1 << 2 |
| 179 | VLINES = 1 << 3 |
| 180 | |
| 181 | def __init__(self, max_width=80): |
| 182 | """Constructor |
| 183 | |
| 184 | - max_width is an integer, specifying the maximum width of the table |
| 185 | - if set to 0, size is unlimited, therefore cells won't be wrapped |
| 186 | """ |
| 187 | |
| 188 | if max_width <= 0: |
| 189 | max_width = False |
| 190 | self._max_width = max_width |
| 191 | self._precision = 3 |
| 192 | |
| 193 | self._deco = Texttable.VLINES | Texttable.HLINES | Texttable.BORDER | Texttable.HEADER |
| 194 | self._reset() |
| 195 | ## left, horiz, cross, right |
| 196 | self._chars_top = (chr(0x250c), chr(0x2500), chr(0x252c), chr(0x2510)) |
| 197 | # self.chars_header = (chr(0x255e), chr(0x2550), chr(0x256a), chr(0x2561)) |
| 198 | self._chars_header = (chr(0x251d), chr(0x2501), chr(0x253f), chr(0x2525)) |
| 199 | self._chars_middle = (chr(0x251c), chr(0x2500), chr(0x253c), chr(0x2524)) |
| 200 | self._chars_bottom = (chr(0x2514), chr(0x2500), chr(0x2534), chr(0x2518)) |
| 201 | self._char_vert = chr(0x2502) |
| 202 | self._align = None |
| 203 | |
| 204 | def _reset(self): |
| 205 | """Reset the instance |
| 206 | |
| 207 | - reset rows and header |
| 208 | """ |
| 209 | |
| 210 | self._row_size = None |
| 211 | self._header = [] |
| 212 | self._rows = [] |
| 213 | |
| 214 | def set_cols_align(self, array): |
| 215 | """Set the desired columns alignment |
| 216 | |
| 217 | - the elements of the array should be either "l", "c" or "r": |
| 218 | |
| 219 | * "l": column flushed left |
| 220 | * "c": column centered |
| 221 | * "r": column flushed right |
| 222 | """ |
| 223 | |
| 224 | self._check_row_size(array) |
| 225 | self._align = array |
| 226 | |
| 227 | def set_cols_valign(self, array): |
| 228 | """Set the desired columns vertical alignment |
| 229 | |
| 230 | - the elements of the array should be either "t", "m" or "b": |
| 231 | |
| 232 | * "t": column aligned on the top of the cell |
| 233 | * "m": column aligned on the middle of the cell |
| 234 | * "b": column aligned on the bottom of the cell |
| 235 | """ |
| 236 | |
| 237 | self._check_row_size(array) |
| 238 | self._valign = array |
| 239 | |
| 240 | def set_cols_dtype(self, array): |
| 241 | """Set the desired columns datatype for the cols. |
| 242 | |
| 243 | - the elements of the array should be either "a", "t", "f", "e" or "i": |
| 244 | |
| 245 | * "a": automatic (try to use the most appropriate datatype) |
| 246 | * "t": treat as text |
| 247 | * "f": treat as float in decimal format |
| 248 | * "e": treat as float in exponential format |
| 249 | * "i": treat as int |
| 250 | |
| 251 | - by default, automatic datatyping is used for each column |
| 252 | """ |
| 253 | |
| 254 | self._check_row_size(array) |
| 255 | self._dtype = array |
| 256 | |
| 257 | def set_cols_width(self, array): |
| 258 | """Set the desired columns width |
| 259 | |
| 260 | - the elements of the array should be integers, specifying the |
| 261 | width of each column. For example: |
| 262 | |
| 263 | [10, 20, 5] |
| 264 | """ |
| 265 | |
| 266 | self._check_row_size(array) |
| 267 | try: |
| 268 | array = list(map(int, array)) |
| 269 | if reduce(min, array) <= 0: |
| 270 | raise ValueError |
| 271 | except ValueError: |
| 272 | sys.stderr.write("Wrong argument in column width specification\n") |
| 273 | raise |
| 274 | self._width = array |
| 275 | |
| 276 | def set_precision(self, width): |
| 277 | """Set the desired precision for float/exponential formats |
| 278 | |
| 279 | - width must be an integer >= 0 |
| 280 | |
| 281 | - default value is set to 3 |
| 282 | """ |
| 283 | |
| 284 | if not type(width) is int or width < 0: |
| 285 | raise ValueError('width must be an integer greater then 0') |
| 286 | self._precision = width |
| 287 | |
| 288 | def header(self, array): |
| 289 | """Specify the header of the table |
| 290 | """ |
| 291 | |
| 292 | self._check_row_size(array) |
| 293 | self._header = list(map(obj2unicode, array)) |
| 294 | |
| 295 | def add_row(self, array): |
| 296 | """Add a row in the rows stack |
| 297 | |
| 298 | - cells can contain newlines and tabs |
| 299 | """ |
| 300 | |
| 301 | self._check_row_size(array) |
| 302 | |
| 303 | if not hasattr(self, "_dtype"): |
| 304 | self._dtype = ["a"] * self._row_size |
| 305 | |
| 306 | cells = [] |
| 307 | for i, x in enumerate(array): |
| 308 | cells.append(self._str(i, x)) |
kdanylov aka koder | cdfcdaf | 2017-04-29 10:03:39 +0300 | [diff] [blame] | 309 | |
kdanylov aka koder | 150b219 | 2017-04-01 16:53:01 +0300 | [diff] [blame] | 310 | self._rows.append(cells) |
| 311 | |
| 312 | def add_rows(self, rows, header=True): |
| 313 | """Add several rows in the rows stack |
| 314 | |
| 315 | - The 'rows' argument can be either an iterator returning arrays, |
| 316 | or a by-dimensional array |
| 317 | - 'header' specifies if the first row should be used as the header |
| 318 | of the table |
| 319 | """ |
| 320 | |
| 321 | # nb: don't use 'iter' on by-dimensional arrays, to get a |
| 322 | # usable code for python 2.1 |
| 323 | if header: |
| 324 | if hasattr(rows, '__iter__') and hasattr(rows, 'next'): |
| 325 | self.header(rows.next()) |
| 326 | else: |
| 327 | self.header(rows[0]) |
| 328 | rows = rows[1:] |
| 329 | for row in rows: |
| 330 | self.add_row(row) |
| 331 | |
| 332 | def draw(self): |
| 333 | """Draw the table |
| 334 | |
| 335 | - the table is returned as a whole string |
| 336 | """ |
| 337 | |
| 338 | if not self._header and not self._rows: |
| 339 | return |
| 340 | self._compute_cols_width() |
| 341 | self._check_align() |
| 342 | out = "" |
| 343 | |
| 344 | if self._has_border(): |
| 345 | out += self._hline(*self._chars_top) |
| 346 | |
| 347 | if self._header: |
| 348 | out += self._draw_line(self._header, isheader=True) |
| 349 | if self._has_header(): |
| 350 | out += self._hline(*self._chars_header) |
| 351 | |
| 352 | length = 0 |
| 353 | for row in self._rows: |
| 354 | length += 1 |
| 355 | out += self._draw_line(row) |
| 356 | if self._has_hlines() and length < len(self._rows): |
| 357 | out += self._hline(*self._chars_middle) |
| 358 | |
| 359 | if self._has_border(): |
| 360 | out += self._hline(*self._chars_bottom) |
| 361 | |
| 362 | return out[:-1] |
| 363 | |
| 364 | def _str(self, i, x): |
| 365 | """Handles string formatting of cell data |
| 366 | |
| 367 | i - index of the cell datatype in self._dtype |
| 368 | x - cell data to format |
| 369 | """ |
kdanylov aka koder | cdfcdaf | 2017-04-29 10:03:39 +0300 | [diff] [blame] | 370 | if isinstance(x, str): |
| 371 | return x |
| 372 | |
kdanylov aka koder | 150b219 | 2017-04-01 16:53:01 +0300 | [diff] [blame] | 373 | try: |
| 374 | f = float(x) |
| 375 | except: |
| 376 | return obj2unicode(x) |
| 377 | |
| 378 | n = self._precision |
| 379 | dtype = self._dtype[i] |
| 380 | |
| 381 | if dtype == 'i': |
| 382 | return str(int(round(f))) |
| 383 | elif dtype == 'f': |
| 384 | return '%.*f' % (n, f) |
| 385 | elif dtype == 'e': |
| 386 | return '%.*e' % (n, f) |
| 387 | elif dtype == 't': |
| 388 | return obj2unicode(x) |
| 389 | else: |
| 390 | if f - round(f) == 0: |
| 391 | if abs(f) > 1e8: |
| 392 | return '%.*e' % (n, f) |
| 393 | else: |
| 394 | return str(int(round(f))) |
| 395 | else: |
| 396 | if abs(f) > 1e8: |
| 397 | return '%.*e' % (n, f) |
| 398 | else: |
| 399 | return '%.*f' % (n, f) |
| 400 | |
| 401 | def _check_row_size(self, array): |
| 402 | """Check that the specified array fits the previous rows size |
| 403 | """ |
| 404 | |
| 405 | if not self._row_size: |
| 406 | self._row_size = len(array) |
| 407 | elif self._row_size != len(array): |
| 408 | raise ArraySizeError("array should contain %d elements" \ |
| 409 | % self._row_size) |
| 410 | |
| 411 | def _has_vlines(self): |
| 412 | """Return a boolean, if vlines are required or not |
| 413 | """ |
| 414 | |
| 415 | return self._deco & Texttable.VLINES > 0 |
| 416 | |
| 417 | def _has_hlines(self): |
| 418 | """Return a boolean, if hlines are required or not |
| 419 | """ |
| 420 | |
| 421 | return self._deco & Texttable.HLINES > 0 |
| 422 | |
| 423 | def _has_border(self): |
| 424 | """Return a boolean, if border is required or not |
| 425 | """ |
| 426 | |
| 427 | return self._deco & Texttable.BORDER > 0 |
| 428 | |
| 429 | def _has_header(self): |
| 430 | """Return a boolean, if header line is required or not |
| 431 | """ |
| 432 | |
| 433 | return self._deco & Texttable.HEADER > 0 |
| 434 | |
| 435 | def _hline(self, left, horiz, cross, right): |
| 436 | """Return a string used to separated rows or separate header from rows""" |
| 437 | |
| 438 | # compute cell separator |
| 439 | sep = horiz + (cross if self._has_vlines() else horiz) + horiz |
| 440 | |
| 441 | # build the line |
| 442 | line = sep.join([horiz * n for n in self._width]) |
| 443 | |
| 444 | # add border if needed |
| 445 | if self._has_border(): |
| 446 | line = left + horiz + line + horiz + right |
| 447 | |
| 448 | return line + "\n" |
| 449 | |
| 450 | def _len_cell(self, cell): |
| 451 | """Return the width of the cell |
| 452 | |
| 453 | Special characters are taken into account to return the width of the |
| 454 | cell, such like newlines and tabs |
| 455 | """ |
| 456 | |
| 457 | cell_lines = cell.split('\n') |
| 458 | maxi = 0 |
| 459 | for line in cell_lines: |
| 460 | length = 0 |
| 461 | parts = line.split('\t') |
| 462 | for part, i in zip(parts, list(range(1, len(parts) + 1))): |
| 463 | length = length + len(part) |
| 464 | if i < len(parts): |
| 465 | length = (length//8 + 1) * 8 |
| 466 | maxi = max(maxi, length) |
| 467 | return maxi |
| 468 | |
| 469 | def _compute_cols_width(self): |
| 470 | """Return an array with the width of each column |
| 471 | |
| 472 | If a specific width has been specified, exit. If the total of the |
| 473 | columns width exceed the table desired width, another width will be |
| 474 | computed to fit, and cells will be wrapped. |
| 475 | """ |
| 476 | |
| 477 | if hasattr(self, "_width"): |
| 478 | return |
| 479 | maxi = [] |
| 480 | if self._header: |
| 481 | maxi = [ self._len_cell(x) for x in self._header ] |
| 482 | for row in self._rows: |
| 483 | for cell,i in zip(row, list(range(len(row)))): |
| 484 | try: |
| 485 | maxi[i] = max(maxi[i], self._len_cell(cell)) |
| 486 | except (TypeError, IndexError): |
| 487 | maxi.append(self._len_cell(cell)) |
| 488 | items = len(maxi) |
| 489 | length = sum(maxi) |
| 490 | if self._max_width and length + items * 3 + 1 > self._max_width: |
| 491 | maxi = [ |
| 492 | int(round(self._max_width / (length + items * 3 + 1) * n)) |
| 493 | for n in maxi |
| 494 | ] |
| 495 | self._width = maxi |
| 496 | |
| 497 | def _check_align(self): |
| 498 | """Check if alignment has been specified, set default one if not |
| 499 | """ |
| 500 | |
| 501 | if not hasattr(self, "_align"): |
| 502 | self._align = ["l"] * self._row_size |
| 503 | if not hasattr(self, "_valign"): |
| 504 | self._valign = ["t"] * self._row_size |
| 505 | |
| 506 | def _draw_line(self, line, isheader=False): |
| 507 | """Draw a line |
| 508 | |
| 509 | Loop over a single cell length, over all the cells |
| 510 | """ |
| 511 | |
| 512 | line = self._splitit(line, isheader) |
| 513 | space = " " |
| 514 | out = "" |
| 515 | for i in range(len(line[0])): |
| 516 | if self._has_border(): |
| 517 | out += "%s " % self._char_vert |
| 518 | length = 0 |
| 519 | for cell, width, align in zip(line, self._width, self._align): |
| 520 | length += 1 |
| 521 | cell_line = cell[i] |
| 522 | fill = width - len(cell_line) |
| 523 | if isheader: |
| 524 | align = "c" |
| 525 | if align == "r": |
| 526 | out += "%s " % (fill * space + cell_line) |
| 527 | elif align == "c": |
| 528 | out += "%s " % (int(fill/2) * space + cell_line + int(fill/2 + fill%2) * space) |
| 529 | else: |
| 530 | out += "%s " % (cell_line + fill * space) |
| 531 | if length < len(line): |
| 532 | out += "%s " % [space, self._char_vert][self._has_vlines()] |
| 533 | out += "%s\n" % ['', self._char_vert][self._has_border()] |
| 534 | return out |
| 535 | |
| 536 | def _splitit(self, line, isheader): |
| 537 | """Split each element of line to fit the column width |
| 538 | |
| 539 | Each element is turned into a list, result of the wrapping of the |
| 540 | string to the desired width |
| 541 | """ |
| 542 | |
| 543 | line_wrapped = [] |
| 544 | for cell, width in zip(line, self._width): |
| 545 | array = [] |
| 546 | for c in cell.split('\n'): |
| 547 | if c.strip() == "": |
| 548 | array.append("") |
| 549 | else: |
| 550 | array.extend(textwrap.wrap(c, width)) |
| 551 | line_wrapped.append(array) |
| 552 | max_cell_lines = reduce(max, list(map(len, line_wrapped))) |
| 553 | for cell, valign in zip(line_wrapped, self._valign): |
| 554 | if isheader: |
| 555 | valign = "t" |
| 556 | if valign == "m": |
| 557 | missing = max_cell_lines - len(cell) |
| 558 | cell[:0] = [""] * int(missing / 2) |
| 559 | cell.extend([""] * int(missing / 2 + missing % 2)) |
| 560 | elif valign == "b": |
| 561 | cell[:0] = [""] * (max_cell_lines - len(cell)) |
| 562 | else: |
| 563 | cell.extend([""] * (max_cell_lines - len(cell))) |
| 564 | return line_wrapped |
| 565 | |
| 566 | |
| 567 | if __name__ == '__main__': |
| 568 | table = Texttable() |
| 569 | table.set_cols_align(["l", "r", "c"]) |
| 570 | table.set_cols_valign(["t", "m", "b"]) |
| 571 | table.add_rows([["Name", "Age", "Nickname"], |
| 572 | ["Mr\nXavier\nHuon", 32, "Xav'"], |
| 573 | ["Mr\nBaptiste\nClement", 1, "Baby"], |
| 574 | ["Mme\nLouise\nBourgeau", 28, "Lou\n \nLoue"]]) |
| 575 | print(table.draw() + "\n") |
| 576 | |
| 577 | table = Texttable() |
| 578 | table.set_deco(Texttable.HEADER) |
| 579 | table.set_cols_dtype(['t', # text |
| 580 | 'f', # float (decimal) |
| 581 | 'e', # float (exponent) |
| 582 | 'i', # integer |
| 583 | 'a']) # automatic |
| 584 | table.set_cols_align(["l", "r", "r", "r", "l"]) |
| 585 | table.add_rows([["text", "float", "exp", "int", "auto"], |
| 586 | ["abcd", "67", 654, 89, 128.001], |
| 587 | ["efghijk", 67.5434, .654, 89.6, 12800000000000000000000.00023], |
| 588 | ["lmn", 5e-78, 5e-78, 89.4, .000000000000128], |
| 589 | ["opqrstu", .023, 5e+78, 92., 12800000000000000000000]]) |
| 590 | print(table.draw()) |