File: zj.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 inspect import getfullargspec 27 from itertools import islice 28 from json import load, dump 29 from sys import argv, stderr, stdin, stdout 30 from typing import Any, Callable, Dict, Iterable, NoReturn, Tuple 31 32 33 from re import compile as zj_compile_re 34 35 36 zj_info_msg = ''' 37 zj [keys/indices...] 38 39 40 Zoom Json digs into a subset of valid JSON input, using the given mix of 41 keys and array-indices, the latter being either 0-based or negative, to 42 index backward from the ends of arrays. 43 44 Zooming on object keys is first tried as an exact key-match, failing that 45 as a case-insensitive key-match (first such match): when both approaches 46 fail, if the key is a valid integer, the key at the (even negative) index 47 given is used. 48 49 Invalid array-indices and missing object-keys result in null values, when 50 none of the special keys/fallbacks shown later apply. 51 52 You can slice arrays the exclusive/go/python way using index-pairs with a 53 `:` between the start/end pair, as long as it's a single argument; you can 54 even use `..` as the index-pair separator to include the stop index in the 55 result. Either way, as with go/python, you can omit either of the indices 56 when slicing. 57 58 Special key `.` acts as implicit loops on arrays, and even objects without 59 that specific key: in the unlikely case that an object has `.` as one of 60 its keys, you can use one of loop-fallback aliases, shown later. 61 62 Another special key is `+` (no quotes): when used, the rest of the keys 63 are used `in parallel`, allowing multiple picks from the current value. 64 When picking array items, you can also use either type (`:` or `..`) of 65 slicing, even mixing it with individual indices. 66 67 Similar to `+`, the `-` fallback-key drops keys, which means all items are 68 picked, except for those mentioned after the `-`. 69 70 Unlike the looping special key, after the first `+` special-key, all keys 71 following it, special or not, are picked normally. 72 73 In case any of the special keys are actual keys in the data loaded, some 74 aliases are available: 75 76 . /. ./ :. .: 77 + /+ +/ :+ +: 78 - /- -/ :- -: 79 80 .i :i .info :info :info: 81 .k :k .keys :keys :keys: 82 .t :t .type :type :type: 83 .l :l .len .length :len :len: :length :length: 84 85 These aliases allow using the special functionality even on objects whose 86 keys match some of these special names, as it's extremely unlikely data use 87 all aliases as actual keys at any level. 88 89 The only input supported is valid JSON coming from standard-input: there's 90 no way to load files using their names. To load data from files/URIs use 91 tools like `cat` or `curl`, and pipe their output into this tool. 92 ''' 93 94 zj_slice_re = zj_compile_re('''^(([+-]?[0-9]+)?)(:|\.\.)(([+-]?[0-9]+)?)$''') 95 96 97 def zj_zoom(data: Any, keys: Tuple[str, ...]) -> Any: 98 eval_due = False 99 pyf = ('.pyl', ':pyl', 'pyl:', ':pyl:', '.pyf', ':pyf', 'pyf:', ':pyf:') 100 101 for i, k in enumerate(keys): 102 try: 103 if eval_due: 104 data = eval(k)(data) 105 eval_due = False 106 continue 107 108 if isinstance(data, dict): 109 m = zj_match_key(data, k) 110 if m in data: 111 data = data[m] 112 continue 113 m = zj_slice_re.match(k) 114 if m: 115 data = {k: data[k] for k in zj_match_keys(data, k)} 116 continue 117 118 if isinstance(data, (list, tuple)): 119 try: 120 i = int(k) 121 l = len(data) 122 data = data[i] if -l <= i < l else None 123 continue 124 except Exception: 125 m = zj_slice_re.match(k) 126 if m: 127 data = [data[i] for i in zj_match_indices(data, k)] 128 continue 129 130 if k in pyf: 131 eval_due = True 132 continue 133 134 if k in ('.', '/.', './', ':.', '.:'): 135 if isinstance(data, dict): 136 rest = tuple(keys[i + 1:]) 137 return {k: zj_zoom(v, rest) for k, v in data.items()} 138 if isinstance(data, (list, tuple)): 139 rest = tuple(keys[i + 1:]) 140 return tuple(zj_zoom(v, rest) for v in data) 141 142 # doing nothing amounts to an identity-op for simple values 143 continue 144 145 fn = zj_final_fallbacks.get(k, None) 146 if fn: 147 return fn(data, tuple(keys[i + 1:])) 148 149 fn = zj_fallbacks.get(k, None) 150 if fn: 151 data = fn(data) 152 continue 153 154 if isinstance(data, (dict, list, tuple)): 155 data = None 156 continue 157 158 kind = zj_type(data) 159 msg = f'value of type {kind} has no properties to zoom into' 160 raise Exception(msg) 161 except Exception as e: 162 key_path = ' > '.join(islice(keys, None, i + 1)) 163 raise Exception(f'{key_path}: {e}') 164 165 return data 166 167 168 def zj_match_key(src: Dict, key: str) -> str: 169 if key in src: 170 return key 171 172 low = key.casefold() 173 for k in src.keys(): 174 if low == k.casefold(): 175 return k 176 177 try: 178 i = int(key) 179 l = len(src) 180 if i < 0: 181 i += l 182 if i < 0 or i >= l: 183 return None 184 for j, k in enumerate(src.keys()): 185 if i == j: 186 return k 187 except Exception: 188 return key 189 return key 190 191 192 def zj_match_keys(src: Any, key: str) -> Iterable: 193 if isinstance(src, (list, tuple)): 194 yield from zj_match_indices(src, key) 195 yield from zj_match_fallbacks(src, key) 196 return 197 198 if isinstance(src, dict): 199 if key in src: 200 yield key 201 return 202 203 low = key.casefold() 204 for k in src.keys(): 205 if low == k.casefold(): 206 yield k 207 return 208 209 yield from zj_match_indices(src, key) 210 yield from zj_match_fallbacks(src, key) 211 return 212 213 yield from zj_match_fallbacks(src, key) 214 215 216 def zj_match_indices(src: Any, key: str) -> Iterable: 217 try: 218 i = int(key) 219 220 if isinstance(src, (list, tuple)): 221 l = len(src) 222 yield src[i] if -l <= i < l else None 223 return 224 225 if isinstance(src, dict): 226 l = len(src) 227 if i < 0: 228 i += l 229 if i < 0 or i >= l: 230 return 231 232 for j, k in enumerate(src.keys()): 233 if i == j: 234 yield k 235 return 236 237 return 238 except Exception: 239 pass 240 241 m = zj_slice_re.match(key) 242 if not m: 243 return 244 245 l = len(src) 246 247 (start, _, kind, stop, _) = m.groups() 248 start = int(start) if start != '' else 0 249 stop = int(stop) if stop != '' else l 250 251 if start < 0: 252 start += l 253 start = max(start, 0) 254 if stop < 0: 255 stop += l 256 stop = min(stop, l) 257 if kind == '..': 258 stop += 1 259 stop = min(stop, l) 260 261 if start > stop: 262 return 263 if (start < 0 and stop < 0) or (start >= l and stop >= l): 264 return 265 266 267 if isinstance(src, dict): 268 for i, k in enumerate(src.keys()): 269 if i >= stop: 270 return 271 if start <= i: 272 yield k 273 return 274 275 if isinstance(src, (list, tuple)): 276 yield from range(start, stop) 277 return 278 279 280 281 def zj_match_fallbacks(src: Any, key: str) -> Iterable: 282 fn = zj_fallbacks.get(key, None) 283 if fn: 284 yield fn(src) 285 286 287 def zj_help(*_) -> NoReturn: 288 print(zj_info_msg.strip(), file=stderr) 289 exit(1) 290 291 292 def zj_keys(src: Any) -> Any: 293 if isinstance(src, dict): 294 return tuple(src.keys()) 295 if isinstance(src, (list, tuple)): 296 return tuple(range(len(src))) 297 return None 298 299 300 def zj_info(x: Any) -> str: 301 if isinstance(x, dict): 302 return f'object ({len(x)} items)' 303 if isinstance(x, (list, tuple)): 304 return f'array ({len(x)} items)' 305 return zj_type(x) 306 307 308 def zj_type(x: Any) -> str: 309 return { 310 type(None): 'null', 311 bool: 'boolean', 312 dict: 'object', 313 float: 'number', 314 int: 'number', 315 str: 'string', 316 list: 'array', 317 tuple: 'array', 318 }.get(type(x), 'other') 319 320 321 zj_fallbacks: Dict[str, Callable] = { 322 '.h': zj_help, 323 '.help': zj_help, 324 ':h': zj_help, 325 ':help': zj_help, 326 ':help:': zj_help, 327 '.i': zj_info, 328 '.info': zj_info, 329 ':i': zj_info, 330 ':info': zj_info, 331 ':info:': zj_info, 332 '.k': zj_keys, 333 '.keys': zj_keys, 334 ':keys': zj_keys, 335 ':keys:': zj_keys, 336 '.l': len, 337 '.len': len, 338 '.length': len, 339 ':l': len, 340 ':len': len, 341 ':length': len, 342 ':len:': len, 343 ':length:': len, 344 '.t': zj_type, 345 '.type': zj_type, 346 ':t': zj_type, 347 ':type': zj_type, 348 ':type:': zj_type, 349 } 350 351 352 def zj_pick(src: Any, keys: Tuple[str, ...]) -> Any: 353 if isinstance(src, dict): 354 picked = {} 355 for k in keys: 356 for k in zj_match_keys(src, k): 357 picked[k] = src[k] 358 return picked 359 360 if isinstance(src, (list, tuple)): 361 picked = [] 362 for k in keys: 363 for i in zj_match_indices(src, k): 364 picked.append(src[i]) 365 return tuple(picked) 366 367 msg = f'can\'t pick properties from value of type {zj_type(src)}' 368 raise Exception(msg) 369 370 371 def zj_drop(src: Any, keys: Tuple[str, ...]) -> Any: 372 if isinstance(src, dict): 373 avoid = set() 374 for k in keys: 375 for k in zj_match_keys(src, k): 376 avoid.add(k) 377 return {k: v for k, v in src.items() if not k in avoid} 378 379 if isinstance(src, (list, tuple)): 380 l = len(src) 381 avoid = set() 382 for k in keys: 383 for i in zj_match_indices(src, k): 384 avoid.add(i if i >= 0 else i + l) 385 return tuple(v for i, v in enumerate(src) if not i in avoid) 386 387 msg = f'can\'t drop properties from value of type {zj_type(src)}' 388 raise Exception(msg) 389 390 391 zj_final_fallbacks: Dict[str, Callable] = { 392 '+': zj_pick, 393 ':+:': zj_pick, 394 ':+': zj_pick, 395 '+:': zj_pick, 396 '/+': zj_pick, 397 '+/': zj_pick, 398 399 '-': zj_drop, 400 ':-:': zj_drop, 401 ':-': zj_drop, 402 '-:': zj_drop, 403 '/-': zj_drop, 404 '-/': zj_drop, 405 } 406 407 408 # extra imports for the `python-lambda` option 409 410 pyf = ('.pyl', ':pyl', 'pyl:', ':pyl:', '.pyf', ':pyf', 'pyf:', ':pyf:') 411 412 if any(s in pyf for s in argv): 413 import decimal 414 import fractions 415 import json 416 import functools 417 import itertools 418 import math 419 Math = math 420 from math import \ 421 acos, acosh, asin, asinh, atan, atan2, atanh, ceil, comb, \ 422 copysign, cos, cosh, degrees, dist, e, erf, erfc, exp, expm1, \ 423 fabs, factorial, floor, fmod, frexp, fsum, gamma, gcd, hypot, inf, \ 424 isclose, isfinite, isinf, isnan, isqrt, lcm, ldexp, lgamma, log, \ 425 log10, log1p, log2, modf, nan, nextafter, perm, pi, pow, prod, \ 426 radians, remainder, sin, sinh, sqrt, tan, tanh, tau, trunc, ulp 427 try: 428 from math import cbrt, exp2 429 except Exception: 430 pass 431 power = pow 432 import operator 433 import statistics 434 import string 435 import textwrap 436 import time 437 import urllib.parse 438 439 440 apo = '\'' 441 apos = '\'' 442 backquote = '`' 443 backtick = '`' 444 ball = '●' 445 block = '█' 446 btick = '`' 447 bullet = '•' 448 cdot = '·' 449 circle = '●' 450 cross = '×' 451 dquo = '"' 452 dquote = '"' 453 emdash = '—' 454 endash = '–' 455 ge = '≥' 456 geq = '≥' 457 hellip = '…' 458 hole = '○' 459 lcurly = '{' 460 ldquo = '“' 461 ldquote = '“' 462 le = '≤' 463 leq = '≤' 464 mdash = '—' 465 mdot = '·' 466 miniball = '•' 467 ndash = '–' 468 neq = '≠' 469 rcurly = '}' 470 rdquo = '”' 471 rdquote = '”' 472 sball = '•' 473 square = '■' 474 squo = '\'' 475 squote = '\'' 476 477 478 def dive(into: Any, doing: Callable) -> Any: 479 'Transform a nested value by calling a func via depth-first recursion.' 480 481 # support args in either order 482 if callable(into): 483 into, doing = doing, into 484 485 return _dive_kv(None, into, doing) 486 487 deepmap = dive 488 dive1 = dive 489 490 491 def divebin(x: Any, y: Any, doing: Callable) -> Any: 492 'Nested 2-value version of depth-first-recursive func dive.' 493 494 # support args in either order 495 if callable(x): 496 x, y, doing = y, doing, x 497 498 narg = required_arg_count(doing) 499 if narg == 2: 500 return dive(x, lambda a: dive(y, lambda b: doing(a, b))) 501 if narg == 4: 502 return dive(x, lambda i, a: dive(y, lambda j, b: doing(i, a, j, b))) 503 raise Exception('divebin(...) only supports funcs with 2 or 4 args') 504 505 bindive = divebin 506 # diveboth = divebin 507 # dualdive = divebin 508 # duodive = divebin 509 dive2 = divebin 510 511 512 def _dive_kv(key: Any, into: Any, doing: Callable) -> Any: 513 if isinstance(into, dict): 514 return {k: _dive_kv(k, v, doing) for k, v in into.items()} 515 if isinstance(into, Iterable) and not isinstance(into, str): 516 return [_dive_kv(i, e, doing) for i, e in enumerate(into)] 517 518 narg = required_arg_count(doing) 519 return doing(key, into) if narg == 2 else doing(into) 520 521 522 def recover(attempt: Callable, fallback: Any = None) -> Any: 523 try: 524 return attempt() 525 except Exception as e: 526 if callable(fallback): 527 return fallback(e) 528 return fallback 529 530 attempt = recover 531 attempted = recover 532 recovered = recover 533 recoverred = recover 534 rescue = recover 535 rescued = recover 536 trycall = recover 537 538 539 def required_arg_count(f: Callable) -> int: 540 if isinstance(f, type): 541 return 1 542 543 meta = getfullargspec(f) 544 n = len(meta.args) 545 if meta.defaults: 546 n -= len(meta.defaults) 547 return n 548 549 550 typeof = zj_type 551 552 553 # deny file-access to expression-evaluators 554 open = None 555 556 try: 557 # load data, trying to handle help-like options as well 558 try: 559 data = load(stdin.buffer) 560 except Exception as e: 561 if len(argv) == 2 and argv[1] in ('-h', '--h', '-help', '--help'): 562 zj_help(None) 563 else: 564 raise e 565 566 data = zj_zoom(data, tuple(argv[1:])) 567 dump(data, stdout, indent=2, allow_nan=False, check_circular=False) 568 # dump(data, stdout, indent=None, allow_nan=False, check_circular=False) 569 stdout.write('\n') 570 except BrokenPipeError: 571 # quit quietly, instead of showing a confusing error message 572 stderr.close() 573 except KeyboardInterrupt: 574 exit(2) 575 except Exception as e: 576 print(f'\x1b[31m{e}\x1b[0m', file=stderr) 577 exit(1)