243 lines
11 KiB
Python
243 lines
11 KiB
Python
#!/usr/bin/env python
|
|
# -*- coding: utf-8 -*-
|
|
"""Universal bootstrapper for macro-enabled Python programs powered by mcpyrate."""
|
|
|
|
# TODO: Currently tested in CPython 3.6, and PyPy3 7.3.0 (Python 3.6). Test in 3.7+.
|
|
|
|
import argparse
|
|
import atexit
|
|
from importlib import import_module
|
|
from importlib.util import resolve_name, module_from_spec
|
|
import os
|
|
import pathlib
|
|
import sys
|
|
|
|
from ..coreutils import relativize
|
|
|
|
from .. import activate # noqa: F401
|
|
|
|
__version__ = "3.0.0"
|
|
|
|
_config_dir = "~/.config/mcpyrate"
|
|
_macropython_module = None # sys.modules doesn't always seem to keep it, so stash it locally too.
|
|
|
|
def import_module_as_main(name, script_mode):
|
|
"""Import a module, pretending it's __main__.
|
|
|
|
We support only new-style loaders that provide `exec_module`; old-style
|
|
loaders that provide `load_module` and `create_module` are not supported.
|
|
|
|
Upon success, replaces `sys.modules["__main__"]` with the module that
|
|
was imported. Upon failure, propagates any exception raised.
|
|
|
|
This is a customized approximation of the standard import semantics, based on:
|
|
|
|
https://docs.python.org/3/library/importlib.html#approximating-importlib-import-module
|
|
https://docs.python.org/3/reference/import.html#loading
|
|
https://docs.python.org/3/reference/import.html#import-related-module-attributes
|
|
https://docs.python.org/3/reference/import.html#module-path
|
|
https://docs.python.org/3/reference/import.html#special-considerations-for-main
|
|
"""
|
|
absolute_name = resolve_name(name, package=None)
|
|
|
|
# If already loaded, normally we should return the module from `sys.modules`,
|
|
# but `sys.modules["__main__"]` is for now this bootstrapper, not the user __main__.
|
|
#
|
|
# Note __main__ and somemod are distinct modules, even when the same file,
|
|
# because __main__ triggers `if __name__ == '__main__'` checks, but somemod doesn't.
|
|
#
|
|
# try:
|
|
# return sys.modules[absolute_name]
|
|
# except KeyError:
|
|
# pass
|
|
|
|
path = None
|
|
if '.' in absolute_name:
|
|
if script_mode:
|
|
raise ValueError("In script mode, please add the containing directory to `sys.path` and then top-level import the final component of the name.")
|
|
# Import the module's parent package normally to initialize parent packages.
|
|
# Get the appropriate `path` for `find_spec` to find subpackages and modules.
|
|
parent_name, _, child_name = absolute_name.rpartition('.')
|
|
parent_module = import_module(parent_name)
|
|
path = parent_module.__spec__.submodule_search_locations
|
|
|
|
for finder in sys.meta_path:
|
|
if not hasattr(finder, "find_spec"): # pkg_resources.extern.VendorImporter has no find_spec
|
|
continue
|
|
# https://docs.python.org/3/library/importlib.html#importlib.abc.MetaPathFinder.find_spec
|
|
spec = finder.find_spec(absolute_name, path)
|
|
if spec is not None:
|
|
break
|
|
else:
|
|
msg = f"No module named {absolute_name}"
|
|
raise ModuleNotFoundError(msg, name=absolute_name)
|
|
|
|
spec.name = "__main__"
|
|
if spec.loader:
|
|
spec.loader.name = "__main__"
|
|
|
|
module = module_from_spec(spec)
|
|
try_mainpy = False
|
|
if script_mode: # e.g. "macropython somepackage/__init__.py"
|
|
module.__package__ = ""
|
|
elif path: # e.g. "macropython -m somepackage.module"
|
|
module.__package__ = parent_name
|
|
elif spec.origin.endswith("__init__.py"): # e.g. "macropython -m somepackage"
|
|
module.__package__ = absolute_name
|
|
try_mainpy = True
|
|
|
|
if spec.origin == "namespace":
|
|
# https://docs.python.org/3/reference/import.html#__path__
|
|
module.__path__ = spec.submodule_search_locations
|
|
|
|
if try_mainpy:
|
|
# e.g. "import somepackage" in the above case; it's not __main__, so import it normally.
|
|
assert not script_mode
|
|
parent_module = import_module(absolute_name)
|
|
elif spec.loader:
|
|
sys.modules["__main__"] = module # replace this bootstrapper with the new __main__
|
|
try:
|
|
spec.loader.exec_module(module)
|
|
except Exception:
|
|
sys.modules["__main__"] = _macropython_module
|
|
raise
|
|
# # __main__ has no parent module so we don't need to do this.
|
|
# if path is not None:
|
|
# setattr(parent_module, child_name, module)
|
|
else: # namespace packages have loader=None
|
|
try_mainpy = True
|
|
|
|
# __init__.py (if any) has run; run __main__.py, like `python -m somepackage` does.
|
|
if try_mainpy:
|
|
has_mainpy = True
|
|
try:
|
|
# __main__.py doesn't need to have its name set to "__main__", so import it normally.
|
|
import_module(f"{absolute_name}.__main__")
|
|
except ImportError as e:
|
|
if "No module named" in e.msg:
|
|
has_mainpy = False
|
|
else:
|
|
raise
|
|
if not has_mainpy:
|
|
raise ImportError(f"No module named {absolute_name}.__main__; '{absolute_name}' is a package and cannot be directly executed")
|
|
|
|
return module
|
|
|
|
def main():
|
|
"""Handle command-line arguments and run the specified main program."""
|
|
parser = argparse.ArgumentParser(description="""Run a Python program or an interactive interpreter with mcpyrate enabled.""",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
|
|
parser.add_argument('-v', '--version', action='version', version=('%(prog)s (mcpyrate ' + __version__ + ')'))
|
|
parser.add_argument(dest='filename', nargs='?', default=None, type=str, metavar='file',
|
|
help='script to run')
|
|
parser.add_argument('-m', '--module', dest='module', default=None, type=str, metavar='mod',
|
|
help='run library module as a script (like python -m mod)')
|
|
parser.add_argument('-i', '--interactive', dest='interactive', action="store_true", default=False,
|
|
help='interactive mode (macro-enabled Python interpreter). '
|
|
'If -i is given, <filename> and -m <mod> are ignored.')
|
|
parser.add_argument('-p', '--pylab', dest='pylab', action="store_true", default=False,
|
|
help='For use together with "-i". Automatically "import numpy as np", '
|
|
'"import matplotlib.pyplot as plt", and enable mpl\'s interactive '
|
|
'mode (somewhat like IPython\'s pylab mode).')
|
|
opts = parser.parse_args()
|
|
|
|
if opts.interactive:
|
|
from .console import MacroConsole
|
|
import readline # noqa: F401, side effect: enable GNU readline in input()
|
|
import rlcompleter # noqa: F401, side effects: readline tab completion
|
|
repl_locals = {}
|
|
if opts.pylab: # like IPython's pylab mode, but we keep things in separate namespaces.
|
|
import numpy
|
|
import matplotlib.pyplot
|
|
repl_locals["np"] = numpy
|
|
repl_locals["plt"] = matplotlib.pyplot
|
|
matplotlib.pyplot.ion()
|
|
readline.set_completer(rlcompleter.Completer(namespace=repl_locals).complete)
|
|
readline.parse_and_bind("tab: complete") # PyPy ignores this, but not needed there.
|
|
|
|
config_dir = pathlib.Path(_config_dir).expanduser().resolve()
|
|
try:
|
|
readline.read_history_file(config_dir / "macropython_history")
|
|
except FileNotFoundError:
|
|
pass
|
|
|
|
def save_history():
|
|
config_dir.mkdir(parents=True, exist_ok=True)
|
|
readline.set_history_length(1000)
|
|
readline.write_history_file(config_dir / "macropython_history")
|
|
atexit.register(save_history)
|
|
|
|
# Add CWD to import path like the builtin interactive console does.
|
|
if sys.path[0] != "":
|
|
sys.path.insert(0, "")
|
|
m = MacroConsole(locals=repl_locals)
|
|
return m.interact()
|
|
|
|
if not opts.filename and not opts.module:
|
|
parser.print_help()
|
|
sys.exit(0)
|
|
if opts.filename and opts.module:
|
|
raise ValueError("Please specify just one main program to run (either filename.py or -m module, not both).")
|
|
|
|
# Import the module, pretending its __name__ is "__main__".
|
|
#
|
|
# We must import so that macros get expanded, so we can't use
|
|
# runpy.run_module here (which just execs without importing).
|
|
#
|
|
# As for sys.path, we're not interested in the directory the `macropython`
|
|
# script itself lives in. To imitate how Python itself behaves:
|
|
#
|
|
# - If run as `macropython -m something`, then `sys.path[0]`
|
|
# should be set to the cwd.
|
|
# - If run as `macropython some/path/to/script.py`, then `sys.path[0]`
|
|
# should be set to the directory containing `script.py`.
|
|
#
|
|
if opts.module:
|
|
# Python 3.7 and later resolve the cwd at interpreter startup time, and use that.
|
|
# Python 3.6 and earlier use the empty string, which dynamically resolves to
|
|
# whatever is the current cwd whenever a module is imported.
|
|
# https://bugs.python.org/issue33053
|
|
if sys.version_info >= (3, 7, 0):
|
|
cwd = str(pathlib.Path.cwd().expanduser().resolve())
|
|
if sys.path[0] != cwd:
|
|
sys.path.insert(0, cwd)
|
|
else:
|
|
if sys.path[0] != "":
|
|
sys.path.insert(0, "")
|
|
|
|
import_module_as_main(opts.module, script_mode=False)
|
|
|
|
else: # no module, so use opts.filename
|
|
fullpath = pathlib.Path(opts.filename).expanduser().resolve()
|
|
if not fullpath.is_file():
|
|
raise FileNotFoundError(f"Can't open file '{opts.filename}'")
|
|
|
|
containing_directory = str(fullpath.parent)
|
|
if sys.path[0] != containing_directory:
|
|
sys.path.insert(0, containing_directory)
|
|
|
|
# This approach finds the standard loader even for macro-enabled scripts.
|
|
# TODO: For mcpyrate, that could be ok, since we monkey-patch just that.
|
|
# TODO: But maybe better to leave the option to replace the whole loader later.
|
|
# spec = spec_from_file_location("__main__", str(fullpath))
|
|
# if not spec:
|
|
# raise ImportError(f"Not a Python module: '{opts.filename}'"
|
|
# module = module_from_spec(spec)
|
|
# TODO: if we use this approach, we should first initialize parent packages.
|
|
# sys.modules[module.__name__] = module
|
|
# spec.loader.exec_module(module)
|
|
|
|
root_path, relative_path = relativize(opts.filename)
|
|
module_name = relative_path.replace(os.path.sep, '.')
|
|
if module_name.endswith(".__init__.py"):
|
|
module_name = module_name[:-12]
|
|
elif module_name.endswith(".py"):
|
|
module_name = module_name[:-3]
|
|
|
|
import_module_as_main(module_name, script_mode=True)
|
|
|
|
if __name__ == '__main__':
|
|
_macropython_module = sys.modules["__macropython__"] = sys.modules["__main__"]
|
|
main()
|