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)