Skip to content

Commit 10d6480

Browse files
committed
pythonrc.py is now async aware !
1 parent 594414d commit 10d6480

File tree

1 file changed

+190
-15
lines changed

1 file changed

+190
-15
lines changed

pythonrc.py

Lines changed: 190 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,10 @@
6060
except NameError:
6161
pass
6262

63+
import ast
64+
import asyncio
6365
import atexit
66+
import concurrent
6467
import glob
6568
import importlib
6669
import inspect
@@ -74,13 +77,15 @@
7477
import shlex
7578
import signal
7679
import subprocess
80+
import threading
81+
import warnings
7782
import webbrowser
7883
from code import InteractiveConsole
7984
from functools import cached_property, lru_cache, partial
8085
from itertools import chain
8186
from operator import attrgetter
8287
from tempfile import NamedTemporaryFile
83-
from types import SimpleNamespace
88+
from types import FunctionType, ModuleType, SimpleNamespace
8489

8590
__version__ = "0.9.0"
8691

@@ -108,9 +113,11 @@
108113
# - should path completion expand ~ using os.path.expanduser()
109114
COMPLETION_EXPANDS_TILDE=True,
110115
# - when executing edited history, should we also print comments
111-
POST_EDIT_PRINT_COMMENTS = True,
116+
POST_EDIT_PRINT_COMMENTS=True,
112117
# - Attempt to auto-import top-level module names on NameError
113-
ENABLE_AUTO_IMPORTS = True
118+
ENABLE_AUTO_IMPORTS=True,
119+
# - Start/Stop the asyncio loop in the interpreter (similar to `python -m asyncio`)
120+
TOGGLE_ASYNCIO_LOOP_CMD=r"\A",
114121
)
115122

116123
# Color functions. These get initialized in init_color_functions() later
@@ -297,7 +304,9 @@ def __init__(self, *args, **kwargs):
297304
self.session_history = [] # This holds the last executed statements
298305
self.buffer = [] # This holds the statement to be executed
299306
self._indent = ""
307+
self.loop = None
300308
super(ImprovedConsole, self).__init__(*args, **kwargs)
309+
301310
self.init_color_functions()
302311
self.init_readline()
303312
self.init_prompt()
@@ -309,6 +318,7 @@ def __init__(self, *args, **kwargs):
309318
config.SH_EXEC: self.process_sh_cmd,
310319
config.HELP_CMD: self.process_help_cmd,
311320
config.TOGGLE_AUTO_INDENT_CMD: self.toggle_auto_indent,
321+
config.TOGGLE_ASYNCIO_LOOP_CMD: self.toggle_asyncio,
312322
}
313323
# - regex to identify and extract commands and their arguments
314324
self.commands_re = re.compile(
@@ -387,14 +397,14 @@ def append_history(len_at_start):
387397
self.completer = ImprovedCompleter(self.locals)
388398
readline.set_completer(self.completer.complete)
389399

390-
def init_prompt(self):
400+
def init_prompt(self, nested=False):
391401
"""Activates color on the prompt based on python version.
392402
393403
Also adds the hosts IP if running on a remote host over a
394404
ssh connection.
395405
"""
396406
prompt_color = green if sys.version_info.major == 2 else yellow
397-
sys.ps1 = prompt_color(">>> ", readline_workaround=True)
407+
sys.ps1 = prompt_color(">=> " if nested else ">>> ", readline_workaround=True)
398408
sys.ps2 = red("... ", readline_workaround=True)
399409
# - if we are over a remote connection, modify the ps1
400410
if os.getenv("SSH_CONNECTION"):
@@ -431,6 +441,94 @@ def pprint_callback(value):
431441

432442
sys.displayhook = pprint_callback
433443

444+
def _init_nested_repl(self):
445+
self.loop = asyncio.new_event_loop()
446+
asyncio.set_event_loop(self.loop)
447+
self.compile.compiler.flags |= ast.PyCF_ALLOW_TOP_LEVEL_AWAIT
448+
self.locals["asyncio"] = asyncio
449+
self.locals["repl_future"] = None
450+
self.locals["repl_future_interrupted"] = False
451+
self.runcode = self.runcode_async
452+
453+
def repl_thread():
454+
try:
455+
self.init_prompt(nested=True)
456+
self.interact(
457+
banner=(
458+
"An asyncio loop has been started in the main thread.\n"
459+
"This nested interpreter is now running in a seperate thread.\n"
460+
f"Use {config.TOGGLE_ASYNCIO_LOOP_CMD} to stop the asyncio loop "
461+
"and simply exit this nested interpreter to stop this thread\n"
462+
),
463+
exitmsg="exiting nested REPL, exit again to quit main thread...\n",
464+
)
465+
finally:
466+
warnings.filterwarnings(
467+
"ignore",
468+
message=r"^coroutine .* was never awaited$",
469+
category=RuntimeWarning,
470+
)
471+
if self.loop.is_running():
472+
self.loop.call_soon_threadsafe(self.loop.stop)
473+
del self.locals["repl_future"]
474+
del self.locals["repl_future_interrupted"]
475+
self.runcode = self.runcode_sync
476+
self.loop = None
477+
478+
self.init_prompt()
479+
threading.main_thread().join()
480+
481+
self.repl_thread = threading.Thread(target=repl_thread, daemon=True)
482+
self.repl_thread.start()
483+
484+
def _start_asyncio_loop(self):
485+
self.locals["repl_future"] = None
486+
self.locals["repl_future_interrupted"] = False
487+
self.runcode = self.runcode_async
488+
489+
while True:
490+
try:
491+
self.loop.run_forever()
492+
except KeyboardInterrupt:
493+
if (
494+
repl_future := self.locals["repl_future"]
495+
) and not repl_future.done():
496+
repl_future.cancel()
497+
self.locals["repl_future_interrupted"] = True
498+
continue
499+
else:
500+
break
501+
self.runcode = self.runcode_sync
502+
503+
@_doc_to_usage
504+
def toggle_asyncio(self, _):
505+
"""{config.TOGGLE_ASYNCIO_LOOP_CMD} - Starts/stops the asyncio loop
506+
507+
Configures the interpreter in a similar manner to `python -m asyncio`
508+
"""
509+
if self.loop is None:
510+
self._init_nested_repl()
511+
self._start_asyncio_loop()
512+
elif not self.loop.is_running():
513+
print(red("Restarting previously stopped asyncio loop"))
514+
self._start_asyncio_loop()
515+
else:
516+
if (
517+
repl_future := self.locals.get("repl_future", None)
518+
) and not repl_future.done():
519+
repl_future.cancel()
520+
521+
self.loop.call_soon_threadsafe(self.loop.stop)
522+
print(
523+
red(
524+
f"Stopped the asyncio loop. Use {config.TOGGLE_ASYNCIO_LOOP_CMD} to restart it."
525+
)
526+
)
527+
528+
del self.locals["repl_future"]
529+
del self.locals["repl_future_interrupted"]
530+
self.runcode = self.runcode_sync
531+
434532
def auto_indent_hook(self):
435533
"""Hook called by readline between printing the prompt and
436534
starting to read input.
@@ -515,7 +613,50 @@ def push(self, line):
515613
self._indent = ""
516614
return more
517615

518-
def runcode(self, code):
616+
def runcode_async(self, code):
617+
future = concurrent.futures.Future()
618+
619+
def callback():
620+
self.locals["repl_future"] = None
621+
self.locals["repl_future_interrupted"] = False
622+
623+
func = FunctionType(code, self.locals)
624+
try:
625+
coro = func()
626+
except SystemExit:
627+
raise
628+
except KeyboardInterrupt as ex:
629+
self.locals["repl_future_interrupted"] = True
630+
future.set_exception(ex)
631+
return
632+
except BaseException as ex:
633+
future.set_exception(ex)
634+
return
635+
636+
if not inspect.iscoroutine(coro):
637+
future.set_result(coro)
638+
return
639+
640+
try:
641+
self.locals["repl_future"] = self.loop.create_task(coro)
642+
asyncio.futures._chain_future(self.locals["repl_future"], future)
643+
except BaseException as exc:
644+
future.set_exception(exc)
645+
646+
self.loop.call_soon_threadsafe(callback)
647+
648+
try:
649+
return future.result()
650+
except SystemExit:
651+
raise
652+
except BaseException:
653+
self.write("\nKeyboardInterrupt\n")
654+
if self.locals["repl_future_interrupted"]:
655+
self.write("\nKeyboardInterrupt\n")
656+
else:
657+
self.showtraceback()
658+
659+
def runcode_sync(self, code):
519660
"""Wrapper around super().runcode() to enable auto-importing"""
520661

521662
if not config.ENABLE_AUTO_IMPORTS:
@@ -524,11 +665,11 @@ def runcode(self, code):
524665
try:
525666
exec(code, self.locals)
526667
except NameError as err:
527-
if (match := re.search(r"'(\w+)' is not defined", err.args[0])):
668+
if match := re.search(r"'(\w+)' is not defined", err.args[0]):
528669
name = match.group(1)
529670
if name in self.completer.modlist:
530671
mod = importlib.import_module(name)
531-
print(grey(f'# imported undefined module: {name}', bold=False))
672+
print(grey(f"# imported undefined module: {name}", bold=False))
532673
self.locals[name] = mod
533674
return self.runcode(code)
534675
self.showtraceback()
@@ -537,6 +678,8 @@ def runcode(self, code):
537678
except Exception:
538679
self.showtraceback()
539680

681+
runcode = runcode_sync
682+
540683
def write(self, data):
541684
"""Write out data to stderr"""
542685
sys.stderr.write(data if data.startswith("\033[") else red(data))
@@ -762,6 +905,37 @@ def process_list_cmd(self, arg):
762905
for line_no, line in enumerate(src_lines, offset + 1):
763906
self.write(cyan(f"{line_no:03d}: {line}"))
764907

908+
@_doc_to_usage
909+
def process_reload_cmd(self, arg):
910+
"""{config.RELOAD_CMD} <object> - Reload object, if possible.
911+
912+
- if argument is a module, simply call importlib.reload() for it.
913+
914+
- if argument is not a module, try hard to re-execute the
915+
(presumably updated) source code in the namespace of the object,
916+
in effect reloading it.
917+
918+
credit: inspired by the description at https://github.com/hoh/reloadr
919+
"""
920+
if not arg:
921+
return self.writeline(
922+
"reload command requires an " f"argument (eg: {config.RELOAD_CMD} foo)"
923+
)
924+
925+
try:
926+
obj = self.lookup(arg)
927+
if isinstance(obj, ModuleType):
928+
self.locals[arg] = importlib.reload(obj)
929+
else:
930+
namespace = inspect.getmodule(obj)
931+
exec(
932+
compile(inspect.getsource(obj), namespace.__file__, "exec"),
933+
namespace.__dict__,
934+
self.locals,
935+
)
936+
except OSError as e:
937+
self.writeline(e)
938+
765939
def process_help_cmd(self, arg):
766940
if arg:
767941
if keyword.iskeyword(arg):
@@ -773,7 +947,7 @@ def process_help_cmd(self, arg):
773947
else:
774948
print(cyan(self.__doc__).format(**config.__dict__))
775949

776-
def interact(self):
950+
def interact(self, banner="", exitmsg=""):
777951
"""A forgiving wrapper around InteractiveConsole.interact()"""
778952
venv_rc_done = cyan("(no venv rc found)")
779953
try:
@@ -785,16 +959,17 @@ def interact(self):
785959
except IOError:
786960
pass
787961

788-
banner = (
789-
f"Welcome to the ImprovedConsole (version {__version__})\n"
790-
f"Type in {config.HELP_CMD} for list of features.\n"
791-
f"{venv_rc_done}"
792-
)
962+
if not banner:
963+
banner = (
964+
f"Welcome to the ImprovedConsole (version {__version__})\n"
965+
f"Type in {config.HELP_CMD} for list of features.\n"
966+
f"{venv_rc_done}"
967+
)
793968

794969
retries = 2
795970
while retries:
796971
try:
797-
super(ImprovedConsole, self).interact(banner=banner)
972+
super(ImprovedConsole, self).interact(banner=banner, exitmsg=exitmsg)
798973
except SystemExit:
799974
# Fixes #2: exit when 'quit()' invoked
800975
break

0 commit comments

Comments
 (0)