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