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
260
261
262
263
264
265
|
"""
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.
Raises:
ReplError: if module cannot be loaded
"""
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.
Raises:
ReplError: if caught exception is empty
"""
# 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()
|