File: nt.py 1 #!/usr/bin/python3 2 3 # The MIT License (MIT) 4 # 5 # Copyright © 2024 pacman64 6 # 7 # Permission is hereby granted, free of charge, to any person obtaining a copy 8 # of this software and associated documentation files (the “Software”), to deal 9 # in the Software without restriction, including without limitation the rights 10 # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 # copies of the Software, and to permit persons to whom the Software is 12 # furnished to do so, subject to the following conditions: 13 # 14 # The above copyright notice and this permission notice shall be included in 15 # all copies or substantial portions of the Software. 16 # 17 # THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 # SOFTWARE. 24 25 26 # nt [filepaths/URIs...] 27 # 28 # Nice Tables realigns and styles TSV (tab-separated values) data using ANSI 29 # sequences. When not given filepaths/URIs to read data from, this tool reads 30 # from standard input. 31 32 33 from io import TextIOWrapper 34 from json import dumps, load 35 from math import isinf, isnan, nan 36 from sys import argv, exit, stderr, stdin, stdout 37 from urllib.request import urlopen 38 from typing import Dict, List, Tuple 39 40 41 # info is the help message shown when asked to 42 info = ''' 43 nt [filepaths/URIs...] 44 45 Nice Tables realigns and styles TSV (tab-separated values) data using ANSI 46 sequences. When not given filepaths/URIs to read data from, this tool reads 47 from standard input. 48 '''.strip() 49 50 # handle standard help cmd-line options, quitting right away in that case 51 if len(argv) == 2 and argv[1] in ('-h', '--h', '-help', '--help'): 52 print(info, file=stderr) 53 exit(0) 54 55 56 # spaces is used by func emit_spaces 57 spaces = tuple(' ' * i for i in range(64)) 58 59 60 def emit_spaces(w, n: int) -> None: 61 '''Speed-up writing spaces, by minimizing calls to func write.''' 62 63 if n < 1: 64 return 65 66 while n >= len(spaces): 67 w.write(spaces[-1]) 68 n -= len(spaces) 69 w.write(spaces[n]) 70 71 72 def count_decimals(s: str) -> int: 73 ''' 74 Count the trailing decimal digits in a string, excluding the decimal dot. 75 This func assumes the string can parse as a valid decimal-format float. 76 ''' 77 k = s.find('.') 78 return len(s) - k - 1 if k >= 0 else 0 79 80 81 def get_table_info(w, src) -> Dict: 82 '''Gather metadata by reading TSV rows from the input given.''' 83 84 rows = [] 85 totals = [] 86 numeric = [] 87 max_decs = [] 88 max_widths = [] 89 90 for line in src: 91 line = line.rstrip('\r\n').rstrip('\n') 92 if line == '': 93 continue 94 95 items = tuple(line.split('\t')) 96 rows.append(items) 97 98 if len(max_widths) < len(items): 99 extras = len(items) - len(max_widths) 100 totals.extend([0.0] * extras) 101 numeric.extend([False] * extras) 102 max_decs.extend([0] * extras) 103 max_widths.extend([0] * extras) 104 105 for i, s in enumerate(items): 106 max_widths[i] = max(max_widths[i], len(s)) 107 try: 108 f = float(s) 109 except: 110 continue 111 112 if not (isnan(f) or isinf(f)): 113 max_decs[i] = max(max_decs[i], count_decimals(s)) 114 totals[i] += f 115 numeric[i] = True 116 117 # format column-totals as strings, and update column widths with those 118 for i, num in enumerate(numeric): 119 if num: 120 s = f'{totals[i]:.{max_decs[i]}f}' 121 totals[i] = s 122 max_widths[i] = max(max_widths[i], len(s)) 123 else: 124 totals[i] = '-' 125 max_widths[i] = max(max_widths[i], 1) 126 127 return { 128 'rows': rows, 129 'max-widths': tuple(max_widths), 130 'max-decimals': tuple(max_decs), 131 'totals': tuple(totals), 132 } 133 134 135 def show_table(w, meta: Dict) -> None: 136 '''Show/render table using the metadata given.''' 137 138 gap = ' ' 139 rows = meta['rows'] 140 max_widths = meta['max-widths'] 141 max_decs = meta['max-decimals'] 142 totals = meta['totals'] 143 144 if len(rows) == 0: 145 return 146 147 for i, row in enumerate(rows): 148 # pick the 1st row, and then every 5th row after it; this script 149 # no longer underlines output lines, but the code for this tricky 150 # condition is kept for reference, just in case 151 # 152 # underlined = i == 0 or (i > 1 and i % 5 == 0) 153 show_tiles(w, row, len(max_widths)) 154 show_row(w, row, max_widths, max_decs, gap, '') 155 156 style = '\x1b[38;5;244m' 157 w.write(style) 158 # make the totals line stand out by padding it with spaces, instead of 159 # showing tiles: their lack should give a hint this final `row` isn't 160 # part of the input 161 emit_spaces(w, len(totals)) 162 show_row(w, totals, max_widths, max_decs, gap, style) 163 164 165 def show_row(w, row: Tuple[str], widths: Tuple[int], max_decs: Tuple[int], 166 gap: str, style: str) -> None: 167 for j, s in enumerate(row): 168 w.write(gap) 169 170 try: 171 f = float(s) 172 except: 173 f = nan 174 175 if not (isnan(f) or isinf(f)): 176 pad = widths[j] - len(s) 177 decs = count_decimals(s) 178 if decs > 0: 179 trail = max_decs[j] - decs 180 elif max_decs[j] > 0: 181 trail = max_decs[j] + 1 182 else: 183 trail = 0 184 185 emit_spaces(w, pad - trail) 186 if f > 0: 187 w.write(f'\x1b[38;5;29m{s}\x1b[0m{style}') 188 elif f < 0: 189 w.write(f'\x1b[38;5;1m{s}\x1b[0m{style}') 190 elif f == 0: 191 # w.write(f'\x1b[38;5;33m{s}\x1b[0m{style}') 192 w.write(f'\x1b[38;5;26m{s}\x1b[0m{style}') 193 else: 194 w.write(s) 195 emit_spaces(w, trail) 196 else: 197 w.write(s) 198 if j < len(row) - 1: 199 emit_spaces(w, widths[j] - len(s)) 200 201 # don't forget to end the row/line 202 if style != '': 203 w.write('\x1b[0m') 204 w.write('\n') 205 206 207 def show_tiles(w, row: Tuple[str], expected: int) -> None: 208 w.write('\x1b[48;5;255m') 209 for s in row: 210 try: 211 f = float(s) 212 if f > 0: 213 w.write('\x1b[38;5;29m■') 214 elif f < 0: 215 w.write('\x1b[38;5;1m■') 216 elif f == 0: 217 # w.write('\x1b[38;5;33m■') 218 w.write('\x1b[38;5;26m■') 219 else: 220 w.write('\x1b[0m■') 221 except: 222 if s == '': 223 w.write('\x1b[0m\x1b[48;5;255m○') 224 else: 225 w.write('\x1b[38;5;244m■') 226 227 if len(row) - expected: 228 w.write('\x1b[0m\x1b[48;5;255m') 229 for i in range(expected - len(row)): 230 w.write('×') 231 w.write('\x1b[0m') 232 233 234 def handle_tsv(w, src) -> None: 235 show_table(w, get_table_info(w, src)) 236 237 238 def seems_url(s: str) -> bool: 239 for prot in ('https://', 'http://', 'file://', 'ftp://', 'data:'): 240 if s.startswith(prot): 241 return True 242 return False 243 244 245 try: 246 stdout.reconfigure(newline='\n', encoding='utf-8') 247 248 args = argv[1:] 249 if args.count('-') > 1: 250 msg = 'reading from `-` (standard input) more than once not allowed' 251 raise ValueError(msg) 252 253 for path in args: 254 if path == '-': 255 stdin.reconfigure(encoding='utf-8') 256 handle_tsv(stdout, stdin) 257 elif seems_url(path): 258 with urlopen(path) as inp: 259 with TextIOWrapper(inp, encoding='utf-8') as txt: 260 handle_tsv(stdout, txt) 261 else: 262 with open(path, encoding='utf-8') as inp: 263 handle_tsv(stdout, inp) 264 265 if len(args) == 0: 266 stdin.reconfigure(encoding='utf-8') 267 handle_tsv(stdout, stdin) 268 except (BrokenPipeError, KeyboardInterrupt): 269 # quit quietly, instead of showing a confusing error message 270 stderr.flush() 271 stderr.close() 272 except Exception as e: 273 print(f'\x1b[31m{e}\x1b[0m', file=stderr) 274 exit(1)