File: nj.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 math import modf
  28 from sys import argv, exit, stderr, stdin, stdout
  29 
  30 
  31 info = '''
  32 nj [filepath/URI...]
  33 
  34 
  35 Nice Json indents and styles JSON data. When not given a filepath to read
  36 data from, this tool reads from standard input.
  37 '''
  38 
  39 if len(argv) == 2 and argv[1] in ('-h', '--h', '-help', '--help'):
  40     print(info.strip(), file=stderr)
  41     exit(0)
  42 
  43 
  44 # level2indent speeds up func indent
  45 level2indent = tuple(i * '  ' for i in range(64))
  46 
  47 
  48 def indent(w, level: int) -> None:
  49     'Emit 2 spaces for each indentation level.'
  50 
  51     if level < 1:
  52         return
  53 
  54     if level < len(level2indent):
  55         w.write(level2indent[level])
  56         return
  57 
  58     while level >= len(level2indent):
  59         w.write(level2indent[-1])
  60         level -= len(level2indent)
  61     w.write(level2indent[level])
  62 
  63 
  64 def restyle(w, data, pre: int = 0, level: int = 0) -> None:
  65     'This func is where all the recursive output-action starts/happens.'
  66 
  67     f = type2func[type(data)]
  68     if f != None:
  69         f(w, data, pre, level)
  70         # don't forget to end the final line, if at `top-level`
  71         if level == 0:
  72             w.write('\n')
  73     else:
  74         raise ValueError(f'unsupported type {type(data)}')
  75 
  76 
  77 def restyle_bool(w, data: bool, pre: int, level: int) -> None:
  78     indent(w, pre)
  79     if data:
  80         w.write('\x1b[38;5;74mtrue\x1b[0m')
  81     else:
  82         w.write('\x1b[38;5;74mfalse\x1b[0m')
  83 
  84 
  85 def restyle_dict(w, data: dict, pre: int, level: int) -> None:
  86     if len(data) == 0:
  87         indent(w, pre)
  88         w.write('\x1b[38;5;248m{}\x1b[0m')
  89         return
  90 
  91     indent(w, pre)
  92     w.write('\x1b[38;5;248m{\x1b[0m\n')
  93     for (i, k) in enumerate(data):
  94         if i > 0:
  95             w.write('\x1b[38;5;248m,\x1b[0m\n')
  96         restyle_key(w, k, level + 1)
  97         restyle(w, data[k], 0, level + 1)
  98     w.write('\n')
  99     indent(w, level)
 100     w.write('\x1b[38;5;248m}\x1b[0m')
 101 
 102 
 103 def restyle_float(w, data: float, pre: int, level: int) -> None:
 104     indent(w, pre)
 105     # can't figure out a direct float-format option which both avoids the
 106     # chance of scientific notation (invalid as JSON), and the chance of
 107     # unneeded trailing decimal 0s; neither {data:-1} nor {data:-1f} work
 108     if modf(data)[0] == 0.0:
 109         w.write(f'\x1b[38;5;29m{data}\x1b[0m')
 110     else:
 111         s = f'{data:-1f}'.rstrip('0')
 112         w.write(f'\x1b[38;5;29m{s}\x1b[0m')
 113 
 114 
 115 def restyle_int(w, data: int, pre: int, level: int) -> None:
 116     indent(w, pre)
 117     w.write(f'\x1b[38;5;29m{data}\x1b[0m')
 118 
 119 
 120 def restyle_key(w, k: str, level: int) -> None:
 121     indent(w, level)
 122     f = dumps
 123     w.write(f'\x1b[38;5;248m"\x1b[38;5;99m{f(k)[1:-1]}\x1b[38;5;248m": \x1b[0m')
 124 
 125 
 126 def restyle_list(w, data: list, pre: int, level: int) -> None:
 127     if len(data) == 0:
 128         indent(w, pre)
 129         w.write('\x1b[38;5;248m[]\x1b[0m')
 130         return
 131 
 132     indent(w, pre)
 133     w.write('\x1b[38;5;248m[\x1b[0m\n')
 134     for (i, e) in enumerate(data):
 135         if i > 0:
 136             w.write('\x1b[38;5;248m,\x1b[0m\n')
 137         restyle(w, e, level + 1, level + 1)
 138     w.write('\n')
 139     indent(w, level)
 140     w.write('\x1b[38;5;248m]\x1b[0m')
 141 
 142 
 143 def restyle_none(w, data: bool, pre: int, level: int) -> None:
 144     indent(w, pre)
 145     w.write('\x1b[38;5;248mnull\x1b[0m')
 146 
 147 
 148 def restyle_str(w, data: str, pre: int, level: int) -> None:
 149     indent(w, pre)
 150     # f'\x1b[38;5;248m"\x1b[38;5;24m{dumps(data)[1:-1]}\x1b[38;5;248m"\x1b[0m'
 151     w.write(f'\x1b[38;5;248m"\x1b[0m{dumps(data)[1:-1]}\x1b[38;5;248m"\x1b[0m')
 152 
 153 
 154 def seems_url(s: str) -> bool:
 155     protocols = ('https://', 'http://', 'file://', 'ftp://', 'data:')
 156     return any(s.startswith(p) for p in protocols)
 157 
 158 
 159 # type2func helps func restyle dispatch styler funcs, using values' types
 160 # as the keys; while JSON doesn't have int values, the stdlib auto-promotes
 161 # round JSON numbers into ints, and so this table has a func for those too
 162 type2func = {
 163     type(None): restyle_none,
 164     bool: restyle_bool,
 165     int: restyle_int,
 166     float: restyle_float,
 167     str: restyle_str,
 168     list: restyle_list,
 169     tuple: restyle_list,
 170     dict: restyle_dict,
 171 }
 172 
 173 
 174 try:
 175     if len(argv) < 2:
 176         restyle(stdout, load(stdin))
 177     elif len(argv) == 2:
 178         name = argv[1]
 179         if name == '-':
 180             restyle(stdout, load(stdin))
 181         elif seems_url(name):
 182             from urllib.request import urlopen
 183             with urlopen(name) as inp:
 184                 restyle(stdout, load(inp))
 185         else:
 186             with open(name, encoding='utf-8') as inp:
 187                 restyle(stdout, load(inp))
 188     else:
 189         raise ValueError('multiple inputs not allowed')
 190 except BrokenPipeError:
 191     # quit quietly, instead of showing a confusing error message
 192     stderr.close()
 193 except KeyboardInterrupt:
 194     exit(2)
 195 except Exception as e:
 196     print(f'\x1b[31m{e}\x1b[0m', file=stderr)
 197     exit(1)