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)