File: ntsv.py 1 #!/usr/bin/python3 2 3 # The MIT License (MIT) 4 # 5 # Copyright © 2020-2025 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 from itertools import islice 27 from math import isinf, isnan, modf, nan 28 from sys import argv, exit, stderr, stdin, stdout 29 from typing import Dict, Tuple 30 31 32 info = ''' 33 ntsv [filepaths/URIs...] 34 35 36 Nice Tab Separated Values realigns and styles data tables using ANSI color 37 sequences. When not given filepaths/URIs to read data from, this tool reads 38 from standard input. 39 ''' 40 41 # handle standard help cmd-line options, quitting right away in that case 42 if len(argv) > 1 and argv[1] in ('-h', '--h', '-help', '--help'): 43 print(info.strip()) 44 exit(0) 45 46 47 # spaces is used by func emit_spaces 48 spaces = tuple(' ' * i for i in range(64)) 49 50 51 def emit_spaces(w, n: int) -> None: 52 'Speed-up writing spaces, by minimizing calls to func write.' 53 54 if n < 1: 55 return 56 57 while n >= len(spaces): 58 w.write(spaces[-1]) 59 n -= len(spaces) 60 w.write(spaces[n]) 61 62 63 def count_decimals(s: str) -> int: 64 ''' 65 Count the trailing decimal digits in a string, excluding the decimal dot. 66 This func assumes the string can parse as a valid decimal-format float. 67 ''' 68 k = s.find('.') 69 return len(s) - k - 1 if k >= 0 else 0 70 71 72 def get_table_info(src) -> Dict: 73 'Gather metadata by reading TSV rows from the input given.' 74 75 rows = [] 76 totals = [] 77 numeric = [] 78 max_decs = [] 79 max_widths = [] 80 81 def handle_row(items: Tuple[str]) -> None: 82 nonlocal totals, numeric, max_decs, max_widths 83 84 if len(max_widths) < len(items): 85 extras = len(items) - len(max_widths) 86 totals.extend([0.0] * extras) 87 numeric.extend([False] * extras) 88 max_decs.extend([0] * extras) 89 max_widths.extend([0] * extras) 90 91 for i, s in enumerate(items): 92 max_widths[i] = max(max_widths[i], len(s)) 93 try: 94 f = float(s) 95 except Exception: 96 continue 97 98 if not (isnan(f) or isinf(f)): 99 max_decs[i] = max(max_decs[i], count_decimals(s)) 100 totals[i] += f 101 numeric[i] = True 102 103 for line in src: 104 line = line.rstrip('\r\n').rstrip('\n') 105 if line == '': 106 continue 107 108 items = tuple(line.split('\t')) 109 rows.append(items) 110 handle_row(items) 111 112 # format column-totals as strings, and update column widths with those 113 for i, num in enumerate(numeric): 114 s = f'{totals[i]:.{max_decs[i]}f}' if num else '-' 115 # s = f'{totals[i]:.{max_decs[i]}f}' if num else '' 116 max_widths[i] = max(max_widths[i], len(s)) 117 totals[i] = s 118 119 return { 120 'rows': rows, 121 'max-widths': tuple(max_widths), 122 'max-decimals': tuple(max_decs), 123 'totals': tuple(totals), 124 } 125 126 127 def show_table(w, meta: Dict) -> None: 128 'Show/render table using the metadata given.' 129 130 gap = len(' ') 131 rows = meta['rows'] 132 max_widths = meta['max-widths'] 133 max_decs = meta['max-decimals'] 134 totals = meta['totals'] 135 136 if len(rows) == 0: 137 return 138 139 for row in rows: 140 show_tiles(w, row, len(max_widths)) 141 show_row(w, row, max_widths, max_decs, gap) 142 143 # make the totals line stand out by padding it with spaces, instead of 144 # showing tiles: their lack should give a hint this final `row` isn't 145 # part of the input 146 emit_spaces(w, len(totals)) 147 show_row(w, totals, max_widths, max_decs, gap) 148 149 150 def show_row(w, row: Tuple[str], widths: Tuple[int], max_decs: Tuple[int], 151 gap: int) -> None: 152 pad = 0 153 154 for i, s in enumerate(row): 155 try: 156 f = float(s) 157 except Exception: 158 f = nan 159 160 if not (isnan(f) or isinf(f)): 161 decs = count_decimals(s) 162 if decs > 0: 163 trail = max_decs[i] - decs 164 elif max_decs[i] > 0: 165 trail = max_decs[i] + 1 166 else: 167 trail = 0 168 169 lead = widths[i] - len(s) 170 emit_spaces(w, max(pad + lead - trail, 0) + gap) 171 rem, _ = modf(f) 172 if rem != 0: 173 if f > 0: 174 w.write(f'\x1b[38;2;0;135;95m{s}\x1b[0m') 175 elif f < 0: 176 w.write(f'\x1b[38;2;215;95;95m{s}\x1b[0m') 177 elif f == 0: 178 w.write(f'\x1b[38;2;0;95;215m{s}\x1b[0m') 179 else: 180 w.write(s) 181 else: 182 if f > 0: 183 w.write(f'\x1b[38;2;0;95;0m{s}\x1b[0m') 184 elif f < 0: 185 w.write(f'\x1b[38;2;204;0;0m{s}\x1b[0m') 186 elif f == 0: 187 w.write(f'\x1b[38;2;0;95;215m{s}\x1b[0m') 188 else: 189 w.write(s) 190 pad = trail 191 else: 192 emit_spaces(w, pad + gap) 193 w.write(s) 194 pad = widths[i] - len(s) 195 196 # don't forget to end the row/line 197 w.write('\n') 198 199 200 def show_tiles(w, row: Tuple[str], expected: int) -> None: 201 for s in row: 202 try: 203 f = float(s) 204 rem, _ = modf(f) 205 if f > 0: 206 w.write('\x1b[38;2;0;135;95m■' if rem != 0 else '\x1b[38;2;0;95;0m■') 207 elif f < 0: 208 w.write('\x1b[38;2;215;95;95m■' if rem != 0 else '\x1b[38;2;204;0;0m■') 209 elif f == 0: 210 w.write('\x1b[38;2;0;95;215m■') 211 else: 212 w.write('\x1b[0m■') 213 except Exception: 214 if s == '': 215 w.write('\x1b[0m○') 216 else: 217 w.write('\x1b[38;2;128;128;128m■') 218 219 if len(row) - expected: 220 w.write('\x1b[0m') 221 for _ in range(expected - len(row)): 222 w.write('×') 223 w.write('\x1b[0m') 224 225 226 def handle_tsv(w, src) -> None: 227 show_table(w, get_table_info(src)) 228 229 230 def seems_url(s: str) -> bool: 231 protocols = ('https://', 'http://', 'file://', 'ftp://', 'data:') 232 return any(s.startswith(p) for p in protocols) 233 234 235 try: 236 if argv.count('-') > 1: 237 msg = 'reading from `-` (standard input) more than once not allowed' 238 raise ValueError(msg) 239 240 if any(seems_url(e) for e in argv): 241 from io import TextIOWrapper 242 from urllib.request import urlopen 243 244 for path in islice(argv, 1, None): 245 if path == '-': 246 handle_tsv(stdout, stdin) 247 elif seems_url(path): 248 with urlopen(path) as inp: 249 with TextIOWrapper(inp, encoding='utf-8') as txt: 250 handle_tsv(stdout, txt) 251 else: 252 with open(path, encoding='utf-8') as inp: 253 handle_tsv(stdout, inp) 254 255 if len(argv) < 2: 256 handle_tsv(stdout, stdin) 257 except BrokenPipeError: 258 # quit quietly, instead of showing a confusing error message 259 stderr.close() 260 except KeyboardInterrupt: 261 exit(2) 262 except Exception as e: 263 print(f'\x1b[31m{e}\x1b[0m', file=stderr) 264 exit(1)