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 Options
  46 
  47 All options can start with either a single or a double leading dash:
  48 
  49     -h, -help                      show this help message
  50     -m, -mod, -module, -modules    import modules named in the next argument,
  51                                    where multiple names are comma-separated
  52     -n, -nil, -none, -null         don't read any input & run expression once
  53     -t, -trace, -traceback         turn exceptions into multi-line tracebacks
  54 '''
  55 
  56 
  57 from sys import argv, exit, stderr, stdin, stdout
  58 from time import sleep
  59 from typing import Generator
  60 
  61 
  62 if len(argv) < 2:
  63     print(info.strip(), file=stderr)
  64     exit(1)
  65 if len(argv) > 1 and argv[1] in ('-h', '--h', '-help', '--help'):
  66     print(info.strip())
  67     exit(0)
  68 
  69 
  70 class Skip:
  71     def __call__(self, x):
  72         return isinstance(x, self.__class__)
  73 
  74 skip = Skip()
  75 
  76 def cond(*args):
  77     if len(args) == 0:
  78         return None
  79 
  80     for i, e in enumerate(args):
  81         if i % 2 == 0 and i < len(args) - 1 and e:
  82             return args[i + 1]
  83 
  84     return args[-1] if len(args) % 2 == 1 else None
  85 
  86 def maybe(f, x):
  87     try:
  88         return f(x)
  89     except Exception as _:
  90         return x
  91 
  92 def number(x):
  93     try:
  94         return int(x)
  95     except Exception as _:
  96         pass
  97     try:
  98         return float(x)
  99     except Exception as _:
 100         return x
 101 
 102 def rescue(attempt, fallback = None):
 103     try:
 104         return attempt()
 105     except BrokenPipeError as e:
 106         raise e
 107     except Exception as e:
 108         if callable(fallback):
 109             return fallback(e)
 110         return fallback
 111 
 112 rescued = rescue
 113 
 114 def wait(seconds, result):
 115     t = (int, float)
 116     if (not isinstance(seconds, t)) and isinstance(result, t):
 117         seconds, result = result, seconds
 118     sleep(seconds)
 119     return result
 120 
 121 delay = wait
 122 
 123 def make_open_read(open):
 124     'Restrict the file-open func to a read-only-binary file-open func.'
 125     def open_read(name):
 126         return open(name, mode='rb')
 127     return open_read
 128 
 129 def fail(msg, code = 1):
 130     print(str(msg), file=stderr)
 131     exit(code)
 132 
 133 def message(msg, result = None):
 134     print(msg, file=stderr)
 135     return result
 136 
 137 msg = message
 138 
 139 def seemsurl(s):
 140     protocols = ('https://', 'http://', 'file://', 'ftp://', 'data:')
 141     return any(s.startswith(p) for p in protocols)
 142 
 143 def adapt_result(x, default):
 144     if x is True:
 145         return default
 146     if x is False:
 147         return None
 148 
 149     if isinstance(x, Skip):
 150         return None
 151 
 152     if callable(x):
 153         return x(default)
 154     return x
 155 
 156 def emit_result(w, x):
 157     if isinstance(x, (list, range, set, tuple, Generator)):
 158         for e in x:
 159             emit_simple_value(w, e)
 160     else:
 161         emit_simple_value(w, x)
 162 
 163 def emit_simple_value(w, x):
 164     if x is None:
 165         return
 166     if isinstance(x, int):
 167         x = str(x)
 168     w.write(bytes(x, encoding='utf-8'))
 169 
 170 def eval_expr(expr, using):
 171     global v, val, value, d, dat, data
 172     # offer several aliases for the variable with the input string
 173     s = v = val = value = d = dat = data = using
 174     return adapt_result(eval(expr), using)
 175 
 176 
 177 nil = none = null = None
 178 
 179 
 180 exec = None
 181 open = make_open_read(open)
 182 
 183 no_input_opts = (
 184     '=', '-n', '--n', '-nil', '--nil', '-none', '--none', '-null', '--null',
 185 )
 186 modules_opts = (
 187     '-m', '--m', '-mod', '--mod', '-module', '--module',
 188     '-modules', '--modules',
 189 )
 190 
 191 args = argv[1:]
 192 load_input = True
 193 expression = None
 194 
 195 while len(args) > 0:
 196     if args[0] == '--':
 197         args = args[1:]
 198         break
 199 
 200     if args[0] in no_input_opts:
 201         load_input = False
 202         args = args[1:]
 203         continue
 204 
 205     if args[0] in no_input_opts:
 206         no_input = True
 207         args = args[1:]
 208         continue
 209 
 210     if args[0] in modules_opts:
 211         try:
 212             if len(args) < 2:
 213                 msg = 'a module name or a comma-separated list of modules'
 214                 raise Exception('expected ' + msg)
 215 
 216             g = globals()
 217             from importlib import import_module
 218             for e in args[1].split(','):
 219                 g[e] = import_module(e)
 220 
 221             g = None
 222             import_module = None
 223             args = args[2:]
 224         except Exception as e:
 225             fail(e, 1)
 226 
 227         continue
 228 
 229     break
 230 
 231 dquo = dquote = '"'
 232 lcurly = '{'
 233 rcurly = '}'
 234 squo = squote = '\''
 235 utf8bom = '\xef\xbb\xbf'
 236 bom = {
 237     'utf8': '\xef\xbb\xbf',
 238     'utf16be': '\xfe\xff',
 239     'utf16le': '\xff\xfe',
 240     'utf32be': '\x00\x00\xfe\xff',
 241     'utf32le': '\xff\xfe\x00\x00',
 242 }
 243 
 244 if len(args) > 0:
 245     expression = args[0]
 246     args = args[1:]
 247 
 248 if expression is None:
 249     print(info.strip(), file=stderr)
 250     exit(0)
 251 
 252 try:
 253     if not expression or expression == '.':
 254         expression = 'data'
 255     expression = compile(expression, expression, 'eval')
 256 
 257     got_stdin = False
 258     all_stdin = None
 259     dashes = args.count('-')
 260 
 261     data = None
 262 
 263     if not load_input:
 264         emit_result(stdout.buffer, eval_expr(expression, None))
 265         exit(0)
 266 
 267     if any(seemsurl(name) for name in args):
 268         from urllib.request import urlopen
 269 
 270     for name in args:
 271         if name == '-':
 272             if dashes > 1:
 273                 if not got_stdin:
 274                     all_stdin = stdin.buffer.read()
 275                     got_stdin = True
 276                 data = all_stdin
 277             else:
 278                 data = stdin.buffer.read()
 279 
 280             data = s = str(data, encoding='utf-8')
 281         elif seemsurl(name):
 282             with urlopen(name) as inp:
 283                 data = inp.read()
 284         else:
 285             with open(name) as inp:
 286                 data = inp.read()
 287 
 288         data = s = str(data, encoding='utf-8')
 289         emit_result(stdout.buffer, eval_expr(expression, data))
 290 
 291     if len(args) == 0:
 292         data = stdin.buffer.read()
 293         data = s = str(data, encoding='utf-8')
 294         emit_result(stdout.buffer, eval_expr(expression, data))
 295 except BrokenPipeError:
 296     # quit quietly, instead of showing a confusing error message
 297     stderr.close()
 298     exit(0)
 299 except KeyboardInterrupt:
 300     exit(2)
 301 except Exception as e:
 302     s = str(e)
 303     s = s if s else '<generic exception>'
 304     print(s, file=stderr)
 305     exit(1)