File: minitl.py 1 #!/usr/bin/python 2 3 # The MIT License (MIT) 4 # 5 # Copyright (c) 2026 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 info = ''' 27 minitl [options...] [python expression] [files/URIs...] 28 29 This is the MINImal version of the Transform Lines tool. 30 ''' 31 32 33 from json import dumps, loads 34 from sys import argv, exit, stderr, stdin 35 from typing import Generator 36 37 38 if len(argv) < 2: 39 print(info.strip(), file=stderr) 40 exit(1) 41 if len(argv) > 1 and argv[1] in ('-h', '--h', '-help', '--help'): 42 print(info.strip()) 43 exit(0) 44 45 46 def handle_no_input(expr): 47 res = eval(expr) 48 if isinstance(res, (list, range, tuple, Generator)): 49 for e in res: 50 if not isinstance(e, Skip): 51 print(e, flush=True) 52 return 53 54 res = adapt_result(res, None) 55 if not (res is None): 56 print(res, flush=True) 57 58 59 def handle_lines(src, expr): 60 # `comprehension` expressions seem to ignore local variables: even 61 # lambda-based workarounds fail 62 global i, l, line, v, val, value, e, err, error 63 64 i = 0 65 e = err = error = None 66 67 for l in src: 68 l = l.rstrip('\r\n').rstrip('\n') 69 if i == 0: 70 l = l.lstrip('\xef\xbb\xbf') 71 72 line = l 73 try: 74 e = err = error = None 75 v = val = value = loads(l) 76 except Exception as ex: 77 e = err = error = ex 78 v = val = value = Skip() 79 res = eval(expr) 80 i += 1 81 82 if isinstance(res, (list, range, tuple, Generator)): 83 for e in res: 84 if not isinstance(e, Skip): 85 print(e, flush=True) 86 continue 87 88 res = adapt_result(res, line) 89 if not (res is None): 90 print(res, flush=True) 91 92 93 def hold_lines(src, lines): 94 for e in src: 95 lines.append(e) 96 yield e 97 98 99 def adapt_result(res, fallback): 100 if isinstance(res, BaseException): 101 raise res 102 if isinstance(res, Skip): 103 return res 104 if res is None or res is False: 105 return None 106 if callable(res): 107 return res(fallback) 108 if res is True: 109 return fallback 110 if isinstance(res, dict): 111 return dumps(res, allow_nan=False) 112 return str(res) 113 114 115 class Skip: 116 pass 117 118 119 skip = Skip() 120 121 122 def rescue(attempt, fallback = None): 123 try: 124 return attempt() 125 except Exception as e: 126 if callable(fallback): 127 return fallback(e) 128 return fallback 129 130 catch = rescue 131 catched = rescue 132 caught = rescue 133 recover = rescue 134 recovered = rescue 135 rescued = rescue 136 137 138 def make_open_utf8(open): 139 def open_utf8_readonly(path): 140 return open(path, encoding='utf-8') 141 return open_utf8_readonly 142 143 144 def seems_url(path): 145 protocols = ('https://', 'http://', 'file://', 'ftp://', 'data:') 146 return any(path.startswith(p) for p in protocols) 147 148 149 dquo = '"' 150 dquote = '"' 151 lcurly = '{' 152 rcurly = '}' 153 squo = '\'' 154 squote = '\'' 155 utf8bom = '\xef\xbb\xbf' 156 157 nil = None 158 none = None 159 null = None 160 161 162 exec = None 163 open_utf8 = make_open_utf8(open) 164 open = open_utf8 165 166 no_input_opts = ( 167 '=', '--n', '-nil', '--nil', '-none', '--none', '-null', '--null', 168 ) 169 more_modules_opts = ('-mm', '--mm', '-more', '--more') 170 171 args = argv[1:] 172 if any(seems_url(e) for e in args): 173 from io import TextIOWrapper 174 from urllib.request import urlopen 175 176 no_input = False 177 while len(args) > 0: 178 if args[0] in no_input_opts: 179 no_input = True 180 args = args[1:] 181 continue 182 183 if args[0] in more_modules_opts: 184 import functools 185 import itertools 186 import math 187 import random 188 import statistics 189 import string 190 import time 191 args = args[1:] 192 continue 193 194 break 195 196 197 try: 198 expr = '.' 199 if len(args) > 0: 200 expr = args[0] 201 args = args[1:] 202 203 # if expr in globals().keys(): 204 # v = globals().get(expr) 205 # if callable(v): 206 # expr = f'{expr}(line)' 207 208 if expr == '.' and no_input: 209 print(info.strip(), file=stderr) 210 exit(0) 211 212 if expr == '.': 213 expr = 'line' 214 expr = compile(expr, expr, 'eval') 215 216 if no_input: 217 handle_no_input(expr) 218 exit(0) 219 220 if len(args) == 0: 221 handle_lines(stdin, expr) 222 exit(0) 223 224 got_stdin = False 225 all_stdin = None 226 dashes = args.count('-') 227 228 for path in args: 229 if path == '-': 230 if dashes > 1: 231 if not got_stdin: 232 handle_lines(hold_lines(stdin, all_stdin), expr) 233 got_stdin = True 234 else: 235 handle_lines(all_stdin, expr) 236 else: 237 handle_lines(stdin, expr) 238 continue 239 240 if seems_url(path): 241 with urlopen(path) as inp: 242 with TextIOWrapper(inp, encoding='utf-8') as txt: 243 handle_lines(txt, expr) 244 continue 245 246 with open_utf8(path) as txt: 247 handle_lines(txt, expr) 248 except BrokenPipeError: 249 # quit quietly, instead of showing a confusing error message 250 stderr.close() 251 exit(0) 252 except KeyboardInterrupt: 253 exit(2) 254 except Exception as e: 255 print(f'\x1b[31m{str(e)}\x1b[0m', file=stderr) 256 exit(1)