diff options
author | Ben Sima <ben@bsima.me> | 2024-05-20 22:06:34 -0400 |
---|---|---|
committer | Ben Sima <ben@bsima.me> | 2024-05-20 23:15:31 -0400 |
commit | 3d4d36e5b4b61cbbee93032425c822f2f478449c (patch) | |
tree | f77a70c8191c60a0d3188f1523a43f430355f5e7 /Biz | |
parent | 70c293597d0ad25a87008cd136ee63798aba8e53 (diff) |
Greatly expand Repl.py
This adds a few things I found from [this gist][1], but cleaned up quite a bit I
think, and designed a bit closer to the ghci user experience.
Along the way I figured out what ruff settings will autoformat my imports in one
alphabetized section, rather than splitting it into multiple sections for
builtins and external deps. So I made that change in the whole repo, but there
weren't too many changes.
[1]: https://gist.github.com/aliles/1153926
Diffstat (limited to 'Biz')
-rw-r--r-- | Biz/Bild/Example.py | 3 | ||||
-rw-r--r-- | Biz/Mynion.py | 10 | ||||
-rw-r--r-- | Biz/Repl.py | 168 |
3 files changed, 148 insertions, 33 deletions
diff --git a/Biz/Bild/Example.py b/Biz/Bild/Example.py index 1bd30ae..dd9a6ef 100644 --- a/Biz/Bild/Example.py +++ b/Biz/Bild/Example.py @@ -6,9 +6,8 @@ Example Python file that also serves as a test case for bild. # : out example # : dep cryptography -import sys - import cryptography.fernet +import sys def cryptic_hello(name: str) -> str: diff --git a/Biz/Mynion.py b/Biz/Mynion.py index 3b80f5f..83d427b 100644 --- a/Biz/Mynion.py +++ b/Biz/Mynion.py @@ -4,19 +4,17 @@ # : dep exllama # : dep slixmpp import argparse +import Biz.Log import dataclasses +import exllama # type: ignore[import] import logging import os import pathlib -import sys -import typing - -import exllama # type: ignore[import] import slixmpp import slixmpp.exceptions +import sys import torch - -import Biz.Log +import typing def smoosh(s: str) -> str: diff --git a/Biz/Repl.py b/Biz/Repl.py index 2f71c33..595b555 100644 --- a/Biz/Repl.py +++ b/Biz/Repl.py @@ -14,16 +14,22 @@ path to the same file. In the future this could be expanded to be a list of additional files to load. """ +# : out python-improved-repl +# : dep mypy +import Biz.Log as Log import importlib import importlib.util +import inspect import logging +import mypy.api import os +import pydoc +import string import subprocess import sys - -import mypy.api - -from Biz import Log +import textwrap +import types +import typing def use(ns: str, path: str) -> None: @@ -34,6 +40,9 @@ def use(ns: str, path: str) -> None: """ logging.info("loading %s from %s", ns, path) spec = importlib.util.spec_from_file_location(ns, path) + if spec is None or spec.loader is None: + msg = "spec could not be loaded for %s at %s" + raise ValueError(msg, ns, path) module = importlib.util.module_from_spec(spec) # delete module and its imported names if its already loaded if ns in sys.modules: @@ -70,30 +79,139 @@ def edit_file(ns: str, path: str, editor: str) -> None: try: proc = subprocess.run([editor, path], check=False) except FileNotFoundError: - Log.fail("editor '%s' not found", editor) + logging.exception("editor '%s' not found", editor) if proc.returncode == 0: use(ns, path) typecheck(path) -if __name__ == "__main__": - Log.setup() - NS = sys.argv[1] - PATH = sys.argv[2] - EDITOR = os.environ.get("EDITOR", "$EDITOR") - use(NS, PATH) - typecheck(PATH) - - logging.info("use edit() to open %s in %s", NS, EDITOR) - logging.info("use reload() after making changes") - sys.ps1 = f"{NS}> " - sys.ps2 = f"{NS}| " - - def reload() -> None: - """Reload the namespace.""" - use(NS, PATH) - typecheck(PATH) - - def edit() -> None: +class CustomRepl: + """Custom repl commands, heavily inspired by ghci.""" + + def __init__(self, ns: str, path: str, editor: str) -> None: + """Create the custom repl for given ns and path.""" + self.ns = ns + self.path = path + self.editor = editor + self.default = sys.__excepthook__ + self.isframe = inspect.isframe + self.stack = inspect.stack + self.stdout = sys.stdout + self.whitespace = string.whitespace + + def setup(self) -> None: + """ + Load the NS, setup hooks and prompt. + + This basically does all the heavy lifting of customizing the Python + repl. + """ + # load given namespace + use(self.ns, self.path) + typecheck(self.path) + # setup hooks and prompt + sys.excepthook = self.excepthook + pydoc.pager = lambda text: pydoc.pipepager(text, "more") + sys.ps1 = f"{self.ns}> " + sys.ps2 = f"{self.ns}| " + + def show_help(self) -> None: + """Print info about how to use this repl.""" + sys.stdout.write( + textwrap.dedent(f""" + repl commands: + :e open {self.ns} in {self.editor} + :r reload {self.ns} + :t obj show the type of obj + obj? expands to 'help(obj)' + :? show this help + """), + ) + sys.stdout.flush() + + def excepthook( + self, + type_: type[BaseException], + value: BaseException, + traceback: types.TracebackType | None, + ) -> typing.Any: + """ + Pre-process Python repl exceptions. + + This is called on `sys.excepthook`, which runs when the repl doesn't + know how to handle some input. So, we inspect `value` and provide + alternate functionality, bottoming out at the default exception. + """ + # ruff: noqa: PLR0911 + if not isinstance(value, SyntaxError): + return self.default(type_, value, traceback) + if value.text is None: + msg = "value.text is None: %s" + raise ValueError(msg, value) + stmt = value.text.rstrip() + if stmt == ":?": + self.repl_help() + return None + if stmt.endswith("?"): + name = stmt.rstrip("?(" + self.whitespace) + self.wut(name) + return None + if stmt == ":e": + edit_file(self.ns, self.path, self.editor) + return None + if stmt == ":r": + use(self.ns, self.path) + typecheck(self.path) + return None + if stmt.startswith(":t"): + var = stmt.split()[1] + self.get_type(var) + return None + return self.default(type_, value, traceback) + + def get_type(self, name: str) -> typing.Any | None: + """Return the type of `name` to the caller.""" + for record in self.stack(): + frame = record[0] + if not self.isframe(frame): + continue + cmd = f"typing.reveal_type({name})" + return eval(cmd, frame.f_globals, frame.f_locals) # noqa: S307 + return None + + def wut(self, name: str) -> typing.Any | None: + """Return the documentation for `name` to the caller.""" + for record in self.stack(): + frame = record[0] + if not self.isframe(frame): + continue + cmd = f"help({name})" + return eval(cmd, frame.f_globals, frame.f_locals) # noqa: S307 + return None + + def reload(self) -> None: + """Reload the current namespace.""" + use(self.ns, self.path) + typecheck(self.path) + + def edit(self) -> None: """Edit the current namespace.""" - edit_file(NS, PATH, EDITOR) + edit_file(self.ns, self.path, self.editor) + + +def main() -> None: + """Entrypoint.""" + if sys.argv[1] == "test": + pass + else: + Log.setup() + ns = sys.argv[1] + path = sys.argv[2] + editor = os.environ.get("EDITOR", "$EDITOR") + repl = CustomRepl(ns, path, editor) + repl.setup() + repl.show_help() + + +if __name__ == "__main__": + main() |