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)