File: nn.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 # nn [options...] [filepaths/URIs...] 27 # 28 # Nice Numbers restyles all runs of 4+ digits by alternating ANSI-styles 29 # every 3-digit group, so long numbers become easier to read at a glance. 30 # 31 # All (optional) leading options start with either single or double-dash, 32 # and most of them change the style/color used. Some of the options are, 33 # shown in their single-dash form: 34 # 35 # -h show this help message 36 # -help show this help message 37 # 38 # -b use a blue color 39 # -blue use a blue color 40 # -bold bold-style digits 41 # -g use a green color 42 # -gray use a gray color (default) 43 # -green use a green color 44 # -hi use a highlighting/inverse style 45 # -m use a magenta color 46 # -magenta use a magenta color 47 # -o use an orange color 48 # -orange use an orange color 49 # -p use a purple color 50 # -purple use a purple color 51 # -r use a red color 52 # -red use a red color 53 # -u underline digits 54 # -underline underline digits 55 56 57 # Note: string slicing is a major source of inefficiencies in this script, 58 # making it viable only for small inputs; it's not clear what the stdlib 59 # offers to loop over sub-strings without copying data, which is really 60 # needed in this case. 61 # 62 # In the end the code has become much uglier by using explicit index-pairs, 63 # which are used/updated all over to avoid copying sub-strings. Standard 64 # output seems already line-buffered by default, which means explicit 65 # output-buffering is unlikely to bring any noticeable speed-ups. 66 67 68 from io import TextIOWrapper 69 from sys import argv, exit, stderr, stdin, stdout 70 from urllib.request import urlopen 71 72 73 # info is the help message shown when asked to 74 info = ''' 75 nn [options...] [filepaths/URIs...] 76 77 Nice Numbers restyles all runs of 4+ digits by alternating ANSI-styles 78 every 3-digit group, so long numbers become easier to read at a glance. 79 80 All (optional) leading options start with either single or double-dash, 81 and most of them change the style/color used. Some of the options are, 82 shown in their single-dash form: 83 84 -h show this help message 85 -help show this help message 86 87 -b use a blue color 88 -blue use a blue color 89 -bold bold-style digits 90 -g use a green color 91 -gray use a gray color (default) 92 -green use a green color 93 -hi use a highlighting/inverse style 94 -m use a magenta color 95 -magenta use a magenta color 96 -o use an orange color 97 -orange use an orange color 98 -p use a purple color 99 -purple use a purple color 100 -r use a red color 101 -red use a red color 102 -u underline digits 103 -underline underline digits 104 '''.strip() 105 106 # handle standard help cmd-line options, quitting right away in that case 107 if len(argv) == 2 and argv[1] in ('-h', '--h', '-help', '--help'): 108 print(info, file=stderr) 109 exit(0) 110 111 # names_aliases normalizes lookup keys for table names2styles 112 names_aliases = { 113 'b': 'blue', 114 'g': 'green', 115 'm': 'magenta', 116 'o': 'orange', 117 'p': 'purple', 118 'r': 'red', 119 'u': 'underline', 120 121 'hi': 'inverse', 122 'inv': 'inverse', 123 'mag': 'magenta', 124 125 'flip': 'inverse', 126 'swap': 'inverse', 127 128 'reset': 'plain', 129 'highlight': 'inverse', 130 'hilite': 'inverse', 131 'invert': 'inverse', 132 'inverted': 'inverse', 133 'swapped': 'inverse', 134 } 135 136 # names2styles matches color/style names to their ANSI-style strings 137 names2styles = { 138 'blue': '\x1b[38;5;26m', 139 'bold': '\x1b[1m', 140 'gray': '\x1b[38;5;249m', 141 'green': '\x1b[38;5;29m', 142 'inverse': '\x1b[7m', 143 'magenta': '\x1b[38;5;165m', 144 'orange': '\x1b[38;5;166m', 145 'plain': '\x1b[0m', 146 'purple': '\x1b[38;5;99m', 147 'red': '\x1b[31m', 148 'underline': '\x1b[4m', 149 } 150 151 152 def restyle_line(w, line: str, style: str) -> None: 153 '''Alternate styles for runs of digits in the string given''' 154 155 start = 0 156 end = len(line) 157 158 if end > 1 and line[end - 2] == '\r' and line[end - 1] == '\n': 159 end -= 2 160 elif end > 0 and line[end - 1] == '\n': 161 end -= 1 162 163 while True: 164 # see if line is over 165 if start >= end: 166 w.write('\n') 167 return 168 169 # find where the next run of digits starts; a negative index means 170 # none were found 171 i = -1 172 for j in range(start, end): 173 if line[j].isdigit(): 174 i = j 175 break 176 177 # check if rest of the line has no more digits 178 if i < 0: 179 w.write(line[start:end]) 180 w.write('\n') 181 return 182 183 # emit line up to right before the next run of digits starts 184 w.write(line[start:i]) 185 start = i 186 187 # find where/if the current run of digits ends; a negative index 188 # means the run reaches the end of the line 189 i = -1 190 for j in range(start, end): 191 if not line[j].isdigit(): 192 i = j 193 break 194 195 # check if rest of the line has only digits in it 196 if i < 0: 197 restyle_digits(w, line, start, end, style) 198 w.write('\n') 199 return 200 201 # emit digits using alternate styling, and advance past them 202 restyle_digits(w, line, start, i, style) 203 start = i 204 205 206 def restyle_digits(w, digits: str, start: int, end: int, style: str) -> None: 207 '''Alternate styles on 3-item chunks from the string given''' 208 diff = end - start 209 210 # it's overall quicker to just emit short-enough digit-runs verbatim 211 if diff < 4: 212 w.write(digits[start:end]) 213 return 214 215 # emit leading chunk of digits, which is the only one which 216 # can have fewer than 3 items 217 lead = diff % 3 218 w.write(digits[start:start + lead]) 219 220 # the rest of the sub-string now has a multiple of 3 items left 221 start += lead 222 223 # start by styling the next digit-group only if there was a 224 # non-empty leading group at the start of the full digit-run 225 use_style = lead > 0 226 227 # alternate styles until the string is over 228 while start < end: 229 # the digits left are always a multiple of 3 230 stop = start + 3 231 232 if use_style: 233 w.write(style) 234 w.write(digits[start:stop]) 235 w.write('\x1b[0m') 236 else: 237 w.write(digits[start:stop]) 238 239 # switch style and advance to the next 3-digit chunk 240 use_style = not use_style 241 start = stop 242 243 244 def seems_url(s: str) -> bool: 245 for prot in ('https://', 'http://', 'file://', 'ftp://', 'data:'): 246 if s.startswith(prot): 247 return True 248 return False 249 250 251 def handle_lines(w, src, style: str) -> None: 252 for line in src: 253 restyle_line(w, line, style) 254 255 256 args = argv[1:] 257 # default (alternate) style is a light-gray color 258 style = names2styles['gray'] 259 260 # handle leading style/color option, if present 261 if len(args) > 0 and args[0].startswith('-'): 262 s = args[0].lstrip('-') 263 if s in names_aliases: 264 s = names_aliases[s] 265 if s in names2styles: 266 style = names2styles[s] 267 # skip leading arg, since it's clearly not a filepath 268 args = args[1:] 269 270 try: 271 if args.count('-') > 1: 272 msg = 'reading from `-` (standard input) more than once not allowed' 273 raise ValueError(msg) 274 275 stdout.reconfigure(newline='\n', encoding='utf-8') 276 277 # handle all named inputs given 278 for path in args: 279 if path == '-': 280 handle_lines(stdout, stdin, style) 281 continue 282 283 if seems_url(path): 284 with urlopen(path) as inp: 285 with TextIOWrapper(inp, encoding='utf-8') as txt: 286 handle_lines(stdout, txt, style) 287 continue 288 289 with open(path) as inp: 290 handle_lines(stdout, inp, style) 291 292 # when no filenames are given, handle lines from stdin 293 if len(args) == 0: 294 handle_lines(stdout, stdin, style) 295 except (BrokenPipeError, KeyboardInterrupt): 296 # quit quietly, instead of showing a confusing error message 297 stderr.flush() 298 stderr.close() 299 except Exception as e: 300 print(f'\x1b[31m{e}\x1b[0m', file=stderr) 301 exit(1)