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)