#!/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, and -m 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()