File: tsp.py
   1 #!/usr/bin/python
   2 
   3 # The MIT License (MIT)
   4 #
   5 # Copyright © 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 tsp [options...] [python expression] [files/URIs...]
  28 
  29 
  30 Transform Strings with Python runs a python expression on each whole-input,
  31 read as a string value.
  32 
  33 The expression can use either `s`, `v`, `value`, `d`, or `data` for the
  34 current input.
  35 
  36 Input-sources can be either files or web-URIs. When not given any explicit
  37 named sources, the standard input is used. It's even possible to reuse the
  38 standard input using multiple single dashes (-) in the order needed: stdin
  39 is only read once in this case, and kept for later reuse.
  40 
  41 When the expression results in None, the current input is ignored. When the
  42 expression results in a boolean, this determines whether the whole input is
  43 copied/appended back to the standard output, or ignored.
  44 '''
  45 
  46 
  47 from sys import argv, exit, stderr, stdin, stdout
  48 from time import sleep
  49 from typing import Generator
  50 
  51 
  52 if len(argv) < 2:
  53     print(info.strip(), file=stderr)
  54     exit(0)
  55 if len(argv) > 1 and argv[1] in ('-h', '--h', '-help', '--help'):
  56     print(info.strip())
  57     exit(0)
  58 
  59 
  60 class Skip:
  61     pass
  62 
  63 skip = Skip()
  64 
  65 def cond(*args):
  66     if len(args) == 0:
  67         return None
  68 
  69     for i, e in enumerate(args):
  70         if i % 2 == 0 and i < len(args) - 1 and e:
  71             return args[i + 1]
  72 
  73     return args[-1] if len(args) % 2 == 1 else None
  74 
  75 def rescue(attempt, fallback = None):
  76     try:
  77         return attempt()
  78     except BrokenPipeError as e:
  79         raise e
  80     except Exception as e:
  81         if callable(fallback):
  82             return fallback(e)
  83         return fallback
  84 
  85 rescued = rescue
  86 
  87 def wait(seconds, result):
  88     t = (int, float)
  89     if (not isinstance(seconds, t)) and isinstance(result, t):
  90         seconds, result = result, seconds
  91     sleep(seconds)
  92     return result
  93 
  94 delay = wait
  95 
  96 def make_open_read(open):
  97     'Restrict the file-open func to a read-only-binary file-open func.'
  98     def open_read(name):
  99         return open(name, mode='rb')
 100     return open_read
 101 
 102 def fail(msg, code = 1):
 103     print(f'\x1b[31m{str(msg)}\x1b[0m', file=stderr)
 104     exit(code)
 105 
 106 def message(msg, result = None):
 107     print(msg, file=stderr)
 108     return result
 109 
 110 msg = message
 111 
 112 def seemsurl(s):
 113     protocols = ('https://', 'http://', 'file://', 'ftp://', 'data:')
 114     return any(s.startswith(p) for p in protocols)
 115 
 116 def adapt_result(x, default):
 117     if x is True:
 118         return default
 119     if x is False:
 120         return None
 121 
 122     if isinstance(x, Skip):
 123         return None
 124 
 125     if callable(x):
 126         return x(default)
 127     return x
 128 
 129 def emit_result(w, x):
 130     if x is None:
 131         return
 132 
 133     if isinstance(x, int):
 134         w.write(str(x))
 135         return
 136 
 137     if isinstance(x, (list, tuple, range, Generator)):
 138         for e in x:
 139             w.write(str(e, encoding='utf-8'))
 140         return
 141 
 142     w.write(str(x))
 143 
 144 def eval_expr(expr, using):
 145     global v, val, value, d, dat, data
 146     # offer several aliases for the variable with the input string
 147     s = v = val = value = d = dat = data = using
 148     return adapt_result(eval(expr), using)
 149 
 150 
 151 nil = none = null = None
 152 
 153 
 154 exec = None
 155 open = make_open_read(open)
 156 
 157 no_input_opts = (
 158     '=', '-n', '--n', '-nil', '--nil', '-none', '--none', '-null', '--null',
 159 )
 160 modules_opts = (
 161     '-m', '--m', '-mod', '--mod', '-module', '--module',
 162     '-modules', '--modules',
 163 )
 164 more_modules_opts = ('-mm', '--mm', '-more', '--more')
 165 
 166 args = argv[1:]
 167 load_input = True
 168 expression = None
 169 
 170 while len(args) > 0:
 171     if args[0] == '--':
 172         args = args[1:]
 173         break
 174 
 175     if args[0] in no_input_opts:
 176         load_input = False
 177         args = args[1:]
 178         continue
 179 
 180     if args[0] in no_input_opts:
 181         no_input = True
 182         args = args[1:]
 183         continue
 184 
 185     if args[0] in modules_opts:
 186         try:
 187             if len(args) < 2:
 188                 msg = 'a module name or a comma-separated list of modules'
 189                 raise Exception('expected ' + msg)
 190 
 191             g = globals()
 192             from importlib import import_module
 193             for e in args[1].split(','):
 194                 g[e] = import_module(e)
 195 
 196             g = None
 197             import_module = None
 198             args = args[2:]
 199         except Exception as e:
 200             fail(e, 1)
 201 
 202         continue
 203 
 204     if args[0] in more_modules_opts:
 205         import functools, itertools, json, math, random, statistics, string, time
 206         args = args[1:]
 207         continue
 208 
 209     break
 210 
 211 cr = '\r'
 212 crlf = '\r\n'
 213 dquo = dquote = '"'
 214 empty = ''
 215 lcurly = '{'
 216 lf = '\n'
 217 rcurly = '}'
 218 space = ' '
 219 squo = squote = '\''
 220 tab = '\t'
 221 utf8bom = '\xef\xbb\xbf'
 222 bom = {
 223     'utf8': '\xef\xbb\xbf',
 224     'utf16be': '\xfe\xff',
 225     'utf16le': '\xff\xfe',
 226     'utf32be': '\x00\x00\xfe\xff',
 227     'utf32le': '\xff\xfe\x00\x00',
 228 }
 229 
 230 if len(args) > 0:
 231     expression = args[0]
 232     args = args[1:]
 233 
 234 if expression is None:
 235     print(info.strip(), file=stderr)
 236     exit(0)
 237 
 238 try:
 239     if not expression or expression == '.':
 240         expression = 'data'
 241     expression = compile(expression, expression, 'eval')
 242 
 243     got_stdin = False
 244     all_stdin = None
 245     dashes = args.count('-')
 246 
 247     data = None
 248 
 249     if not load_input:
 250         emit_result(stdout.buffer, eval_expr(expression, None))
 251         exit(0)
 252 
 253     if any(seemsurl(name) for name in args):
 254         from urllib.request import urlopen
 255 
 256     for name in args:
 257         if name == '-':
 258             if dashes > 1:
 259                 if not got_stdin:
 260                     all_stdin = stdin.buffer.read()
 261                     got_stdin = True
 262                 data = all_stdin
 263             else:
 264                 data = stdin.buffer.read()
 265 
 266             data = s = str(data, encoding='utf-8')
 267         elif seemsurl(name):
 268             with urlopen(name) as inp:
 269                 data = inp.read()
 270         else:
 271             with open(name) as inp:
 272                 data = inp.read()
 273 
 274         data = s = str(data, encoding='utf-8')
 275         emit_result(stdout.buffer, eval_expr(expression, data))
 276 
 277     if len(args) == 0:
 278         data = stdin.buffer.read()
 279         data = s = str(data, encoding='utf-8')
 280         emit_result(stdout.buffer, eval_expr(expression, data))
 281 except BrokenPipeError:
 282     # quit quietly, instead of showing a confusing error message
 283     stderr.close()
 284     exit(0)
 285 except KeyboardInterrupt:
 286     exit(2)
 287 except Exception as e:
 288     s = str(e)
 289     s = s if s else '<generic exception>'
 290     print(s, file=stderr)
 291     exit(1)