summaryrefslogtreecommitdiff
path: root/Biz/Repl.py
diff options
context:
space:
mode:
authorBen Sima <ben@bsima.me>2024-05-20 22:06:34 -0400
committerBen Sima <ben@bsima.me>2024-05-20 23:15:31 -0400
commit3d4d36e5b4b61cbbee93032425c822f2f478449c (patch)
treef77a70c8191c60a0d3188f1523a43f430355f5e7 /Biz/Repl.py
parent70c293597d0ad25a87008cd136ee63798aba8e53 (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/Repl.py')
-rw-r--r--Biz/Repl.py168
1 files changed, 143 insertions, 25 deletions
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()