__init__.py 18.6 KB
Newer Older
1 2
#!/usr/bin/env python
# -*- coding: utf-8 -*-
3
# Copyright (C) 2018-2021  Nexedi SA and Contributors.
4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
#                          Kirill Smelkov <kirr@nexedi.com>
#
# This program is free software: you can Use, Study, Modify and Redistribute
# it under the terms of the GNU General Public License version 3, or (at your
# option) any later version, as published by the Free Software Foundation.
#
# You can also Link and Combine this program with other software covered by
# the terms of any of the Free Software licenses or any of the Open Source
# Initiative approved licenses and Convey the resulting work. Corresponding
# source of such a combination shall include the source code for all other
# software used.
#
# This program is distributed WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
#
# See COPYING file for full licensing terms.
# See https://www.nexedi.com/licensing for rationale and options.
21
"""gpython ... - run python ... with gevent & golang activated.
22 23 24 25 26 27 28

gpython is substitute for standard python interpreter with the following
differences:

- gevent is pre-activated and stdlib is patched to be gevent aware;
- go, chan, select etc are put into builtin namespace;
- default string encoding is always set to UTF-8.
29 30 31

Gevent activation can be disabled via `-X gpython.runtime=threads`, or
$GPYTHON_RUNTIME=threads.
32 33 34 35 36 37 38 39 40
"""

# NOTE gpython is kept out of golang/ , since even just importing e.g. golang.cmd.gpython,
# would import golang, and we need to do gevent monkey-patching ASAP - before that.
#
# we also keep it in gpython/__init__.py instead of gpython.py, since the latter does not
# work correctly with `pip install` (gpython script is installed, but gpython module is not).

# NOTE don't import anything at global scope - we need gevent to be imported first.
41
from __future__ import print_function, absolute_import
42 43


Kirill Smelkov's avatar
Kirill Smelkov committed
44
_pyopt = "c:m:OVW:X:"
45 46
_pyopt_long = ('version',)

47 48
# pymain mimics `python ...`
#
49
# argv is full argument vector including first [0] for path to main program itself.
50 51 52
# init, if provided, is called after options are parsed, but before interpreter start.
def pymain(argv, init=None):
    import sys
53
    from os.path import dirname, realpath
54 55 56 57 58 59 60

    # sys.executable
    # on windows there are
    #   gpython-script.py
    #   gpython.exe
    #   gpython.manifest
    # and argv[0] is gpython-script.py
61
    exe  = realpath(argv[0])
62
    argv = argv[1:]
63 64 65 66 67 68 69 70 71 72
    if exe.endswith('-script.py'):
        exe = exe[:-len('-script.py')]
        exe = exe + '.exe'
    sys._gpy_underlying_executable = sys.executable
    sys.executable  = exe

    # `python /path/to/gpython` adds /path/to to sys.path[0] - remove it.
    # `gpython file` will add path-to-file to sys.path[0] by itself, and
    # /path/to/gpython is unneccessary and would create difference in behaviour
    # in between gpython and python.
73
    exedir = dirname(exe)
74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90
    if sys.path[0] == exedir:
        del sys.path[0]
    else:
        # buildout injects `sys.path[0:0] = eggs` into python scripts.
        # detect that and remove sys.path entry corresponding to exedir.
        if not _is_buildout_script(exe):
            raise RuntimeError('pymain: internal error: sys.path[0] was not set by underlying python to dirname(exe):'
                    '\n\n\texe:\t%s\n\tsys.path[0]:\t%s' % (exe, sys.path[0]))
        else:
            if exedir in sys.path:
                sys.path.remove(exedir)
            else:
                raise RuntimeError('pymain: internal error: sys.path does not contain dirname(exe):'
                    '\n\n\texe:\t%s\n\tsys.path:\t%s' % (exe, sys.path))



91
    run = None          # function to run according to -c/-m/file/stdin/interactive
92
    version = False     # set if `-V`
Kirill Smelkov's avatar
Kirill Smelkov committed
93
    warnoptions = []    # collected `-W arg`
Kirill Smelkov's avatar
Kirill Smelkov committed
94 95
    reexec_with = []    # reexecute underlying python with those options (e.g. -O, -S, ...)
    reexec_argv = []    # if reexecuting, reexecute with this application-level argv
96

97 98
    igetopt = _IGetOpt(argv, _pyopt, _pyopt_long)
    for (opt, arg) in igetopt:
Kirill Smelkov's avatar
Kirill Smelkov committed
99 100 101 102 103 104 105 106 107 108 109 110 111 112
        # options that require reexecuting through underlying python with that -<opt>
        if opt in (
                '-O',   # optimize
            ):
            reexec_with.append(opt)
            if arg is not None:
                reexec_with.append(arg)
            continue

        reexec_argv.append(opt)
        if arg is not None:
            reexec_argv.append(arg)


113
        # -V / --version
114
        if opt in ('-V', '--version'):
115 116
            version = True
            break
117 118

        # -c command
119 120 121
        elif opt == '-c':
            cmd = arg
            sys.argv = ['-c'] + igetopt.argv # python leaves '-c' as argv[0]
122 123
            sys.path.insert(0, '')   # cwd
            def run():
124
                import six
125 126 127 128 129 130 131 132
                # exec with the same globals `python -c ...` does
                g = {'__name__':    '__main__',
                     '__doc__':     None,
                     '__package__': None}
                six.exec_(cmd, g)
            break

        # -m module
133 134 135
        elif opt == '-m':
            mod = arg
            sys.argv = [mod] + igetopt.argv
136 137 138 139 140
            # sys.path <- cwd
            # NOTE python2 injects '', while python3 injects realpath('')
            # we stick to python3 behaviour, as it is more sane because e.g.
            # import path does not change after chdir.
            sys.path.insert(0, realpath(''))  # realpath(cwd)
141
            def run():
142
                import runpy
143 144 145 146 147
                # search sys.path for module and run corresponding .py file as script
                runpy.run_module(mod, init_globals={'__doc__': None},
                                 run_name='__main__', alter_sys=True)
            break

Kirill Smelkov's avatar
Kirill Smelkov committed
148
        # -W arg  (warning control)
149 150 151 152 153
        elif opt == '-W':
            warnoptions.append(arg)

        else:
            print("unknown option: '%s'" % opt, file=sys.stderr)
154 155
            sys.exit(2)

156
    argv = igetopt.argv
Kirill Smelkov's avatar
Kirill Smelkov committed
157
    reexec_argv += argv
158
    if run is None:
159
        # file
160
        if len(argv) > 0 and argv[0] != '-':
161 162
            sys.argv = argv
            filepath = argv[0]
163 164 165 166
            # starting from cpython 3.9 __file__ is always absolute
            # https://bugs.python.org/issue20443
            if sys.version_info >= (3, 9):
                filepath = realpath(filepath)
Kirill Smelkov's avatar
Kirill Smelkov committed
167

168
            sys.path.insert(0, realpath(dirname(filepath))) # not abspath -> see PySys_SetArgvEx
169 170 171 172 173 174 175 176 177
            def run():
                # exec with same globals `python file.py` does
                # XXX use runpy.run_path() instead?
                g = {'__name__':    '__main__',
                     '__file__':    filepath,
                     '__doc__':     None,
                     '__package__': None}
                _execfile(filepath, g)

178
        # interactive console / program on non-tty stdin
179
        else:
180
            sys.argv = ['']  if len(argv) == 0  else  argv # e.g. ['-']
181 182
            sys.path.insert(0, '')  # cwd

183 184 185 186 187 188 189 190 191 192
            if sys.stdin.isatty():
                def run():
                    _interact()
            else:
                def run():
                    import six
                    prog = sys.stdin.read()
                    g = {'__name__': '__main__',
                         '__file__': '<stdin>'}
                    six.exec_(prog, g)
193

194 195

    # ---- options processed -> start the interpreter ----
Kirill Smelkov's avatar
Kirill Smelkov committed
196 197 198 199 200 201 202 203 204 205 206 207 208 209

    # reexec underlying interpreter on options that we cannot handle at python
    # level after underlying interpreter is already started. For example
    #
    #   gpython -O file.py
    #
    # is reexecuted as
    #
    #   python -O gpython file.py
    if len(reexec_with) > 0:
        import os
        argv = [sys._gpy_underlying_executable] + reexec_with + [sys.executable] + reexec_argv
        os.execv(argv[0], argv)

210 211
    if init is not None:
        init()
212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245

    # handle -V/--version
    if version:
        ver = []
        if 'GPython' in sys.version:
            golang = sys.modules['golang'] # must be already imported
            gevent = sys.modules.get('gevent', None)
            gpyver = 'GPython %s' % golang.__version__
            if gevent is not None:
                gpyver += ' [gevent %s]' % gevent.__version__
            else:
                gpyver += ' [threads]'
            ver.append(gpyver)

        import platform
        pyimpl = platform.python_implementation()

        v = _version_info_str
        if pyimpl == 'CPython':
            ver.append('CPython %s' % v(sys.version_info))
        elif pyimpl == 'PyPy':
            ver.append('PyPy %s'   % v(sys.pypy_version_info))
            ver.append('Python %s' % v(sys.version_info))
        else:
            ver = [] # unknown

        ver = ' / '.join(ver)
        if ver == '':
            # unknown implementation: just print full sys.version
            ver = sys.version

        print(ver, file=sys.stderr)
        return

Kirill Smelkov's avatar
Kirill Smelkov committed
246 247 248 249 250 251 252 253 254 255 256 257 258
    # init warnings
    if len(warnoptions) > 0:
        # NOTE warnings might be already imported by code that calls pymain.
        # This way we cannot set `sys.warnoptions = warnoptions` and just
        # import/reload warnings (if we reload warnings, it will loose all
        # previous setup that pymain caller might have done to it).
        # -> only amend warnings setup
        #
        # NOTE $PYTHONWARNINGS is handled by underlying python natively.
        import warnings
        sys.warnoptions += warnoptions
        warnings._processoptions(warnoptions)

259 260
    # execute -m/-c/file/interactive
    run()
261

262

263 264 265 266 267 268 269 270 271 272 273 274 275
# _interact runs interactive console.
def _interact():
    import code, sys
    from six.moves import input as raw_input
    # like code.interact() but with overridden console.raw_input _and_
    # readline imported (code.interact mutually excludes those two).
    try:
        import readline # enable interactive editing
    except ImportError:
        pass

    console = code.InteractiveConsole()
    def _(prompt):
276 277 278
        # python behaviour:
        # - use stdout for prompt by default;
        # - use stderr for prompt if any of stdin/stderr is not a tty
279
        # (builtin raw_input always prints prompt)
280 281 282 283 284 285
        promptio = sys.stdout
        if (not sys.stdin.isatty()) or (not sys.stdout.isatty()):
            promptio = sys.stderr
        promptio.write(prompt)
        promptio.flush()
        return raw_input('')
286 287 288 289 290
    console.raw_input = _

    console.interact()


291 292 293 294 295
# execfile was removed in py3
def _execfile(path, globals=None, locals=None):
    import six
    with open(path, "rb") as f:
        src = f.read()
296
    code = compile(src, path, 'exec', dont_inherit=True)
297 298
    six.exec_(code, globals, locals)

299 300 301 302 303 304 305 306
# _version_info_str converts version_info -> str.
def _version_info_str(vi):
    major, minor, micro, release, serial = vi
    v = '%d.%d.%d' % (major, minor, micro)
    if (release, serial) != ('final', 0):
        v += '.%s%s' % (release, serial)
    return v

307 308

def main():
309 310
    # import sys early.
    # it is ok to import sys before gevent because sys is anyway always
311 312 313 314 315 316 317 318
    # imported first, e.g. to support sys.modules.
    import sys

    # safety check that we are not running from a setuptools entrypoint, where
    # it would be too late to monkey-patch stdlib.
    #
    # (os and signal are imported by python startup itself)
    # (on py3 _thread is imported by the interpreter early to support fine-grained import lock)
319 320
    avoid = ['pkg_resources', 'golang', 'socket', 'select', 'threading',
             'thread', 'ssl', 'subprocess']
321
    # pypy7 made time always pre-imported (https://foss.heptapod.net/pypy/pypy/-/commit/f4fa167b)
322 323
    # cpython3.8 made time always pre-imported via zipimport hook:
    # https://github.com/python/cpython/commit/79d1c2e6c9d1 (`import time` in zipimport.py)
324
    pypy = ('PyPy' in sys.version)
325
    if (not pypy) and (sys.version_info < (3, 8)):
326
        avoid.append('time')
327
    bad = []
328
    for mod in avoid:
329 330 331 332 333 334 335 336
        if mod in sys.modules:
            bad.append(mod)
    if bad:
        sysmodv = list(sys.modules.keys())
        sysmodv.sort()
        raise RuntimeError('gpython: internal error: the following modules are pre-imported, but must be not:'
                '\n\n\t%s\n\nsys.modules:\n\n\t%s' % (bad, sysmodv))

337 338 339 340 341 342 343
    # set UTF-8 as default encoding.
    if sys.getdefaultencoding() != 'utf-8':
        reload(sys)
        sys.setdefaultencoding('utf-8')
        delattr(sys, 'setdefaultencoding')


344 345 346 347 348 349
    # import os to get access to environment.
    # it is practically ok to import os before gevent, because os is always
    # imported by site. Yes, `import site` can be disabled by -S, but there is
    # no harm wrt gevent monkey-patching even if we import os first.
    import os

350
    # extract and process `-X gpython.*`
351 352 353 354
    # -X gpython.runtime=(gevent|threads)    + $GPYTHON_RUNTIME
    sys._xoptions = getattr(sys, '_xoptions', {})
    argv_ = []
    gpy_runtime = os.getenv('GPYTHON_RUNTIME', 'gevent')
355
    igetopt = _IGetOpt(sys.argv[1:], _pyopt, _pyopt_long)
356 357 358 359 360 361
    for (opt, arg) in igetopt:
        if opt == '-X':
            if arg.startswith('gpython.'):
                if arg.startswith('gpython.runtime='):
                    gpy_runtime = arg[len('gpython.runtime='):]
                    sys._xoptions['gpython.runtime'] = gpy_runtime
362

363 364
                else:
                    raise RuntimeError('gpython: unknown -X option %s' % opt)
365

366
                continue
367

368 369 370
        argv_.append(opt)
        if arg is not None:
            argv_.append(arg)
371

372 373 374 375
        # options after -c / -m are not for python itself
        if opt in ('-c', '-m'):
            break

376
    argv = [sys.argv[0]] + argv_ + igetopt.argv
377

378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414
    # init initializes according to selected runtime
    # it is called after options are parsed and sys.path is setup correspondingly.
    # this way golang and gevent are imported from exactly the same place as
    # they would be in standard python after regular import (ex from golang/
    # under cwd if run under `python -c ...` or interactive console.
    def init():
        if gpy_runtime == 'gevent':
            # make gevent pre-available & stdlib patched
            import gevent
            from gevent import monkey
            # XXX workaround for gevent vs pypy2 crash.
            # XXX remove when gevent-1.4.1 is relased (https://github.com/gevent/gevent/pull/1357).
            patch_thread=True
            if pypy and sys.version_info.major == 2:
                _ = monkey.patch_thread(existing_locks=False)
                assert _ in (True, None)
                patch_thread=False
            _ = monkey.patch_all(thread=patch_thread)      # XXX sys=True ?
            if _ not in (True, None):   # patched or nothing to do
                # XXX provide details
                raise RuntimeError('gevent monkey-patching failed')
            gpy_verextra = 'gevent %s' % gevent.__version__

        elif gpy_runtime == 'threads':
            gpy_verextra = 'threads'

        else:
            raise RuntimeError('gpython: invalid runtime %s' % gpy_runtime)

        # put go, chan, select, ... into builtin namespace
        import golang
        from six.moves import builtins
        for k in golang.__all__:
            setattr(builtins, k, getattr(golang, k))

        # sys.version
        sys.version += (' [GPython %s] [%s]' % (golang.__version__, gpy_verextra))
415 416

    # tail to pymain
417
    pymain(argv, init)
418

419 420 421 422 423 424 425 426 427 428 429 430

# _is_buildout_script returns whether file @path is generated as python buildout script.
def _is_buildout_script(path):
    with open(path, 'r') as f:
        src = f.read()
    # buildout injects the following prologues into python scripts:
    #   sys.path[0:0] = [
    #     ...
    #   ]
    return ('\nsys.path[0:0] = [\n' in src)


431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531
# _IGetOpt provides getopt-style incremental options parsing.
# ( we cannot use getopt directly, because it complains about "unrecognized options"
#   on e.g. `gpython file.py -opt` )
class _IGetOpt:
    def __init__(self, argv, shortopts, longopts):
        self.argv = argv
        self._opts = {}         # opt -> bool(arg-required)
        self._shortopttail = '' # current tail of short options from e.g. -abcd
        # parse shortopts -> ._opts
        opt = None
        for _ in shortopts:
            if _ == ':':
                if opt is None:
                    raise RuntimeError("invalid shortopts: unexpected ':'")
                self._opts['-'+opt] = True
                opt = None # prevent ::

            else:
                opt = _
                if opt in self._opts:
                    raise RuntimeError("invalid shortopts: double '%s'" % opt)
                self._opts['-'+opt] = False

        # parse longopts -> ._opts
        for opt in longopts:
            arg_required = (opt[-1:] == '=')
            if arg_required:
                opt = opt[:-1]
            self._opts['--'+opt] = arg_required


    def __iter__(self):
        return self
    def __next__(self):
        # yield e.g. -b -c -d  from -abcd
        if len(self._shortopttail) > 0:
            opt = '-'+self._shortopttail[0]
            self._shortopttail = self._shortopttail[1:]

            if opt not in self._opts:
                raise RuntimeError('unexpected option %s' % opt)

            arg = None
            if self._opts[opt]: # arg required
                if len(self._shortopttail) > 0:
                    # -o<arg>
                    arg = self._shortopttail
                    self._shortopttail = ''
                else:
                    # -o <arg>
                    if len(self.argv) == 0:
                        raise RuntimeError('option %s requires an argument' % opt)
                    arg = self.argv[0]
                    self.argv = self.argv[1:]

            return (opt, arg)

        # ._shortopttail is empty - proceed with .argv

        if len(self.argv) == 0:
            raise StopIteration # end of argv

        opt = self.argv[0]
        if not opt.startswith('-'):
            raise StopIteration # not an option

        if opt == '-':
            raise StopIteration # not an option

        self.argv = self.argv[1:]

        if opt == '--':
            raise StopIteration # options -- args delimiter

        # short option
        if not opt.startswith('--'):
            self._shortopttail = opt[1:]
            return self.__next__()

        # long option
        arg = None
        if '=' in opt:
            opt, arg = opt.split('=')
        if opt not in self._opts:
            raise RuntimeError('unexpected option %s' % opt)
        arg_required = self._opts[opt]
        if not arg_required:
            if arg is not None:
                raise RuntimeError('option %s requires no argument' % opt)
        else:
            if arg is None:
                if len(self.argv) == 0:
                    raise RuntimeError('option %s requires no argument' % opt)
                arg = self.argv[0]
                self.argv[0] = self.argv[1:]

        return (opt, arg)

    next = __next__ # for py2


532 533
if __name__ == '__main__':
    main()