File: ngron.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 # ngron [options...] [filepath/URI...]
  27 #
  28 # Nice GRON converts JSON data into `grep`-friendly lines, similar to what
  29 # tool `gron` (GRep jsON; https://github.com/tomnomnom/gron) does.
  30 #
  31 # This tool uses `nicer` ANSI styles than the original, hence its name, but
  32 # can't convert its output back into JSON, unlike the latter.
  33 #
  34 # Unlike the original `gron`, there's no sort-mode. When not given a named
  35 # source (filepath/URI) to read from, data are read from standard input.
  36 #
  37 # Options, where leading double-dashes are also allowed:
  38 #
  39 #     -h         show this help message
  40 #     -help      show this help message
  41 #
  42 #     -m         monochrome (default), enables unstyled output-mode
  43 #     -c         color, enables ANSI-styled output-mode
  44 #     -color     enables ANSI-styled output-mode
  45 
  46 
  47 from json import dumps, load
  48 from re import compile as compile_re
  49 from sys import argv, exit, stderr, stdin, stdout
  50 from typing import List, Union
  51 from urllib.request import urlopen
  52 
  53 
  54 # info is the help message shown when asked to
  55 info = '''
  56 ngron [options...] [filepath/URI...]
  57 
  58 Nice GRON converts JSON data into `grep`-friendly lines, similar to what
  59 tool `gron` (GRep jsON; https://github.com/tomnomnom/gron) does.
  60 
  61 This tool uses `nicer` ANSI styles than the original, hence its name, but
  62 can't convert its output back into JSON, unlike the latter.
  63 
  64 Unlike the original `gron`, there's no sort-mode. When not given a named
  65 source (filepath/URI) to read from, data are read from standard input.
  66 
  67 Options, where leading double-dashes are also allowed:
  68 
  69     -h         show this help message
  70     -help      show this help message
  71 
  72     -m         monochrome (default), enables unstyled output-mode
  73     -c         color, enables ANSI-styled output-mode
  74     -color     enables ANSI-styled output-mode
  75 '''.strip()
  76 
  77 # handle standard help cmd-line options, quitting right away in that case
  78 if len(argv) == 2 and argv[1] in ('-h', '--h', '-help', '--help'):
  79     print(info, file=stderr)
  80     exit(0)
  81 
  82 
  83 # simple_str_re helps funcs restyle_path and emit_path figure out which
  84 # strings need quoting
  85 simple_str_re = compile_re('[a-zA-Z_][a-zA-Z_0-9]*')
  86 
  87 
  88 def gron_path(w, path: List[Union[int, str]]) -> None:
  89     w.write('json')
  90     for e in path:
  91         if isinstance(e, int):
  92             w.write(f'[{e}]')
  93             continue
  94         if simple_str_re.match(e):
  95             w.write(f'.{e}')
  96             continue
  97         w.write(f'[{dumps(e)}]')
  98     w.write(' = ')
  99 
 100 
 101 def restyle_path(w, path: List[Union[int, str]]) -> None:
 102     w.write('\x1b[38;5;99mjson')
 103     syntax = False
 104 
 105     for e in path:
 106         if isinstance(e, int):
 107             w.write(f'\x1b[38;5;249m[\x1b[38;5;29m{e}\x1b[38;5;249m]')
 108             syntax = True
 109             continue
 110         if simple_str_re.match(e):
 111             w.write(f'\x1b[38;5;249m.\x1b[38;5;99m{e}')
 112             syntax = False
 113             continue
 114         w.write(f'\x1b[38;5;249m["\x1b[38;5;24m{dumps(e)[1:-1]}\x1b[38;5;249m"]')
 115         syntax = True
 116 
 117     if syntax:
 118         w.write(' = ')
 119     else:
 120         w.write('\x1b[38;5;249m = ')
 121 
 122 
 123 def end_styled_line(w) -> None:
 124     w.write('\x1b[38;5;249m;\x1b[0m\n')
 125 
 126 
 127 def restyle(w, data, path: List[Union[int, str]]) -> None:
 128     '''This func is where all the recursive output-action starts/happens.'''
 129 
 130     f = type2ansi[type(data)]
 131     if f != None:
 132         f(w, data, path)
 133     else:
 134         raise ValueError(f'unsupported type {type(data)}')
 135 
 136 
 137 def restyle_bool(w, data: bool, path: List[Union[int, str]]) -> None:
 138     '''Handle booleans for func restyle'''
 139     restyle_path(w, path)
 140     if data:
 141         w.write('\x1b[38;5;74mtrue')
 142     else:
 143         w.write('\x1b[38;5;74mfalse')
 144     end_styled_line(w)
 145 
 146 
 147 def restyle_dict(w, data: dict, path: List[Union[int, str]]) -> None:
 148     '''Handle dictionaries for func restyle'''
 149 
 150     restyle_path(w, path)
 151     w.write('\x1b[38;5;249m{};\x1b[0m\n')
 152 
 153     path.append('')
 154     for k, v in data.items():
 155         path[-1] = k
 156         restyle(w, v, path)
 157     del path[-1]
 158 
 159 
 160 def restyle_float(w, data: float, path: List[Union[int, str]]) -> None:
 161     '''Handle floats for func restyle, emitting as many decimals as needed'''
 162     restyle_path(w, path)
 163     w.write(f'\x1b[38;5;29m{data:-1}\x1b[0m')
 164     end_styled_line(w)
 165 
 166 
 167 def restyle_int(w, data: int, path: List[Union[int, str]]) -> None:
 168     '''Handle integers for func restyle'''
 169     restyle_path(w, path)
 170     w.write(f'\x1b[38;5;29m{data}\x1b[0m')
 171     end_styled_line(w)
 172 
 173 
 174 def restyle_list(w, data: list, path: List[Union[int, str]]) -> None:
 175     '''Handle lists for func restyle'''
 176 
 177     restyle_path(w, path)
 178     w.write('\x1b[38;5;249m[];\x1b[0m\n')
 179 
 180     path.append(0)
 181     for i, v in enumerate(data):
 182         path[-1] = i
 183         restyle(w, v, path)
 184     del path[-1]
 185 
 186 
 187 def restyle_none(w, data: bool, path: List[Union[int, str]]) -> None:
 188     '''Handle nulls for func restyle'''
 189     restyle_path(w, path)
 190     w.write('\x1b[38;5;249mnull')
 191     end_styled_line(w)
 192 
 193 
 194 def restyle_str(w, data: str, path: List[Union[int, str]]) -> None:
 195     '''Handle strings for func restyle'''
 196     restyle_path(w, path)
 197     w.write(f'\x1b[38;5;249m"\x1b[38;5;24m{dumps(data)[1:-1]}\x1b[38;5;249m"')
 198     end_styled_line(w)
 199 
 200 
 201 def gron(w, data, path: List[Union[int, str]]) -> None:
 202     '''
 203     This func is where all the recursive gron/plain-text output-action
 204     starts/happens.
 205     '''
 206 
 207     f = type2gron[type(data)]
 208     if f != None:
 209         f(w, data, path)
 210     else:
 211         raise ValueError(f'unsupported type {type(data)}')
 212 
 213 
 214 def gron_bool(w, data: bool, path: List[Union[int, str]]) -> None:
 215     '''Handle booleans for func gron'''
 216     gron_path(w, path)
 217     if data:
 218         w.write('true;\n')
 219     else:
 220         w.write('false;\n')
 221 
 222 
 223 def gron_dict(w, data: dict, path: List[Union[int, str]]) -> None:
 224     '''Handle dictionaries for func gron'''
 225 
 226     gron_path(w, path)
 227     w.write('{};\n')
 228 
 229     path.append('')
 230     for k, v in data.items():
 231         path[-1] = k
 232         gron(w, v, path)
 233     del path[-1]
 234 
 235 def gron_float(w, data: float, path: List[Union[int, str]]) -> None:
 236     '''Handle floats for func gron, emitting as many decimals as needed'''
 237     gron_path(w, path)
 238     w.write(f'{data:-1};\n')
 239 
 240 
 241 def gron_int(w, data: int, path: List[Union[int, str]]) -> None:
 242     '''Handle integers for func gron'''
 243     gron_path(w, path)
 244     w.write(f'{data};\n')
 245 
 246 
 247 def gron_list(w, data: list, path: List[Union[int, str]]) -> None:
 248     '''Handle lists for func gron'''
 249 
 250     gron_path(w, path)
 251     w.write('[];\n')
 252 
 253     path.append(0)
 254     for i, v in enumerate(data):
 255         path[-1] = i
 256         gron(w, v, path)
 257     del path[-1]
 258 
 259 
 260 def gron_none(w, data: bool, path: List[Union[int, str]]) -> None:
 261     '''Handle nulls for func gron'''
 262     gron_path(w, path)
 263     w.write('null;\n')
 264 
 265 
 266 def gron_str(w, data: str, path: List[Union[int, str]]) -> None:
 267     '''Handle strings for func gron'''
 268     gron_path(w, path)
 269     w.write(f'{dumps(data)};\n')
 270 
 271 
 272 def seems_url(s: str) -> bool:
 273     for prot in ('https://', 'http://', 'file://', 'ftp://', 'data:'):
 274         if s.startswith(prot):
 275             return True
 276     return False
 277 
 278 
 279 # type2ansi helps func restyle dispatch styler funcs, using values' types
 280 # as the keys; while JSON doesn't have int values, the stdlib auto-promotes
 281 # round JSON numbers into ints, and so this table has a func for those too
 282 type2ansi = {
 283     type(None): restyle_none,
 284     bool: restyle_bool,
 285     int: restyle_int,
 286     float: restyle_float,
 287     str: restyle_str,
 288     list: restyle_list,
 289     tuple: restyle_list,
 290     dict: restyle_dict,
 291 }
 292 
 293 
 294 # type2gron helps func emit_gron dispatch emitter funcs, using values' types
 295 # as the keys; while JSON doesn't have int values, the stdlib auto-promotes
 296 # round JSON numbers into ints, and so this table has a func for those too
 297 type2gron = {
 298     type(None): gron_none,
 299     bool: gron_bool,
 300     int: gron_int,
 301     float: gron_float,
 302     str: gron_str,
 303     list: gron_list,
 304     tuple: gron_list,
 305     dict: gron_dict,
 306 }
 307 
 308 
 309 try:
 310     stdout.reconfigure(newline='\n', encoding='utf-8')
 311 
 312     if len(argv) > 1 and argv[1].startswith('-'):
 313         l = argv[1].lstrip('-').lower()
 314         if l in ('c', 'color', 'nice'):
 315             emit = restyle
 316             args = argv[2:]
 317         elif l in ('g', 'gron', 'm', 'monochrome', 'p', 'plain'):
 318             emit = gron
 319             args = argv[2:]
 320         else:
 321             emit = gron
 322             args = argv[1:]
 323     else:
 324         args = argv[1:]
 325         emit = gron
 326 
 327     if len(args) == 0:
 328         stdin.reconfigure(encoding='utf-8')
 329         emit(stdout, load(stdin), [])
 330     elif len(args) == 1:
 331         name = args[0]
 332         if name == '-':
 333             stdin.reconfigure(encoding='utf-8')
 334             emit(stdout, load(stdin), [])
 335         elif seems_url(name):
 336             with urlopen(name) as inp:
 337                 emit(stdout, load(inp), [])
 338         else:
 339             with open(name, encoding='utf-8') as inp:
 340                 emit(stdout, load(inp), [])
 341     else:
 342         raise ValueError('multiple inputs not allowed')
 343 except (BrokenPipeError, KeyboardInterrupt):
 344     # quit quietly, instead of showing a confusing error message
 345     stderr.flush()
 346     stderr.close()
 347 except Exception as e:
 348     print(f'\x1b[31m{e}\x1b[0m', file=stderr)
 349     exit(1)