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