File: tsp.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 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 isinstance(x, (list, tuple, range, Generator)):
 131         for e in x:
 132             emit_simple_value(w, e)
 133     else:
 134         emit_simple_value(w, x)
 135 
 136 def emit_simple_value(w, x):
 137     if x is None:
 138         return
 139     if isinstance(x, int):
 140         x = str(x)
 141     w.write(bytes(x, encoding='utf-8'))
 142 
 143 def eval_expr(expr, using):
 144     global v, val, value, d, dat, data
 145     # offer several aliases for the variable with the input string
 146     s = v = val = value = d = dat = data = using
 147     return adapt_result(eval(expr), using)
 148 
 149 
 150 nil = none = null = None
 151 
 152 
 153 exec = None
 154 open = make_open_read(open)
 155 
 156 no_input_opts = (
 157     '=', '-n', '--n', '-nil', '--nil', '-none', '--none', '-null', '--null',
 158 )
 159 modules_opts = (
 160     '-m', '--m', '-mod', '--mod', '-module', '--module',
 161     '-modules', '--modules',
 162 )
 163 more_modules_opts = ('-mm', '--mm', '-more', '--more')
 164 
 165 args = argv[1:]
 166 load_input = True
 167 expression = None
 168 
 169 while len(args) > 0:
 170     if args[0] == '--':
 171         args = args[1:]
 172         break
 173 
 174     if args[0] in no_input_opts:
 175         load_input = False
 176         args = args[1:]
 177         continue
 178 
 179     if args[0] in no_input_opts:
 180         no_input = True
 181         args = args[1:]
 182         continue
 183 
 184     if args[0] in modules_opts:
 185         try:
 186             if len(args) < 2:
 187                 msg = 'a module name or a comma-separated list of modules'
 188                 raise Exception('expected ' + msg)
 189 
 190             g = globals()
 191             from importlib import import_module
 192             for e in args[1].split(','):
 193                 g[e] = import_module(e)
 194 
 195             g = None
 196             import_module = None
 197             args = args[2:]
 198         except Exception as e:
 199             fail(e, 1)
 200 
 201         continue
 202 
 203     if args[0] in more_modules_opts:
 204         import functools, itertools, json, math, random, statistics, string, time
 205         args = args[1:]
 206         continue
 207 
 208     break
 209 
 210 cr = '\r'
 211 crlf = '\r\n'
 212 dquo = dquote = '"'
 213 empty = ''
 214 lcurly = '{'
 215 lf = '\n'
 216 rcurly = '}'
 217 space = ' '
 218 squo = squote = '\''
 219 tab = '\t'
 220 utf8bom = '\xef\xbb\xbf'
 221 bom = {
 222     'utf8': '\xef\xbb\xbf',
 223     'utf16be': '\xfe\xff',
 224     'utf16le': '\xff\xfe',
 225     'utf32be': '\x00\x00\xfe\xff',
 226     'utf32le': '\xff\xfe\x00\x00',
 227 }
 228 
 229 if len(args) > 0:
 230     expression = args[0]
 231     args = args[1:]
 232 
 233 if expression is None:
 234     print(info.strip(), file=stderr)
 235     exit(0)
 236 
 237 try:
 238     if not expression or expression == '.':
 239         expression = 'data'
 240     expression = compile(expression, expression, 'eval')
 241 
 242     got_stdin = False
 243     all_stdin = None
 244     dashes = args.count('-')
 245 
 246     data = None
 247 
 248     if not load_input:
 249         emit_result(stdout.buffer, eval_expr(expression, None))
 250         exit(0)
 251 
 252     if any(seemsurl(name) for name in args):
 253         from urllib.request import urlopen
 254 
 255     for name in args:
 256         if name == '-':
 257             if dashes > 1:
 258                 if not got_stdin:
 259                     all_stdin = stdin.buffer.read()
 260                     got_stdin = True
 261                 data = all_stdin
 262             else:
 263                 data = stdin.buffer.read()
 264 
 265             data = s = str(data, encoding='utf-8')
 266         elif seemsurl(name):
 267             with urlopen(name) as inp:
 268                 data = inp.read()
 269         else:
 270             with open(name) as inp:
 271                 data = inp.read()
 272 
 273         data = s = str(data, encoding='utf-8')
 274         emit_result(stdout.buffer, eval_expr(expression, data))
 275 
 276     if len(args) == 0:
 277         data = stdin.buffer.read()
 278         data = s = str(data, encoding='utf-8')
 279         emit_result(stdout.buffer, eval_expr(expression, data))
 280 except BrokenPipeError:
 281     # quit quietly, instead of showing a confusing error message
 282     stderr.close()
 283     exit(0)
 284 except KeyboardInterrupt:
 285     exit(2)
 286 except Exception as e:
 287     s = str(e)
 288     s = s if s else '<generic exception>'
 289     print(s, file=stderr)
 290     exit(1)