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