Repository: ContextLab/davos (language: python)

Issue:
updated parsing for PEP 484-like comment syntax, more efficient dependency resolution, etc



Discussion / hints:

Fix patch:
diff --git a/.gitattributes b/.gitattributes
index 3d20b22a..f4546b5b 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -2,4 +2,4 @@
 *.ipynb linguist-documentation
 
 # count file with JS functions as JavaScript
-davos/implementations/js_funcs.py linguist-language=JavaScript
+davos/implementations/js_functions.py linguist-language=JavaScript
diff --git a/README.md b/README.md
index 5215fcd2..e8cfea7a 100644
--- a/README.md
+++ b/README.md
@@ -427,7 +427,7 @@ In practice, onion comments are identified as matches for the
 - If multiple _separate_ `smuggle` statements appear on a single line separated by semicolons, an onion comment 
   may be used to modify the **last** `smuggle` statement:
   ```python
-  smuggle IPython; smuggle ipywidgets; smuggle ipykernel    # pip: ipykernel~=6.0 --pre
+  smuggle gensim; smuggle spacy; smuggle nltk    # pip: nltk~=3.5 --pre
   ```
 - For multiline `smuggle` statements, an onion comment may be placed on either the first or last line:
   ```python
diff --git a/davos/__init__.py b/davos/__init__.py
index 8149723f..61be2e0d 100644
--- a/davos/__init__.py
+++ b/davos/__init__.py
@@ -3,13 +3,14 @@
 
 
 __all__ = [
-    'activate', 
-    'config', 
-    'configure', 
-    'deactivate', 
-    'is_active', 
+    'activate',
+    'config',
+    'configure',
+    'deactivate',
+    'is_active',
     'smuggle'
 ]
+
 __version__ = pkg_resources.get_distribution('davos').version
 
 
@@ -27,11 +28,11 @@ def activate():
 
 
 def configure(
-        *, 
-        active=..., 
+        *,
+        active=...,
         auto_rerun=...,
-        conda_env=..., 
-        confirm_install=..., 
+        conda_env=...,
+        confirm_install=...,
         noninteractive=...,
         pip_executable=...,
         suppress_stdout=...
diff --git a/davos/cli.py b/davos/cli.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/davos/cli.pyi b/davos/cli.pyi
deleted file mode 100644
index e69de29b..00000000
diff --git a/davos/core/config.py b/davos/core/config.py
index feb10c2c..4001af92 100644
--- a/davos/core/config.py
+++ b/davos/core/config.py
@@ -35,6 +35,7 @@ def __init__(self):
         ########################################
         try:
             # noinspection PyUnresolvedReferences
+            # (function exists globally when imported into IPython context)
             self._ipython_shell = get_ipython()
         except NameError:
             # see _block_greedy_ipython_completer() docstring
@@ -73,12 +74,12 @@ def __init__(self):
         # pip is assumed to be installed -- been included by default
         # with Python binary installers since 3.4
         if (
-                self._environment != 'Colaboratory' and 
+                self._environment != 'Colaboratory' and
                 pathlib.Path(f'{sys.exec_prefix}/bin/pip').is_file()
         ):
             self._pip_executable = f'{sys.exec_prefix}/bin/pip'
         else:
-            # pip exe wasn't in expected location (or it's colab, where 
+            # pip exe wasn't in expected location (or it's colab, where
             # things are all over the place)
             try:
                 pip_exe = check_output(['which', 'pip'], encoding='utf-8').strip()
@@ -221,17 +222,17 @@ def suppress_stdout(self, value):
 
 def _block_greedy_ipython_completer():
     # ADD DOCSTRING - can borrow from https://github.com/ContextLab/hypertools/blob/e7b7446/hypertools/plot/backend.py#L374
-    # extract 20 most recent entries. Completer module usually appears 
-    # ~12 deep after davos & importlib, so add small buffer to be safe 
+    # extract 20 most recent entries. Completer module usually appears
+    # ~12 deep after davos & importlib, so add small buffer to be safe
     # but start searching from oldest.
     stack_trace = traceback.extract_stack(limit=20)
     # drop most recent 3 frames, which are always internal davos calls
     for frame in stack_trace[:-3]:
         if frame.filename.endswith('IPython/core/completerlib.py'):
-            # if stack contains calls to functions in completer module, 
-            # davos was imported for greed TAB-completion. Remove 
-            # davos.core & davos.core.config from sys.modules so they're 
-            # reloaded when imported for real and raise generic 
+            # if stack contains calls to functions in completer module,
+            # davos was imported for greed TAB-completion. Remove
+            # davos.core & davos.core.config from sys.modules so they're
+            # reloaded when imported for real and raise generic
             # exception to make completer function return early.
             del sys.modules['davos.core']
             del sys.modules['davos.core.config']
diff --git a/davos/core/core.py b/davos/core/core.py
index 1849be1d..71555001 100644
--- a/davos/core/core.py
+++ b/davos/core/core.py
@@ -7,11 +7,11 @@
 
 
 __all__ = [
-    'capture_stdout', 
+    'capture_stdout',
     'check_conda',
     'Onion',
     'parse_line',
-    'prompt_input', 
+    'prompt_input',
     'run_shell_command'
 ]
 
@@ -30,17 +30,22 @@
 from davos import config
 from davos.core.exceptions import (
     DavosError,
-    InstallerError, 
-    OnionParserError, 
-    ParserNotImplementedError
+    InstallerError,
+    OnionParserError,
+    ParserNotImplementedError,
+    SmugglerError
 )
 from davos.core.parsers import pip_parser
-# noinspection PyUnresolvedReferences
-from davos.core.regexps import _pip_installed_pkgs_re, smuggle_statement_regex
+from davos.core.regexps import (
+    pip_installed_pkgs_regex,
+    smuggle_statement_regex
+)
 # noinspection PyUnresolvedReferences
 from davos.implementations import (
-    _check_conda_avail_helper, 
-    _run_shell_command_helper
+    _check_conda_avail_helper,
+    _run_shell_command_helper,
+    auto_restart_rerun,
+    prompt_restart_rerun_buttons
 )
 
 
@@ -95,11 +100,11 @@ def check_conda():
         return
 
     config._conda_avail = True
-    # try to create mapping of environment names to paths to validate 
-    # environments used in onion comments or to set config.conda_env. 
-    # Want both names and paths so we can check both `-n`/`--name` & 
+    # try to create mapping of environment names to paths to validate
+    # environments used in onion comments or to set config.conda_env.
+    # Want both names and paths so we can check both `-n`/`--name` &
     # `-p`/`--prefix` when parsing onion comments
-    envs_dict_command = "conda info --envs | grep -E '^\w' | sed -E 's/ +\*? +/ /g'"
+    envs_dict_command = r"conda info --envs | grep -E '^\w' | sed -E 's/ +\*? +/ /g'"
     # noinspection PyBroadException
     try:
         conda_info_output = run_shell_command(envs_dict_command,
@@ -107,18 +112,18 @@ def check_conda():
         # noinspection PyTypeChecker
         envs_dirs_dict = dict(map(str.split, conda_info_output.splitlines()))
     except Exception:
-        # if no environments are found or output can't be parsed for 
-        # some reason, just count any conda env provided as valid. This 
-        # doesn't cause any major problems, we just can't catch errors 
-        # as early and defer to the conda executable to throw an error 
-        # when the user actually goes to install a package into an 
+        # if no environments are found or output can't be parsed for
+        # some reason, just count any conda env provided as valid. This
+        # doesn't cause any major problems, we just can't catch errors
+        # as early and defer to the conda executable to throw an error
+        # when the user actually goes to install a package into an
         # environment that doesn't exist.
         # (just set to None so it can be referenced down below)
         envs_dirs_dict = None
 
     config._conda_envs_dirs = envs_dirs_dict
-    # format of first line of output seems to reliably be: `# packages 
-    # in environment at /path/to/environment/dir:` but can't hurt to 
+    # format of first line of output seems to reliably be: `# packages
+    # in environment at /path/to/environment/dir:` but can't hurt to
     # parse more conservatively since output is so short
     for w in conda_list_output.split():
         if '/' in w:
@@ -132,10 +137,10 @@ def check_conda():
             return
 
     if envs_dirs_dict is not None:
-        # if we somehow fail to parse the environment directory path 
-        # from the output, but we DID successfully create the 
-        # environment mapping, something weird is going on and it's 
-        # worth throwing an error with some info now. Otherwise, defer 
+        # if we somehow fail to parse the environment directory path
+        # from the output, but we DID successfully create the
+        # environment mapping, something weird is going on and it's
+        # worth throwing an error with some info now. Otherwise, defer
         # potential errors to conda executable
         raise DavosError(
             "Failed to programmatically determine path to conda environment "
@@ -152,10 +157,10 @@ def get_previously_imported_pkgs(install_cmd_stdout, installer):
     # ADD DOCSTRING
     if installer == 'conda':
         raise NotImplementedError(
-            "conda-install stdout parsing not implemented yet"
+            "conda-install stdout parsing is not yet implemented"
         )
     else:
-        installed_pkg_regex = _pip_installed_pkgs_re
+        installed_pkg_regex = pip_installed_pkgs_regex
 
     matches = installed_pkg_regex.findall(install_cmd_stdout)
     if len(matches) == 0:
@@ -164,19 +169,20 @@ def get_previously_imported_pkgs(install_cmd_stdout, installer):
     # flatten and split matches to separate packages
     matches_iter = itertools.chain(*(map(str.split, matches)))
     prev_imported_pkgs = []
+    # install command's stdout contains install names (e.g.,
+    # scikit-learn), but we need import names (e.g., sklearn).
     for dist_name in matches_iter:
+        # get the install name without the version
         pkg_name = dist_name.rsplit('-', maxsplit=1)[0]
+        # use the install name to get the package distribution object
+        dist = pkg_resources.get_distribution(pkg_name)
         try:
-            dist = pkg_resources.get_distribution(pkg_name)
-        except pkg_resources.DistributionNotFound:
-            # package either either new (was not previously installed) 
-            # or an implicit namespace package that will show up in 
-            # another package's top-level names
-            continue
-
-        try:
+            # check the distribution's metadata for a file containing
+            # top-level import names. Also includes names of namespace
+            # packages (e.g. mpl_toolkits from matplotlib), if any.
             toplevel_names = dist.get_metadata('top_level.txt').split()
         except FileNotFoundError:
+            # if file doesn't exist, the import name is the install name
             toplevel_names = (pkg_name,)
 
         for name in toplevel_names:
@@ -188,8 +194,8 @@ def get_previously_imported_pkgs(install_cmd_stdout, installer):
 
 def import_name(name):
     """
-    Import and return a module, submodule, class, function, or other 
-    object given its qualified name. 
+    Import and return a module, submodule, class, function, or other
+    object given its qualified name.
 
     Parameters
     ----------
@@ -204,8 +210,8 @@ def import_name(name):
     Notes
     -----
     This is a near-exact reimplementation of
-    `IPython.utils.importstring.import_item`. The function is 
-    reimplemented here to make it available in pure Python environments 
+    `IPython.utils.importstring.import_item`. The function is
+    reimplemented here to make it available in pure Python environments
     where `IPython` may not be installed.
     """
     parts = name.rsplit('.', maxsplit=1)
@@ -217,8 +223,7 @@ def import_name(name):
             obj = getattr(module, obj_name)
         except AttributeError as e:
             raise ImportError(
-                f'No object or submodule "{obj_name}" found in module '
-                f'"{mod_name}"'
+                f'No module or object "{obj_name}" found in "{mod_name}"'
             ) from e
         return obj
     else:
@@ -380,8 +385,8 @@ def is_installed(self):
                 pkg_resources.get_distribution(full_spec)
             except pkg_resources.DistributionNotFound:
                 # noinspection PyUnresolvedReferences
-                # suppressed due to issue with importlib stubs in 
-                # typeshed repo
+                # (suppressed due to issue with importlib stubs in
+                # typeshed repo)
                 module_spec = importlib.util.find_spec(self.import_name)
                 if module_spec is None:
                     # requested package is not installed
@@ -408,14 +413,10 @@ def _conda_install_package(self):
         )
 
     def _pip_install_package(self):
-        live_stdout = self.verbosity > -3
         try:
-            stdout = run_shell_command(self.install_cmd, live_stdout=live_stdout)
+            stdout = run_shell_command(self.install_cmd)
         except CalledProcessError as e:
-            err_msg = (f"the command '{e.cmd}' returned a non-zero "
-                       f"exit code: {e.returncode}. See above output "
-                       f"for details")
-            raise InstallerError(err_msg, e)
+            raise InstallerError(e)
         # handle packages installed in non-standard locations
         install_dir = self.installer_kwargs.get('target')
         if install_dir is not None and install_dir not in sys.path:
@@ -554,7 +555,7 @@ def prompt_input(prompt, default=None, interrupt=None):
 def run_shell_command(command, live_stdout=None):
     # ADD DOCSTRING
     if live_stdout is None:
-        live_stdout = not config._suppress_stdout
+        live_stdout = not config.suppress_stdout
     if live_stdout:
         command_context = capture_stdout
     else:
@@ -575,10 +576,10 @@ def run_shell_command(command, live_stdout=None):
 
 
 def smuggle(
-        name, 
-        as_=None, 
-        installer='pip', 
-        args_str='', 
+        name,
+        as_=None,
+        installer='pip',
+        args_str='',
         installer_kwargs=None
 ):
     # ADD DOCSTRING
@@ -600,30 +601,35 @@ def smuggle(
             # already present.
             smuggled_obj = import_name(name)
         except ModuleNotFoundError as e:
-            # TODO: check for --yes (conda) and bypass if passed
-            if config._confirm_install:
-                msg = (f"package '{pkg_name}' will be installed with the "
-                       f"following command:\n\t`{onion.install_cmd}`\n"
-                       f"Proceed?")
-                install_pkg = prompt_input(msg, default='y')
-                if not install_pkg:
-                    raise e
-            else:
-                install_pkg = True
+            install_pkg = True
         else:
             install_pkg = False
     else:
         install_pkg = True
 
     if install_pkg:
+        # TODO: check for --yes (conda) and bypass if passed
+        if config.confirm_install:
+            msg = (f"package '{pkg_name}' will be installed with the "
+                   f"following command:\n\t`{onion.install_cmd}`\n"
+                   f"Proceed?")
+            confirmed = prompt_input(msg, default='y')
+            if not confirmed:
+                raise SmugglerError(
+                    f"package '{pkg_name}' not installed"
+                ) from None
         installer_stdout = onion.install_package()
-        # check whether the smuggled package and/or any installed/updated
-        # dependencies were already imported during the current runtime
-        prev_imported_pkgs = get_previously_imported_pkgs(installer_stdout, 
-                                                          onion.installer)
         # invalidate sys.meta_path module finder caches. Forces import
         # machinery to notice newly installed module
         importlib.invalidate_caches()
+        # reload pkg_resources so that just-installed package will be
+        # recognized when querying local versions
+        importlib.reload(sys.modules['pkg_resources'])
+        # check whether the smuggled package and/or any
+        # installed/updated dependencies were already imported during
+        # the current runtime
+        prev_imported_pkgs = get_previously_imported_pkgs(installer_stdout,
+                                                          onion.installer)
         # if the smuggled package was previously imported, deal with
         # it last so it's reloaded after its dependencies are in place
         try:
@@ -670,30 +676,45 @@ def smuggle(
         if any(failed_reloads):
             # packages with C extensions (e.g., numpy, pandas) cannot be
             # reloaded within an interpreter session. If the package was
-            # previously imported in the current runtime (even if not by
-            # user), warn about needing to reset runtime for changes to
-            # take effect with "RESTART RUNTIME" button in output
-            # (doesn't raise an exception, remaining code in cell still
-            # runs)
-            _display_mimetype(
-                "application/vnd.colab-display-data+json",
-                (
-                    {'pip_warning': {'packages': ', '.join(failed_reloads)}},
-                ),
-                raw=True
-            )
+            # previously imported (even if not by the user), the kernel
+            # will most likely need to be restarted for changes to take
+            # effect
+            if config.auto_rerun:
+                auto_restart_rerun(failed_reloads)
+            elif config.noninteractive:
+                # if not auto_rerun, only remaining non-interactive
+                # option is to raise error
+                msg = (
+                    "The following packages were previously imported by the "
+                    "interpreter and could not be reloaded because their C "
+                    "extensions have changed:\n\t[{', '.join(pkgs)}]\nRestart "
+                    "the kernel to use the newly installed version."
+                )
+                if config.environment != 'Colaboratory':
+                    msg = (
+                        f"{msg}\nTo make this happen automatically, set "
+                        "'davos.config.auto_rerun = True'."
+                    )
+                raise SmugglerError(msg)
+            else:
+                prompt_restart_rerun_buttons(failed_reloads)
+
         smuggled_obj = import_name(name)
-        # finally, reload pkg_resources so that just-installed package
-        # will be recognized when querying local versions for later
-        # checks
-        importlib.reload(sys.modules['pkg_resources'])
 
     # add the object name/alias to the notebook's global namespace
     if as_ is None:
+        # noinspection PyUnboundLocalVariable
+        # (false-positive warning, PyCharm doesn't parse logic fully)
         config.ipython_shell.user_ns[name] = smuggled_obj
     else:
+        # noinspection PyUnboundLocalVariable
+        # (false-positive warning, PyCharm doesn't parse logic fully)
         config.ipython_shell.user_ns[as_] = smuggled_obj
     # cache the smuggled (top-level) package by its full onion comment
     # so rerunning cells is more efficient, but any change to version,
     # source, etc. is caught
     config.smuggled[pkg_name] = onion.cache_key
+
+
+
+
diff --git a/davos/core/core.pyi b/davos/core/core.pyi
index 0fb0e8c5..bda99d24 100644
--- a/davos/core/core.pyi
+++ b/davos/core/core.pyi
@@ -1,35 +1,34 @@
 from collections.abc import Callable
 from io import StringIO
 from types import (
-    BuiltinFunctionType, 
-    FunctionType, 
-    ModuleType, 
+    BuiltinFunctionType,
+    FunctionType,
+    ModuleType,
     TracebackType
 )
 from typing import (
     Any,
     AnyStr,
-    Generic, 
+    Generic,
     Literal,
-    NoReturn, 
-    Optional, 
-    overload, 
-    TextIO, 
+    NoReturn,
+    Optional,
+    overload,
+    TextIO,
     Tuple,
-    Type, 
-    TypedDict, 
-    TypeVar, 
+    Type,
+    TypedDict,
+    TypeVar,
     Union
 )
 
 __all__: list[
     Literal[
-        'capture_stdout', 
+        'capture_stdout',
         'Onion',
         'parse_line',
-        'prompt_input', 
-        'run_shell_command', 
-        'smuggle_statement_regex'
+        'prompt_input',
+        'run_shell_command',
     ]
 ]
 
@@ -42,7 +41,7 @@ class _SingleStream(tuple[_StringOrFileIO]):
 
 class _MultiStream(Tuple[_StringOrFileIO, ...]):
     def __len__(self) -> int: ...
-    
+
 class PipInstallerKwargs(TypedDict, total=False):
     abi: str
     cache_dir: str
@@ -114,7 +113,7 @@ class capture_stdout(Generic[_S]):
 
 def check_conda() -> None: ...
 def get_previously_imported_pkgs(
-        install_cmd_stdout: str, 
+        install_cmd_stdout: str,
         installer: Literal['conda', 'pip']
 ) -> list[str]: ...
 def import_name(name: str) -> Union[ModuleType, FunctionType, BuiltinFunctionType, Any]: ...
@@ -155,9 +154,9 @@ def prompt_input(
 ) -> bool: ...
 def run_shell_command(command: str, live_stdout: Optional[bool] = ...) -> str: ...
 def smuggle(
-        name: str, 
-        as_: Optional[str] = ..., 
-        installer: Literal['conda', 'pip'] = ..., 
-        args_str: str = ..., 
+        name: str,
+        as_: Optional[str] = ...,
+        installer: Literal['conda', 'pip'] = ...,
+        args_str: str = ...,
         installer_kwargs: Optional[PipInstallerKwargs] = ...
 ) -> None: ...
diff --git a/davos/core/exceptions.py b/davos/core/exceptions.py
index 47799318..ef5483bc 100644
--- a/davos/core/exceptions.py
+++ b/davos/core/exceptions.py
@@ -13,7 +13,9 @@
 import IPython
 
 from argparse import ArgumentError
+from shutil import get_terminal_size
 from subprocess import CalledProcessError
+from textwrap import fill, indent
 
 
 class DavosError(Exception):
@@ -150,8 +152,7 @@ class for problems encountered by the installer (pip/conda) itself
     (e.g., failed to connect to internet, resolve environment, find
     package with given name, etc.)
     """
-    def __init__(self, msg, *args, output=None, stderr=None,
-                 show_stdout=None):
+    def __init__(self, *args, output=None, stderr=None, show_output=None):
         cpe_or_retcode = args[0]
         if isinstance(cpe_or_retcode, CalledProcessError):
             returncode = cpe_or_retcode.returncode
@@ -162,27 +163,30 @@ def __init__(self, msg, *args, output=None, stderr=None,
             try:
                 returncode, cmd = args
             except ValueError:
-                # TODO: raise this from the line of the constructor's
-                #  signature
                 raise TypeError(
                     "InstallerError requires, at minimum, either a "
                     "'subprocess.CalledProcessError' object or a return code "
                     "[int] and cmd [str]"
-                ) from None
+                )
         super().__init__(returncode=returncode, cmd=cmd, output=output,
                          stderr=stderr)
-        self.msg = msg
-        if show_stdout is None:
+        if show_output is None:
             from davos import config
-            if config.suppress_stdout and output is not None:
-                show_stdout = True
-            else:
-                show_stdout = False
-        if show_stdout:
-            # TODO: change this so the command's stdout is inserted as
-            #  a FrameSummary object in the stack trace at the line
-            #  where davos.run_shell_command was called
-            self.args = (*self.args, output)
+            # if stdout from installer command that raised error was
+            # suppressed, include it in the error message
+            self.show_output = config.suppress_stdout
+        else:
+            self.show_output = show_output
 
     def __str__(self):
-        return self.msg + '\n\t' + super().__str__()
+        msg = super().__str__()
+        if self.show_output and (self.output or self.stderr):
+            msg = f"{msg} See below for details."
+            textwidth = min(get_terminal_size().columns, 85)
+            if self.output:
+                text = fill(self.output, textwidth, replace_whitespace=False)
+                msg = f"{msg}\n\nstdout:\n{indent(text, '    ')}"
+            if self.stderr:
+                text = fill(self.stderr, textwidth, replace_whitespace=False)
+                msg = f"{msg}\n\nstderr:\n{indent(text, '    ')}"
+        return msg
diff --git a/davos/core/exceptions.pyi b/davos/core/exceptions.pyi
index b6b196a5..e4b49383 100644
--- a/davos/core/exceptions.pyi
+++ b/davos/core/exceptions.pyi
@@ -1,6 +1,6 @@
 from argparse import ArgumentError
 from subprocess import CalledProcessError
-from typing import Literal, Optional, Union
+from typing import Literal, Optional, overload, Union
 
 __all__: list[
     Literal[
@@ -47,13 +47,29 @@ class ParserNotImplementedError(OnionParserError, NotImplementedError): ...
 class SmugglerError(DavosError): ...
 
 class InstallerError(SmugglerError, CalledProcessError):
-    msg: str
+    show_output: Optional[bool]
+    @overload
+    def __init__(
+            self,
+            __cpe: CalledProcessError,
+            output: Optional[str] = ...,
+            stderr: Optional[str] = ...,
+            show_output: Optional[bool] = ...
+    ) -> None: ...
+    @overload
+    def __init__(
+            self,
+            __returncode: int,
+            __cmd: str,
+            output: Optional[str] = ...,
+            stderr: Optional[str] = ...,
+            show_output: Optional[bool] = ...
+    ) -> None: ...
     def __init__(
             self,
-            msg: str,
-            *args: Union[CalledProcessError, str],
+            *args: Union[CalledProcessError, tuple[int, str]],
             output: Optional[str] = ...,
             stderr: Optional[str] = ...,
-            show_stdout: Optional[bool] = ...
+            show_output: Optional[bool] = ...
     ) -> None: ...
     def __str__(self) -> str: ...
diff --git a/davos/core/regexps.py b/davos/core/regexps.py
index 3c37f25a..a443343f 100644
--- a/davos/core/regexps.py
+++ b/davos/core/regexps.py
@@ -1,10 +1,10 @@
-import re
+# ADD DOCSTRING
 
 
-__all__ = ['smuggle_statement_regex']
+__all__ = ['pip_installed_pkgs_regex', 'smuggle_statement_regex']
 
 
-_pip_installed_pkgs_re = re.compile("^Successfully installed (.*)$", re.MULTILINE)
+import re
 
 
 _name_re = r'[a-zA-Z]\w*'
@@ -17,6 +17,8 @@
     'comment_re': r'(?m:\#+.*$)'
 }
 
+pip_installed_pkgs_regex = re.compile("^Successfully installed (.*)$", re.MULTILINE)
+
 
 smuggle_statement_regex = re.compile((
     r'^\s*'                                                               # match only if statement is first non-whitespace chars
@@ -110,7 +112,7 @@
 ).format_map(_smuggle_subexprs))
 
 # Condensed, fully substituted regex:
-# ^\s*(?P<FULL_CMD>(?:smuggle +[a-zA-Z]\w*(?: *\. *[a-zA-Z]\w*)*(?: +as 
+# ^\s*(?P<FULL_CMD>(?:smuggle +[a-zA-Z]\w*(?: *\. *[a-zA-Z]\w*)*(?: +as
 # +[a-zA-Z]\w*)?(?: *, *[a-zA-Z]\w*(?: *\. *[a-zA-Z]\w*)*(?: +as +[a-zA-
 # Z]\w*)?)*(?P<SEMICOLON_SEP>(?= *; *(?:smuggle|from)))?(?(SEMICOLON_SEP
 # )|(?: *(?=\#+ *(?:pip|conda) *: *[^#\n ].+?(?= +\#| *\n| *$))(?P<ONION
diff --git a/davos/core/regexps.pyi b/davos/core/regexps.pyi
index ad642e69..64e88226 100644
--- a/davos/core/regexps.pyi
+++ b/davos/core/regexps.pyi
@@ -1,9 +1,8 @@
 from re import Pattern
 from typing import Literal, TypedDict
 
-__all__: list[Literal['smuggle_statement_regex']]
+__all__: list[Literal['pip_installed_pkgs_regex', 'smuggle_statement_regex']]
 
-_pip_installed_pkgs_re: Pattern[str]
 _name_re: Literal[r'[a-zA-Z]\w*']
 
 # noinspection PyPep8Naming
@@ -14,4 +13,5 @@ class _smuggle_subexprs(TypedDict):
     onion_re: Literal[r'\# *(?:pip|conda) *: *[^#\n ].+?(?= +\#| *\n| *$)']
     qualname_re: Literal[r'[a-zA-Z]\w*(?: *\. *[a-zA-Z]\w*)*']
 
-smuggle_statement_regex: Pattern[str]
\ No newline at end of file
+pip_installed_pkgs_regex: Pattern[str]
+smuggle_statement_regex: Pattern[str]
diff --git a/davos/implementations/__init__.py b/davos/implementations/__init__.py
index 4e550ed9..1e49e7b3 100644
--- a/davos/implementations/__init__.py
+++ b/davos/implementations/__init__.py
@@ -1,10 +1,15 @@
 # ADD DOCSTRING
 
-# TODO: THIS MODULE SHOULD CONTAIN THE CORRECT IMPLEMENTATION FOR EACH 
+# TODO: THIS MODULE SHOULD CONTAIN THE CORRECT IMPLEMENTATION FOR EACH
 #  INTERCHANGEABLE FUNCTION SO THAT OTHER FILES CAN IMPORT FROM HERE
 
 
-__all__ = ['full_parser']
+__all__ = [
+    'auto_restart_rerun',
+    'full_parser',
+    'generate_parser_func',
+    'prompt_restart_rerun_buttons'
+]
 
 
 from davos import config
@@ -16,38 +21,52 @@
 
 if import_environment == 'Python':
     from davos.implementations.python import (
-        _activate_helper, 
+        _activate_helper,
         _check_conda_avail_helper,
         _deactivate_helper,
         _run_shell_command_helper,
-        generate_parser_func
+        auto_restart_rerun,
+        generate_parser_func,
+        prompt_restart_rerun_buttons
     )
 else:
     from davos.implementations.ipython_common import (
         _check_conda_avail_helper,
-        _run_shell_command_helper, 
+        _run_shell_command_helper,
         _set_custom_showsyntaxerror,
         _showsyntaxerror_davos
     )
+
+
     _set_custom_showsyntaxerror()
+
+
     if import_environment == 'IPython>=7.0':
         from davos.implementations.ipython_post7 import (
-            _activate_helper, 
+            _activate_helper,
             _deactivate_helper,
             generate_parser_func
         )
+        from davos.implementations.jupyter import (
+            auto_restart_rerun,
+            prompt_restart_rerun_buttons
+        )
     else:
         from davos.implementations.ipython_pre7 import (
-            _activate_helper, 
+            _activate_helper,
             _deactivate_helper,
             generate_parser_func
         )
         if import_environment == 'Colaboratory':
-            # from davos.implementations.colab import ...
-            pass
+            from davos.implementations.colab import (
+                auto_restart_rerun,
+                prompt_restart_rerun_buttons
+            )
         else:
-            # from davos.implementations.ipython_pre7 import ...
-            pass
+            from davos.implementations.jupyter import (
+                auto_restart_rerun,
+                prompt_restart_rerun_buttons
+            )
 
 
 from davos.core.core import check_conda, smuggle, parse_line
@@ -60,13 +79,13 @@
 ########################################
 #  ADDITIONAL DAVOS.CONFIG PROPERTIES  #
 ########################################
-# some properties are added to the davos.core.config.DavosConfig class 
-# here rather than when it's initially defined, because they depend on 
+# some properties are added to the davos.core.config.DavosConfig class
+# here rather than when it's initially defined, because they depend on
 # either:
-#  1. an implementation-specific function that hasn't been determined 
+#  1. an implementation-specific function that hasn't been determined
 #     yet, or
-#  2. a function defined in the `davos.core.core` module (namely, 
-#     `check_conda`) which needs to be imported after 
+#  2. a function defined in the `davos.core.core` module (namely,
+#     `check_conda`) which needs to be imported after
 #     implementation-specific functions are set here.
 
 def _active_fget(conf):
@@ -102,8 +121,8 @@ def _conda_avail_fset(conf, _):
 def _conda_env_fget(conf):
     """getter for davos.config.conda_env"""
     if conf._conda_avail is None:
-        # _conda_env is None if we haven't checked conda yet *and* if 
-        # conda is not available vs _conda_avail is None only if we 
+        # _conda_env is None if we haven't checked conda yet *and* if
+        # conda is not available vs _conda_avail is None only if we
         # haven't checked yet
         check_conda()
 
@@ -122,7 +141,7 @@ def _conda_env_fset(conf, new_env):
         )
     elif new_env != conf._conda_env:
         if (
-                conf._conda_envs_dirs is not None and 
+                conf._conda_envs_dirs is not None and
                 new_env not in conf._conda_envs_dirs.keys()
         ):
             local_envs = {"', '".join(conf._conda_envs_dirs.keys())}
@@ -149,8 +168,11 @@ def _conda_envs_dirs_fset(conf, _):
 
 
 DavosConfig.active = property(fget=_active_fget, fset=_active_fset)
-DavosConfig.conda_avail = property(fget=_conda_avail_fget, 
+
+DavosConfig.conda_avail = property(fget=_conda_avail_fget,
                                    fset=_conda_avail_fset)
+
 DavosConfig.conda_env = property(fget=_conda_env_fget, fset=_conda_env_fset)
-DavosConfig.conda_envs_dirs = property(fget=_conda_envs_dirs_fget, 
+
+DavosConfig.conda_envs_dirs = property(fget=_conda_envs_dirs_fget,
                                        fset=_conda_envs_dirs_fset)
diff --git a/davos/implementations/__init__.pyi b/davos/implementations/__init__.pyi
index de9e2a52..fd00abbd 100644
--- a/davos/implementations/__init__.pyi
+++ b/davos/implementations/__init__.pyi
@@ -3,20 +3,26 @@ from typing import Any, Literal, NoReturn, Optional, Protocol, Union
 from davos.core.config import DavosConfig, IpythonShell
 from davos.core.core import PipInstallerKwargs
 from davos.implementations.ipython_post7 import IPyPost7FullParserFunc
-from davos.implementations.python import PyFullParserFunc
 
-__all__ = list[Literal['full_parser']]
+__all__ = list[
+    Literal[
+        'auto_restart_rerun',
+        'full_parser',
+        'generate_parser_func',
+        'prompt_restart_rerun_buttons'
+    ]
+]
 
 LineParserFunc = Callable[[str], str]
-FullParserFunc = Union[IPyPost7FullParserFunc, LineParserFunc, PyFullParserFunc]
+FullParserFunc = Union[IPyPost7FullParserFunc, LineParserFunc]
 
 class SmuggleFunc(Protocol):
     def __call__(
-            self, 
-            name: str, 
-            as_: Optional[str] = ..., 
-            installer: Literal['conda', 'pip'] = ..., 
-            args_str: str = ..., 
+            self,
+            name: str,
+            as_: Optional[str] = ...,
+            installer: Literal['conda', 'pip'] = ...,
+            args_str: str = ...,
             installer_kwargs: Optional[PipInstallerKwargs] = ...
     ) -> None: ...
 
@@ -25,14 +31,16 @@ _check_conda_avail_helper: Callable[[], Optional[str]]
 _deactivate_helper: Callable[[SmuggleFunc, FullParserFunc], None]
 _run_shell_command_helper: Callable[[str], None]
 _set_custom_showsyntaxerror: Callable[[], None]
-generate_parser_func: Callable[[LineParserFunc], FullParserFunc]
+auto_restart_rerun: Callable[[list[str]], NoReturn]
 full_parser: FullParserFunc
+generate_parser_func: Callable[[LineParserFunc], FullParserFunc]
+prompt_restart_rerun_buttons: Callable[[list[str]], None]
 
 class ShowSyntaxErrorDavos(Protocol):
     def __call__(
-            self, 
-            ipy_shell: IpythonShell, 
-            filename: Optional[str] = ..., 
+            self,
+            ipy_shell: IpythonShell,
+            filename: Optional[str] = ...,
             running_compiled_code: bool = ...
     ) -> None: ...
 
@@ -43,4 +51,4 @@ def _conda_avail_fset(conf: DavosConfig, _: Any) -> NoReturn: ...
 def _conda_env_fget(conf: DavosConfig) -> Optional[str]: ...
 def _conda_env_fset(conf: DavosConfig, new_env: str) -> None: ...
 def _conda_envs_dirs_fget(conf: DavosConfig) -> Optional[dict[str, str]]: ...
-def _conda_envs_dirs_fset(conf: DavosConfig, _: Any) -> NoReturn: ...
\ No newline at end of file
+def _conda_envs_dirs_fset(conf: DavosConfig, _: Any) -> NoReturn: ...
diff --git a/davos/implementations/colab.py b/davos/implementations/colab.py
index 283f8068..aeba0b83 100644
--- a/davos/implementations/colab.py
+++ b/davos/implementations/colab.py
@@ -1,7 +1,20 @@
+# ADD DOCSTRING
+
+
+__all__ = ['auto_restart_rerun', 'prompt_rerun_buttons']
+
+
 from IPython.core.display import _display_mimetype
 
 
-def display_reload_warning(pkgs):
+def auto_restart_rerun(pkgs):
+    raise NotImplementedError(
+        "automatic rerunning of cells not available in Colaboratory (this "
+        "function should not be reachable through normal use)."
+    )
+
+
+def prompt_restart_rerun_buttons(pkgs):
     _display_mimetype(
         "application/vnd.colab-display-data+json",
         (
diff --git a/davos/implementations/colab.pyi b/davos/implementations/colab.pyi
index e69de29b..79f6b01a 100644
--- a/davos/implementations/colab.pyi
+++ b/davos/implementations/colab.pyi
@@ -0,0 +1,6 @@
+from typing import Literal, NoReturn
+
+__all__ = list[Literal['auto_restart_rerun', 'prompt_restart_rerun_buttons']]
+
+def auto_restart_rerun(pkgs: list[str]) -> NoReturn: ...
+def prompt_restart_rerun_buttons(pkgs: list[str]) -> None: ...
diff --git a/davos/implementations/js_functions.py b/davos/implementations/js_functions.py
index 401532f7..16c7bec2 100644
--- a/davos/implementations/js_functions.py
+++ b/davos/implementations/js_functions.py
@@ -4,6 +4,9 @@
 __all__ = ['DotDict', 'JS_FUNCTIONS']
 
 
+from textwrap import dedent
+
+
 class DotDict(dict):
     # ADD DOCSTRING
     # simple helper that allows a dict to be accessed like a JS object
@@ -11,6 +14,7 @@ class DotDict(dict):
     __getattr__ = dict.__getitem__
     __setattr__ = dict.__setitem__
 
+    # noinspection PyMissingConstructor
     def __init__(self, d):
         # ADD DOCSTRING
         for k, v in d.items():
@@ -19,9 +23,16 @@ def __init__(self, d):
             self[k] = v
 
 
+# noinspection ThisExpressionReferencesGlobalObjectJS
+# (expressions are passed to
+# IPython.display.display(IPython.display.Javascript()), wherein `this`
+# refers to the top-level output element for the cell from which it was
+# invoked)
+# noinspection JSUnusedLocalSymbols
+# (accept JS functions defined but not called in language injection)
 JS_FUNCTIONS = DotDict({
     'jupyter': {
-        'restartRunCellsAbove': """\
+        'restartRunCellsAbove': dedent("""
             const restartRunCellsAbove = function() {
                 const outputArea = this,
                     notebook = Jupyter.notebook,
@@ -34,6 +45,12 @@ def __init__(self, d):
                     runningCellIndex = allCells.index(runningCell);
 
                 const queueCellsAndResetSelection = function() {
+                    /*
+                     * Queue all cells above plus currently running cell. 
+                     * Queueing cells unsets currently highlighted selected 
+                     * cells, so re-select highlighted cell or group of cells/
+                     */
+                    // noinspection JSCheckFunctionSignatures
                     notebook.execute_cell_range(0, runningCellIndex + 1);
                     notebook.select(anchorCellIndex);
                     if (selectedCellIndex !== anchorCellIndex) {
@@ -41,43 +58,57 @@ def __init__(self, d):
                         notebook.select(selectedCellIndex, false);
                     }
                 }
-
+                // pass queueCellsAndResetSelection as callback to run after 
+                // notebook kernel restarts and is available
                 notebook.kernel.restart(queueCellsAndResetSelection);
+            
+            // when passed to Ipython.display.display(Ipython.display.Javascript()), 
+            // "this" will be the [class=]"output" element of the cell from 
+            // which it's displayed
             }.bind(this)
-        """,
+        """),
         # TODO: don't forget to remove event listeners from all buttons
-        'displayButtonPrompt': """\
+        'displayButtonPrompt': dedent("""
             /**
-             * Display one or more buttons for user selection and block until one is clicked
-             * @param {Object[]} buttonArgs - Array of objects containing data for each 
-             *     button/option to be displayed.
-             * @param {String} [buttonArgs[].text=""] - Text label for the given button. 
-             *     Defaults to an empty string.
-             * @param {(BigInt|Boolean|null|Number|Object|String|undefined)} [buttonArgs[].result] - 
-             *     Value assigned to the "result" trait of the ipywidgets object whose 
-             *     model_id attribute is widgetModelId (if provided), if the given button is 
-             *     clicked. Used to forward user input information to Python. JS types are 
-             *     converted ty Python types, within reason (Boolean -> bool, Object -> dict, 
-             *     null/undefined -> None, etc.). If omitted, the return value of onClick 
-             *     will be used instead.
-             * @param {Function} [buttonArgs[].onClick] - Callback run when the given button is 
-             *     clicked, before the result value is set on the ipywidgets object and 
-             *     Python execution resumes. Omit the result property to use the return value 
-             *     of this function as a dynamically computed result value. 
-             *     Defaults to a noop.
-             * @param {String} [buttonArgs[].id] - Optional id for the given button element.
-             * @param {String} [widgetModelId] - The ID of the ipywidgets WidgetModel object 
-             *     whose "result" property will be assigned the result value for the clicked 
-             *     button.
+             * Display one or more buttons on the notebook frontend for user 
+             * selection and (together with kernel-side Python function) block 
+             * until one is clicked. Optionally send a per-button "result" 
+             * value to the IPython kernel's stdin socket to capture user 
+             * selection in a Python variable.
+             * 
+             * @param {Object[]} buttonArgs - Array of objects containing data 
+             *     for each button to be displayed.
+             * @param {String} [buttonArgs[].text=""] - Text label for the 
+             *     given button. Defaults to an empty string.
+             * @param {BigInt|Boolean|null|Number|Object|String|undefined} [buttonArgs[].result] - 
+             *     Value sent to the notebook kernel's stdin socket if the 
+             *     given button is clicked and sendResult is true. Used to 
+             *     forward user input information to Python. JS types are 
+             *     converted ty Python types, within reason (Boolean -> bool, 
+             *     Object -> dict, Array -> list, null -> None, 
+             *     undefined -> '', etc.). If omitted, the return value of 
+             *     onClick will be used instead.
+             * @param {Function} [buttonArgs[].onClick] - Callback executed 
+             *     when the given button is clicked, before the result value is 
+             *     sent to the IPython kernel and Python execution resumes. 
+             *     Omit the result property to use this function's return value 
+             *     as a dynamically computed result value. Defaults to a noop.
+             * @param {String} [buttonArgs[].id] - Optional id for the given 
+             *     button element.
+             * @param {Boolean} [sendResult=false] - Whether to send the result 
+             *     value to  the IPython kernel's stdin socket as simulated 
+             *     user input.
              */
-            const displayButtonPrompt = async function(buttonArgs, widgetModelId) {
-                let onClick, clickedButtonCallback, clickedButtonResult, resolutionFunc;
-                const outputArea = this,
-                    outputDisplayArea = element[0],
-                    // store resolve function in outer scope so it can be called from an 
-                    // event listener
+            const displayButtonPrompt = async function(buttonArgs, sendResult) {
+                if (typeof sendResult === 'undefined') {
+                    sendResult = false;
+                }
+                let clickedButtonCallback, clickedButtonResult, resolutionFunc;
+                const outputDisplayArea = element[0],
+                    // store resolve function in outer scope so it can be 
+                    // called from an event listener
                     callbackPromise = new Promise((resolve) => resolutionFunc = resolve );
-                
+            
                 buttonArgs.forEach(function (buttonObj, ix) {
                     let buttonElement = document.createElement('BUTTON');
                     buttonElement.style.marginLeft = '1rem';
@@ -86,42 +117,52 @@ def __init__(self, d):
                         buttonElement.id = buttonObj.id;
                     }
                     if (typeof buttonObj.text === 'undefined') {
-                        buttonElement.textContent = '';
+                        buttonElement.textContent = `Button ${ix}`;
                     } else {
                         buttonElement.textContent = buttonObj.text;
                     }
                     if (typeof buttonObj.onClick === 'undefined') {
-                        onClick = () => {};
-                    } else {
-                        onClick = buttonObj.onClick;
+                        // mutating object passed as argument isn't ideal, but 
+                        // it's an easy way to make scoping in event listener 
+                        // execution work, and should be pretty harmless since 
+                        // this is internal use only
+                        buttonObj.onClick = () => {};
                     }
                     buttonElement.addEventListener('click', () => {
-                        // store clicked button's callback & result, resolve awaited promise
-                        clickedButtonCallback = onClick;
+                        // store clicked button's callback & result
+                        clickedButtonCallback = buttonObj.onClick;
                         clickedButtonResult = buttonObj.result;
+                        // resolve callbackPromise when any button is clicked
                         resolutionFunc();
                     });
                     outputDisplayArea.appendChild(buttonElement);
                 })
-                // attach handler to run when promise is fulfilled, pause until button is clicked
+                
+                // attach handler to run when promise is fulfilled, await 
+                // callbackPromise resolution (any button clicked)
                 await callbackPromise.then(() => {
-                    // TODO: make sure this doesn't remove the widget model before we need it below
+                    // remove element in output area containing buttons
                     outputDisplayArea.remove();
+                    // execute clicked button's callback, store return value 
+                    // (if any)
                     const CbReturnVal = clickedButtonCallback();
                     
-                    if (typeof widgetModelId !== 'undefined') {
+                    if (sendResult === true) {
                         if (typeof clickedButtonResult === 'undefined') {
+                            // if result should be sent to IPython kernel and 
+                            // button's 'result' property was not specified, 
+                            // use return value of button's onClick callback
                             clickedButtonResult = CbReturnVal;
                         }
-                        const widgetManager = Jupyter.WidgetManager._managers[0],
-                            widgetModel = widgetManager.get_model(widgetModelId);
-                        widgetModel.then((model) => {
-                            model.set('result', clickedButtonResult);
-                            model.save_changes();
-                        })
+                        // send result value to IPython kernel's stdin 
+                        Jupyter.notebook.kernel.send_input_reply(clickedButtonResult);
                     }
                 })
+            
+            // when passed to Ipython.display.display(Ipython.display.Javascript()), 
+            // "this" will be the [class=]"output" element of the cell from 
+            // which it's displayed
             }.bind(this)
-        """
+        """)    # noqa: E124
     }
 })
diff --git a/davos/implementations/jupyter.py b/davos/implementations/jupyter.py
new file mode 100644
index 00000000..3e35b6b0
--- /dev/null
+++ b/davos/implementations/jupyter.py
@@ -0,0 +1,148 @@
+# ADD DOCSTRING
+
+
+__all__ = ['auto_restart_rerun', 'prompt_restart_rerun_buttons']
+
+
+import sys
+import time
+from textwrap import dedent
+
+import ipykernel
+import zmq
+from IPython.display import display, Javascript
+from ipython_genutils import py3compat
+
+from davos import config
+from davos.implementations.js_functions import JS_FUNCTIONS
+
+
+def auto_restart_rerun(pkgs):
+    # ADD DOCSTRING
+    msg = (
+        "Restarting kernel and rerunning cells (required to smuggle "
+        f"{', '.join(pkgs)})..."
+    )
+
+    js_full = dedent(f"""
+        {JS_FUNCTIONS.jupyter.restartRunCellsAbove};
+        console.log('restartRunCellsAbove defined');
+
+        console.log(`{msg}`);
+        restartRunCellsAbove();
+    """)
+
+    if not config.suppress_stdout:
+        print(f"\033[0;31;1m{msg}\033[0m")
+
+    # flush output before creating display
+    sys.stdout.flush()
+    sys.stderr.flush()
+
+    # noinspection PyTypeChecker
+    display(Javascript(js_full))
+    # block execution for clarity -- kernel restart can sometimes take a
+    # few seconds to trigger, so prevent any queued code from running in
+    # the interim in case it has effects that persist across kernel
+    # sessions
+    while True:
+        time.sleep(10)
+
+
+def prompt_restart_rerun_buttons(pkgs):
+    # ADD DOCSTRING
+    msg = (
+        "WARNING: The following packages were previously imported by the "
+        "interpreter and could not be reloaded because their C extensions "
+        f"have changed:\n\t[{', '.join(pkgs)}]\nRestart the kernel to use "
+        "the newly installed version."
+    )
+
+    # noinspection JSUnusedLocalSymbols,JSUnresolvedFunction
+    button_args = dedent("""
+        const buttonArgs = [
+            {
+                text: 'Restart Kernel and Rerun Cells',
+                onClick: () => {restartRunCellsAbove();},
+            },
+            {
+                text: 'Continue Running',
+                result: null,
+            },
+        ]
+    """)
+    display_button_prompt_full = dedent(f"""
+        {JS_FUNCTIONS.jupyter.restartRunCellsAbove};
+        console.log('restartRunCellsAbove defined');
+        
+        {JS_FUNCTIONS.jupyter.displayButtonPrompt};
+        console.log('displayButtonPrompt defined');
+        
+        {button_args};
+        console.warn(`{msg}`);
+        displayButtonPrompt(buttonArgs, true);
+    """)
+
+    # noinspection PyUnresolvedReferences
+    # (function exists globally when imported into IPython context)
+    kernel = get_ipython().kernel
+    stdin_sock = kernel.stdin_socket
+
+    print(f"\033[0;31;1m{msg}\033[0m")
+
+    # flush output before creating button display
+    sys.stdout.flush()
+    sys.stderr.flush()
+
+    # flush ipykernel stdin socket to purge stale replies
+    while True:
+        try:
+            # noinspection PyUnresolvedReferences
+            # (dynamically imported names not included in stub files)
+            stdin_sock.recv_multipart(zmq.NOBLOCK)
+        except zmq.ZMQError as e:
+            # noinspection PyUnresolvedReferences
+            # (dynamically imported names not included in stub files)
+            if e.errno == zmq.EAGAIN:
+                break
+            else:
+                raise
+
+    # noinspection PyTypeChecker
+    display(Javascript(display_button_prompt_full))
+
+    while True:
+        try:
+            # zmq.select args:
+            #   - sockets/FDs to be polled for read events
+            #   - sockets/FDs to be polled for write events
+            #   - sockets/FDs to be polled for error events
+            #   - timeout (in seconds; None implies no timeout)
+            # https://github.com/zeromq/pyzmq/blob/c02e8a1094be8d817020af221a283176ff22eed5/zmq/sugar/poll.py#L108
+            # ('main' branch as of 7/21/2021)
+            rlist, _, xlist = zmq.select([stdin_sock], [], [stdin_sock], 0.01)
+            if rlist or xlist:
+                ident, reply = kernel.session.recv(stdin_sock)
+                if ident is not None or reply is not None:
+                    break
+        except KeyboardInterrupt:
+            # re-raise KeyboardInterrupt with simplified traceback
+            # (excludes some convoluted calls to internal IPython/zmq
+            # machinery)
+            raise KeyboardInterrupt("Interrupted by user") from None
+
+    # noinspection PyBroadException
+    try:
+        value = py3compat.unicode_to_str(reply['content']['value'])
+    except Exception:
+        if ipykernel.version_info[0] >= 6:
+            _parent_header = kernel._parent_ident['shell']
+        else:
+            _parent_header = kernel._parent_ident
+        kernel.log.error(f"Bad input_reply: {_parent_header}")
+        value = ''
+
+    if value == '\x04':
+        # end of transmission
+        raise EOFError
+    return value
diff --git a/davos/implementations/jupyter.pyi b/davos/implementations/jupyter.pyi
new file mode 100644
index 00000000..fbd79091
--- /dev/null
+++ b/davos/implementations/jupyter.pyi
@@ -0,0 +1,6 @@
+from typing import Literal, NoReturn, Union
+
+__all__ = list[Literal['auto_restart_rerun', 'prompt_restart_rerun_buttons']]
+
+def auto_restart_rerun(pkgs: list[str]) -> NoReturn: ...
+def prompt_restart_rerun_buttons(pkgs: list[str]) -> None: ...
diff --git a/davos/implementations/python.py b/davos/implementations/python.py
index ce63ba6e..2ba90ee8 100644
--- a/davos/implementations/python.py
+++ b/davos/implementations/python.py
@@ -1,7 +1,11 @@
 # ADD DOCSTRING
 
 
-# TODO: add __all__
+__all__ = [
+    'auto_restart_rerun',
+    'generate_parser_func',
+    'prompt_restart_rerun_buttons'
+]
 
 
 import locale
@@ -13,21 +17,22 @@
 from subprocess import CalledProcessError, PIPE, Popen
 
 
-def _activate_helper(smuggle_func, parser_func): 
-    # TODO: implement me
-    ...
+def _activate_helper(smuggle_func, parser_func):
+    raise NotImplementedError(
+        "davos does not yet support non-interactive Python environments"
+    )
 
 
 def _check_conda_avail_helper():
     """
-    Pure Python implementation of helper function for 
-    `davos.core.core.check_conda`. Checks whether conda executable is 
+    Pure Python implementation of helper function for
+    `davos.core.core.check_conda`. Checks whether conda executable is
     available for use
     """
     try:
         with redirect_stdout(StringIO()) as conda_list_output:
-            # using `conda list` instead of a more straightforward 
-            # command so stdout is formatted the same as the IPython 
+            # using `conda list` instead of a more straightforward
+            # command so stdout is formatted the same as the IPython
             # implementation (which must use `conda list`)
             _run_shell_command_helper('conda list Python')
     except CalledProcessError:
@@ -36,8 +41,9 @@ def _check_conda_avail_helper():
 
 
 def _deactivate_helper(smuggle_func, parser_func):
-    # TODO: implement me
-    ...
+    raise NotImplementedError(
+        "davos does not yet support non-interactive Python environments"
+    )
 
 
 def _run_shell_command_helper(command):
@@ -61,11 +67,21 @@ def _run_shell_command_helper(command):
         raise
 
 
+def auto_restart_rerun(pkgs):
+    raise NotImplementedError(
+        "automatic rerunning not available in non-interactive Python (this "
+        "function should not be reachable through normal use)."
+    )
+
+
 def generate_parser_func(line_parser):
-    # ADD DOCSTRING
-    # TODO: implement me
-    def full_parser(lines):
-        # TODO: implement me
-        return lines
+    raise NotImplementedError(
+        "davos does not yet support non-interactive Python environments"
+    )
+
 
-    return full_parser
+def prompt_restart_rerun_buttons(pkgs):
+    raise NotImplementedError(
+        "button-based user input prompts are not available in non-interactive "
+        "Python (this function should not be reachable through normal use)."
+    )
diff --git a/davos/implementations/python.pyi b/davos/implementations/python.pyi
index 51414887..9590546d 100644
--- a/davos/implementations/python.pyi
+++ b/davos/implementations/python.pyi
@@ -1,13 +1,17 @@
-from collections.abc import Callable
-from typing import Literal, Optional
+from typing import Literal, NoReturn, Optional
 from davos.implementations import LineParserFunc, SmuggleFunc
 
-__all__ = list[Literal['generate_parser_func']]
+__all__ = list[
+    Literal[
+        'auto_restart_rerun',
+        'generate_parser_func',
+        'prompt_restart_rerun_buttons'
+    ]
+]
 
-PyFullParserFunc = Callable[[list[str]], list[str]]
-
-def _activate_helper(smuggle_func: SmuggleFunc, parser_func: LineParserFunc) -> None: ...
+def _activate_helper(smuggle_func: SmuggleFunc, parser_func: LineParserFunc) -> NoReturn: ...
 def _check_conda_avail_helper() -> Optional[str]: ...
-def _deactivate_helper(smuggle_func: SmuggleFunc, parser_func: LineParserFunc) -> None: ...
+def _deactivate_helper(smuggle_func: SmuggleFunc, parser_func: LineParserFunc) -> NoReturn: ...
 def _run_shell_command_helper(command: str) -> None: ...
-def generate_parser_func(line_parser: LineParserFunc) -> PyFullParserFunc: ...
\ No newline at end of file
+def auto_restart_rerun(pkgs: list[str]) -> NoReturn: ...
+def generate_parser_func(line_parser: LineParserFunc) -> NoReturn: ...


Test patch:
diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml
index 4ae2becf..ad131573 100644
--- a/.github/workflows/ci-tests.yml
+++ b/.github/workflows/ci-tests.yml
@@ -14,8 +14,8 @@ on:
       - '.gitattributes'
       - 'LICENSE'
       - 'README.md'
-  workflow_dispatch: 
-    inputs: 
+  workflow_dispatch:
+    inputs:
       debug_enabled:
         description: 'Pause before tests for tmate debugging'
         required: false
@@ -66,14 +66,16 @@ jobs:
           - notebook-type: colab
             python-version: 3.7       # I/Python versions are arbitrary for Colab-based
             ipython-version: 5.5.0    # tests. Chosen to denote versions Colab uses
+            experimental: true
     env:
-      NOTEBOOK_TYPE: ${{ matrix.notebook-type }}
-      PYTHON_VERSION: ${{ matrix.python-version }}
-      IPYTHON_VERSION: ${{ matrix.ipython-version }}
+      ARTIFACTS_DIR: ${{ github.workspace }}/artifacts
       GMAIL_ADDRESS: ${{ secrets.DAVOS_GMAIL_ADDRESS }}
       GMAIL_PASSWORD: ${{ secrets.DAVOS_GMAIL_PASSWORD }}
-      RECOVERY_GMAIL_ADDRESS: ${{ secrets.DAVOS_RECOVERY_GMAIL_ADDRESS }}
       HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
+      IPYTHON_VERSION: ${{ matrix.ipython-version }}
+      NOTEBOOK_TYPE: ${{ matrix.notebook-type }}
+      PYTHON_VERSION: ${{ matrix.python-version }}
+      RECOVERY_GMAIL_ADDRESS: ${{ secrets.DAVOS_RECOVERY_GMAIL_ADDRESS }}
     steps:
       - uses: actions/checkout@v2
 
@@ -88,28 +90,28 @@ jobs:
         run: |
           # install Python 3.9 (used to run notebooks via selenium, not the tests themselves)
           conda install python=3.9
-          
+
           # install Firefox browser
           sudo apt-get install firefox
-          
+
           # install python packages
           pip install pytest==6.2 "selenium>=3.141" geckodriver-autoinstaller
-          
+
           # install geckodriver
           driver_path=$(python -c '
-          
+
           import shutil
           from pathlib import Path
-          
+
           import geckodriver_autoinstaller
-          
+
           driver_src = Path(geckodriver_autoinstaller.install(cwd=True))
           driver_dest = driver_src.rename(driver_src.parents[1].joinpath(driver_src.name))
           driver_src.parent.rmdir()
           print(driver_dest)
-          
+
           ')
-          
+
           # export path to driver as environment variable
           echo "DRIVER_PATH=$driver_path" >> $GITHUB_ENV
 
@@ -118,9 +120,9 @@ jobs:
         run: |
           # install jupyter notebooks application in base environment
           pip install notebook "ipykernel==5.0.0"
-          
+
           # create kernel environment
-          conda create -n kernel-env python=$PYTHON_VERSION          
+          conda create -n kernel-env python=$PYTHON_VERSION
 
           # install davos & various test requirements in kernel environment
           conda activate kernel-env
@@ -138,7 +140,7 @@ jobs:
           # make environment available as a jupyter kernel
           python -m ipykernel install --prefix=/usr/share/miniconda --name=kernel-env
           conda deactivate
-          
+
       - name: launch Jupyter server
         if: matrix.notebook-type == 'jupyter'
         run: jupyter notebook --no-browser --port=8888 --NotebookApp.token= &
@@ -151,11 +153,13 @@ jobs:
         uses: mxschmitt/action-tmate@v3
 
       - name: run pytest
+        id: run-pytest
+        continue-on-error: ${{ matrix.experimental }}
         run: pytest -sv tests/
 
-      - name: upload selenium error artifact
-        if: env.ERROR_PAGE_SOURCE != ''
+      - name: upload selenium error artifacts
+        if: matrix.experimental
         uses: actions/upload-artifact@v2
         with:
-          name: error-page-source
-          path: ${{ env.ERROR_PAGE_SOURCE }}
+          path: ${{ env.ARTIFACTS_DIR }}
+          if-no-files-found: ignore
diff --git a/tests/conftest.py b/tests/conftest.py
index 3ac1621b..aaee71a1 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -141,6 +141,43 @@ def __init__(self, url: str) -> None:
         )
         self.driver.get(url)
 
+    def capture_error_artifacts(
+            self,
+            page_source: bool = True,
+            screenshot: bool = True
+    ) -> Optional[tuple[Optional[Path]]]:
+        if not (page_source or screenshot):
+            return None
+        artifacts_dir = Path(getenv('ARTIFACTS_DIR')).resolve()
+        if not artifacts_dir.is_dir():
+            artifacts_dir.mkdir()
+            # set_output_cmd = f"::set-output name=artifacts_exist::true"
+            # print(set_output_cmd)
+        artifact_n = 1
+        while True:
+            page_src_fname = f'page_source_at_error_{artifact_n}.html'
+            page_src_fpath = artifacts_dir.joinpath(page_src_fname)
+            screenshot_fname = f'screenshot_at_error_{artifact_n}.png'
+            screenshot_fpath = artifacts_dir.joinpath(screenshot_fname)
+            if page_src_fpath.is_file() or screenshot_fpath.is_file():
+                artifact_n += 1
+                continue
+            break
+        centered_msg = f"ARTIFACT {artifact_n} CREATED HERE".center(100, ' ')
+        print(f"\n\n{'='*100}\n{centered_msg}\n{'='*100}\n\n")
+        return_val = []
+        if page_source:
+            page_src_fpath.write_text(self.driver.page_source)
+            return_val.append(page_src_fpath)
+        if screenshot:
+            success = self.driver.save_screenshot(str(screenshot_fpath))
+            # False if writing file raised IOError, else True
+            if success:
+                return_val.append(screenshot_fpath)
+            else:
+                return_val.append(None)
+        return tuple(return_val)
+
     # noinspection PyCompatibility
     def click(
             self,
@@ -184,7 +221,7 @@ def get_test_result(self, func_name: str) -> list[str]:
         result_element = self.driver.find_element_by_id(f"{func_name}_result")
         result: list[str] = result_element.text.split(maxsplit=2)[1:]
         if len(result) == 0:
-            # managed to read element text in the moment between when it 
+            # managed to read element text in the moment between when it
             # was created and when it was populated
             time.sleep(3)
             result_element = self.driver.find_element_by_id(f"{func_name}_result")
@@ -192,7 +229,7 @@ def get_test_result(self, func_name: str) -> list[str]:
         return result
 
     def get_test_runner_cell(self) -> WebElement:
-        # TODO: make this more robust --  maybe loop in reverse over 
+        # TODO: make this more robust --  maybe loop in reverse over
         #  cells and find the one whose text == "run_cell()"
         return self.driver.find_elements_by_class_name("cell")[-1]
 
@@ -207,8 +244,15 @@ class ColabDriver(NotebookDriver):
     def __init__(self, notebook_path: Union[Path, str]) -> None:
         url = f"https://colab.research.google.com/github/{GITHUB_USERNAME}/davos/blob/{GITHUB_REF}/{notebook_path}"
         super().__init__(url=url)
-        self.sign_in_google()
-        self.factory_reset_runtime()
+        try:
+            self.sign_in_google()
+            self.factory_reset_runtime()
+        except WebDriverException as e:
+            self.capture_error_artifacts()
+            pytest.exit(
+                "Failed to either sign into Google account or reset notebook "
+                "runtime. Error artifacts will be uploaded"
+            )
 
     def clear_all_outputs(self):
         self.driver.execute_script("colab.global.notebook.clearAllOutputs()")
@@ -252,10 +296,14 @@ def sign_in_google(self) -> None:
         if self.driver.current_url != self.url:
             # handle additional "verify it's you" page
             self.click(
-                "#view_container > div > div > div.pwWryf.bxPAYd > div > div.WEQkZc > div > form > span > section > div > div > div > ul > li:nth-child(2) > div"
+                "#view_container > div > div > div.pwWryf.bxPAYd > div > "
+                "div.WEQkZc > div > form > span > section > div > div > div > "
+                "ul > li:nth-child(1) > div"
             )
             time.sleep(3)
-            recovery_email_input_box = self.driver.find_element_by_id('knowledge-preregistered-email-response')
+            recovery_email_input_box = self.driver.find_element_by_id(
+                'knowledge-preregistered-email-response'
+            )
             recovery_email_input_box.send_keys(getenv('RECOVERY_GMAIL_ADDRESS'))
             self.click("button[jsname='LgbsSe']")
             time.sleep(3)
@@ -268,24 +316,11 @@ def run_all_cells(self, pre_approved: bool = False) -> None:
         keyboard_shortcut.perform()
         if not pre_approved:
             # approve notebook not authored by Google
-            # (button takes a second to become clickable, seems to be 
-            # obscured by other element, raises 
+            # (button takes a second to become clickable, seems to be
+            # obscured by other element, raises
             # ElementClickInterceptedException)
             time.sleep(3)
-            try:
-                self.driver.find_element_by_id("ok").click()
-            except WebDriverException as e:
-                # write source of current page to file
-                page_src_path = Path('page_at_error.html').resolve()
-                page_src_path.write_text(self.driver.page_source)
-                # export path to file as environment variable
-                with open(getenv('GITHUB_ENV'), 'a') as f:
-                    f.write(f"\nERROR_PAGE_SOURCE={page_src_path}")
-                # raise exception and show URL
-                raise WebDriverException(
-                    f"Error on page: {self.driver.current_url}. Page source "
-                    f"in {page_src_path} will be uploaded as build artifact"
-                ) from e
+            self.driver.find_element_by_id("ok").click()
 
     def wait_for_test_start(self) -> None:
         test_runner_cell = self.get_test_runner_cell()
@@ -328,7 +363,7 @@ def __init__(
         url = f"http://{ip}:{port}/notebooks/{notebook_path}"
         super().__init__(url=url)
         self.clear_all_outputs()
-    
+
     def clear_all_outputs(self):
         # takes a moment for Jupyter.notebook JS object to be defined
         time.sleep(5)
@@ -444,8 +479,8 @@ def setup(self) -> None:
         self.driver.run_all_cells()
         self.driver.wait_for_test_start()
         # driver will wait for up to 5 minutes for each test to complete
-        # and its result to become readable. Upper limit for *driver* 
-        # timeout is intentionally high so individual *tests* can 
+        # and its result to become readable. Upper limit for *driver*
+        # timeout is intentionally high so individual *tests* can
         # specify pytest-style timeouts via the @mark.timeout()
         # decorator in the notebook
         self.driver.set_max_timeout(300)
diff --git a/tests/test_core.ipynb b/tests/test_core.ipynb
index 927a0cf1..5b253619 100644
--- a/tests/test_core.ipynb
+++ b/tests/test_core.ipynb
@@ -5,8 +5,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:28.787464Z",
-     "start_time": "2021-07-13T06:12:28.781073Z"
+     "end_time": "2021-07-21T23:36:14.827895Z",
+     "start_time": "2021-07-21T23:36:14.819055Z"
     }
    },
    "outputs": [],
@@ -23,8 +23,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:28.872691Z",
-     "start_time": "2021-07-13T06:12:28.790154Z"
+     "end_time": "2021-07-21T23:36:14.896413Z",
+     "start_time": "2021-07-21T23:36:14.830609Z"
     }
    },
    "outputs": [],
@@ -52,8 +52,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.001014Z",
-     "start_time": "2021-07-13T06:12:28.874753Z"
+     "end_time": "2021-07-21T23:36:15.016165Z",
+     "start_time": "2021-07-21T23:36:14.899449Z"
     }
    },
    "outputs": [],
@@ -71,7 +71,6 @@
     "import davos\n",
     "import IPython\n",
     "import pkg_resources\n",
-    "import tqdm\n",
     "from IPython.utils.io import capture_output as capture_ipython_display\n",
     "\n",
     "from utils import (\n",
@@ -91,8 +90,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.007702Z",
-     "start_time": "2021-07-13T06:12:29.003811Z"
+     "end_time": "2021-07-21T23:36:15.021856Z",
+     "start_time": "2021-07-21T23:36:15.018321Z"
     },
     "code_folding": []
    },
@@ -122,8 +121,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.015822Z",
-     "start_time": "2021-07-13T06:12:29.009772Z"
+     "end_time": "2021-07-21T23:36:15.030402Z",
+     "start_time": "2021-07-21T23:36:15.023535Z"
     }
    },
    "outputs": [],
@@ -177,8 +176,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.021212Z",
-     "start_time": "2021-07-13T06:12:29.017583Z"
+     "end_time": "2021-07-21T23:36:15.036456Z",
+     "start_time": "2021-07-21T23:36:15.032537Z"
     }
    },
    "outputs": [],
@@ -206,8 +205,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.026170Z",
-     "start_time": "2021-07-13T06:12:29.023074Z"
+     "end_time": "2021-07-21T23:36:15.041990Z",
+     "start_time": "2021-07-21T23:36:15.038641Z"
     }
    },
    "outputs": [],
@@ -229,8 +228,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.032757Z",
-     "start_time": "2021-07-13T06:12:29.029666Z"
+     "end_time": "2021-07-21T23:36:15.048789Z",
+     "start_time": "2021-07-21T23:36:15.045510Z"
     }
    },
    "outputs": [],
@@ -249,8 +248,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.042320Z",
-     "start_time": "2021-07-13T06:12:29.035405Z"
+     "end_time": "2021-07-21T23:36:15.059438Z",
+     "start_time": "2021-07-21T23:36:15.051624Z"
     }
    },
    "outputs": [],
@@ -321,8 +320,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.050559Z",
-     "start_time": "2021-07-13T06:12:29.044156Z"
+     "end_time": "2021-07-21T23:36:15.069679Z",
+     "start_time": "2021-07-21T23:36:15.061505Z"
     }
    },
    "outputs": [],
@@ -380,8 +379,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.058913Z",
-     "start_time": "2021-07-13T06:12:29.052496Z"
+     "end_time": "2021-07-21T23:36:15.079884Z",
+     "start_time": "2021-07-21T23:36:15.071916Z"
     }
    },
    "outputs": [],
@@ -445,8 +444,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.065283Z",
-     "start_time": "2021-07-13T06:12:29.061218Z"
+     "end_time": "2021-07-21T23:36:15.085553Z",
+     "start_time": "2021-07-21T23:36:15.081974Z"
     }
    },
    "outputs": [],
@@ -454,14 +453,14 @@
     "def test_get_previously_imported_pkgs():\n",
     "    \"\"\"\n",
     "    should return names of packages imported during this interpreter \n",
-    "    session, whether from within the current notebook (davos, requests)\n",
-    "    or elsewhere (IPython, urllib3), but ignore all other names \n",
-    "    (scikit-learn, selenium). Uses mock 'pip-install'-like output to set \n",
+    "    session, whether from within the current notebook (davos, IPython, \n",
+    "    requests) or elsewhere (urllib3), but ignore all other names \n",
+    "    (fastdtw, tqdm). Uses mock 'pip-install'-like output to set \n",
     "    up expected results. Tests against real output in \n",
     "    'test_regexps.ipynb'.\n",
     "    \"\"\"\n",
     "    mock_stdout = (\"Successfully installed davos-0.0.0 IPython-1.1.1 \"\n",
-    "              \"requests-2.2.2 scikit-learn-3.3.3 selenium-4.4.4 urllib3-5.5.5\")\n",
+    "              \"requests-2.2.2 fastdtw-0.3.3 tqdm-4.4.4 urllib3-5.5.5\")\n",
     "    expected = ['davos', 'IPython', 'requests', 'urllib3']\n",
     "    result = davos.core.core.get_previously_imported_pkgs(mock_stdout, 'pip')\n",
     "    assert result == expected, f\"Expected:\\n'{expected}'\\nFound:\\n'{result}'\""
@@ -472,8 +471,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.072419Z",
-     "start_time": "2021-07-13T06:12:29.068021Z"
+     "end_time": "2021-07-21T23:36:15.090549Z",
+     "start_time": "2021-07-21T23:36:15.087694Z"
     }
    },
    "outputs": [],
@@ -489,8 +488,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.080889Z",
-     "start_time": "2021-07-13T06:12:29.075080Z"
+     "end_time": "2021-07-21T23:36:15.097116Z",
+     "start_time": "2021-07-21T23:36:15.092533Z"
     }
    },
    "outputs": [],
@@ -518,8 +517,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.090659Z",
-     "start_time": "2021-07-13T06:12:29.084482Z"
+     "end_time": "2021-07-21T23:36:15.104894Z",
+     "start_time": "2021-07-21T23:36:15.099724Z"
     }
    },
    "outputs": [],
@@ -548,8 +547,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.097951Z",
-     "start_time": "2021-07-13T06:12:29.092914Z"
+     "end_time": "2021-07-21T23:36:15.115816Z",
+     "start_time": "2021-07-21T23:36:15.107966Z"
     }
    },
    "outputs": [],
@@ -579,8 +578,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.103925Z",
-     "start_time": "2021-07-13T06:12:29.100463Z"
+     "end_time": "2021-07-21T23:36:15.122621Z",
+     "start_time": "2021-07-21T23:36:15.118201Z"
     }
    },
    "outputs": [],
@@ -598,8 +597,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.109872Z",
-     "start_time": "2021-07-13T06:12:29.106110Z"
+     "end_time": "2021-07-21T23:36:15.128037Z",
+     "start_time": "2021-07-21T23:36:15.124863Z"
     }
    },
    "outputs": [],
@@ -616,8 +615,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.115252Z",
-     "start_time": "2021-07-13T06:12:29.112314Z"
+     "end_time": "2021-07-21T23:36:15.134846Z",
+     "start_time": "2021-07-21T23:36:15.130599Z"
     }
    },
    "outputs": [],
@@ -634,8 +633,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.122088Z",
-     "start_time": "2021-07-13T06:12:29.117427Z"
+     "end_time": "2021-07-21T23:36:15.140842Z",
+     "start_time": "2021-07-21T23:36:15.137237Z"
     }
    },
    "outputs": [],
@@ -653,8 +652,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.127494Z",
-     "start_time": "2021-07-13T06:12:29.124526Z"
+     "end_time": "2021-07-21T23:36:15.146587Z",
+     "start_time": "2021-07-21T23:36:15.143347Z"
     }
    },
    "outputs": [],
@@ -672,8 +671,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.132560Z",
-     "start_time": "2021-07-13T06:12:29.129318Z"
+     "end_time": "2021-07-21T23:36:15.152101Z",
+     "start_time": "2021-07-21T23:36:15.148571Z"
     }
    },
    "outputs": [],
@@ -693,8 +692,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.142103Z",
-     "start_time": "2021-07-13T06:12:29.138918Z"
+     "end_time": "2021-07-21T23:36:15.165040Z",
+     "start_time": "2021-07-21T23:36:15.160398Z"
     }
    },
    "outputs": [],
@@ -715,8 +714,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.155005Z",
-     "start_time": "2021-07-13T06:12:29.146533Z"
+     "end_time": "2021-07-21T23:36:15.181033Z",
+     "start_time": "2021-07-21T23:36:15.171680Z"
     }
    },
    "outputs": [],
@@ -750,8 +749,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.166625Z",
-     "start_time": "2021-07-13T06:12:29.157124Z"
+     "end_time": "2021-07-21T23:36:15.194840Z",
+     "start_time": "2021-07-21T23:36:15.183258Z"
     }
    },
    "outputs": [],
@@ -792,8 +791,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.178955Z",
-     "start_time": "2021-07-13T06:12:29.168645Z"
+     "end_time": "2021-07-21T23:36:15.207819Z",
+     "start_time": "2021-07-21T23:36:15.196871Z"
     }
    },
    "outputs": [],
@@ -833,8 +832,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.184098Z",
-     "start_time": "2021-07-13T06:12:29.181245Z"
+     "end_time": "2021-07-21T23:36:15.214532Z",
+     "start_time": "2021-07-21T23:36:15.210439Z"
     }
    },
    "outputs": [],
@@ -850,8 +849,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.188737Z",
-     "start_time": "2021-07-13T06:12:29.185778Z"
+     "end_time": "2021-07-21T23:36:15.220797Z",
+     "start_time": "2021-07-21T23:36:15.217071Z"
     }
    },
    "outputs": [],
@@ -867,8 +866,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.200754Z",
-     "start_time": "2021-07-13T06:12:29.190694Z"
+     "end_time": "2021-07-21T23:36:15.236140Z",
+     "start_time": "2021-07-21T23:36:15.223628Z"
     }
    },
    "outputs": [],
@@ -910,8 +909,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.212379Z",
-     "start_time": "2021-07-13T06:12:29.202531Z"
+     "end_time": "2021-07-21T23:36:15.251854Z",
+     "start_time": "2021-07-21T23:36:15.238630Z"
     }
    },
    "outputs": [],
@@ -954,8 +953,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.224600Z",
-     "start_time": "2021-07-13T06:12:29.214404Z"
+     "end_time": "2021-07-21T23:36:15.267486Z",
+     "start_time": "2021-07-21T23:36:15.254087Z"
     }
    },
    "outputs": [],
@@ -998,8 +997,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.237325Z",
-     "start_time": "2021-07-13T06:12:29.226828Z"
+     "end_time": "2021-07-21T23:36:15.284556Z",
+     "start_time": "2021-07-21T23:36:15.270481Z"
     }
    },
    "outputs": [],
@@ -1043,8 +1042,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.287911Z",
-     "start_time": "2021-07-13T06:12:29.240155Z"
+     "end_time": "2021-07-21T23:36:15.300473Z",
+     "start_time": "2021-07-21T23:36:15.286833Z"
     }
    },
    "outputs": [],
@@ -1086,8 +1085,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.300786Z",
-     "start_time": "2021-07-13T06:12:29.290048Z"
+     "end_time": "2021-07-21T23:36:15.316198Z",
+     "start_time": "2021-07-21T23:36:15.302721Z"
     }
    },
    "outputs": [],
@@ -1130,8 +1129,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.311827Z",
-     "start_time": "2021-07-13T06:12:29.302166Z"
+     "end_time": "2021-07-21T23:36:15.332404Z",
+     "start_time": "2021-07-21T23:36:15.318520Z"
     }
    },
    "outputs": [],
@@ -1175,8 +1174,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.317213Z",
-     "start_time": "2021-07-13T06:12:29.313743Z"
+     "end_time": "2021-07-21T23:36:15.338601Z",
+     "start_time": "2021-07-21T23:36:15.334869Z"
     }
    },
    "outputs": [],
@@ -1199,8 +1198,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.323321Z",
-     "start_time": "2021-07-13T06:12:29.318921Z"
+     "end_time": "2021-07-21T23:36:15.345869Z",
+     "start_time": "2021-07-21T23:36:15.340741Z"
     }
    },
    "outputs": [],
@@ -1234,8 +1233,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.329468Z",
-     "start_time": "2021-07-13T06:12:29.325258Z"
+     "end_time": "2021-07-21T23:36:15.354092Z",
+     "start_time": "2021-07-21T23:36:15.348617Z"
     }
    },
    "outputs": [],
@@ -1268,8 +1267,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.334614Z",
-     "start_time": "2021-07-13T06:12:29.331212Z"
+     "end_time": "2021-07-21T23:36:15.360263Z",
+     "start_time": "2021-07-21T23:36:15.356146Z"
     }
    },
    "outputs": [],
@@ -1298,8 +1297,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.341353Z",
-     "start_time": "2021-07-13T06:12:29.336659Z"
+     "end_time": "2021-07-21T23:36:15.366651Z",
+     "start_time": "2021-07-21T23:36:15.362473Z"
     }
    },
    "outputs": [],
@@ -1327,8 +1326,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.347774Z",
-     "start_time": "2021-07-13T06:12:29.343495Z"
+     "end_time": "2021-07-21T23:36:15.374454Z",
+     "start_time": "2021-07-21T23:36:15.369565Z"
     }
    },
    "outputs": [],
@@ -1360,8 +1359,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.354351Z",
-     "start_time": "2021-07-13T06:12:29.350100Z"
+     "end_time": "2021-07-21T23:36:15.380939Z",
+     "start_time": "2021-07-21T23:36:15.376932Z"
     }
    },
    "outputs": [],
@@ -1393,8 +1392,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.363651Z",
-     "start_time": "2021-07-13T06:12:29.357075Z"
+     "end_time": "2021-07-21T23:36:15.393525Z",
+     "start_time": "2021-07-21T23:36:15.383177Z"
     }
    },
    "outputs": [],
@@ -1444,8 +1443,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.373096Z",
-     "start_time": "2021-07-13T06:12:29.366150Z"
+     "end_time": "2021-07-21T23:36:15.403668Z",
+     "start_time": "2021-07-21T23:36:15.395659Z"
     }
    },
    "outputs": [],
@@ -1496,8 +1495,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.379509Z",
-     "start_time": "2021-07-13T06:12:29.375539Z"
+     "end_time": "2021-07-21T23:36:15.409811Z",
+     "start_time": "2021-07-21T23:36:15.405672Z"
     }
    },
    "outputs": [],
@@ -1514,8 +1513,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.384570Z",
-     "start_time": "2021-07-13T06:12:29.381234Z"
+     "end_time": "2021-07-21T23:36:15.417146Z",
+     "start_time": "2021-07-21T23:36:15.412820Z"
     }
    },
    "outputs": [],
@@ -1532,8 +1531,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.389968Z",
-     "start_time": "2021-07-13T06:12:29.386165Z"
+     "end_time": "2021-07-21T23:36:15.424408Z",
+     "start_time": "2021-07-21T23:36:15.420136Z"
     }
    },
    "outputs": [],
@@ -1550,8 +1549,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.395700Z",
-     "start_time": "2021-07-13T06:12:29.391994Z"
+     "end_time": "2021-07-21T23:36:15.431400Z",
+     "start_time": "2021-07-21T23:36:15.426631Z"
     }
    },
    "outputs": [],
@@ -1567,8 +1566,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.403682Z",
-     "start_time": "2021-07-13T06:12:29.397853Z"
+     "end_time": "2021-07-21T23:36:15.441054Z",
+     "start_time": "2021-07-21T23:36:15.434111Z"
     }
    },
    "outputs": [],
@@ -1597,8 +1596,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.408690Z",
-     "start_time": "2021-07-13T06:12:29.405890Z"
+     "end_time": "2021-07-21T23:36:15.446580Z",
+     "start_time": "2021-07-21T23:36:15.443804Z"
     }
    },
    "outputs": [],
@@ -1611,8 +1610,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.414561Z",
-     "start_time": "2021-07-13T06:12:29.410814Z"
+     "end_time": "2021-07-21T23:36:15.454228Z",
+     "start_time": "2021-07-21T23:36:15.449173Z"
     }
    },
    "outputs": [],
@@ -1630,8 +1629,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.421121Z",
-     "start_time": "2021-07-13T06:12:29.416332Z"
+     "end_time": "2021-07-21T23:36:15.461806Z",
+     "start_time": "2021-07-21T23:36:15.456382Z"
     }
    },
    "outputs": [],
@@ -1652,8 +1651,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.436068Z",
-     "start_time": "2021-07-13T06:12:29.426595Z"
+     "end_time": "2021-07-21T23:36:15.472427Z",
+     "start_time": "2021-07-21T23:36:15.464407Z"
     }
    },
    "outputs": [],
@@ -1674,8 +1673,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.443522Z",
-     "start_time": "2021-07-13T06:12:29.438185Z"
+     "end_time": "2021-07-21T23:36:15.479729Z",
+     "start_time": "2021-07-21T23:36:15.474457Z"
     }
    },
    "outputs": [],
@@ -1699,8 +1698,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.452204Z",
-     "start_time": "2021-07-13T06:12:29.446008Z"
+     "end_time": "2021-07-21T23:36:15.491312Z",
+     "start_time": "2021-07-21T23:36:15.482206Z"
     }
    },
    "outputs": [],
@@ -1724,8 +1723,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.460067Z",
-     "start_time": "2021-07-13T06:12:29.454230Z"
+     "end_time": "2021-07-21T23:36:15.498552Z",
+     "start_time": "2021-07-21T23:36:15.493297Z"
     }
    },
    "outputs": [],
@@ -1749,8 +1748,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.468205Z",
-     "start_time": "2021-07-13T06:12:29.462406Z"
+     "end_time": "2021-07-21T23:36:15.508852Z",
+     "start_time": "2021-07-21T23:36:15.500904Z"
     }
    },
    "outputs": [],
@@ -1774,8 +1773,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.475568Z",
-     "start_time": "2021-07-13T06:12:29.470114Z"
+     "end_time": "2021-07-21T23:36:15.518163Z",
+     "start_time": "2021-07-21T23:36:15.512276Z"
     }
    },
    "outputs": [],
@@ -1803,8 +1802,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.484367Z",
-     "start_time": "2021-07-13T06:12:29.477838Z"
+     "end_time": "2021-07-21T23:36:15.527759Z",
+     "start_time": "2021-07-21T23:36:15.520240Z"
     }
    },
    "outputs": [],
@@ -1832,8 +1831,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.491375Z",
-     "start_time": "2021-07-13T06:12:29.486423Z"
+     "end_time": "2021-07-21T23:36:15.536867Z",
+     "start_time": "2021-07-21T23:36:15.530683Z"
     }
    },
    "outputs": [],
@@ -1857,8 +1856,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.498736Z",
-     "start_time": "2021-07-13T06:12:29.493115Z"
+     "end_time": "2021-07-21T23:36:15.547286Z",
+     "start_time": "2021-07-21T23:36:15.539487Z"
     }
    },
    "outputs": [],
@@ -1882,8 +1881,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.504539Z",
-     "start_time": "2021-07-13T06:12:29.500706Z"
+     "end_time": "2021-07-21T23:36:15.555123Z",
+     "start_time": "2021-07-21T23:36:15.549990Z"
     }
    },
    "outputs": [],
@@ -1900,8 +1899,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.510692Z",
-     "start_time": "2021-07-13T06:12:29.506693Z"
+     "end_time": "2021-07-21T23:36:15.562134Z",
+     "start_time": "2021-07-21T23:36:15.557682Z"
     }
    },
    "outputs": [],
@@ -1918,8 +1917,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.516284Z",
-     "start_time": "2021-07-13T06:12:29.512409Z"
+     "end_time": "2021-07-21T23:36:15.570219Z",
+     "start_time": "2021-07-21T23:36:15.564603Z"
     }
    },
    "outputs": [],
@@ -1938,8 +1937,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.523880Z",
-     "start_time": "2021-07-13T06:12:29.518110Z"
+     "end_time": "2021-07-21T23:36:15.578614Z",
+     "start_time": "2021-07-21T23:36:15.572825Z"
     }
    },
    "outputs": [],
@@ -1958,8 +1957,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.540087Z",
-     "start_time": "2021-07-13T06:12:29.527647Z"
+     "end_time": "2021-07-21T23:36:15.587235Z",
+     "start_time": "2021-07-21T23:36:15.581105Z"
     }
    },
    "outputs": [],
@@ -1979,8 +1978,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.558455Z",
-     "start_time": "2021-07-13T06:12:29.550539Z"
+     "end_time": "2021-07-21T23:36:15.597010Z",
+     "start_time": "2021-07-21T23:36:15.590410Z"
     }
    },
    "outputs": [],
@@ -2000,8 +1999,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.577495Z",
-     "start_time": "2021-07-13T06:12:29.571982Z"
+     "end_time": "2021-07-21T23:36:15.617808Z",
+     "start_time": "2021-07-21T23:36:15.612008Z"
     }
    },
    "outputs": [],
@@ -2023,8 +2022,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.585934Z",
-     "start_time": "2021-07-13T06:12:29.581360Z"
+     "end_time": "2021-07-21T23:36:15.626530Z",
+     "start_time": "2021-07-21T23:36:15.621859Z"
     }
    },
    "outputs": [],
@@ -2044,8 +2043,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.593334Z",
-     "start_time": "2021-07-13T06:12:29.587893Z"
+     "end_time": "2021-07-21T23:36:15.635477Z",
+     "start_time": "2021-07-21T23:36:15.629321Z"
     }
    },
    "outputs": [],
@@ -2066,8 +2065,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.600570Z",
-     "start_time": "2021-07-13T06:12:29.595235Z"
+     "end_time": "2021-07-21T23:36:15.643872Z",
+     "start_time": "2021-07-21T23:36:15.638499Z"
     }
    },
    "outputs": [],
@@ -2088,8 +2087,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.607402Z",
-     "start_time": "2021-07-13T06:12:29.602941Z"
+     "end_time": "2021-07-21T23:36:15.652188Z",
+     "start_time": "2021-07-21T23:36:15.646154Z"
     }
    },
    "outputs": [],
@@ -2110,8 +2109,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.614404Z",
-     "start_time": "2021-07-13T06:12:29.609583Z"
+     "end_time": "2021-07-21T23:36:15.660367Z",
+     "start_time": "2021-07-21T23:36:15.654909Z"
     }
    },
    "outputs": [],
@@ -2134,8 +2133,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.620784Z",
-     "start_time": "2021-07-13T06:12:29.616221Z"
+     "end_time": "2021-07-21T23:36:15.668724Z",
+     "start_time": "2021-07-21T23:36:15.663200Z"
     }
    },
    "outputs": [],
@@ -2158,8 +2157,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.627437Z",
-     "start_time": "2021-07-13T06:12:29.623153Z"
+     "end_time": "2021-07-21T23:36:15.679009Z",
+     "start_time": "2021-07-21T23:36:15.672400Z"
     }
    },
    "outputs": [],
@@ -2182,8 +2181,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.634206Z",
-     "start_time": "2021-07-13T06:12:29.629220Z"
+     "end_time": "2021-07-21T23:36:15.692354Z",
+     "start_time": "2021-07-21T23:36:15.683305Z"
     }
    },
    "outputs": [],
@@ -2208,8 +2207,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.640000Z",
-     "start_time": "2021-07-13T06:12:29.635920Z"
+     "end_time": "2021-07-21T23:36:15.699832Z",
+     "start_time": "2021-07-21T23:36:15.694935Z"
     }
    },
    "outputs": [],
@@ -2232,8 +2231,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.646116Z",
-     "start_time": "2021-07-13T06:12:29.641898Z"
+     "end_time": "2021-07-21T23:36:15.707658Z",
+     "start_time": "2021-07-21T23:36:15.702100Z"
     }
    },
    "outputs": [],
@@ -2263,8 +2262,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.652438Z",
-     "start_time": "2021-07-13T06:12:29.647970Z"
+     "end_time": "2021-07-21T23:36:15.716927Z",
+     "start_time": "2021-07-21T23:36:15.710716Z"
     }
    },
    "outputs": [],
@@ -2294,8 +2293,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.659822Z",
-     "start_time": "2021-07-13T06:12:29.654991Z"
+     "end_time": "2021-07-21T23:36:15.724106Z",
+     "start_time": "2021-07-21T23:36:15.719239Z"
     }
    },
    "outputs": [],
@@ -2328,8 +2327,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.666527Z",
-     "start_time": "2021-07-13T06:12:29.661622Z"
+     "end_time": "2021-07-21T23:36:15.732855Z",
+     "start_time": "2021-07-21T23:36:15.726089Z"
     }
    },
    "outputs": [],
@@ -2369,8 +2368,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.671830Z",
-     "start_time": "2021-07-13T06:12:29.668254Z"
+     "end_time": "2021-07-21T23:36:15.739943Z",
+     "start_time": "2021-07-21T23:36:15.735232Z"
     }
    },
    "outputs": [],
@@ -2395,8 +2394,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.677662Z",
-     "start_time": "2021-07-13T06:12:29.673557Z"
+     "end_time": "2021-07-21T23:36:15.748221Z",
+     "start_time": "2021-07-21T23:36:15.742316Z"
     }
    },
    "outputs": [],
@@ -2431,8 +2430,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.684086Z",
-     "start_time": "2021-07-13T06:12:29.679745Z"
+     "end_time": "2021-07-21T23:36:15.756514Z",
+     "start_time": "2021-07-21T23:36:15.750767Z"
     }
    },
    "outputs": [],
@@ -2466,8 +2465,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.691013Z",
-     "start_time": "2021-07-13T06:12:29.685801Z"
+     "end_time": "2021-07-21T23:36:15.763331Z",
+     "start_time": "2021-07-21T23:36:15.758525Z"
     }
    },
    "outputs": [],
@@ -2492,8 +2491,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.698959Z",
-     "start_time": "2021-07-13T06:12:29.693637Z"
+     "end_time": "2021-07-21T23:36:15.769121Z",
+     "start_time": "2021-07-21T23:36:15.765525Z"
     }
    },
    "outputs": [],
@@ -2510,8 +2509,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.705655Z",
-     "start_time": "2021-07-13T06:12:29.701671Z"
+     "end_time": "2021-07-21T23:36:15.775948Z",
+     "start_time": "2021-07-21T23:36:15.771583Z"
     }
    },
    "outputs": [],
@@ -2530,8 +2529,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.711097Z",
-     "start_time": "2021-07-13T06:12:29.707340Z"
+     "end_time": "2021-07-21T23:36:15.783098Z",
+     "start_time": "2021-07-21T23:36:15.778081Z"
     }
    },
    "outputs": [],
@@ -2554,8 +2553,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.720432Z",
-     "start_time": "2021-07-13T06:12:29.713392Z"
+     "end_time": "2021-07-21T23:36:15.794623Z",
+     "start_time": "2021-07-21T23:36:15.785593Z"
     }
    },
    "outputs": [],
@@ -2587,8 +2586,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T06:12:29.732366Z",
-     "start_time": "2021-07-13T06:12:29.722931Z"
+     "end_time": "2021-07-21T23:36:15.806486Z",
+     "start_time": "2021-07-21T23:36:15.797133Z"
     }
    },
    "outputs": [],
@@ -2620,8 +2619,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T03:53:59.139865Z",
-     "start_time": "2021-07-13T03:53:59.129864Z"
+     "end_time": "2021-07-21T23:36:15.817423Z",
+     "start_time": "2021-07-21T23:36:15.808825Z"
     }
    },
    "outputs": [],
@@ -2640,6 +2639,7 @@
     "        f\"{type(fastdtw)}\"\n",
     "    )\n",
     "    assert is_installed('fastdtw==0.3.2')\n",
+    "    assert is_imported('fastdtw')\n",
     "    assert davos.config.smuggled['fastdtw'] == 'pip;fastdtw==0.3.2', (\n",
     "        \"Expected: 'pip;fastdtw==0.3.2'\\nFound: \"\n",
     "        f\"{davos.config.smuggled['fastdtw']}\"\n",
@@ -2651,8 +2651,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T03:53:59.148885Z",
-     "start_time": "2021-07-13T03:53:59.142135Z"
+     "end_time": "2021-07-21T23:36:15.826353Z",
+     "start_time": "2021-07-21T23:36:15.819450Z"
     }
    },
    "outputs": [],
@@ -2663,8 +2663,11 @@
     "    smuggle a new version of a package that was already imported during \n",
     "    the current interpreter session\n",
     "    \"\"\"\n",
+    "    import tqdm\n",
     "    assert tqdm.__version__ == '4.41.1'\n",
     "    smuggle tqdm    # pip: tqdm==4.45.0\n",
+    "    assert is_installed('tqdm==4.45.0')\n",
+    "    assert tqdm is sys.modules['tqdm']\n",
     "    assert tqdm.__version__ == '4.45.0'\n",
     "    assert davos.config.smuggled['tqdm'] == 'pip;tqdm==4.45.0', (\n",
     "        f\"Expected: 'pip;tqdm==4.45.0'\\nFound: {davos.config.smuggled['tqdm']}\"\n",
@@ -2676,8 +2679,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T03:53:59.159851Z",
-     "start_time": "2021-07-13T03:53:59.151580Z"
+     "end_time": "2021-07-21T23:36:15.840858Z",
+     "start_time": "2021-07-21T23:36:15.829349Z"
     }
    },
    "outputs": [],
@@ -2687,11 +2690,12 @@
     "    \"\"\"smuggle a specific VCS revision of a package from GitHub\"\"\"\n",
     "    assert not is_installed('hypertools')\n",
     "    smuggle hypertools as hyp    # pip: git+https://github.com/ContextLab/hypertools.git@36c12fd#egg=hypertools\n",
+    "    assert is_installed('hypertools==0.6.3')\n",
+    "    assert hyp is sys.modules['hypertools']\n",
     "    assert isinstance(hyp, types.ModuleType)\n",
     "    assert hyp is sys.modules['hypertools']\n",
     "    assert hasattr(hyp, 'DataGeometry')\n",
     "    assert hyp.__version__ == '0.6.3'\n",
-    "    assert is_installed('hypertools==0.6.3')\n",
     "    expected_cache_key = 'pip;git+https://github.com/ContextLab/hypertools.git@36c12fd#egg=hypertools'\n",
     "    assert davos.config.smuggled['hypertools'] == expected_cache_key, (\n",
     "        f\"Expected: '{expected_cache_key}'\\nFound: \"\n",
@@ -2704,8 +2708,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T03:53:59.171080Z",
-     "start_time": "2021-07-13T03:53:59.161939Z"
+     "end_time": "2021-07-21T23:36:15.855074Z",
+     "start_time": "2021-07-21T23:36:15.842939Z"
     }
    },
    "outputs": [],
@@ -2725,6 +2729,7 @@
     "    assert isinstance(quail, types.ModuleType)\n",
     "    assert hasattr(quail, 'Egg')\n",
     "    assert is_installed('quail==0.2.0')\n",
+    "    assert is_imported('quail')\n",
     "    expected_cache_key = 'pip;-e;git+https://github.com/ContextLab/quail.git@v0.2.0#egg=quail;--src;gh_clones'\n",
     "    assert davos.config.smuggled['quail'] == expected_cache_key, (\n",
     "        f\"Expected: '{expected_cache_key}'\\nFound: \"\n",
@@ -2737,8 +2742,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T03:53:59.181740Z",
-     "start_time": "2021-07-13T03:53:59.173400Z"
+     "end_time": "2021-07-21T23:36:15.867224Z",
+     "start_time": "2021-07-21T23:36:15.858069Z"
     }
    },
    "outputs": [],
@@ -2758,6 +2763,7 @@
     "    assert hasattr(sherlock_helpers, 'github_url')\n",
     "    assert sherlock_helpers.__version__ == '0.0.1'\n",
     "    assert is_installed('sherlock_helpers==0.0.1')\n",
+    "    assert is_imported('sherlock_helpers')\n",
     "    expected_cache_key = 'pip;git+https://github.com/ContextLab/sherlock-topic-model-paper.git@v1.0#subdirectory=code/sherlock_helpers'\n",
     "    assert davos.config.smuggled['sherlock_helpers'] == expected_cache_key, (\n",
     "        f\"Expected: '{expected_cache_key}'\\nFound: \"\n",
@@ -2765,12 +2771,88 @@
     "    )"
    ]
   },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "@mark.timeout(30)\n",
+    "def test_smuggle_confirm_install_yes():\n",
+    "    \"\"\"\n",
+    "    enable confirm_install and ensure user input is prompted before \n",
+    "    installing a smuggled package\n",
+    "    \"\"\"\n",
+    "    prompt_input_called = False\n",
+    "    \n",
+    "    def _mock_prompt_input(prompt, default=None, interrupt=None):\n",
+    "        nonlocal prompt_input_called\n",
+    "        prompt_input_called = True\n",
+    "        return True\n",
+    "    \n",
+    "    old_prompt_input = davos.core.core.prompt_input\n",
+    "    assert not is_installed('spur')\n",
+    "\n",
+    "    try:\n",
+    "        davos.config.confirm_install = True\n",
+    "        davos.core.core.prompt_input = _mock_prompt_input\n",
+    "        \n",
+    "        smuggle spur\n",
+    "        \n",
+    "        assert prompt_input_called, \"mocked prompt_input function not called\"\n",
+    "        assert is_installed('spur')\n",
+    "        assert is_imported('spur')\n",
+    "        assert spur is sys.modules['spur']\n",
+    "        \n",
+    "    finally:\n",
+    "        davos.config.confirm_install = False\n",
+    "        davos.core.core.prompt_input = old_prompt_input"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "@mark.timeout(30)\n",
+    "def test_smuggle_confirm_install_no_raises():\n",
+    "    \"\"\"\n",
+    "    when confirm_install is enabled, \"no\" response should raise an error \n",
+    "    and *not* install or import the package\n",
+    "    \"\"\"\n",
+    "    prompt_input_called = False\n",
+    "    \n",
+    "    def _mock_prompt_input(prompt, default=None, interrupt=None):\n",
+    "        nonlocal prompt_input_called\n",
+    "        prompt_input_called = True\n",
+    "        return False\n",
+    "    \n",
+    "    old_prompt_input = davos.core.core.prompt_input\n",
+    "    assert not is_installed('spurplus')\n",
+    "\n",
+    "    try:\n",
+    "        davos.config.confirm_install = True\n",
+    "        davos.core.core.prompt_input = _mock_prompt_input\n",
+    "        \n",
+    "        with raises(davos.core.exceptions.SmugglerError):\n",
+    "            smuggle spurplus\n",
+    "        \n",
+    "        assert prompt_input_called, \"mocked prompt_input function not called\"\n",
+    "        assert not is_installed('spurplus')\n",
+    "        \n",
+    "    finally:\n",
+    "        davos.config.confirm_install = False\n",
+    "        davos.core.core.prompt_input = old_prompt_input"
+   ]
+  },
   {
    "cell_type": "code",
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "start_time": "2021-07-13T03:53:58.241Z"
+     "end_time": "2021-07-21T23:36:29.346578Z",
+     "start_time": "2021-07-21T23:36:15.871668Z"
     },
     "scrolled": false
    },
diff --git a/tests/test_regexps.ipynb b/tests/test_regexps.ipynb
index 08c3c512..f84866d2 100644
--- a/tests/test_regexps.ipynb
+++ b/tests/test_regexps.ipynb
@@ -5,8 +5,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T05:21:56.189941Z",
-     "start_time": "2021-07-13T05:21:56.181252Z"
+     "end_time": "2021-07-21T22:54:18.091773Z",
+     "start_time": "2021-07-21T22:54:18.079372Z"
     }
    },
    "outputs": [],
@@ -23,8 +23,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T05:21:56.302179Z",
-     "start_time": "2021-07-13T05:21:56.192382Z"
+     "end_time": "2021-07-21T22:54:18.225003Z",
+     "start_time": "2021-07-21T22:54:18.094255Z"
     }
    },
    "outputs": [],
@@ -52,8 +52,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T05:21:56.536227Z",
-     "start_time": "2021-07-13T05:21:56.305771Z"
+     "end_time": "2021-07-21T22:54:18.550822Z",
+     "start_time": "2021-07-21T22:54:18.228854Z"
     }
    },
    "outputs": [],
@@ -62,7 +62,7 @@
     "from textwrap import dedent\n",
     "\n",
     "import davos\n",
-    "from davos.core.regexps import _pip_installed_pkgs_re, smuggle_statement_regex\n",
+    "from davos.core.regexps import pip_installed_pkgs_regex, smuggle_statement_regex\n",
     "\n",
     "from utils import run_tests"
    ]
@@ -72,8 +72,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T05:21:56.541300Z",
-     "start_time": "2021-07-13T05:21:56.538909Z"
+     "end_time": "2021-07-21T22:54:18.556147Z",
+     "start_time": "2021-07-21T22:54:18.082Z"
     }
    },
    "outputs": [],
@@ -93,8 +93,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T05:21:56.546620Z",
-     "start_time": "2021-07-13T05:21:56.543164Z"
+     "end_time": "2021-07-21T22:54:18.557817Z",
+     "start_time": "2021-07-21T22:54:18.085Z"
     }
    },
    "outputs": [],
@@ -121,7 +121,7 @@
     "    Installing collected packages: davos\n",
     "    Successfully installed davos-0.0.1\"\"\")\n",
     "    expected = ['davos-0.0.1']\n",
-    "    result = _pip_installed_pkgs_re.findall(stdout)\n",
+    "    result = pip_installed_pkgs_regex.findall(stdout)\n",
     "    assert result == expected, (\n",
     "        f\"Expected: {expected}\\nFound: {result}\"\n",
     "    )"
@@ -132,8 +132,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T05:21:56.553195Z",
-     "start_time": "2021-07-13T05:21:56.548892Z"
+     "end_time": "2021-07-21T22:54:18.559943Z",
+     "start_time": "2021-07-21T22:54:18.086Z"
     }
    },
    "outputs": [],
@@ -194,7 +194,7 @@
     "    Installing collected packages: deepdish, PPCA, pynndescent, umap-learn, hypertools\n",
     "    Successfully installed PPCA-0.0.4 deepdish-0.3.6 hypertools-0.7.0 pynndescent-0.5.4 umap-learn-0.5.1\"\"\")\n",
     "    expected = ['PPCA-0.0.4 deepdish-0.3.6 hypertools-0.7.0 pynndescent-0.5.4 umap-learn-0.5.1']\n",
-    "    result = _pip_installed_pkgs_re.findall(stdout)\n",
+    "    result = pip_installed_pkgs_regex.findall(stdout)\n",
     "    assert result == expected, (\n",
     "        f\"Expected: {expected}\\nFound: {result}\"\n",
     "    )"
@@ -205,8 +205,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T05:21:56.558273Z",
-     "start_time": "2021-07-13T05:21:56.555298Z"
+     "end_time": "2021-07-21T22:54:18.561983Z",
+     "start_time": "2021-07-21T22:54:18.088Z"
     }
    },
    "outputs": [],
@@ -224,7 +224,7 @@
     "    Requirement already satisfied: chardet<4,>=3.0.2 in /usr/local/lib/python3.7/dist-packages (from requests) (3.0.4)\n",
     "    Requirement already satisfied: urllib3!=1.25.0,!=1.25.1,<1.26,>=1.21.1 in /usr/local/lib/python3.7/dist-packages (from requests) (1.24.3)\"\"\")\n",
     "    expected = []\n",
-    "    result = _pip_installed_pkgs_re.findall(stdout)\n",
+    "    result = pip_installed_pkgs_regex.findall(stdout)\n",
     "    assert result == expected, (\n",
     "        f\"Expected: {expected}\\nFound: {result}\"\n",
     "    )"
@@ -235,8 +235,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T05:21:56.565658Z",
-     "start_time": "2021-07-13T05:21:56.563226Z"
+     "end_time": "2021-07-21T22:54:18.563495Z",
+     "start_time": "2021-07-21T22:54:18.090Z"
     }
    },
    "outputs": [],
@@ -253,8 +253,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T05:21:56.572614Z",
-     "start_time": "2021-07-13T05:21:56.569407Z"
+     "end_time": "2021-07-21T22:54:18.564756Z",
+     "start_time": "2021-07-21T22:54:18.091Z"
     }
    },
    "outputs": [],
@@ -271,8 +271,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T05:21:56.578133Z",
-     "start_time": "2021-07-13T05:21:56.574696Z"
+     "end_time": "2021-07-21T22:54:18.565717Z",
+     "start_time": "2021-07-21T22:54:18.093Z"
     }
    },
    "outputs": [],
@@ -289,8 +289,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T05:21:56.586012Z",
-     "start_time": "2021-07-13T05:21:56.581400Z"
+     "end_time": "2021-07-21T22:54:18.566815Z",
+     "start_time": "2021-07-21T22:54:18.095Z"
     }
    },
    "outputs": [],
@@ -307,8 +307,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T05:21:56.598226Z",
-     "start_time": "2021-07-13T05:21:56.588777Z"
+     "end_time": "2021-07-21T22:54:18.567946Z",
+     "start_time": "2021-07-21T22:54:18.096Z"
     }
    },
    "outputs": [],
@@ -341,8 +341,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T05:21:56.609938Z",
-     "start_time": "2021-07-13T05:21:56.600613Z"
+     "end_time": "2021-07-21T22:54:18.569132Z",
+     "start_time": "2021-07-21T22:54:18.098Z"
     }
    },
    "outputs": [],
@@ -375,8 +375,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T05:21:56.623353Z",
-     "start_time": "2021-07-13T05:21:56.612843Z"
+     "end_time": "2021-07-21T22:54:18.570405Z",
+     "start_time": "2021-07-21T22:54:18.100Z"
     }
    },
    "outputs": [],
@@ -409,8 +409,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T05:21:56.636428Z",
-     "start_time": "2021-07-13T05:21:56.625678Z"
+     "end_time": "2021-07-21T22:54:18.571994Z",
+     "start_time": "2021-07-21T22:54:18.102Z"
     }
    },
    "outputs": [],
@@ -443,8 +443,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T05:21:56.650407Z",
-     "start_time": "2021-07-13T05:21:56.639732Z"
+     "end_time": "2021-07-21T22:54:18.573132Z",
+     "start_time": "2021-07-21T22:54:18.103Z"
     }
    },
    "outputs": [],
@@ -477,8 +477,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T05:21:56.666369Z",
-     "start_time": "2021-07-13T05:21:56.653207Z"
+     "end_time": "2021-07-21T22:54:18.574309Z",
+     "start_time": "2021-07-21T22:54:18.105Z"
     }
    },
    "outputs": [],
@@ -511,8 +511,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T05:21:56.679651Z",
-     "start_time": "2021-07-13T05:21:56.670182Z"
+     "end_time": "2021-07-21T22:54:18.575401Z",
+     "start_time": "2021-07-21T22:54:18.106Z"
     }
    },
    "outputs": [],
@@ -545,8 +545,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T05:21:56.692381Z",
-     "start_time": "2021-07-13T05:21:56.683058Z"
+     "end_time": "2021-07-21T22:54:18.576515Z",
+     "start_time": "2021-07-21T22:54:18.108Z"
     }
    },
    "outputs": [],
@@ -579,8 +579,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T05:21:56.705089Z",
-     "start_time": "2021-07-13T05:21:56.695004Z"
+     "end_time": "2021-07-21T22:54:18.577627Z",
+     "start_time": "2021-07-21T22:54:18.109Z"
     }
    },
    "outputs": [],
@@ -616,8 +616,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T05:21:56.717752Z",
-     "start_time": "2021-07-13T05:21:56.708166Z"
+     "end_time": "2021-07-21T22:54:18.578616Z",
+     "start_time": "2021-07-21T22:54:18.111Z"
     }
    },
    "outputs": [],
@@ -654,8 +654,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T05:21:56.728362Z",
-     "start_time": "2021-07-13T05:21:56.720393Z"
+     "end_time": "2021-07-21T22:54:18.580055Z",
+     "start_time": "2021-07-21T22:54:18.113Z"
     }
    },
    "outputs": [],
@@ -688,8 +688,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T05:21:56.746144Z",
-     "start_time": "2021-07-13T05:21:56.737020Z"
+     "end_time": "2021-07-21T22:54:18.581743Z",
+     "start_time": "2021-07-21T22:54:18.115Z"
     }
    },
    "outputs": [],
@@ -728,8 +728,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T05:21:56.759930Z",
-     "start_time": "2021-07-13T05:21:56.750505Z"
+     "end_time": "2021-07-21T22:54:18.582735Z",
+     "start_time": "2021-07-21T22:54:18.117Z"
     }
    },
    "outputs": [],
@@ -767,8 +767,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T05:21:56.773173Z",
-     "start_time": "2021-07-13T05:21:56.762928Z"
+     "end_time": "2021-07-21T22:54:18.583891Z",
+     "start_time": "2021-07-21T22:54:18.119Z"
     }
    },
    "outputs": [],
@@ -801,8 +801,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T05:21:56.787538Z",
-     "start_time": "2021-07-13T05:21:56.776313Z"
+     "end_time": "2021-07-21T22:54:18.584977Z",
+     "start_time": "2021-07-21T22:54:18.121Z"
     }
    },
    "outputs": [],
@@ -835,8 +835,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T05:21:56.800997Z",
-     "start_time": "2021-07-13T05:21:56.790352Z"
+     "end_time": "2021-07-21T22:54:18.586131Z",
+     "start_time": "2021-07-21T22:54:18.123Z"
     }
    },
    "outputs": [],
@@ -869,8 +869,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T05:21:56.812411Z",
-     "start_time": "2021-07-13T05:21:56.803187Z"
+     "end_time": "2021-07-21T22:54:18.587014Z",
+     "start_time": "2021-07-21T22:54:18.125Z"
     }
    },
    "outputs": [],
@@ -903,8 +903,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-13T05:21:56.823944Z",
-     "start_time": "2021-07-13T05:21:56.815325Z"
+     "end_time": "2021-07-21T22:54:18.588371Z",
+     "start_time": "2021-07-21T22:54:18.126Z"
     }
    },
    "outputs": [],
@@ -937,8 +937,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-11T21:33:07.597091Z",
-     "start_time": "2021-07-11T21:33:07.588080Z"
+     "end_time": "2021-07-21T22:54:18.589588Z",
+     "start_time": "2021-07-21T22:54:18.127Z"
     }
    },
    "outputs": [],
@@ -975,8 +975,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-11T21:33:07.607609Z",
-     "start_time": "2021-07-11T21:33:07.599074Z"
+     "end_time": "2021-07-21T22:54:18.590555Z",
+     "start_time": "2021-07-21T22:54:18.128Z"
     }
    },
    "outputs": [],
@@ -1014,8 +1014,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-11T21:33:07.620172Z",
-     "start_time": "2021-07-11T21:33:07.609975Z"
+     "end_time": "2021-07-21T22:54:18.591556Z",
+     "start_time": "2021-07-21T22:54:18.130Z"
     }
    },
    "outputs": [],
@@ -1054,8 +1054,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-11T21:33:07.631210Z",
-     "start_time": "2021-07-11T21:33:07.622153Z"
+     "end_time": "2021-07-21T22:54:18.592696Z",
+     "start_time": "2021-07-21T22:54:18.132Z"
     }
    },
    "outputs": [],
@@ -1098,7 +1098,12 @@
   {
    "cell_type": "code",
    "execution_count": null,
-   "metadata": {},
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T22:54:18.594030Z",
+     "start_time": "2021-07-21T22:54:18.134Z"
+    }
+   },
    "outputs": [],
    "source": [
     "def test_smuggle_from_multiline_implicit_1_onion_2():\n",
@@ -1137,7 +1142,12 @@
   {
    "cell_type": "code",
    "execution_count": null,
-   "metadata": {},
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T22:54:18.595749Z",
+     "start_time": "2021-07-21T22:54:18.136Z"
+    }
+   },
    "outputs": [],
    "source": [
     "def test_smuggle_from_multiline_implicit_1_onion_3():\n",
@@ -1177,7 +1187,12 @@
   {
    "cell_type": "code",
    "execution_count": null,
-   "metadata": {},
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T22:54:18.597450Z",
+     "start_time": "2021-07-21T22:54:18.137Z"
+    }
+   },
    "outputs": [],
    "source": [
     "def test_smuggle_from_multiline_implicit_1_onion_4():\n",
@@ -1218,7 +1233,12 @@
   {
    "cell_type": "code",
    "execution_count": null,
-   "metadata": {},
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T22:54:18.599058Z",
+     "start_time": "2021-07-21T22:54:18.139Z"
+    }
+   },
    "outputs": [],
    "source": [
     "def test_smuggle_from_multiline_implicit_1_onion_5():\n",
@@ -1259,7 +1279,12 @@
   {
    "cell_type": "code",
    "execution_count": null,
-   "metadata": {},
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T22:54:18.601087Z",
+     "start_time": "2021-07-21T22:54:18.141Z"
+    }
+   },
    "outputs": [],
    "source": [
     "def test_smuggle_from_multiline_implicit_2_onion_1():\n",
@@ -1300,7 +1325,12 @@
   {
    "cell_type": "code",
    "execution_count": null,
-   "metadata": {},
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T22:54:18.603266Z",
+     "start_time": "2021-07-21T22:54:18.143Z"
+    }
+   },
    "outputs": [],
    "source": [
     "def test_smuggle_from_multiline_implicit_2_onion_2():\n",
@@ -1341,7 +1371,12 @@
   {
    "cell_type": "code",
    "execution_count": null,
-   "metadata": {},
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T22:54:18.605507Z",
+     "start_time": "2021-07-21T22:54:18.145Z"
+    }
+   },
    "outputs": [],
    "source": [
     "def test_smuggle_from_multiline_implicit_2_onion_3():\n",
@@ -1383,7 +1418,12 @@
   {
    "cell_type": "code",
    "execution_count": null,
-   "metadata": {},
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T22:54:18.607111Z",
+     "start_time": "2021-07-21T22:54:18.146Z"
+    }
+   },
    "outputs": [],
    "source": [
     "def test_smuggle_from_multiline_implicit_2_onion_4():\n",
@@ -1426,7 +1466,12 @@
   {
    "cell_type": "code",
    "execution_count": null,
-   "metadata": {},
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T22:54:18.608483Z",
+     "start_time": "2021-07-21T22:54:18.149Z"
+    }
+   },
    "outputs": [],
    "source": [
     "def test_smuggle_from_multiline_implicit_2_onion_5():\n",
@@ -1469,7 +1514,12 @@
   {
    "cell_type": "code",
    "execution_count": null,
-   "metadata": {},
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T22:54:18.610168Z",
+     "start_time": "2021-07-21T22:54:18.152Z"
+    }
+   },
    "outputs": [],
    "source": [
     "def test_smuggle_multi_semicolon():\n",
@@ -1507,7 +1557,12 @@
   {
    "cell_type": "code",
    "execution_count": null,
-   "metadata": {},
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T22:54:18.611597Z",
+     "start_time": "2021-07-21T22:54:18.153Z"
+    }
+   },
    "outputs": [],
    "source": [
     "def test_smuggle_from_multi_semicolon():\n",
@@ -1543,8 +1598,8 @@
    "execution_count": null,
    "metadata": {
     "ExecuteTime": {
-     "end_time": "2021-07-11T22:09:59.634678Z",
-     "start_time": "2021-07-11T22:09:59.629895Z"
+     "end_time": "2021-07-21T22:54:18.613154Z",
+     "start_time": "2021-07-21T22:54:18.155Z"
     }
    },
    "outputs": [],