summaryrefslogtreecommitdiff
path: root/Biz/Repl.py
blob: cd7bad6110243d0d165a91d776e648776c91d9c1 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
"""
Improve the standard Python REPL.

This module attempts to emulate the workflow of ghci or lisp repls. It uses
importlib to load a namespace from the provided path, typechecks it with mypy,
and provides some tools for improving repl-driven development.

This module is called in Biz/Ide/repl.sh like so:

    python -i Biz/Repl.py NS PATH

where NS is the dot-partitioned namespace of the main module, and PATH is the
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 pathlib
import pydoc
import string
import subprocess
import sys
import textwrap
import types
import typing
import unittest


class ReplError(Exception):
    """Type for errors at the repl."""


def use(ns: str, path: str) -> None:
    """
    Load or reload the module named 'ns' from 'path'.

    Like `use` in the Guile Scheme repl.
    """
    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 = f"spec could not be loaded for {ns} at {path}"
        raise ReplError(msg)
    module = importlib.util.module_from_spec(spec)
    # delete module and its imported names if its already loaded
    if ns in sys.modules:
        del sys.modules[ns]
        for name in module.__dict__:
            if name in globals():
                del globals()[name]
    sys.modules[ns] = module
    spec.loader.exec_module(module)
    names = list(module.__dict__)
    globals().update({k: getattr(module, k) for k in names})


def typecheck(path: str) -> None:
    """Typecheck this namespace."""
    # this envvar is undocumented, but it works
    # https://github.com/python/mypy/issues/13815
    os.environ["MYPY_FORCE_COLOR"] = "1"
    logging.info("typechecking %s", path)
    stdout, stderr, _ = mypy.api.run([path])
    sys.stdout.write(stdout)
    sys.stdout.flush()
    sys.stderr.write(stderr)
    sys.stderr.flush()


def edit_file(ns: str, path: str, editor: str) -> None:
    """
    Edit and reload the given namespace and path.

    It is assumed ns and path go together. If `editor` returns something other
    than 0, this function will not reload the ns.
    """
    try:
        proc = subprocess.run([editor, path], check=False)
    except FileNotFoundError:
        logging.exception("editor '%s' not found", editor)
    if proc.returncode == 0:
        use(ns, path)
        typecheck(path)


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 help(self) -> str:
        """Return help text."""
        return 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
        """)

    def show_help(self) -> None:
        """Print info about how to use this repl."""
        sys.stdout.write(self.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 = f"value.text is None: {value}"
            raise ReplError(msg)
        stmt = value.text.rstrip()
        if stmt == ":?":
            self.show_help()
            return None
        if stmt.endswith("?"):
            name = stmt.rstrip("?(" + self.whitespace)
            self.get_help(name)
            return None
        if stmt == ":e":
            self.edit()
            return None
        if stmt == ":r":
            self.reload()
            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 get_help(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(self.ns, self.path, self.editor)


class TestCustomRepl(unittest.TestCase):
    """Test the CustomRepl functionality."""

    def setUp(self) -> None:
        """Create a CustomRepl for testing."""
        ns = __name__
        path = pathlib.Path(__name__.replace(".", "/"))
        path = path.with_suffix(".py")
        self.repl = CustomRepl(ns, str(path), "true")
        self.repl.setup()

    def tearDown(self) -> None:
        """Undo `self.setUp`."""
        sys.excepthook = self.repl.default
        del self.repl

    def test_help(self) -> None:
        """Help message should include the ns and path."""
        self.assertIn(self.repl.ns, self.repl.help())


def test() -> None:
    """Run this module's test suite."""
    suite = unittest.TestSuite()
    suite.addTests(
        unittest.defaultTestLoader.loadTestsFromTestCase(TestCustomRepl),
    )
    unittest.TextTestRunner().run(suite)


def move() -> None:
    """Actual entrypoint."""
    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()


def main() -> None:
    """Entrypoint, should be replaced by a `Biz.Cli.main`."""
    if sys.argv[1] == "test":
        test()
    else:
        move()


if __name__ == "__main__":
    main()