6060except NameError :
6161 pass
6262
63+ import ast
64+ import asyncio
6365import atexit
66+ import concurrent
6467import glob
6568import importlib
6669import inspect
7477import shlex
7578import signal
7679import subprocess
80+ import threading
81+ import warnings
7782import webbrowser
7883from code import InteractiveConsole
7984from functools import cached_property , lru_cache , partial
8085from itertools import chain
8186from operator import attrgetter
8287from tempfile import NamedTemporaryFile
83- from types import SimpleNamespace
88+ from types import FunctionType , ModuleType , SimpleNamespace
8489
8590__version__ = "0.9.0"
8691
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 ("\n KeyboardInterrupt\n " )
654+ if self .locals ["repl_future_interrupted" ]:
655+ self .write ("\n KeyboardInterrupt\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