From 3d4d36e5b4b61cbbee93032425c822f2f478449c Mon Sep 17 00:00:00 2001 From: Ben Sima Date: Mon, 20 May 2024 22:06:34 -0400 Subject: 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 --- Biz/Bild/Example.py | 3 +- Biz/Mynion.py | 10 ++-- Biz/Repl.py | 168 ++++++++++++++++++++++++++++++++++++++++++++-------- pyproject.toml | 33 +++++++---- 4 files changed, 169 insertions(+), 45 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() diff --git a/pyproject.toml b/pyproject.toml index 62eebf4..025e630 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ exclude = ["_", ".git"] line-length = 80 indent-width = 4 target-version = "py310" +unsafe-fixes = true [tool.ruff.format] preview = true @@ -20,16 +21,24 @@ preview = true select = ["ALL"] fixable = ["ALL"] ignore = [ - "ANN401", # any-type, we allow typing.Any, although we shouldn't - "CPY001", # missing-copyright-notice - "D203", # no-blank-line-before-class - "D212", # multi-line-summary-first-line - "E203", # whitespace-before-punctuation, doesn't work with ruff format - "INP001", # implicit-namespace-package - "N999", # invalid-module-name - "PT009", # pytest-unittest-assertion, conflicts with assert (S101) - "S310", # suspicious-url-open-usage, doesn't work in 0.1.5 - "S404", # suspicious-subprocess-import, not stable - "S603", # subprocess-without-shell-equals-true, false positives - "S607", # start-process-with-partial-path + "ANN401", # any-type, we allow typing.Any, although we shouldn't + "CPY001", # missing-copyright-notice + "D203", # no-blank-line-before-class + "D212", # multi-line-summary-first-line + "E203", # whitespace-before-punctuation, doesn't work with ruff format + "INP001", # implicit-namespace-package + "N999", # invalid-module-name + "PLR0402", # manual-from-import, prefer imports like Haskell + "PT009", # pytest-unittest-assertion, conflicts with assert (S101) + "S310", # suspicious-url-open-usage, doesn't work in 0.1.5 + "S404", # suspicious-subprocess-import, not stable + "S603", # subprocess-without-shell-equals-true, false positives + "S607", # start-process-with-partial-path + "TD002", # missing-todo-author, just don't allow todos + "TD003", # missing-todo-link, just don't allow todos ] + +[tool.ruff.lint.isort] +no-sections = true +force-single-line = true + -- cgit v1.2.3