File: ngron.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 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()) 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;2;135;95;255mjson') 81 syntax = False 82 83 for e in path: 84 if isinstance(e, int): 85 w.write('\x1b[38;2;168;168;168m[\x1b[38;2;0;135;95m') 86 w.write(str(e)) 87 w.write('\x1b[38;2;168;168;168m]') 88 syntax = True 89 continue 90 91 if simple_str_re.match(e): 92 w.write(f'\x1b[38;2;168;168;168m.\x1b[38;2;135;95;255m{e}') 93 syntax = False 94 continue 95 96 w.write('\x1b[38;2;168;168;168m["\x1b[38;2;0;95;135m') 97 w.write(dumps(e)[1:-1]) 98 w.write('\x1b[38;2;168;168;168m"]') 99 syntax = True 100 101 if syntax: 102 w.write(' = ') 103 else: 104 w.write('\x1b[38;2;168;168;168m = ') 105 106 107 def end_styled_line(w) -> None: 108 w.write('\x1b[38;2;168;168;168m;\x1b[0m\n') 109 110 111 def colored_gron(w, data, path: List[Union[int, str]]) -> None: 112 'This func is where all the recursive output-action starts/happens.' 113 114 f = type2ansi[type(data)] 115 if f != None: 116 f(w, data, path) 117 else: 118 raise ValueError(f'unsupported type {type(data)}') 119 120 121 def restyle_bool(w, data: bool, path: List[Union[int, str]]) -> None: 122 'Handle booleans for func restyle.' 123 124 restyle_path(w, path) 125 if data: 126 w.write('\x1b[38;2;95;175;215mtrue') 127 else: 128 w.write('\x1b[38;2;95;175;215mfalse') 129 end_styled_line(w) 130 131 132 def restyle_dict(w, data: dict, path: List[Union[int, str]]) -> None: 133 'Handle dictionaries for func restyle.' 134 135 restyle_path(w, path) 136 w.write('\x1b[38;2;168;168;168m{};\x1b[0m\n') 137 138 path.append('') 139 for k, v in data.items(): 140 path[-1] = k 141 colored_gron(w, v, path) 142 del path[-1] 143 144 145 def restyle_float(w, data: float, path: List[Union[int, str]]) -> None: 146 'Handle floats for func restyle, emitting as many decimals as needed.' 147 148 restyle_path(w, path) 149 # n = int(data) 150 # if float(n) != data: 151 # w.write(f'\x1b[38;2;0;135;95m{data:-1}\x1b[0m') 152 # else: 153 # w.write(f'\x1b[38;2;0;135;95m{n}\x1b[0m') 154 w.write(f'\x1b[38;2;0;135;95m{data:-1}\x1b[0m') 155 end_styled_line(w) 156 157 158 def restyle_int(w, data: int, path: List[Union[int, str]]) -> None: 159 'Handle integers for func restyle.' 160 161 restyle_path(w, path) 162 w.write(f'\x1b[38;2;0;135;95m{data}\x1b[0m') 163 end_styled_line(w) 164 165 166 def restyle_list(w, data: list, path: List[Union[int, str]]) -> None: 167 'Handle lists for func restyle.' 168 169 restyle_path(w, path) 170 w.write('\x1b[38;2;168;168;168m[];\x1b[0m\n') 171 172 path.append(0) 173 for i, v in enumerate(data): 174 path[-1] = i 175 colored_gron(w, v, path) 176 del path[-1] 177 178 179 def restyle_none(w, data: bool, path: List[Union[int, str]]) -> None: 180 'Handle nulls for func restyle.' 181 182 restyle_path(w, path) 183 w.write('\x1b[38;2;168;168;168mnull') 184 end_styled_line(w) 185 186 187 def restyle_str(w, data: str, path: List[Union[int, str]]) -> None: 188 'Handle strings for func restyle.' 189 190 restyle_path(w, path) 191 w.write(f'\x1b[38;2;168;168;168m"\x1b[0m') 192 w.write(dumps(data)[1:-1]) 193 w.write(f'\x1b[38;2;168;168;168m"\x1b[0m') 194 end_styled_line(w) 195 196 197 def gron(w, data, path: List[Union[int, str]]) -> None: 198 'Recursively output data into gron-format lines.' 199 200 f = type2gron[type(data)] 201 if f != None: 202 f(w, data, path) 203 else: 204 raise ValueError(f'unsupported type {type(data)}') 205 206 207 def gron_bool(w, data: bool, path: List[Union[int, str]]) -> None: 208 'Handle booleans for func gron.' 209 210 gron_path(w, path) 211 if data: 212 w.write('true;\n') 213 else: 214 w.write('false;\n') 215 216 217 def gron_dict(w, data: dict, path: List[Union[int, str]]) -> None: 218 'Handle dictionaries for func gron.' 219 220 gron_path(w, path) 221 w.write('{};\n') 222 223 path.append('') 224 for k, v in data.items(): 225 path[-1] = k 226 gron(w, v, path) 227 del path[-1] 228 229 def gron_float(w, data: float, path: List[Union[int, str]]) -> None: 230 'Handle floats for func gron, emitting as many decimals as needed.' 231 232 gron_path(w, path) 233 # n = int(data) 234 # if float(n) != data: 235 # w.write(f'{data:-1};\n') 236 # else: 237 # w.write(f'{n};\n') 238 w.write(f'{data:-1};\n') 239 240 241 def gron_int(w, data: int, path: List[Union[int, str]]) -> None: 242 'Handle integers for func gron.' 243 244 gron_path(w, path) 245 w.write(f'{data};\n') 246 247 248 def gron_list(w, data: list, path: List[Union[int, str]]) -> None: 249 'Handle lists for func gron.' 250 251 gron_path(w, path) 252 w.write('[];\n') 253 254 path.append(0) 255 for i, v in enumerate(data): 256 path[-1] = i 257 gron(w, v, path) 258 del path[-1] 259 260 261 def gron_none(w, data: bool, path: List[Union[int, str]]) -> None: 262 'Handle nulls for func gron.' 263 264 gron_path(w, path) 265 w.write('null;\n') 266 267 268 def gron_str(w, data: str, path: List[Union[int, str]]) -> None: 269 'Handle strings for func gron.' 270 271 gron_path(w, path) 272 w.write(f'{dumps(data)};\n') 273 274 275 def seems_url(s: str) -> bool: 276 protocols = ('https://', 'http://', 'file://', 'ftp://', 'data:') 277 return any(s.startswith(p) for p in protocols) 278 279 280 # type2ansi helps func restyle dispatch styler funcs, using values' types 281 # as the keys; while JSON doesn't have int values, the stdlib auto-promotes 282 # round JSON numbers into ints, and so this table has a func for those too 283 type2ansi = { 284 type(None): restyle_none, 285 bool: restyle_bool, 286 int: restyle_int, 287 float: restyle_float, 288 str: restyle_str, 289 list: restyle_list, 290 tuple: restyle_list, 291 dict: restyle_dict, 292 } 293 294 295 # type2gron helps func emit_gron dispatch emitter funcs, using values' types 296 # as the keys; while JSON doesn't have int values, the stdlib auto-promotes 297 # round JSON numbers into ints, and so this table has a func for those too 298 type2gron = { 299 type(None): gron_none, 300 bool: gron_bool, 301 int: gron_int, 302 float: gron_float, 303 str: gron_str, 304 list: gron_list, 305 tuple: gron_list, 306 dict: gron_dict, 307 } 308 309 310 if len(argv) > 1 and argv[1].startswith('-'): 311 l = argv[1].lstrip('-').lower() 312 if l in ('c', 'color', 'nice'): 313 emit = colored_gron 314 args = argv[2:] 315 elif l in ('g', 'gron', 'm', 'monochrome', 'p', 'plain'): 316 emit = gron 317 args = argv[2:] 318 else: 319 emit = gron 320 args = argv[1:] 321 else: 322 args = argv[1:] 323 emit = gron 324 325 try: 326 if len(args) > 1: 327 raise ValueError('multiple inputs not allowed') 328 329 name = args[0] if len(args) > 0 else '-' 330 331 if name == '-': 332 emit(stdout, load(stdin), []) 333 elif seems_url(name): 334 from urllib.request import urlopen 335 with urlopen(name) as inp: 336 emit(stdout, load(inp), []) 337 else: 338 with open(name, encoding='utf-8') as inp: 339 emit(stdout, load(inp), []) 340 except BrokenPipeError: 341 # quit quietly, instead of showing a confusing error message 342 stderr.close() 343 except KeyboardInterrupt: 344 exit(2) 345 except Exception as e: 346 print(f'\x1b[31m{e}\x1b[0m', file=stderr) 347 exit(1)