Repository: ContextLab/davos (language: python)

Issue:
New, improved GitHub workflow setup
- Jupyter- and Colab- based CI tests can now be triggered separately and rerun separately
- Jupyter workflow:
  - runs on push to any fork/branch
  - runs on pull requests across forks
  - doesn't run on pull requests between branches of the same fork to avoid duplicating tests run on push to head branch
- Colab workflow:
   - runs automatically on pushes to ContextLab/davos and paxtonfitzpatrick/davos (where required secrets exist)
   - doesn't run on pull requests between branches of the same fork (same reason as Jupyter test workflow)
   - is manually triggered via comment on pull requests across forks (see below)

### Colab workflow and new support workflow behaviors on PRs across forks
1. Opening a pull request from a fork to the base repo (ContextLab/davos) triggers the `request-colab-tests` workflow
   - uses `pull_request_target` event, so the workflow runs in ContextLab/davos, rather than the head fork
   - ignores changes to same files as main CI test workflows
   - also runs when new commits are added to open PR
2. `request-colab-tests` workflow adds a comment to the issue asking if Colab tests should be run, and tags @paxtonfitzpatrick
   - comment is made by the generic "github-actions" bot account
   - comment includes permalink to fork at the commit to be tested
3. commenting on the open PR (in a specific way) triggers the `trigger-colab-tests-pr` workflow
   - workflow runs only if:
      - comment author is @paxtonfitzpatrick
      - `@github-actions` bot is tagged at the beginning of the comment
   - also runs only when comment is created (not edited, deleted, etc.)
   -  allows maintainers to inspect changes to code before running Colab workflow, which requires access to secrets
4. `trigger-colab-tests-pr` workflow parses comment body for specific "approval keywords" and if it finds any, triggers the `ci-tests-colab` workflow
   - keywords that denote approval are "approve", "run", "yes", and "👍"
   - also adds a "👍" reaction to the parsed comment for confirmation
   - uses GitHub JS API to run Colab workflow by triggering workflow_dispatch event
   - passes fork name and head commit hash as inputs
5. `ci-tests-colab` workflow runs tests on pull request in Colaboratory
   - runs in ContextLab/davos, so it has access to secrets
   - checks out head fork and commit of open PR (or any fork & commit when triggered manually)

**To-do**: I don't think the workflow_dispatch event will associate the colab tests as "status checks" for the open PR. Looks like that's possible to do via the [checks API](https://docs.github.com/en/rest/reference/checks) and this [checks-action](https://github.com/LouisBrunner/checks-action)


Discussion / hints:

Fix patch:
diff --git a/.gitattributes b/.gitattributes
index ee143a7b..f4546b5b 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,2 +1,5 @@
 # exclude notebooks from breakdown
 *.ipynb linguist-documentation
+
+# count file with JS functions as JavaScript
+davos/implementations/js_functions.py linguist-language=JavaScript
diff --git a/.gitignore b/.gitignore
index b0ba90ed..f785bc49 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,8 +7,18 @@
 # Python bytecode
 *__pycache__
 
+# IPython notebook checkpoints
+*.ipynb_checkpoints
+
 # Python package distribution egg
 davos.egg-info
 
+# Mypy daemon status file
+.dmypy.json
+
+# selenium Firefox driver & log
+tests/geckodriver
+**/geckodriver.log
+
 # scripts & notebooks for scratch/debugging
-scratch
\ No newline at end of file
+scratch
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 727eb364..371a825f 100644
--- a/davos/__init__.py
+++ b/davos/__init__.py
@@ -1,16 +1,128 @@
 import pkg_resources
+from davos.core.config import DavosConfig
 
-from davos._davos import Davos
 
+__all__ = [
+    'activate',
+    'config',
+    'configure',
+    'deactivate',
+    'is_active',
+    'smuggle'
+]
 
-__all__ = ['activate', 'davos', 'deactivate', 'is_active', 'smuggle']
 __version__ = pkg_resources.get_distribution('davos').version
 
 
-davos = Davos()
-davos.initialize()
+# config must be instantiated before importing implementations module
+config = DavosConfig()
 
-smuggle = davos.smuggler
-activate = davos.activate_parser
-deactivate = davos.deactivate_parser
-is_active = davos.parser_is_active
+
+import davos.implementations
+from davos.core.core import smuggle
+
+
+def activate():
+    """
+    Activate the `davos` parser if it is not currently active.
+
+    Convenience function equivalent to running
+    `davos.config.active = True`. Enables the `davos` parser and injects
+    the `smuggle()` function into the namespace.
+    """
+    config.active = True
+
+
+def configure(
+        *,
+        active=...,
+        auto_rerun=...,
+        conda_env=...,
+        confirm_install=...,
+        noninteractive=...,
+        pip_executable=...,
+        suppress_stdout=...
+):
+    """
+    Set multiple `davos.config` fields at once.
+
+    Parameters
+    ----------
+    active : bool, optional
+        Value to assign to "`active`" field.
+    auto_rerun : bool, optional
+        Value to assign to "`auto_rerun`" field. Must be `False`
+        (default) in Colaboratory notebooks.
+    conda_env : str, optional
+        Value to assign to "`conda_env`" field. Not settable if `conda`
+        is not installed (defaults to `None`).
+    confirm_install : bool, optional
+        Value to assign to "`confirm_install`" field.
+    noninteractive : bool, optional
+        Value to assign to "`noninteractive`" field. Must be `False`
+        (default) in Colaboratory notebooks.
+    pip_executable : str or pathlib.Path
+        Value to assign to "`pip_executable`" field. Must be a path to a
+        real file.
+    suppress_stdout : bool
+        Value to assign to "`suppress_stdout`" field.
+
+    Raises
+    -------
+    core.exceptions.ConfigError
+        If a config field is assigned an invalid value.
+
+    See Also
+    --------
+    config.DavosConfig :
+        The global `davos` Config object. Offers more detailed
+        descriptions of each field.
+    """
+    # TODO: perform some value checks upfront to raise relevant errors
+    #  before setting some fields and make setting values
+    #  order-independent (e.g., noninteractive & confirm_install)
+    kwargs = locals()
+    old_values = {}
+    for name, new_value in kwargs.items():
+        if new_value is not Ellipsis:
+            old_value = getattr(config, name)
+            try:
+                setattr(config, name, new_value)
+            except:
+                # if one assignment fails, no config fields are updated
+                for _name, old_value in old_values.items():
+                    setattr(config, _name, old_value)
+                raise
+            else:
+                old_values[name] = old_value
+
+
+def deactivate():
+    """
+    Deactivate the `davos` parser if it is currently active.
+
+    Convenience function equivalent to running
+    `davos.config.active = False`. Disables the `davos` parser and
+    deletes the name `smuggle` from the namespace if and only if it
+    refers to the `smuggle()` function.
+    """
+    config.active = False
+
+
+def is_active():
+    """
+    Check whether the `davos` parser currently active.
+
+    Convenience function that simply returns the value of
+    `davos.config.active`.
+
+    Returns
+    -------
+    bool
+        Whether or not the `davos` parser is currently active.
+
+    """
+    return config.active
+
+
+activate()
diff --git a/davos/__init__.pyi b/davos/__init__.pyi
index bb4ae30e..d5f4b718 100644
--- a/davos/__init__.pyi
+++ b/davos/__init__.pyi
@@ -1,13 +1,29 @@
-from collections.abc import Callable
-from typing import Literal, Optional
-from davos._davos import Davos
-from davos.core import PipInstallerKwargs
+from typing import Literal
+from davos.core.config import DavosConfig
 
-__all__: list[Literal['activate', 'davos', 'deactivate', 'is_active', 'smuggle']]
+__all__ = list[
+    Literal[
+        'activate', 
+        'config', 
+        'configure', 
+        'deactivate', 
+        'is_active', 
+        'smuggle'
+    ]
+]
 __version__: str
 
-davos: Davos
-smuggle: Callable[[str, Optional[str], Literal['conda', 'pip'], str, Optional[PipInstallerKwargs]], None]
-activate: Callable[[], None]
-deactivate: Callable[[], None]
-is_active: Callable[[], bool]
+config: DavosConfig
+
+def activate() -> None: ...
+def configure(
+        *, 
+        active: bool = ..., 
+        auto_rerun: bool = ...,
+        conda_env: str = ..., 
+        confirm_install: bool = ..., 
+        noninteractive: bool = ..., 
+        suppress_stdout: bool = ...
+) -> None: ...
+def deactivate() -> None: ...
+def is_active() -> bool: ...
diff --git a/davos/_davos.py b/davos/_davos.py
deleted file mode 100644
index 8a751bc2..00000000
--- a/davos/_davos.py
+++ /dev/null
@@ -1,155 +0,0 @@
-"""
-TODO: update me
-Module for managing session-wide global objects, switches, and
-configurable options for both internal and user settings
-"""
-
-
-__all__ = ['capture_stdout', 'Davos']
-
-
-import io
-import sys
-from contextlib import redirect_stdout
-from pathlib import Path
-from subprocess import CalledProcessError
-
-
-# noinspection PyAttributeOutsideInit
-class Davos:
-    # ADD DOCSTRING
-    __instance = None
-
-    def __new__(cls):
-        if cls.__instance is None:
-            cls.__instance = super().__new__(cls)
-        return cls.__instance
-
-    def initialize(self):
-        # ADD DOCSTRING
-        self.confirm_install = False
-        self.suppress_stdout = False
-        self.smuggled = {}
-        try:
-            # noinspection PyUnresolvedReferences
-            self.ipython_shell = get_ipython()
-        except NameError:
-            # running with vanilla Python interpreter
-            import davos.python as internals
-            self.smuggler = internals.smuggle_python
-            self.activate_parser = internals.activate_parser_python
-            self.deactivate_parser = internals.deactivate_parser_python
-            self.parser_is_active = internals.check_parser_active_python
-            self._shell_cmd_helper = internals.run_shell_command_python
-            self.ipython_shell = None
-            self.parser_environment = 'PY'
-        else:
-            import IPython
-            if IPython.version_info[0] < 7:
-                # running in Colaboratory or an old IPython/Jupyter
-                # Notebook version
-                import davos.colab as internals
-                self.smuggler = internals.smuggle_colab
-                self.activate_parser = internals.activate_parser_colab
-                self.deactivate_parser = internals.deactivate_parser_colab
-                self.parser_is_active = internals.check_parser_active_colab
-                self._shell_cmd_helper = internals.run_shell_command_colab
-                self.parser_environment = 'IPY_OLD'
-                # store InteractiveShell's original showsyntaxerror
-                # method so it can be referenced from our overridden
-                # version
-                self._ipython_showsyntaxerror_orig = self.ipython_shell.showsyntaxerror
-            else:
-                # running in a new(-ish) IPython/Jupyter Notebook
-                # version
-                import davos.jupyter as internals
-                self.smuggler = internals.smuggle_jupyter
-                self.activate_parser = internals.activate_parser_jupyter
-                self.deactivate_parser = internals.deactivate_parser_jupyter
-                self.parser_is_active = internals.check_parser_active_jupyter
-                self._shell_cmd_helper = internals.run_shell_command_jupyter
-                self.parser_environment = 'IPY_NEW'
-        self.get_stdlib_modules()
-        self.activate_parser()
-
-    def get_stdlib_modules(self):
-        # ADD DOCSTRING
-        stdlib_dir = Path(io.__file__).parent
-        stdlib_modules = set(p.stem for p in stdlib_dir.iterdir())
-        try:
-            stdlib_modules.remove('site-packages')
-        except KeyError:
-            pass
-        if sys.platform.startswith('linux'):
-            try:
-                stdlib_modules.remove('dist-packages')
-            except KeyError:
-                pass
-        self.stdlib_modules = stdlib_modules
-
-
-    def run_shell_command(self, command, live_stdout=None):
-        # ADD DOCSTRING
-        if live_stdout is None:
-            live_stdout = not self.suppress_stdout
-        if live_stdout:
-            command_context = capture_stdout
-        else:
-            command_context = redirect_stdout
-        with command_context(io.StringIO()) as stdout:
-            try:
-                return_code = self._shell_cmd_helper(command)
-            except CalledProcessError as e:
-                # if the exception doesn't record the output, add it
-                # manually before raising
-                stdout = stdout.getvalue()
-                if e.output is None and stdout != '':
-                    e.output = stdout
-                raise e
-            else:
-                stdout = stdout.getvalue()
-        return stdout, return_code
-
-
-class capture_stdout:
-    # TODO: move me to davos.core?
-    """
-    Context manager similar to `contextlib.redirect_stdout`, but
-    different in that it:
-      - temporarily writes stdout to other streams *in addition to*
-        rather than *instead of* `sys.stdout`
-      - accepts any number of streams and sends stdout to each
-      - can optionally keep streams open after exiting context by
-        passing `closing=False`
-
-    Parameters
-    ----------
-    *streams : `*io.IOBase`
-        stream(s) to receive data sent to `sys.stdout`
-
-    closing : `bool`, optional
-        if [default: `True`], close streams upon exiting the context
-        block.
-    """
-    def __init__(self, *streams, closing=True):
-        self.streams = streams
-        self.closing = closing
-        self.sys_stdout_write = sys.stdout.write
-
-    def __enter__(self):
-        sys.stdout.write = self._write
-        if len(self.streams) == 1:
-            return self.streams[0]
-        return self.streams
-
-    def __exit__(self, exc_type, exc_value, traceback):
-        sys.stdout.write = self.sys_stdout_write
-        if self.closing:
-            for s in self.streams:
-                s.close()
-
-    def _write(self, data):
-        for s in self.streams:
-            s.write(data)
-        self.sys_stdout_write(data)
-        sys.stdout.flush()
diff --git a/davos/_davos.pyi b/davos/_davos.pyi
deleted file mode 100644
index 4c4df27d..00000000
--- a/davos/_davos.pyi
+++ /dev/null
@@ -1,63 +0,0 @@
-import sys
-from collections.abc import Callable
-from io import StringIO
-from types import TracebackType
-from typing import (
-    AnyStr,
-    ClassVar,
-    Generic,
-    Literal,
-    Optional,
-    overload,
-    TextIO,
-    Tuple,
-    Type,
-    TypeVar,
-    Union
-)
-from IPython.core.interactiveshell import InteractiveShell
-from davos.core import PipInstallerKwargs
-
-__all__: list[Literal['capture_stdout', 'Davos']]
-
-_D = TypeVar('_D', bound='Davos')
-_IPY_SHELL = TypeVar('_IPY_SHELL', bound=InteractiveShell)
-_StringOrFileIO = Union[StringIO, TextIO]
-_S = TypeVar('_S')
-_S1 = Tuple[_StringOrFileIO]
-_SN = Tuple[_StringOrFileIO, ...]
-_E = TypeVar('_E', bound=BaseException)
-
-class Davos:
-    __instance: ClassVar[Optional[_D]]
-    _ipython_showsyntaxerror_orig: Callable[[Optional[str]], None]
-    _shell_cmd_helper: Callable[[str], int]
-    activate_parser: Callable[[], None]
-    confirm_install: bool
-    deactivate_parser: Callable[[], None]
-    ipython_shell: Optional[_IPY_SHELL]
-    parser_environment: Literal['IPY_NEW', 'IPY_OLD', 'PY']
-    parser_is_active: Callable[[], bool]
-    smuggled: dict[str, str]
-    smuggler: Callable[[str, Optional[str], Literal['conda', 'pip'], str, Optional[PipInstallerKwargs]], None]
-    stdlib_modules: set[str]
-    suppress_stdout: bool
-    def __new__(cls: Type[Davos]) -> _D: ...
-    def initialize(self) -> None: ...
-    def get_stdlib_modules(self) -> None: ...
-    def run_shell_command(self, command: str, live_stdout: Optional[bool] = ...) -> tuple[str, int]: ...
-
-class capture_stdout(Generic[_S]):
-    closing: bool
-    streams: _S
-    sys_stdout_write: sys.stdout.write
-    def __init__(self, *streams: _StringOrFileIO, closing: bool = ...) -> None: ...
-    @overload
-    def __enter__(self: capture_stdout[_S1]) -> _StringOrFileIO: ...
-    @overload
-    def __enter__(self: capture_stdout[_SN]) -> _SN: ...
-    @overload
-    def __exit__(self, exc_type: None, exc_value: None, traceback: None) -> None: ...
-    @overload
-    def __exit__(self, exc_type: Type[_E], exc_value: _E, traceback: TracebackType) -> bool: ...
-    def _write(self, data: AnyStr) -> None: ...
diff --git a/davos/colab.py b/davos/colab.py
deleted file mode 100644
index 20a7889e..00000000
--- a/davos/colab.py
+++ /dev/null
@@ -1,377 +0,0 @@
-"""
-This module implements the 'smuggle' statement for interactive Python
-environments built on top of IPython<7.0.0 (released 9/27/2018).
-
-This implementation will most commonly be used in Google Colaboratory
-notebooks (hence the module name), which as of 3/9/2021 run IPython
-v5.5.0. However, IPython shells & Jupyter notebooks running older
-versions of IPython will also use this approach
-"""
-
-
-__all__ = [
-    'activate_parser_colab',
-    'check_parser_active_colab',
-    'deactivate_parser_colab',
-    'run_shell_command_colab',
-    'smuggle_colab',
-    'smuggle_parser_colab'
-]
-
-
-import importlib
-import sys
-from subprocess import CalledProcessError
-
-from davos import davos
-from davos.core import Onion, prompt_input, smuggle_statement_regex
-from davos.exceptions import DavosParserError
-
-if davos.ipython_shell is not None:
-    from IPython.core.display import _display_mimetype
-    from IPython.core.inputtransformer import StatelessInputTransformer
-    from IPython.core.interactiveshell import system as _run_shell_cmd
-    from IPython.utils.importstring import import_item
-    # additional check to make sure this is in a Colab notebook rather
-    # than just very old IPython kernel
-    from ipykernel.zmqshell import ZMQInteractiveShell
-    if type(davos.ipython_shell) is not ZMQInteractiveShell:
-        from google.colab._pip import (
-            _previously_imported_packages as get_updated_imported_pkgs
-        )
-
-
-def _showsyntaxerror_davos(colab_shell, filename=None):
-    """
-    METHOD UPDATED BY DAVOS PACKAGE
-
-    When davos is imported into a Google Colab notebook, this method is
-    bound to the IPython InteractiveShell instance in place of its
-    normal `showsyntaxerror` method. This allows us to intercept
-    handling of `DavosParserError` (which must derive from
-    `SyntaxError`; see the `davos.exceptions.DavosParserError` docstring
-    for more info) and its subclasses, and display parser exceptions
-    properly with a full traceback.
-
-    ORIGINAL
-    `IPython.core.interactiveshell.InteractiveShell.showsyntaxerror`
-    DOCSTRING:
-
-    Display the syntax error that just occurred.
-
-    This doesn't display a stack trace because there isn't one.
-
-    If a filename is given, it is stuffed in the exception instead
-    of what was there before (because Python's parser always uses
-    "<string>" when reading from a string).
-    """
-    etype, value, tb = colab_shell._get_exc_info()
-    if issubclass(etype, DavosParserError):
-        try:
-            # noinspection PyBroadException
-            try:
-                stb = value._render_traceback_()
-            except:
-                stb = colab_shell.InteractiveTB.structured_traceback(
-                    etype, value, tb, tb_offset=colab_shell.InteractiveTB.tb_offset
-                )
-            colab_shell._showtraceback(etype, value, stb)
-            if colab_shell.call_pdb:
-                colab_shell.debugger(force=True)
-            return
-        except KeyboardInterrupt:
-            print('\n' + colab_shell.get_exception_only(), file=sys.stderr)
-    else:
-        # original method is stored in Davos instance, but still bound
-        # IPython.core.interactiveshell.InteractiveShell instance
-        return davos._ipython_showsyntaxerror_orig(filename=filename)
-
-
-def activate_parser_colab():
-    """
-    **Available in public API via `davos.activate()`**
-
-    Registers the `davos` parser as an IPython `InputTransformer` that
-    will be called on the contents of each code cell.  Can be run
-    manually (`davos.activate()`) after deactivating parser
-    (`davos.deactivate()`) to re-enable the `davos` parser for all
-    future cells (**including the current cell**).
-
-    Notes
-    -----
-    1. There are multiple groups of `InputTransformer`s that IPython
-       runs on cell content at different pre-execution stages, between
-       various steps of the IPython parser.  The `davos` parser is added
-       as a "python_line_transform", which runs after the last step of
-       the IPython parser, before the code is passed off to the Python
-       parser. At this point, the IPython parser has reassembled both
-       explicit (backslash-based) and implicit (parentheses-based) line
-       continuations, so the parser will receive multi-line `smuggle`
-       statements as a single line
-    2. The entire `IPython.core.inputsplitter` module was deprecated in
-       v7.0.0, but Colab runs v5.5.0, so the input transformer still
-       needs to be registered in both places for it to work correctly
-    """
-    colab_shell = davos.ipython_shell
-    smuggle_transformer = StatelessInputTransformer.wrap(smuggle_parser_colab)
-    # noinspection PyDeprecation
-    splitter_xforms = colab_shell.input_splitter.python_line_transforms
-    manager_xforms = colab_shell.input_transformer_manager.python_line_transforms
-    if not any(t.func is smuggle_parser_colab for t in splitter_xforms):
-        splitter_xforms.append(smuggle_transformer())
-    if not any(t.func is smuggle_parser_colab for t in manager_xforms):
-        manager_xforms.append(smuggle_transformer())
-
-    colab_shell.user_ns['smuggle'] = smuggle_colab
-    colab_shell.showsyntaxerror = _showsyntaxerror_davos.__get__(colab_shell)
-
-
-def check_parser_active_colab():
-    # ADD DOCSTRING
-    colab_shell = davos.ipython_shell
-    # noinspection PyDeprecation
-    splitter_xforms = colab_shell.input_splitter.python_line_transforms
-    manager_xforms = colab_shell.input_transformer_manager.python_line_transforms
-    if (
-            any(t.func is smuggle_parser_colab for t in splitter_xforms) and
-            any(t.func is smuggle_parser_colab for t in manager_xforms)
-    ):
-        return True
-    return False
-
-
-# noinspection PyDeprecation
-def deactivate_parser_colab():
-    """
-    **Available in public API via `davos.deactivate()`**
-
-    Disables the `davos` parser for all future cells (**including the
-    current cell**).  The parser may be re-enabled by calling
-    `davos.activate()`.
-
-    Notes
-    -----
-    1. Any `smuggle` statements following a call to `davos.deactivate()`
-       will result in `SyntaxError`s unless the parser is reactivated
-       first.
-    2. The `davos` parser adds very minimal overhead to cell execution.
-       However, running `davos.deactivate()` once the parser is no
-       longer needed (i.e., after the last `smuggle` statement) may be
-       useful when measuring precise runtimes (e.g. profiling code),
-       particularly because the overhead added is a function of the
-       number of lines rather than complexity.
-    *See notes for `activate_parser_colab()`*
-    """
-    colab_shell = davos.ipython_shell
-    splitter_xforms = colab_shell.input_splitter.python_line_transforms
-    manager_xforms = colab_shell.input_transformer_manager.python_line_transforms
-    for xform in splitter_xforms:
-        if xform.func is smuggle_parser_colab:
-            splitter_xforms.remove(xform)
-            break
-    for xform in manager_xforms:
-        if xform.func is smuggle_parser_colab:
-            manager_xforms.remove(xform)
-            break
-
-
-def run_shell_command_colab(command):
-    # much simpler than plain Python equivalent b/c IPython has a
-    # built-in utility function for running shell commands that handles
-    # pretty much everything we need it to, and also formats outputs
-    # nicely in the notebook
-    retcode = _run_shell_cmd(command)
-    if retcode != 0:
-        raise CalledProcessError(returncode=retcode, cmd=command)
-    return retcode
-
-
-def smuggle_colab(
-        name,
-        as_=None,
-        installer='pip',
-        args_str='',
-        installer_kwargs=None
-):
-    # ADD DOCSTRING
-    if installer_kwargs is None:
-        installer_kwargs = {}
-
-    pkg_name = name.split('.')[0]
-    onion = Onion(pkg_name, installer=installer,
-                  args_str=args_str, **installer_kwargs)
-
-    if onion.is_installed:
-        try:
-            # Unlike regular import, can be called on non-module items:
-            #     ```
-            #     import numpy.array`                   # fails
-            #     array = import_item('numpy.array')    # succeeds
-            #     ```
-            # Also adds module (+ parents, if any) to sys.modules if not
-            # already present.
-            smuggled_obj = import_item(name)
-        except ModuleNotFoundError as e:
-            # TODO: check for --yes (conda) and bypass if passed
-            if davos.confirm_install:
-                msg = (f"package {name} is not installed.  Do you want to "
-                       "install it?")
-                install_pkg = prompt_input(msg, default='n')
-                if not install_pkg:
-                    raise e
-            else:
-                install_pkg = True
-        else:
-            install_pkg = False
-    else:
-        install_pkg = True
-
-    if install_pkg:
-        installer_stdout, exit_code = onion.install_package()
-        # invalidate sys.meta_path module finder caches. Forces import
-        # machinery to notice newly installed module
-        importlib.invalidate_caches()
-        # check whether the smuggled package and/or any installed/updated
-        # dependencies were already imported during the current runtime
-        prev_imported_pkgs = get_updated_imported_pkgs(installer_stdout)
-        # if the smuggled package was previously imported, deal with
-        # it last so it's reloaded after its dependencies are in place
-        try:
-            prev_imported_pkgs.remove(pkg_name)
-        except ValueError:
-            # smuggled package is brand new
-            pass
-        else:
-            prev_imported_pkgs.append(pkg_name)
-
-        failed_reloads = []
-        for dep_name in prev_imported_pkgs:
-            dep_modules_old = {}
-            for mod_name in tuple(sys.modules.keys()):
-                # remove submodules of previously imported packages so
-                # new versions get imported when main package is
-                # reloaded (importlib.reload only reloads top-level
-                # module). IPython.lib.deepreload.reload recursively
-                # reloads submodules, but is basically broken because
-                # it's *too* aggressive. It reloads *all* imported
-                # modules... including the import machinery it needs to
-                # run, which crashes it... (-_-* )
-                if mod_name.startswith(f'{dep_name}.'):
-                    dep_modules_old[mod_name] = sys.modules.pop(mod_name)
-
-            # get (but don't pop) top-level package to that it can be
-            # reloaded (must exist in sys.modules)
-            dep_modules_old[dep_name] = sys.modules[dep_name]
-            try:
-                importlib.reload(sys.modules[dep_name])
-            except (ImportError, ModuleNotFoundError, RuntimeError):
-                # if we aren't able to reload the module, put the old
-                # version's submodules we removed back in sys.modules
-                # for now and prepare to show a warning post-execution.
-                # This way:
-                #   1. the user still has a working module until they
-                #      restart the runtime
-                #   2. the error we got doesn't keep getting raised when
-                #      we try to reload/import other modules that
-                #      import it
-                sys.modules.update(dep_modules_old)
-                failed_reloads.append(dep_name)
-
-        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
-            )
-        smuggled_obj = import_item(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:
-        davos.ipython_shell.user_ns[name] = smuggled_obj
-    else:
-        davos.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
-    davos.smuggled[pkg_name] = onion.cache_key
-
-
-def smuggle_parser_colab(line):
-    # ADD DOCSTRING
-    match = smuggle_statement_regex.match(line)
-    if match is None:
-        return line
-
-    matched_groups = match.groupdict()
-    smuggle_chars = matched_groups['FULL_CMD']
-    before_chars, after_chars = line.split(smuggle_chars)
-    cmd_prefix, to_smuggle = smuggle_chars.split('smuggle ', maxsplit=1)
-    cmd_prefix = cmd_prefix.strip()
-    to_smuggle = to_smuggle.strip()
-
-    if cmd_prefix:
-        # cmd_prefix == '"from" package[.module[...]] '
-        is_from_statement = True
-        onion_chars = matched_groups['FROM_ONION'] or matched_groups['FROM_ONION_1']
-        has_semicolon_sep = matched_groups['FROM_SEMICOLON_SEP'] is not None
-        qualname_prefix = f"{''.join(cmd_prefix.split()[1:])}."
-        # remove parentheses around line continuations
-        to_smuggle = to_smuggle.replace(')', '').replace('(', '')
-        # remove inline comments
-        if '\n' in to_smuggle:
-            to_smuggle = ' '.join(l.split('#')[0] for l in to_smuggle.splitlines())
-        else:
-            to_smuggle = to_smuggle.split('#')[0]
-        # normalize whitespace
-        to_smuggle = ' '.join(to_smuggle.split()).strip(', ')
-    else:
-        # cmd_prefix == ''
-        is_from_statement = False
-        onion_chars = matched_groups['ONION']
-        has_semicolon_sep = matched_groups['SEMICOLON_SEP'] is not None
-        qualname_prefix = ''
-
-    kwargs_str = ''        
-    if has_semicolon_sep:
-        after_chars = '; ' + smuggle_parser_colab(after_chars.lstrip('; '))
-    elif onion_chars is not None:
-        onion_chars = onion_chars.replace('"', "'")
-        to_smuggle = to_smuggle.replace(onion_chars, '').rstrip()
-        # `Onion.parse_onion()` returns a 3-tuple of:
-        #  - the installer name (str)
-        #  - the raw arguments to be passed to the installer (str)
-        #  - an {arg: value} mapping from parsed args & defaults (dict)
-        installer, args_str, installer_kwargs = Onion.parse_onion(onion_chars)
-        kwargs_str = (f', installer={installer}, '
-                      f'args_str={args_str}, '
-                      f'installer_kwargs={installer_kwargs}')
-
-    smuggle_funcs = []
-    names_aliases = to_smuggle.split(',')
-    for na in names_aliases:
-        if ' as ' in na:
-            name, alias = na.split(' as ')
-            name = f'"{qualname_prefix}{name}"'
-            alias = f'"{alias.strip()}"'
-        else:
-            name = f'"{qualname_prefix}{na}"'
-            alias = f'"{na.strip()}"' if is_from_statement else None
-
-        name = name.replace(' ', '')
-        smuggle_funcs.append(f'smuggle(name={name}, as_={alias})')
-
-    smuggle_funcs[0] = smuggle_funcs[0][:-1] + kwargs_str + ')'
-    return before_chars + '; '.join(smuggle_funcs) + after_chars
diff --git a/davos/colab.pyi b/davos/colab.pyi
deleted file mode 100644
index 8ca3b52b..00000000
--- a/davos/colab.pyi
+++ /dev/null
@@ -1,28 +0,0 @@
-from typing import Literal, Optional
-from google.colab._shell import Shell as ColabShell
-from davos.core import PipInstallerKwargs
-
-__all__: list[
-    Literal[
-        'activate_parser_colab',
-        'check_parser_active_colab',
-        'deactivate_parser_colab',
-        'run_shell_command_colab',
-        'smuggle_colab',
-        'smuggle_parser_colab'
-    ]
-]
-
-def _showsyntaxerror_davos(colab_shell: ColabShell, filename: Optional[str] = ...) -> None: ...
-def activate_parser_colab() -> None: ...
-def check_parser_active_colab() -> bool: ...
-def deactivate_parser_colab() -> None: ...
-def run_shell_command_colab(command: str) -> Literal[0]: ...
-def smuggle_colab(
-        name: str,
-        as_: Optional[str] = ...,
-        installer: [Literal['conda', 'pip']] = ...,
-        args_str: str = ...,
-        installer_kwargs: Optional[PipInstallerKwargs] = ...
-) -> None: ...
-def smuggle_parser_colab(line: str) -> str: ...
diff --git a/davos/core.py b/davos/core.py
deleted file mode 100644
index e37d8592..00000000
--- a/davos/core.py
+++ /dev/null
@@ -1,396 +0,0 @@
-"""
-This module contains common utilities used by by the smuggle statement
-in all three environments (Python, old IPython/Google Colab, and new
-IPython/Jupyter Notebook).
-"""
-
-
-__all__ = ['Onion', 'prompt_input', 'smuggle_statement_regex']
-
-
-import importlib
-import re
-import sys
-from pathlib import Path
-from subprocess import CalledProcessError
-
-import pkg_resources
-from packaging.requirements import InvalidRequirement
-
-from davos import davos
-from davos.exceptions import (
-    InstallerError,
-    OnionParserError,
-    ParserNotImplementedError
-)
-from davos.parsers import pip_parser
-
-
-class Onion:
-    # ADD DOCSTRING
-    @staticmethod
-    def parse_onion(onion_text):
-        onion_text = onion_text.lstrip('# ')
-        installer, args_str = onion_text.split(':', maxsplit=1)
-        # normalize whitespace
-        installer = installer.strip()
-        args_str = ' '.join(args_str.split()).strip()
-        # regex parsing to identify onion comments already ensures the
-        # comment will start with "<installer>:"
-        if installer == 'pip':
-            parser = pip_parser
-        elif installer == 'conda':
-            msg = "smuggling packages via conda is not yet supported"
-            raise ParserNotImplementedError(
-                msg, target_text=onion_text,
-                target_offset=onion_text.index('conda')
-            )
-        else:
-            # theoretically not possible to get here given regex parser,
-            # but include as a failsafe for completeness
-            msg = ("An unexpected error occurred while trying to parse onion "
-                   f"comment: {onion_text}")
-            raise OnionParserError(msg, target_text=onion_text)
-        installer_kwargs = vars(parser.parse_args(args_str.split()))
-        # arg_str could potentially have both single and double quotes
-        # in it, so triple quote to be safe
-        return f'"{installer}"', f'"""{args_str}"""', installer_kwargs
-
-    def __init__(self, package_name, installer, args_str, **installer_kwargs):
-        # ADD DOCSTRING
-        self.import_name = package_name
-        if installer == 'pip':
-            self.install_package = self._pip_install_package
-        elif installer == 'conda':
-            raise NotImplementedError(
-                "smuggling packages via conda is not yet supported"
-            )
-        else:
-            # here to handle user calling smuggle() *function* directly
-            raise InstallerError(
-                f"Unsupported installer: '{installer}'. Currently supported "
-                "installers are:\n\t'pip'"  # and 'conda'"
-            )
-        self.build = None
-        self.args_str = args_str
-        self.cache_key = f"{installer};{';'.join(args_str.split())}"
-        if args_str == '':
-            # bare smuggle statement without onion comment
-            self.is_editable = False
-            self.verbosity = 0
-            self.installer_kwargs = {}
-            self.install_name = package_name
-            self.version_spec = ''
-            return
-        full_spec = installer_kwargs.pop('spec').strip("'\"")
-        self.is_editable = installer_kwargs.pop('editable')
-        self.verbosity = installer_kwargs.pop('verbosity', 0)
-        self.installer_kwargs = installer_kwargs
-        if '+' in full_spec:
-            # INSTALLING FROM LOCAL/REMOTE VCS:
-            #   self.install_name is the VCS program + '+' + absolute
-            #   local path or remote URL, plus any optional fields
-            #   (#egg=..., [#|&]subdirectory=...) except for @<ref>
-            #   specifier, which is used as self.version_spec if present
-
-            # parse out <ref> without affecting setuptools extras syntax
-            _before, _sep, _after = full_spec.rpartition('@')
-            if '+' in _before:
-                # @<ref> was present and we split there. Get everything
-                # up to #egg=..., #subdirectory=..., or end of spec
-                ver_spec = _after.split('#')[0]
-                # self.install_name is full spec with @<ref> removed
-                self.install_name = full_spec.replace(f'@{ver_spec}', '')
-                self.version_spec = f'=={ver_spec}'
-            else:
-                # @ either not present or used only for setuptools extra
-                self.install_name = full_spec
-                self.version_spec = ''
-        elif '/' in full_spec:
-            # INSTALLING FROM LOCAL PROJECT OR PEP 440 DIRECT REFERENCE:
-            #   self.install_name is the absolute path to a local
-            #   project or source archive, or the URL for a remote source
-            #   archive
-            self.install_name = full_spec
-            self.version_spec = ''
-        else:
-            # INSTALLING USING A REQUIREMENT SPECIFIER:
-            #   most common usage. self.install_name is package name
-            #   provided in onion comment. self.version_spec is
-            #   (optional) version specifier
-
-            # split on ',' to handle multiple specs ('pkg>=1.0,<=2.0')
-            first_subspec = full_spec.split(',')[0]
-            for spec_delim in ('==', '<=', '>=', '!=', '~=', '<', '>', '='):
-                if spec_delim in first_subspec:
-                    self.install_name = full_spec[:full_spec.index(spec_delim)]
-                    ver_spec = full_spec[full_spec.index(spec_delim):]
-                    # if (
-                    #         installer == 'conda' and
-                    #         '=' in ver_spec[len(spec_delim):]
-                    # ):
-                    #     # if conda-installing specific build of package,
-                    #     # separate build version from package version
-                    #     # and handle independently
-                    #     ver_spec, build = ver_spec.rsplit('=', maxsplit=1)
-                    self.version_spec = ver_spec
-                    break
-            else:
-                # no version specified with package name
-                self.install_name = full_spec
-                self.version_spec = ''
-
-    @property
-    def is_installed(self):
-        installer_kwargs = self.installer_kwargs
-        if self.import_name in davos.stdlib_modules:
-            # smuggled module is part of standard library
-            return True
-        elif (
-                installer_kwargs.get('force_reinstall') or
-                installer_kwargs.get('ignore_installed') or
-                installer_kwargs.get('upgrade')
-        ):
-            # args that trigger install regardless of installed version
-            return False
-        elif self.args_str == davos.smuggled.get(self.cache_key):
-            # if the same version of the same package was smuggled from
-            # the same source with all the same arguments previously in
-            # the current interpreter session/notebook runtime (i.e.,
-            # line is just being rerun)
-            return True
-        elif '/' not in self.install_name:
-            full_spec = self.install_name + self.version_spec.replace("'", "")
-            try:
-                pkg_resources.get_distribution(full_spec)
-            except pkg_resources.DistributionNotFound:
-                module_spec = importlib.util.find_spec(self.import_name)
-                if module_spec is None:
-                    # requested package is not installed
-                    return False
-                else:
-                    # requested package is a namespace package
-                    return True
-            except (
-                # package is installed, but installed version doesn't
-                # fit requested version constraints
-                pkg_resources.VersionConflict,
-                # version_spec is invalid or pkg_resources couldn't
-                # parse it
-                InvalidRequirement
-            ):
-                return False
-            else:
-                return True
-        return False
-
-    def _conda_install_package(self):
-        raise NotImplementedError(
-            "smuggling packages via conda is not yet supported"
-        )
-
-    def _pip_install_package(self):
-        # TODO: default behavior (no onion comment) is currently to try
-        #  to simply pip-install the package name. Could raise an
-        #  exception instead?
-        if self.args_str == '':
-            args = self.install_name
-        else:
-            args = self.args_str.replace("<", "'<'").replace(">", "'>'")
-        cmd_str = f'pip install {args}'
-        live_stdout = self.verbosity > -3
-        try:
-            stdout, exit_code = davos.run_shell_command(cmd_str,
-                                                        live_stdout=live_stdout)
-        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)
-        # 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:
-            # make sure alternate target directory is in the module
-            # search path so import machinery will find it
-            sys.path.insert(0, install_dir)
-        elif self.is_editable:
-            install_dir = self.installer_kwargs.get('src')
-            if install_dir is not None:
-                # passing --src only affects editable VCS installs,
-                # which are guaranteed to have #egg=<pkg_name>
-                proj_name = self.install_name.split('#egg=')[1]
-                # get rid of any quotes used to escape operators
-                proj_name = proj_name.replace('"', '').replace("'", "")
-                # only thing #egg=<pkg_name> may be followed by is
-                # &subdirectory=<subdirectory>
-                proj_name, _, subdir_name = proj_name.partition('&subdirectory=')
-                # pip converts underscores to hyphens when naming dir
-                proj_name = proj_name.replace('_', '-')
-                install_dir = Path(install_dir).resolve().joinpath(proj_name,
-                                                                   subdir_name)
-                if install_dir not in sys.path:
-                    sys.path.insert(0, str(install_dir))
-        return stdout, exit_code
-
-
-def prompt_input(prompt, default=None, interrupt=None):
-    # ADD DOCSTRING
-    # NOTE: interrupt applies only to shell interface, not Jupyter/Colab
-    response_values = {
-        'yes': True,
-        'y': True,
-        'no': False,
-        'n': False
-    }
-    if interrupt is not None:
-        interrupt = interrupt.lower()
-        if interrupt not in response_values.keys():
-            raise ValueError(
-                f"'interrupt' must be one of {tuple(response_values.keys())}"
-            )
-    if default is not None:
-        default = default.lower()
-        try:
-            default_value = response_values[default]
-        except KeyError as e:
-            raise ValueError(
-                f"'default' must be one of: {tuple(response_values.keys())}"
-            ) from e
-        response_values[''] = default_value
-        opts = '[Y/n]' if default_value else '[y/N]'
-    else:
-        opts = '[y/n]'
-
-    while True:
-        try:
-            response = input(f"{prompt}\n{opts} ").lower()
-            return response_values[response]
-        except KeyboardInterrupt:
-            if interrupt is not None:
-                return response_values[interrupt]
-            else:
-                raise
-        except KeyError:
-            pass
-
-
-_name_re = r'[a-zA-Z]\w*'
-_smuggle_subexprs = {
-    'name_re': _name_re,
-    'qualname_re': fr'{_name_re}(?: *\. *{_name_re})*',
-    'as_re': fr' +as +{_name_re}',
-    'onion_re': r'\# *(?:pip|conda) *: *[^#\n ].+?(?= +\#| *\n| *$)',
-    'comment_re': r'(?m:\#+.*$)'
-}
-
-# TODO: pattern currently doesn't enforce commas between names in
-#  multiline smuggle from statement
-smuggle_statement_regex = re.compile((
-    r'^\s*'                                                               # match only if statement is first non-whitespace chars
-    r'(?P<FULL_CMD>'                                                      # capture full text of command in named group
-        r'(?:'                                                            # first valid syntax:
-            r'smuggle +{qualname_re}(?:{as_re})?'                         # match 'smuggle' + pkg/module name + optional alias
-            r'(?:'                                                        # match the following:
-                r' *'                                                     #  - any amount of horizontal whitespace
-                r','                                                      #  - followed by a comma
-                r' *'                                                     #  - followed by any amount of horizontal whitespace
-                r'{qualname_re}(?:{as_re})?'                              #  - followed by another pkg + optional alias
-            r')*'                                                         # ... any number of times
-            r'(?P<SEMICOLON_SEP>(?= *; *(?:smuggle|from)))?'              # check for multiple statements separated by semicolon 
-                                                                          #   (match empty string with positive lookahead assertion 
-                                                                          #   so named group gets defined without consuming)
-            r'(?(SEMICOLON_SEP)|'                                         # if the aren't multiple semicolon-separated statements:
-                r'(?:'
-                    r' *(?={onion_re})'                                   # consume horizontal whitespace only if followed by onion
-                    r'(?P<ONION>{onion_re})?'                             # capture onion comment in named group...
-                r')?'                                                     # ...optionally, if present
-            r')'
-        r')|(?:'                                                          # else (line doesn't match first valid syntax):
-            r'from *{qualname_re} +smuggle +'                             # match 'from' + package[.module[...]] + 'smuggle '
-            r'(?P<OPEN_PARENS>\()?'                                       # capture open parenthesis for later check, if present
-            r'(?(OPEN_PARENS)'                                            # if parentheses opened:
-                r'(?:'                                                    # logic for matching possible multiline statement:
-                    r' *'                                                 # capture any spaces following opening parenthesis
-                    r'(?:'                                                # logic for matching code on *first line*:
-                        r'{name_re}(?:{as_re})?'                          # match a name with optional alias
-                        r' *'                                             # optionally, match any number of spaces
-                        r'(?:'                                            # match the following...:
-                            r','                                          #  - a comma
-                            r' *'                                         #  - optionally followed by any number of spaces
-                            r'{name_re}(?:{as_re})?'                      #  - followed by another name with optional alias
-                            r' *'                                         #  - optionally followed by any number of spaces
-                        r')*'                                             # ...any number of times
-                        r',?'                                             # match optional comma after last name, however many there were
-                        r' *'                                             # finally, match any number of optional spaces
-                    r')?'                                                 # any code on first line (matched by preceding group) is optional
-                    r'(?:'                                                # match 1 of 4 possible ends for first line:
-                        r'(?P<FROM_ONION_1>{onion_re}) *{comment_re}?'    #  1. onion, optionally followed by unrelated comment(s)
-                    r'|'                                                  #
-                        r'{comment_re}'                                   #  2. unrelated, non-onion comment
-                    r'|'                                                  #
-                        r'(?m:$)'                                         #  3. nothing further before EOL
-                    r'|'                                                  #
-                        r'(?P<CLOSE_PARENS_FIRSTLINE>\))'                 #  4. close parenthesis on first line
-                    r')'
-                    r'(?(CLOSE_PARENS_FIRSTLINE)|'                        # if parentheses were NOT closed on first line
-                        r'(?:'                                            # logic for matching subsequent line(s) of multiline smuggle statement:
-                            r'\s*'                                        # match any & all whitespace before 2nd line
-                            r'(?:'                                        # match 1 of 3 possibilities for each additional line:
-                                r'{name_re}(?:{as_re})?'                  #  1. similar to first line, match a name & optional alias...
-                                r' *'                                     #     ...followed by any amount of horizontal whitespace...
-                                r'(?:'                                    #     ...
-                                    r','                                  #     ...followed by comma...
-                                    r' *'                                 #     ...optional whitespace...
-                                    r'{name_re}(?:{as_re})?'              #     ...additional name & optional alias
-                                    r' *'                                 #     ...optional whitespace...
-                                r')*'                                     #     ...repeated any number of times
-                                r'[^)\n]*'                                #     ...plus any other content up to newline or close parenthesis
-                            r'|'
-                                r' *{comment_re}'                         #  2. match full-line comment, indented an arbitrary amount
-                            r'|'
-                                r'\n *'                                   #  3. an empty line (truly empty or only whitespace characters)
-                            r')'
-                        r')*'                                             # and repeat for any number of additional lines
-                        r'\)'                                             # finally, match close parenthesis
-                    r')'
-                r')'
-            r'|'                                                          # else (no open parenthesis, so single line or /-continuation):
-                r'{name_re}(?:{as_re})?'                                  # match name with optional alias
-                r'(?:'                                                    # possibly with additional comma-separated names & aliases ...
-                    r' *'                                                 # ...
-                    r','                                                  # ...
-                    r' *'                                                 # ...
-                    r'{name_re}(?:{as_re})?'                              # ...
-                r')*'                                                     # ...repeated any number of times
-            r')'
-            r'(?P<FROM_SEMICOLON_SEP>(?= *; *(?:smuggle|from)))?'         # check for multiple statements separated by semicolon
-            r'(?(FROM_SEMICOLON_SEP)|'                                    # if there aren't additional ;-separated statements...
-                r'(?(FROM_ONION_1)|'                                      # ...and this isn't a multiline statement with onion on line 1:
-                    r'(?:'
-                        r' *(?={onion_re})'                               # consume horizontal whitespace only if followed by onion
-                        r'(?P<FROM_ONION>{onion_re})'                     # capture onion comment in named group...
-                    r')?'                                                 # ...optionally, if present
-                r')'
-            r')'
-        r')'
-    r')'
-).format_map(_smuggle_subexprs))
-
-
-# Condensed, fully substituted regex:
-# ^\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
-# >\#+ *(?:pip|conda) *: *[^#\n ].+?(?= +\#| *\n| *$))?)?))|(?:from *[a-
-# zA-Z]\w*(?: *\. *[a-zA-Z]\w*)* +smuggle +(?P<OPEN_PARENS>\()?(?(OPEN_P
-# ARENS)(?: *(?:[a-zA-Z]\w*(?: +as +[a-zA-Z]\w*)? *(?:, *[a-zA-Z]\w*(?:
-# +as +[a-zA-Z]\w*)? *)*,? *)?(?:(?P<FROM_ONION_1>\#+ *(?:pip|conda) *:
-# *[^#\n ].+?(?= +\#| *\n| *$)) *(?m:\#+.*$)?|(?m:\#+.*$)|(?m:$)|(?P<CLO
-# SE_PARENS_FIRSTLINE>\)))(?(CLOSE_PARENS_FIRSTLINE)|(?:\s*(?:[a-zA-Z]\w
-# *(?: +as +[a-zA-Z]\w*)? *(?:, *[a-zA-Z]\w*(?: +as +[a-zA-Z]\w*)? *)*[^
-# )\n]*| *(?m:\#+.*$)|\n *))*\)))|[a-zA-Z]\w*(?: +as +[a-zA-Z]\w*)?(?: *
-# , *[a-zA-Z]\w*(?: +as +[a-zA-Z]\w*)?)*)(?P<FROM_SEMICOLON_SEP>(?= *; *
-# (?:smuggle|from)))?(?(FROM_SEMICOLON_SEP)|(?(FROM_ONION_1)|(?: *(?=\#+
-#  *(?:pip|conda) *: *[^#\n ].+?(?= +\#| *\n| *$))(?P<FROM_ONION>\#+ *(?
-# :pip|conda) *: *[^#\n ].+?(?= +\#| *\n| *$)))?))))
diff --git a/davos/core/__init__.py b/davos/core/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/davos/core/__init__.pyi b/davos/core/__init__.pyi
new file mode 100644
index 00000000..e69de29b
diff --git a/davos/core/config.py b/davos/core/config.py
new file mode 100644
index 00000000..2ccee937
--- /dev/null
+++ b/davos/core/config.py
@@ -0,0 +1,380 @@
+"""
+This modules defines the global `davos.config` object. The `davos`
+config consists of public fields that may be set by the user to affect
+`davos`' behavior, as well as private (internal use only) fields that
+store information about the context into which the package was imported
+and available functionality.
+"""
+
+
+__all__ = ['DavosConfig']
+
+
+import pathlib
+import sys
+import traceback
+import warnings
+from os.path import expandvars
+from subprocess import CalledProcessError, check_output
+
+from davos.core.exceptions import DavosConfigError, DavosError
+
+
+class SingletonConfig(type):
+    """Metaclass that enforces singleton behavor for `DavosConfig`"""
+
+    __instance = None
+
+    def __call__(cls, *args, **kwargs):
+        if cls.__instance is None:
+            cls.__instance = super().__call__(*args, **kwargs)
+        return cls.__instance
+
+
+class DavosConfig(metaclass=SingletonConfig):
+    """
+    The global `davos` config object.
+
+    Defines the following fields:
+        **Configurable fields**:
+            active : bool
+                Whether the `davos` parser should be run on subsequent
+                input (cells, in Jupyter/Colab notebooks, lines in pure
+                Python; default: `True`)
+            auto_rerun : bool
+                If `True` (default: `False`), automatically restart the
+                interpreter sesssion and rerun previously executed code
+                upon smuggling a package that cannot be dynamically
+                reloaded (Note: currently implemented for Jupyter
+                notebooks only)
+            conda_env: str or None
+                The name of the resident conda environemnt of the
+                current Python interpreter, if running within a `conda`
+                environment. Otherwise, `None`.
+            confirm_install : bool
+                If `True` (default: `False`), prompt for user input
+                before installing any smuggled packages not already
+                available locally.
+            nonineractive : bool
+                If `True` (default: `False`) run `davos` in
+                non-interactive mode. All user input and confirmation
+                will be disabled. **Note**: In Jupyter environments, the
+                value of `auto_rerun` will determine whether `davos`
+                restarts the kernel or throws an error when a smuggled
+                package cannot be dynamically reloaded.
+            pip_executable : str of pathlib.Path
+                The path to the `pip` executable that should be used.
+                Must be a path to a real file. Defaults to automatically
+                discovered executable, if available.
+            suppress_stdout: bool
+                If `True` (default: `False`), suppress all unnecessary
+                output issued by the program. This is often useful when
+                smuggling packages that need to install many
+                dependencies and therefore generate extensive output.
+        **Read-only fields**:
+            conda_avail : bool
+                Whether or not `conda` is installed and the `conda`
+                executable is accessible from the Python interpreter
+            conda_envs_dirs : dict or None
+                If `conda_avail` is `True`, a mapping of conda
+                environment names to their environment directories.
+                Otherwise, `False`.
+            smuggled : dict
+                A cache of packages previously smuggled during the
+                current interpreter session, implemented as a dict whose
+                keys are package names and whose values are the
+                arguments supplied via the corresponding Onion comment.
+                The cache is implemented as such so that altering any
+                arguments passed to the installer will prompt a
+                re-installation.
+    """
+
+    # noinspection PyFinal
+    # (PyCharm doesn't differentiate between declarations here and in
+    # stub file, which it should, according to PEP 591)
+    def __init__(self):
+        ########################################
+        #           READ-ONLY FIELDS           #
+        ########################################
+        try:
+            # noinspection PyUnresolvedReferences
+            # (function exists globally when imported into IPython context)
+            self._ipython_shell = get_ipython()
+        except NameError:
+            # see _block_greedy_ipython_completer() docstring
+            _block_greedy_ipython_completer()
+            # imported from a non-interactive Python script
+            self._ipython_shell = None
+            self._environment = 'Python'
+        else:
+            import IPython
+            if IPython.version_info[0] < 7:
+                if 'google.colab' in str(self._ipython_shell):
+                    self._environment = 'Colaboratory'
+                else:
+                    self._environment = 'IPython<7.0'
+            else:
+                self._environment = 'IPython>=7.0'
+        self._conda_avail = None
+        self._conda_envs_dirs = None
+        self._ipy_showsyntaxerror_orig = None
+        self._smuggled = {}
+        self._stdlib_modules = _get_stdlib_modules()
+        ########################################
+        #          CONFIGURABLE FIELDS         #
+        ########################################
+        self._active = True
+        self._auto_rerun = False
+        self._conda_env = None
+        self._confirm_install = False
+        self._noninteractive = False
+        self._suppress_stdout = False
+        # re: see ContextLab/davos#10
+        # reference pip executable using full path so IPython shell
+        # commands install packages in the notebook kernel environment,
+        # not the notebook server environment
+        #
+        # pip is assumed to be installed -- been included by default
+        # with Python binary installers since 3.4
+        if (
+                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
+            # things are all over the place)
+            try:
+                pip_exe = check_output(['which', 'pip'], encoding='utf-8').strip()
+            except CalledProcessError:
+                # try one more thing before we just throw up our hands...
+                try:
+                    chceck_output([sys.executable, '-c', 'import pip'])
+                except CalledProcessError as e:
+                    raise DavosError(
+                        "Could not find 'pip' executable in $PATH or package "
+                        "in 'sys.modules'"
+                    ) from e
+                else:
+                    warnings.warn(
+                        "Could not find 'pip' executable in $PATH. Falling "
+                        f"back to module invokation ('{sys.executable} -m "
+                        "pip'). You can fix this by setting "
+                        "'davos.config.pip_executable' to your 'pip' "
+                        "executable's path."
+                    )
+                    self._pip_executable = f'{sys.executable} -m pip'
+            else:
+                self._pip_executable = pip_exe
+
+    def __repr__(self):
+        # TODO: implement me
+        #  also implement _repr_html_ and/or _repr_pretty_ et al.
+        return super().__repr__()
+
+    @property
+    def auto_rerun(self):
+        return self._auto_rerun
+
+    @auto_rerun.setter
+    def auto_rerun(self, value):
+        if not isinstance(value, bool):
+            raise DavosConfigError('auto_rerun',
+                                   "field may be 'True' or 'False'")
+        elif self._environment == 'Colaboratory':
+            raise DavosConfigError(
+                'auto_rerun',
+                'automatic rerunning of cells not available in Colaboratory'
+            )
+        self._auto_rerun = value
+
+    @property
+    def confirm_install(self):
+        return self._confirm_install
+
+    @confirm_install.setter
+    def confirm_install(self, value):
+        if not isinstance(value, bool):
+            raise DavosConfigError('confirm_install',
+                                   "field may be 'True' or 'False'")
+        elif self._noninteractive and value:
+            raise DavosConfigError('confirm_install',
+                                   "field may not be 'True' in noninteractive "
+                                   "mode")
+        self._confirm_install = value
+
+    @property
+    def environment(self):
+        return self._environment
+
+    @environment.setter
+    def environment(self, _):
+        raise DavosConfigError('environment', 'field is read-only')
+
+    @property
+    def ipython_shell(self):
+        return self._ipython_shell
+
+    @ipython_shell.setter
+    def ipython_shell(self, _):
+        raise DavosConfigError('ipython_shell', 'field is read-only')
+
+    @property
+    def noninteractive(self):
+        return self._noninteractive
+
+    @noninteractive.setter
+    def noninteractive(self, value):
+        if not isinstance(value, bool):
+            raise DavosConfigError('noninteractive',
+                                   "field may be 'True' or 'False'")
+        elif self._environment == 'Colaboratory':
+            raise DavosConfigError(
+                'noninteractive',
+                "noninteractive mode not available in Colaboratory"
+            )
+        elif value and self._confirm_install:
+            warnings.warn(
+                "noninteractive mode enabled, setting `confirm_install = False`"
+            )
+            self._confirm_install = False
+        self._noninteractive = value
+
+    @property
+    def pip_executable(self) -> str:
+        return self._pip_executable
+
+    @pip_executable.setter
+    def pip_executable(self, exe_path):
+        exe_path = pathlib.Path(expandvars(exe_path)).expanduser()
+        try:
+            exe_path = exe_path.resolve(strict=True)
+        except FileNotFoundError as e:
+            raise DavosConfigError(
+                'pip_executable',  f"No such file or directory: '{exe_path}'"
+            ) from e
+        if str(exe_path) != self._pip_executable:
+            if not exe_path.is_file():
+                raise DavosConfigError(
+                    'pip_executable', f"'{exe_path}' is not an executable file"
+                )
+            self._pip_executable = str(exe_path)
+
+    @property
+    def smuggled(self):
+        return self._smuggled
+
+    @smuggled.setter
+    def smuggled(self, _):
+        raise DavosConfigError('smuggled', 'field is read-only')
+
+    @property
+    def suppress_stdout(self):
+        return self._suppress_stdout
+
+    @suppress_stdout.setter
+    def suppress_stdout(self, value):
+        if not isinstance(value, bool):
+            raise DavosConfigError('suppress_stdout',
+                                   "field may be 'True' or 'False'")
+        self._suppress_stdout = value
+
+
+def _block_greedy_ipython_completer():
+    """
+    Prevent `IPython` autocomplete from preemptively importing `davos`.
+
+    `IPython` uses "greedy" TAB-completion, meaning that in some cases,
+    it will proactivly execute code in order to provide autocomplete
+    suggestions. For instance, if the user presses the TAB key while
+    typing an `import` statement, `IPython` will actually import the
+    module internally to generate suggestions from the names available
+    in its namespace. And because Python caches the module in
+    `sys.modules` during this initial "hidden" `import`, any top-level
+    (initialization) code it contains is skipped for all subsequent
+    imports.
+
+    This is particularly problematic for packages that set
+    `IPython`- or notebook-specific options or behavors on import (like
+    `davos`) because the autocomplete mechanism imports them *outside*
+    the `IPython` environment. To prevent this, `davos` parses the stack
+    trace for any calls originating from IPython's autocomple module
+    (`IPython.core.completerlib`) and, if any are found, removes
+    the relevant `davos` modules from `sys.modules` so they're reloaded
+    when *actually* imported from the notebook. It then raises a generic
+    exception that triggers the autocomple process to exit.
+
+    See Also
+    --------
+    IPython.core.completerlib.try_import :
+        runs import statements to generate autocomplete suggestions
+
+    Notes
+    -----
+    `IPython`'s greedy completion behavior is enabled by default and can
+    be disabled by running the following "magic" commands:
+    ```python
+    %config IPCompleter.greedy = False
+    %config IPCompleter.use_jedi = False
+    ```
+    or the setting the following options in the `IPython` config
+    file:
+    ```python
+    c.IPCompleter.greedy = False
+    c.IPCompleter.use_jedi = False
+    ```
+    """
+    # extract 20 most recent entries. Completer module usually appears
+    # ~12 entries deep, after davos & importlib, so add small buffer to
+    # be safe and start searching from oldest.
+    stack_trace = traceback.extract_stack(limit=20)
+    # drop most recent 3 frames, which will always be nternal davos
+    # calls due to package layout
+    for frame in stack_trace[:-3]:
+        if frame.filename.endswith('IPython/core/completerlib.py'):
+            # if stack contains calls to functions in completer module,
+            # 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']
+            raise Exception
+
+
+def _get_stdlib_modules():
+    """
+    Get names of standard library modules
+
+    For efficiency, get standard library module names upfront. This
+    allows us to skip file system checks for any smuggled stdlib
+    modules.
+
+    Returns
+    -------
+    set of str
+        names of standard library modules for the user's Python
+        implementation
+
+    Notes
+    -----
+    There's actually a standard library implementation of this, but it's
+    not implemented for Python<3.10:
+    https://docs.python.org/3.10/library/sys.html#sys.stdlib_module_names
+    """
+    stdlib_dir = pathlib.Path(pathlib.__file__).parent
+    stdlib_modules = []
+    for p in stdlib_dir.iterdir():
+        if (
+                (p.is_dir() and p.joinpath('__init__.py').is_file()) or
+                p.suffix == '.py'
+        ):
+            stdlib_modules.append(p.stem)
+    try:
+        builtins_dir = next(d for d in sys.path if d.endswith('lib-dynload'))
+    except StopIteration:
+        pass
+    else:
+        for p in pathlib.Path(builtins_dir).glob('*.cpython*.so'):
+            stdlib_modules.append(p.name.split('.')[0])
+    stdlib_modules.extend(sys.builtin_module_names)
+    return set(stdlib_modules)
diff --git a/davos/core/config.pyi b/davos/core/config.pyi
new file mode 100644
index 00000000..fba5634c
--- /dev/null
+++ b/davos/core/config.pyi
@@ -0,0 +1,97 @@
+from pathlib import PurePosixPath
+from typing import (
+    Any, 
+    ClassVar, 
+    Final, 
+    Literal, 
+    NoReturn, 
+    Optional, 
+    Protocol, 
+    Union
+)
+from google.colab._shell import Shell                            # type: ignore
+from IPython.core.interactiveshell import InteractiveShell       # type: ignore
+
+__all__: list[Literal['DavosConfig']]
+
+_Environment = Literal['Colaboratory', 'IPython<7.0', 'IPython>=7.0', 'Python']
+IpythonShell = Union[InteractiveShell, Shell]
+
+class IpyShowSyntaxErrorPre7(Protocol):
+    def __call__(self, filename: Optional[str] = ...) -> None: ...
+    
+class IpyShowSyntaxErrorPost7(Protocol):
+    def __call__(self, filename: Optional[str] = ..., running_compile_code: bool = ...) -> None: ...
+
+class SingletonConfig(type):
+    __instance: ClassVar[Optional[DavosConfig]]
+    def __call__(cls, *args: Any, **kwargs: Any) -> DavosConfig: ...
+    
+class DavosConfig(metaclass=SingletonConfig):
+    _active: bool
+    _auto_rerun: bool
+    _conda_avail: Optional[bool]
+    _conda_env: Optional[str]
+    _conda_envs_dirs: Optional[dict[str, str]]
+    _confirm_install: bool
+    _environment: Final[_Environment]
+    _ipy_showsyntaxerror_orig: Optional[Union[IpyShowSyntaxErrorPre7, IpyShowSyntaxErrorPost7]]
+    _ipython_shell: Final[Optional[IpythonShell]]
+    _noninteractive: bool
+    _pip_executable: str
+    _smuggled: dict[str, str]
+    _stdlib_modules: Final[set[str]]
+    _suppress_stdout: bool
+    def __init__(self) -> None: ...
+    def __repr__(self) -> str: ...
+    @property
+    def active(self) -> bool: ...
+    @active.setter
+    def active(self, state: bool) -> None: ...
+    @property
+    def auto_rerun(self) -> bool: ...
+    @auto_rerun.setter
+    def auto_rerun(self, value: bool) -> None: ...
+    @property
+    def conda_avail(self) -> bool: ...
+    @conda_avail.setter
+    def conda_avail(self, _: Any) -> NoReturn: ...
+    @property
+    def conda_env(self) -> Optional[str]: ...
+    @conda_env.setter
+    def conda_env(self, new_env: str) -> None: ...
+    @property
+    def conda_envs_dirs(self) -> Optional[dict[str, str]]: ...
+    @conda_envs_dirs.setter
+    def conda_envs_dirs(self, _: Any) -> NoReturn: ...
+    @property
+    def confirm_install(self) -> bool: ...
+    @confirm_install.setter
+    def confirm_install(self, value: bool) -> None: ...
+    @property
+    def environment(self) -> _Environment: ...
+    @environment.setter
+    def environment(self, _: Any) -> NoReturn: ...
+    @property
+    def ipython_shell(self) -> Optional[IpythonShell]: ...
+    @ipython_shell.setter
+    def ipython_shell(self, _: Any) -> NoReturn: ...
+    @property
+    def noninteractive(self) -> bool: ...
+    @noninteractive.setter
+    def noninteractive(self, value: bool) -> None: ...
+    @property
+    def pip_executable(self) -> str: ...
+    @pip_executable.setter
+    def pip_executable(self, exe_path: Union[PurePosixPath, str]) -> None: ...
+    @property
+    def smuggled(self) -> dict[str, str]: ...
+    @smuggled.setter
+    def smuggled(self, _: Any) -> NoReturn: ...
+    @property
+    def suppress_stdout(self) -> bool: ...
+    @suppress_stdout.setter
+    def suppress_stdout(self, value: bool) -> None: ...
+
+def _block_greedy_ipython_completer() -> None: ...
+def _get_stdlib_modules() -> set[str]: ...
diff --git a/davos/core/core.py b/davos/core/core.py
new file mode 100644
index 00000000..a36b010b
--- /dev/null
+++ b/davos/core/core.py
@@ -0,0 +1,967 @@
+"""
+This module implements core functionality and common utilities used
+across the environment-specific `davos` implementations.
+"""
+
+
+__all__ = [
+    'capture_stdout',
+    'check_conda',
+    'Onion',
+    'parse_line',
+    'prompt_input',
+    'run_shell_command'
+]
+
+
+import importlib
+import itertools
+import sys
+from contextlib import redirect_stdout
+from io import StringIO
+from pathlib import Path
+from subprocess import CalledProcessError
+
+import pkg_resources
+from packaging.requirements import InvalidRequirement
+
+from davos import config
+from davos.core.exceptions import (
+    DavosError,
+    InstallerError,
+    OnionParserError,
+    ParserNotImplementedError,
+    SmugglerError
+)
+from davos.core.parsers import pip_parser
+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,
+    auto_restart_rerun,
+    prompt_restart_rerun_buttons
+)
+
+
+class capture_stdout:
+    """
+    Context manager for sending stdout to multiple streams at once.
+
+    Similar to `contextlib.redirect_stdout`, but different in that it:
+      - temporarily writes stdout to other streams *in addition to*
+        rather than *instead of* `sys.stdout`
+      - accepts any number of streams and sends stdout to each
+      - can optionally keep streams open after exiting context by
+        passing `closing=False`
+
+    Works by temporarily replacing `sys.stdout`'s `write` method with
+    its own `_write` method, which writes the received data to all
+    provided streams in addition to `sys.stdout`.
+    """
+
+    def __init__(self, *streams, closing=True):
+        """
+        Parameters
+        ----------
+        streams : io.IOBase
+            stream(s) to receive data sent to `sys.stdout`
+        closing : bool, optional
+            if `True` (default), close streams upon exiting the context
+            block.
+        """
+        self.streams = streams
+        self.closing = closing
+        self.sys_stdout_write = sys.stdout.write
+
+    def __enter__(self):
+        sys.stdout.write = self._write
+        if len(self.streams) == 1:
+            return self.streams[0]
+        return self.streams
+
+    def __exit__(self, exc_type, exc_value, exc_tb):
+        sys.stdout.write = self.sys_stdout_write
+        if self.closing:
+            for s in self.streams:
+                s.close()
+
+    def _write(self, data):
+        for s in self.streams:
+            s.write(data)
+        self.sys_stdout_write(data)
+        sys.stdout.flush()
+
+
+def check_conda():
+    """
+    Check whether conda is installed and get environment info, if so.
+
+    Called lazily to set values for `conda_avail`, `conda_env`, and
+    `conda_envs_dirs` config fields. Checks whether the conda executable
+    is available by running `conda list IPython` (via environment-
+    dependent `_check_conda_avail_helper()` function). If successful,
+    parses command output for name of active conda environment. Also
+    parses {env_name: env_path} mapping of all available environments
+    from the output of `conda info --envs`.
+
+    Raises
+    ------
+    DavosError
+        If the environment mapping could be parsed from the output of
+        `conda info --envs`, but the active environment name couldn't be
+        parsed from the output of `conda list IPython`
+
+    Notes
+    -----
+    The full shell command run to format the output of
+    `conda info --envs` is:
+    ```sh
+    conda info --envs | grep -E '^\w' | sed -E 's/ +\*? +/ /g'
+    ```
+    """
+    conda_list_output = _check_conda_avail_helper()
+    if conda_list_output is None:
+        config._conda_avail = False
+        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` &
+    # `-p`/`--prefix` when parsing onion comments
+    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,
+                                              live_stdout=False)
+        # 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
+        # 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
+    # parse more conservatively since output is so short
+    for w in conda_list_output.split():
+        if '/' in w:
+            env_name = w.split('/')[-1].rstrip(':')
+            if (
+                    envs_dirs_dict is not None and
+                    env_name not in envs_dirs_dict.keys()
+            ):
+                continue
+            config._conda_env = env_name
+            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
+        # potential errors to conda executable
+        raise DavosError(
+            "Failed to programmatically determine path to conda environment "
+            "directory. If you want to install smuggled packages using conda, "
+            "you can either:\n\t1. set `davos.config.conda_env_path` to your "
+            "environment's path (e.g., $CONDA_PREFIX/envs/this_env)\n\t2. "
+            "pass the environment path to `-p`/`--prefix` in each onion "
+            "comment\n\t3. pass your environment's name to `-n`/`--name` in "
+            "each onion comment"
+        )
+
+
+def get_previously_imported_pkgs(install_cmd_stdout, installer):
+    """
+    Get just-installed packages previously imported by the interpreter.
+
+    Parses stdout from installing a smuggled package for the names of
+    all packages/dependencies that were installed or upgraded. Then,
+    converts these "install names" (e.g., `scikit-learn`) to "import
+    names" (e.g., `sklearn`) and checks for them in `sys.modules`.
+    Returns the list import names found.
+
+    Parameters
+    ----------
+    install_cmd_stdout : str
+        Captured stdout generated by the smuggled package's installation
+    installer : {'pip', 'conda'}
+        The name of the program that generated the output to be parsed
+
+    Returns
+    -------
+    list of str
+        Names of packages installed/upgraded when installed the smuggled
+        package that were previously imported by the current interpreter
+
+    See Also
+    --------
+    google.colab._pip._previously_imported_packages :
+        https://github.com/googlecolab/colabtools/blob/2211417/google/colab/_pip.py#L93
+
+    Notes
+    -----
+    Functionally, this is a reimplementation of `colabtools`'s
+    `google.colab._pip._previously_imported_packages()`. This version
+    has some minor tweaks that make it more efficient, but is mostly
+    meant to be available when `colabtools` may not be installed (i.e.,
+    outside of Colaboratory).
+    """
+    if installer == 'conda':
+        raise NotImplementedError(
+            "conda-install stdout parsing is not yet implemented"
+        )
+    else:
+        installed_pkg_regex = pip_installed_pkgs_regex
+
+    matches = installed_pkg_regex.findall(install_cmd_stdout)
+    if len(matches) == 0:
+        return []
+
+    # 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:
+            # 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:
+            if name in sys.modules:
+                prev_imported_pkgs.append(name)
+
+    return prev_imported_pkgs
+
+
+def import_name(name):
+    """
+    Import an object by its qualified name.
+
+    Can be used to load a module, submodule, class, function, or other
+    object, including in situations where the regular `import` statement
+    would fail.
+
+    Parameters
+    ----------
+    name : str
+        The fully qualified name of the object to import
+
+    Returns
+    -------
+    object
+        The imported object
+
+    See Also
+    --------
+    IPython.utils.importstring.import_item :
+        https://github.com/ipython/ipython/blob/b3355a9/IPython/utils/importstring.py#L10
+
+    Notes
+    -----
+    This is a near-exact reimplementation of
+    `IPython.utils.importstring.import_item`. This version exists to
+    make it available in pure Python environments where `IPython` may
+    not be installed.
+    """
+    parts = name.rsplit('.', maxsplit=1)
+    if len(parts) == 2:
+        mod_name, obj_name = parts
+        # built-in __import__ should be faster than importlib.__import__
+        module = __import__(mod_name, fromlist=[obj_name])
+        try:
+            obj = getattr(module, obj_name)
+        except AttributeError as e:
+            raise ImportError(
+                f'No module or object "{obj_name}" found in "{mod_name}"'
+            ) from e
+        return obj
+    else:
+        return __import__(parts[0])
+
+
+class Onion:
+    """
+    Class representing a single package to be smuggled.
+
+    Naturally, "`davos`" smuggles "`Onion`s". Internally, each `smuggle`
+    statement (with or without an "onion comment") creates an `Onion`
+    instance, which contains all the information necessary to import
+    and, if necessary, install it.
+    """
+
+    @staticmethod
+    def parse_onion(onion_text):
+        """
+        Parse the installer name and arguments from an onion comment
+
+        Parameters
+        ----------
+        onion_text : str
+            an onion comment, including the leading "#"
+
+        Returns
+        -------
+        tuple
+            3-tuple comprised of:
+                1. The installer name, enclosed in double quotes (str)
+                2. The raw arguments to be passed to the installer,
+                   enclosed in triple-double quotes (str)
+                3. An {arg: value} of argument values parsed from the
+                  raw argument string, supplemented by default values
+                  (dict)
+        """
+        onion_text = onion_text.lstrip('# ')
+        installer, args_str = onion_text.split(':', maxsplit=1)
+        # normalize whitespace
+        installer = installer.strip()
+        args_str = ' '.join(args_str.split()).strip()
+        # regex parsing to identify onion comments already ensures the
+        # comment will start with "<installer>:"
+        if installer == 'pip':
+            parser = pip_parser
+        elif installer == 'conda':
+            msg = "smuggling packages via conda is not yet supported"
+            raise ParserNotImplementedError(
+                msg, target_text=onion_text,
+                target_offset=onion_text.index('conda')
+            )
+        else:
+            # theoretically not possible to get here given regex parser,
+            # but include as a failsafe for completeness
+            msg = ("An unexpected error occurred while trying to parse onion "
+                   f"comment: {onion_text}")
+            raise OnionParserError(msg, target_text=onion_text)
+        installer_kwargs = vars(parser.parse_args(args_str.split()))
+        # arg_str could potentially have both single and double quotes
+        # in it, so triple quote to be safe
+        return f'"{installer}"', f'"""{args_str}"""', installer_kwargs
+
+    def __init__(self, package_name, installer, args_str, **installer_kwargs):
+        """
+        Parameters
+        ----------
+        package_name : str
+            The name of the package or top-level module to be smuggled.
+        installer : {'pip', 'conda'}
+            The name of the program used to install the package if a
+            compatible distribution (package name, version, build, etc.)
+            cannot be found locally.
+        args_str : str
+            Raw arguments (taken from an onion comment, if provided) to
+            be passed to the `installer` program's "install" command.
+        **installer_kwargs : dict, optional
+            Additional argument values to be passed to the installer.
+        """
+        self.import_name = package_name
+        self.installer = installer
+        if installer == 'pip':
+            self.install_package = self._pip_install_package
+            self.build = None
+        elif installer == 'conda':
+            raise NotImplementedError(
+                "smuggling packages via conda is not yet supported"
+            )
+        else:
+            # here to handle user calling smuggle() *function* directly
+            raise OnionParserError(
+                f"Unsupported installer: '{installer}'. Currently supported "
+                "installers are:\n\t'pip'"  # and 'conda'"
+            )
+        self.args_str = args_str
+        self.cache_key = f"{installer};{';'.join(args_str.split())}"
+        if args_str == '':
+            # bare smuggle statement without onion comment
+            self.is_editable = False
+            self.verbosity = 0
+            self.installer_kwargs = {}
+            self.install_name = package_name
+            self.version_spec = ''
+            return
+        full_spec = installer_kwargs.pop('spec').strip("'\"")
+        self.is_editable = installer_kwargs.pop('editable')
+        self.verbosity = installer_kwargs.pop('verbosity', 0)
+        self.installer_kwargs = installer_kwargs
+        if '+' in full_spec:
+            # INSTALLING FROM LOCAL/REMOTE VCS:
+            #   self.install_name is the VCS program + '+' + absolute
+            #   local path or remote URL, plus any optional fields
+            #   (#egg=..., [#|&]subdirectory=...) except for @<ref>
+            #   specifier, which is used as self.version_spec if present
+
+            # parse out <ref> without affecting setuptools extras syntax
+            _before, _sep, _after = full_spec.rpartition('@')
+            if '+' in _before:
+                # @<ref> was present and we split there. Get everything
+                # up to #egg=..., #subdirectory=..., or end of spec, and
+                # add '@' back to spec
+                ver_spec = f'@{_after.split("#")[0]}'
+                # self.install_name is full spec with @<ref> removed
+                self.install_name = full_spec.replace(ver_spec, '')
+                self.version_spec = ver_spec
+            else:
+                # @ either not present or used only for setuptools extra
+                self.install_name = full_spec
+                self.version_spec = ''
+        elif '/' in full_spec:
+            # INSTALLING FROM LOCAL PROJECT OR PEP 440 DIRECT REFERENCE:
+            #   self.install_name is the absolute path to a local
+            #   project or source archive, or the URL for a remote source
+            #   archive
+            self.install_name = full_spec
+            self.version_spec = ''
+        else:
+            # INSTALLING USING A REQUIREMENT SPECIFIER:
+            #   most common usage. self.install_name is package name
+            #   provided in onion comment. self.version_spec is
+            #   (optional) version specifier
+
+            # split on ',' to handle multiple specs ('pkg>=1.0,<=2.0')
+            first_subspec = full_spec.split(',')[0]
+            for spec_delim in ('==', '<=', '>=', '!=', '~=', '<', '>', '='):
+                if spec_delim in first_subspec:
+                    self.install_name = full_spec[:full_spec.index(spec_delim)]
+                    ver_spec = full_spec[full_spec.index(spec_delim):]
+                    # if (
+                    #         installer == 'conda' and
+                    #         '=' in ver_spec[len(spec_delim):]
+                    # ):
+                    #     # if conda-installing specific build of package,
+                    #     # separate build version from package version
+                    #     # and handle independently
+                    #     ver_spec, build = ver_spec.rsplit('=', maxsplit=1)
+                    self.version_spec = ver_spec
+                    break
+            else:
+                # no version specified with package name
+                self.install_name = full_spec
+                self.version_spec = ''
+
+    @property
+    def install_cmd(self):
+        if self.args_str == '':
+            args = self.install_name
+        else:
+            args = self.args_str.replace("<", "'<'").replace(">", "'>'")
+        if self.installer == 'pip':
+            install_exe = config._pip_executable
+        else:
+            install_exe = self.installer
+
+        return f'{install_exe} install {args}'
+
+    @property
+    def is_installed(self):
+        installer_kwargs = self.installer_kwargs
+        if self.import_name in config._stdlib_modules:
+            # smuggled module is part of standard library
+            return True
+        elif (
+                installer_kwargs.get('force_reinstall') or
+                installer_kwargs.get('ignore_installed') or
+                installer_kwargs.get('upgrade')
+        ):
+            # args that trigger install regardless of installed version
+            return False
+        elif self.args_str == config._smuggled.get(self.cache_key):
+            # if the same version of the same package was smuggled from
+            # the same source with all the same arguments previously in
+            # the current interpreter session/notebook runtime (i.e.,
+            # line is just being rerun)
+            return True
+        elif '/' not in self.install_name:
+            # onion comment does not specify a VCS URL
+            full_spec = self.install_name + self.version_spec.replace("'", "")
+            try:
+                pkg_resources.get_distribution(full_spec)
+            except pkg_resources.DistributionNotFound:
+                # noinspection PyUnresolvedReferences
+                # (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
+                    return False
+                else:
+                    # requested package is a namespace package
+                    return True
+            except (
+                # package is installed, but installed version doesn't
+                # fit requested version constraints
+                pkg_resources.VersionConflict,
+                # version_spec is invalid or pkg_resources couldn't
+                # parse it
+                InvalidRequirement
+            ):
+                return False
+            else:
+                return True
+        return False
+
+    def _conda_install_package(self):
+        raise NotImplementedError(
+            "smuggling packages via conda is not yet supported"
+        )
+
+    def _pip_install_package(self):
+        try:
+            stdout = run_shell_command(self.install_cmd)
+        except CalledProcessError as e:
+            raise InstallerError.from_error(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:
+            # make sure alternate target directory is in the module
+            # search path so import machinery will find it
+            sys.path.insert(0, install_dir)
+        elif self.is_editable:
+            install_dir = self.installer_kwargs.get('src')
+            if install_dir is not None:
+                # passing --src only affects editable VCS installs,
+                # which are guaranteed to have #egg=<pkg_name>
+                proj_name = self.install_name.split('#egg=')[1]
+                # get rid of any quotes used to escape operators
+                proj_name = proj_name.replace('"', '').replace("'", "")
+                # only thing #egg=<pkg_name> may be followed by is
+                # &subdirectory=<subdirectory>
+                proj_name, _, subdir_name = proj_name.partition('&subdirectory=')
+                # pip converts underscores to hyphens when naming dir
+                proj_name = proj_name.replace('_', '-')
+                install_dir = Path(install_dir).resolve().joinpath(proj_name,
+                                                                   subdir_name)
+                if install_dir not in sys.path:
+                    sys.path.insert(0, str(install_dir))
+        return stdout
+
+
+def parse_line(line):
+    """
+    Parse a single line of code, transforming `smuggle` statements.
+
+    Checks a line of user code for a `smuggle` statement and (optional)
+    onion comment. If found, replaces them with a corresponding call to
+    the `smuggle()` function generated from their parsed contents.
+
+    Parameters
+    ----------
+    line : str
+        A line of Python code.
+
+    Returns
+    -------
+    str
+        The input line, with any `smuggle` statements and onion comments
+        replaced with calls to the `smuggle()` function.
+
+    See Also
+    --------
+    regexps.smuggle_statement_regex : Regexp for `smuggle` statements
+    implementations.ipython_pre7.generate_parser_func :
+        Generates full parser wrapper function for `IPython<7.0`
+    implementations.ipython_post7.generate_parser_func :
+        Generates full parser wrapper function for `IPython<7.0`
+
+    Notes
+    -----
+    This function transforms "logical" lines rather than "physical"
+    lines. In other words, line continuations and multi-line
+    statements/expressions (including `smuggle` statements) should be
+    assembled from multiple physical lines before being passed to
+    `parse_line()`. This function is wrapped by an implementation-
+    specific parser function and called for each (logical) line to be
+    parsed.
+    """
+    match = smuggle_statement_regex.match(line)
+    if match is None:
+        return line
+
+    matched_groups = match.groupdict()
+    smuggle_chars = matched_groups['FULL_CMD']
+    before_chars, after_chars = line.split(smuggle_chars)
+    cmd_prefix, to_smuggle = smuggle_chars.split('smuggle ', maxsplit=1)
+    cmd_prefix = cmd_prefix.strip()
+    to_smuggle = to_smuggle.strip()
+
+    if cmd_prefix:
+        # cmd_prefix is `"from" package[.module[...]] `
+        is_from_statement = True
+        onion_chars = matched_groups['FROM_ONION'] or matched_groups['FROM_ONION_1']
+        has_semicolon_sep = matched_groups['FROM_SEMICOLON_SEP'] is not None
+        qualname_prefix = f"{''.join(cmd_prefix.split()[1:])}."
+        # remove parentheses around line continuations
+        to_smuggle = to_smuggle.replace(')', '').replace('(', '')
+        # remove inline comments
+        if '\n' in to_smuggle:
+            to_smuggle = ' '.join(l.split('#')[0] for l in to_smuggle.splitlines())
+        else:
+            to_smuggle = to_smuggle.split('#')[0]
+        # normalize whitespace
+        to_smuggle = ' '.join(to_smuggle.split()).strip(', ')
+    else:
+        # cmd_prefix is ''
+        is_from_statement = False
+        onion_chars = matched_groups['ONION']
+        has_semicolon_sep = matched_groups['SEMICOLON_SEP'] is not None
+        qualname_prefix = ''
+
+    kwargs_str = ''
+    if has_semicolon_sep:
+        after_chars = '; ' + parse_line(after_chars.lstrip('; '))
+    elif onion_chars is not None:
+        onion_chars = onion_chars.replace('"', "'")
+        to_smuggle = to_smuggle.replace(onion_chars, '').rstrip()
+        # `Onion.parse_onion()` returns a 3-tuple of:
+        #  - the installer name (str)
+        #  - the raw arguments to be passed to the installer (str)
+        #  - an {arg: value} mapping from parsed args & defaults (dict)
+        installer, args_str, installer_kwargs = Onion.parse_onion(onion_chars)
+        kwargs_str = (f', installer={installer}, '
+                      f'args_str={args_str}, '
+                      f'installer_kwargs={installer_kwargs}')
+
+    smuggle_funcs = []
+    names_aliases = to_smuggle.split(',')
+    for na in names_aliases:
+        if ' as ' in na:
+            name, alias = na.split(' as ')
+            name = f'"{qualname_prefix}{name}"'
+            alias = f'"{alias.strip()}"'
+        else:
+            name = f'"{qualname_prefix}{na}"'
+            alias = f'"{na.strip()}"' if is_from_statement else None
+
+        name = name.replace(' ', '')
+        smuggle_funcs.append(f'smuggle(name={name}, as_={alias})')
+
+    smuggle_funcs[0] = smuggle_funcs[0][:-1] + kwargs_str + ')'
+    return before_chars + '; '.join(smuggle_funcs) + after_chars
+
+
+def prompt_input(prompt, default=None, interrupt=None):
+    """
+    Prompt the user for [y]es/[n]o input, return a boolean accordingly.
+
+    Parameters
+    ----------
+    prompt : str
+        The prompt text presented on-screen to the user.
+    default : {'y', 'yes', 'n', 'no'}, optional
+        The default response if the user presses return without entering
+        any text. if `None` (default), re-prompt the user until input is
+        provided.
+    interrupt : {'y', 'yes', 'n', 'no'}, optional
+        The default response if a `KeyboardInterrupt` is raised
+        (`CTRL + c` from the command line, "interrupt kernel/runtime"
+        from a Jupyter/Colab notebook). If `None` (default), the
+        exception is raised. Use with caution to avoid unexpected
+        behavior and improperly silencing errors.
+
+    Returns
+    -------
+    bool
+        The boolean value corresponding to the user input (`True` for
+        `'y'`/`'yes'`, `False` for `'n'`/`'no'`).
+
+    Notes
+    -----
+    The `default` value is reflected in the casing of options displayed
+    after the `prompt` text (e.g., "`[Y/n]`" if `default="yes"`)
+    """
+    response_values = {
+        'yes': True,
+        'y': True,
+        'no': False,
+        'n': False
+    }
+    if interrupt is not None:
+        interrupt = interrupt.lower()
+        if interrupt not in response_values.keys():
+            raise ValueError(
+                f"'interrupt' must be one of {tuple(response_values.keys())}"
+            )
+    if default is not None:
+        default = default.lower()
+        try:
+            default_value = response_values[default]
+        except KeyError as e:
+            raise ValueError(
+                f"'default' must be one of: {tuple(response_values.keys())}"
+            ) from e
+        response_values[''] = default_value
+        opts = '[Y/n]' if default_value else '[y/N]'
+    else:
+        opts = '[y/n]'
+
+    while True:
+        try:
+            response = input(f"{prompt}\n{opts} ").lower()
+            return response_values[response]
+        except KeyboardInterrupt:
+            if interrupt is not None:
+                return response_values[interrupt]
+            else:
+                raise
+        except KeyError:
+            pass
+
+
+def run_shell_command(command, live_stdout=None):
+    """
+    Execute a shell command and return the generated stdout as a string.
+
+    Parameters
+    ----------
+    command : str
+        The shell command to run. May not end in "*&*", as background
+        processes are not supported.
+    live_stdout : bool, optional
+        Whether to display streaming stdout from `command` execution in
+        real time *in addition to* capturing and returning it. If `None`
+        (default), behavior is determined by the current value of
+        `davos.config.suppress_stdout`.
+
+    Returns
+    -------
+    str
+        The stdout generated by executing the shell command.
+
+    Raises
+    ------
+    subprocess.CalledProcessError
+        If the command returns a non-zero exit status
+
+    See Also
+    --------
+    capture_stdout : context manager used when displaying live stdout
+    contextlib.redirect_stdout :
+        context manager used when not displaying live stdout
+    implementations.ipython_common._run_shell_command_helper :
+        Helper function to run shell command in IPython environments
+    implementations.python._run_shell_command_helper :
+        Helper function to run shell command in "pure"
+        (non-interactive) Python environments
+
+    Notes
+    -----
+    `IPython` does not allow shell commands to run background processes.
+    `davos` extends this policy to all environments for consistency (and
+    lack of reason not to).
+    """
+    if command.rstrip().endswith('&'):
+        raise OSError("Background processes are not supported.")
+
+    if live_stdout is None:
+        live_stdout = not config.suppress_stdout
+    if live_stdout:
+        command_context = capture_stdout
+    else:
+        command_context = redirect_stdout
+    with command_context(StringIO()) as stdout:
+        try:
+            _run_shell_command_helper(command)
+        except CalledProcessError as e:
+            # if the exception doesn't record the output, add it
+            # manually before raising
+            stdout = stdout.getvalue()
+            if e.output is None and stdout != '':
+                e.output = stdout
+            raise e
+        else:
+            stdout = stdout.getvalue()
+    return stdout
+
+
+def smuggle(
+        name,
+        as_=None,
+        installer='pip',
+        args_str='',
+        installer_kwargs=None
+):
+    """
+    Load a package into the namespace, installing it first if necessary.
+
+    This implements the core functionality of `davos`. Before user code
+    is executed, it is parsed as plain text, and each `smuggle`
+    statement (plus, optionally, its corresponding onion comment) is
+    replaced with a call to this function. The `smuggle()` function
+    first searches for an existing local installation of the specified
+    package (that, if applicable, satisfies the version specifier and
+    other arguments provided via the onion comment). If one is not
+    found, the package is downloaded and installed. The `smuggle()`
+    function then loads the package, module, function, or other
+    `smuggle`d object into the local namespace as though it were
+    `import`ed normally.
+
+    Parameters
+    ----------
+    name : str
+        The qualified name of the package, module, function, or other
+        object to be `smuggled`.
+    as_ : str, optional
+        The alias under which to load the object into the namespace
+        (e.g., "`np`" given the statement "`smuggle numpy as np`").
+    installer : {'pip', 'conda'}, optional
+        The name of the program used to install the package if a
+        satisfactory distribution is not found locally. Defaults to
+        `'pip'` if no onion comment is present
+    args_str : str, optional
+        Raw arguments to be passed to the `installer` program's
+        "install" command. Defaults to an empty string (`''`), if no
+        onion comment is present.
+    installer_kwargs : dict, optional
+        Argument values parsed from `args_str`, supplemented by
+        defaults.
+    """
+    if installer_kwargs is None:
+        installer_kwargs = {}
+
+    pkg_name = name.split('.')[0]
+    onion = Onion(pkg_name, installer=installer,
+                  args_str=args_str, **installer_kwargs)
+
+    if onion.is_installed:
+        try:
+            # Unlike regular import, can be called on non-module items:
+            #     ```
+            #     import numpy.array`                   # fails
+            #     array = import_item('numpy.array')    # succeeds
+            #     ```
+            # Also adds module (+ parents, if any) to sys.modules if not
+            # already present.
+            smuggled_obj = import_name(name)
+        except ModuleNotFoundError as e:
+            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 and not installer_kwargs.get('no_input'):
+            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()
+        # 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:
+            prev_imported_pkgs.remove(pkg_name)
+        except ValueError:
+            # smuggled package is brand new
+            pass
+        else:
+            prev_imported_pkgs.append(pkg_name)
+
+        failed_reloads = []
+        for dep_name in prev_imported_pkgs:
+            dep_modules_old = {}
+            for mod_name in tuple(sys.modules.keys()):
+                # remove submodules of previously imported packages so
+                # new versions get imported when main package is
+                # reloaded (importlib.reload only reloads top-level
+                # module). IPython.lib.deepreload.reload recursively
+                # reloads submodules, but is basically broken because
+                # it's *too* aggressive. It reloads *all* imported
+                # modules... including the import machinery it needs to
+                # run, which crashes it... (-_-* )
+                if mod_name.startswith(f'{dep_name}.'):
+                    dep_modules_old[mod_name] = sys.modules.pop(mod_name)
+
+            # get (but don't pop) top-level package to that it can be
+            # reloaded (must exist in sys.modules)
+            dep_modules_old[dep_name] = sys.modules[dep_name]
+            try:
+                importlib.reload(sys.modules[dep_name])
+            except (ImportError, ModuleNotFoundError, RuntimeError):
+                # if we aren't able to reload the module, put the old
+                # version's submodules we removed back in sys.modules
+                # for now and prepare to show a warning post-execution.
+                # This way:
+                #   1. the user still has a working module until they
+                #      restart the runtime
+                #   2. the error we got doesn't keep getting raised when
+                #      we try to reload/import other modules that
+                #      import it
+                sys.modules.update(dep_modules_old)
+                failed_reloads.append(dep_name)
+
+        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 (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 or installer_kwargs.get('no_input'):
+                # 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)
+
+    # 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.pyi b/davos/core/core.pyi
similarity index 52%
rename from davos/core.pyi
rename to davos/core/core.pyi
index 359a83ef..bda99d24 100644
--- a/davos/core.pyi
+++ b/davos/core/core.pyi
@@ -1,8 +1,46 @@
 from collections.abc import Callable
-from re import Pattern
-from typing import Literal, NoReturn, Optional, TypedDict, Union
+from io import StringIO
+from types import (
+    BuiltinFunctionType,
+    FunctionType,
+    ModuleType,
+    TracebackType
+)
+from typing import (
+    Any,
+    AnyStr,
+    Generic,
+    Literal,
+    NoReturn,
+    Optional,
+    overload,
+    TextIO,
+    Tuple,
+    Type,
+    TypedDict,
+    TypeVar,
+    Union
+)
 
-__all__: list[Literal['Onion', 'prompt_input', 'smuggle_statement_regex']]
+__all__: list[
+    Literal[
+        'capture_stdout',
+        'Onion',
+        'parse_line',
+        'prompt_input',
+        'run_shell_command',
+    ]
+]
+
+_E = TypeVar('_E', bound=BaseException)
+_StringOrFileIO = Union[StringIO, TextIO]
+_S = TypeVar('_S')
+
+class _SingleStream(tuple[_StringOrFileIO]):
+    def __len__(self) -> Literal[1]: ...
+
+class _MultiStream(Tuple[_StringOrFileIO, ...]):
+    def __len__(self) -> int: ...
 
 class PipInstallerKwargs(TypedDict, total=False):
     abi: str
@@ -58,13 +96,36 @@ class PipInstallerKwargs(TypedDict, total=False):
     user: bool
     verbosity: Literal[-3, -2, -1, 0, 1, 2, 3]
 
+class capture_stdout(Generic[_S]):
+    closing: bool
+    streams: _S
+    sys_stdout_write: Callable[[AnyStr], Optional[int]]
+    def __init__(self, *streams: _StringOrFileIO, closing: bool = ...) -> None: ...
+    @overload
+    def __enter__(self: capture_stdout[_SingleStream]) -> _StringOrFileIO: ...
+    @overload
+    def __enter__(self: capture_stdout[_MultiStream]) -> _MultiStream: ...
+    @overload
+    def __exit__(self, exc_type: None, exc_value: None, exc_tb: None) -> None: ...
+    @overload
+    def __exit__(self, exc_type: Type[_E], exc_value: _E, exc_tb: TracebackType) -> bool: ...
+    def _write(self, data: AnyStr) -> None: ...
+
+def check_conda() -> None: ...
+def get_previously_imported_pkgs(
+        install_cmd_stdout: str,
+        installer: Literal['conda', 'pip']
+) -> list[str]: ...
+def import_name(name: str) -> Union[ModuleType, FunctionType, BuiltinFunctionType, Any]: ...
+
 class Onion:
     args_str: str
     build: Optional[str]
     cache_key: str
     import_name: str
     install_name: str
-    install_package: Callable[[], tuple[str, int]]
+    install_package: Callable[[], str]
+    installer: Literal['conda', 'pip']
     installer_kwargs: PipInstallerKwargs
     is_editable: bool
     verbosity: Literal[-3, -2, -1, 0, 1, 2, 3]
@@ -79,22 +140,23 @@ class Onion:
             **installer_kwargs: Union[bool, float, int, str, list[str]]
     ) -> None: ...
     @property
+    def install_cmd(self) -> str: ...
+    @property
     def is_installed(self) -> bool: ...
     def _conda_install_package(self) -> NoReturn: ...
-    def _pip_install_package(self) -> tuple[str, int]: ...
+    def _pip_install_package(self) -> str: ...
 
+def parse_line(line: str) -> str: ...
 def prompt_input(
         prompt: str,
         default: Optional[Literal['n', 'no', 'y', 'yes']] = ...,
         interrupt: Optional[Literal['n', 'no', 'y', 'yes']] = ...
 ) -> bool: ...
-
-# noinspection PyPep8Naming
-class _smuggle_subexprs(TypedDict):
-    as_re: Literal[r' +as +[a-zA-Z]\w*']
-    comment_re: Literal[r'(?m:\#+.*$)']
-    name_re: Literal[r'[a-zA-Z]\w*']
-    onion_re: Literal[r'\#+ *pip:.+?(?= +\#| *\n| *$)']
-    qualname_re: Literal[r'[a-zA-Z][\w.]*\w']
-
-smuggle_statement_regex: Pattern[str]
+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 = ...,
+        installer_kwargs: Optional[PipInstallerKwargs] = ...
+) -> None: ...
diff --git a/davos/core/exceptions.py b/davos/core/exceptions.py
new file mode 100644
index 00000000..8adcb03d
--- /dev/null
+++ b/davos/core/exceptions.py
@@ -0,0 +1,300 @@
+"""
+This module defines the set of `davos`-related exception classes.
+"""
+
+
+__all__ = [
+    'DavosError',
+    'DavosConfigError',
+    'DavosParserError',
+    'OnionParserError',
+    'OnionArgumentError',
+    'ParserNotImplementedError',
+    'SmugglerError',
+    'InstallerError'
+]
+
+
+import IPython
+
+from argparse import ArgumentError
+from shutil import get_terminal_size
+from subprocess import CalledProcessError
+from textwrap import fill, indent
+
+
+class DavosError(Exception):
+    """Base class for all `davos` library exceptions."""
+
+    pass
+
+
+class DavosConfigError(DavosError):
+    """Class for errors related to the `davos.config` object."""
+
+    def __init__(self, field, msg):
+        """
+        Parameters
+        ----------
+        field : str
+            The config field about which the exception should be raised
+        msg : str
+            The specific error message
+        """
+        self.field = field
+        self.msg = msg
+        super().__init__(f"'davos.config.{field}': {msg}")
+
+
+class DavosParserError(SyntaxError, DavosError):
+    """
+    Base class for errors raised during the pre-execution parsing phase.
+
+    Any `davos` exception classes related to user input-parsing step
+    must inherit from this class in order to work in IPython
+    environments.
+
+    Notes
+    -----
+    Since the raw cell contents haven't been passed to the Python
+    compiler yet, IPython doesn't allow input transformers to raise any
+    exceptions other than `SyntaxError` during the input transformation
+    phase.  All others will cause the cell to hang indefinitely, meaning
+    all `davos` library exception classes related to the parsing phrase
+    must inherit from `SyntaxError` in order to work.
+    """
+
+    def __init__(
+            self,
+            msg=None,
+            target_text=None,
+            target_offset=1,
+            *args
+    ):
+        """
+        Parameters
+        ----------
+        msg : str, optional
+            The error message to be displayed.
+        target_text : str, optional
+            The text of the code responsible for the error, to be
+            displayed in Python's `SyntaxError`-specific traceback
+            format. Typically, this is the full text of the line on
+            which the error occurred, with whitespace stripped. If
+            `None` (default), no text is shown and `target_offset` has
+            no effect.
+        target_offset : int, optional
+            The (*1-indexed*) column offset from the beginning of
+            `target_text` where the error occurred. Defaults to `1` (the
+            first character in `target_text`).
+        *args : tuple, optional
+            Additional arguments forwarded to the `SyntaxError`
+            constructor.
+        """
+        # note: flot is a 4-tuple of (filename, lineno, offset, text)
+        # passed to the SyntaxError constructor.
+        if target_text is None or IPython.version_info[0] >= 7:
+            flot = (None, None, None, None)
+        else:
+            from davos import config
+            xform_manager = config.ipython_shell.input_transformer_manager
+            # number of "real" lines in the current cell before the
+            # start of the current "chunk" (potentially multi-line
+            # python statement) being parsed
+            n_prev_lines = len(xform_manager.source.splitlines())
+            all_cell_lines = xform_manager.source_raw.splitlines()
+            # remaining cell lines starting with the beginning of the
+            # current "chunk" (no way to isolate just the current chunk)
+            rest_cell_lines = all_cell_lines[n_prev_lines:]
+            for ix, line in enumerate(rest_cell_lines, start=1):
+                if target_text in line:
+                    lineno = n_prev_lines + ix
+                    offset = line.index(target_text) + target_offset
+                    break
+            else:
+                offset = None
+                lineno = None
+            # leave lineno as None so IPython will fill it in as
+            # "<ipython-input-...>"
+            flot = (None, lineno, offset, target_text)
+        super().__init__(msg, flot, *args)
+
+
+class OnionParserError(DavosParserError):
+    """Class for errors related to parsing the onion comment syntax."""
+
+    pass
+
+
+class OnionArgumentError(ArgumentError, OnionParserError):
+    """
+    Class for errors related to arguments provided via an onion comment.
+
+    This exception class inherits from both `OnionParserError` and
+    `argparse.ArgumentError`. It functions as an onion comment-specific
+    analog of `argparse.ArgumentError`, whose key distinction is that
+    it can be raised during IPython's pre-execution phase (due to
+    inheriting from `DavosParserError`). Instances of this exception can
+    be expected to support attributes defined on both parents.
+    """
+
+    def __init__(self, msg, argument=None, onion_txt=None, *args):
+        """
+        Parameters
+        ----------
+        msg : str
+            The error message to be displayed.
+        argument : str, optional
+            The argument responsible for the error. if `None` (default),
+            determines the argument name from `msg`, which will be the
+            error message from an `argparse.ArgumentError` instance.
+        onion_txt : str, optional
+            The text of the installer arguments from onion comment in
+            which the error occurred (i.e., the full onion comment with
+            `# <installer>:` removed). If `None` (default), the error
+            will not be displayed in Python's `SyntaxError`-specific
+            traceback format.
+        args : tuple, optional
+            Additional arguments forwarded to the `DavosParserError`
+            constructor.
+        """
+        if (
+                msg is not None and
+                argument is None and
+                msg.startswith('argument ')
+        ):
+            split_msg = msg.split()
+            argument = split_msg[1].rstrip(':')
+            msg = ' '.join(split_msg[2:])
+        if (onion_txt is not None) and (argument is not None):
+            # default sorting is alphabetical where '--a' comes before
+            # '-a', so long option name will always be checked first,
+            # which is what we want
+            for aname in sorted(argument.split('/')):
+                if aname in onion_txt:
+                    target_offset = onion_txt.index(aname)
+                    break
+            else:
+                target_offset = 0
+        else:
+            target_offset = 0
+        # both `argparse.ArgumentError` and `SyntaxError` are
+        # non-cooperative, so need to initialize them separately rather
+        # than just running through the MRO via a call to super()
+        ArgumentError.__init__(self, argument=None, message=msg)
+        OnionParserError.__init__(self, msg=msg, target_text=onion_txt,
+                                  target_offset=target_offset, *args)
+        self.argument_name = argument
+
+
+class ParserNotImplementedError(OnionParserError, NotImplementedError):
+    """
+    Class for errors related to yet-to-be-implemented onion parsers.
+
+    This exception is an onion comment-specific subclass of the built-in
+    `NotImplementedError` that also inherits from `OnionParserError`,
+    allowing it to be raised during IPython's pre-execution phase (due
+    to inheriting from `DavosParserError`). This error is specifically
+    raised when a user specifies an installer program (via an onion
+    comment) whose command line parser has not yet been added to `davos`
+    """
+
+    pass
+
+
+class SmugglerError(DavosError):
+    """Base class for errors raised during the smuggle phase"""
+
+    pass
+
+
+class InstallerError(SmugglerError, CalledProcessError):
+    """
+    Class for errors related to the installer program
+
+    This exception is raised when the installer program itself (rather
+    than `davos`) encounters an error (e.g., failure to connect to
+    upstream package repository, find package with a given name, resolve
+    local environment, etc.).
+    """
+
+    @classmethod
+    def from_error(cls, cpe, show_output=None):
+        """
+        Create a class instance from a `subprocess.CalledProcessError`
+
+        Parameters
+        ----------
+        cpe : subprocess.CalledProcessError
+            The exception from which to create the `InstallerError`
+        show_output : bool, optional
+            Whether or not to include the failed command's stdout and/or
+            stderr in the error message. If `None` (default),
+            stdout/stderr will be displayed if the `suppress_stdout`
+            field of `davos.config` is currently set to `True` (i.e.,
+            stdout would have been suppressed during execution).
+
+        Returns
+        -------
+        InstallerError
+            The exception instance.
+        """
+        if not isinstance(cpe, CalledProcessError):
+            raise TypeError(
+                "InstallerError.from_error() requires a "
+                f"'subprocess.CalledProcessError' instance, not a {type(cpe)}"
+            )
+        return cls(returncode=cpe.returncode,
+                   cmd=cpe.cmd,
+                   output=cpe.output,
+                   stderr=cpe.stderr,
+                   show_output=show_output)
+
+    def __init__(
+            self,
+            returncode,
+            cmd,
+            output=None,
+            stderr=None,
+            show_output=None
+    ):
+        """
+        Parameters
+        ----------
+        returncode : int
+            Return code for the failed command.
+        cmd : str
+            Text of the failed command.
+        output : str, optional
+            stdout generated from executing `cmd`.
+        stderr : str, optional
+            stderr generated from executing `cmd`.
+        show_output : bool, optional
+            Whether or not to include the failed command's stdout and/or
+            stderr in the error message. If `None` (default),
+            stdout/stderr will be displayed if the `suppress_stdout`
+            field of `davos.config` is currently set to `True` (i.e.,
+            stdout would have been suppressed during execution).
+        """
+        super().__init__(returncode=returncode, cmd=cmd, output=output,
+                         stderr=stderr)
+        if show_output is None:
+            from davos import config
+            # 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):
+        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/exceptions.pyi b/davos/core/exceptions.pyi
similarity index 72%
rename from davos/exceptions.pyi
rename to davos/core/exceptions.pyi
index de759ccd..c5f1b916 100644
--- a/davos/exceptions.pyi
+++ b/davos/core/exceptions.pyi
@@ -1,10 +1,11 @@
 from argparse import ArgumentError
 from subprocess import CalledProcessError
-from typing import Literal, Optional, Union
+from typing import Literal, Optional, overload, Union
 
 __all__: list[
     Literal[
         'DavosError',
+        'DavosConfigError',
         'DavosParserError',
         'InstallerError',
         'OnionParserError',
@@ -16,6 +17,11 @@ __all__: list[
 
 class DavosError(Exception): ...
 
+class DavosConfigError(DavosError):
+    field: str
+    msg: str
+    def __init__(self, field: str, msg: str) -> None: ...
+
 class DavosParserError(SyntaxError, DavosError):
     def __init__(
             self,
@@ -41,13 +47,19 @@ class ParserNotImplementedError(OnionParserError, NotImplementedError): ...
 class SmugglerError(DavosError): ...
 
 class InstallerError(SmugglerError, CalledProcessError):
-    msg: str
+    show_output: Optional[bool]
+    @classmethod
+    def from_error(
+            cls,
+            cpe: CalledProcessError,
+            show_output: Optional[bool] = ...
+    ) -> InstallerError: ...
     def __init__(
             self,
-            msg: str,
-            *args: Union[CalledProcessError, str],
+            returncode: int,
+            cmd: str,
             output: Optional[str] = ...,
             stderr: Optional[str] = ...,
-            show_stdout: Optional[bool] = ...
+            show_output: Optional[bool] = ...
     ) -> None: ...
     def __str__(self) -> str: ...
diff --git a/davos/parsers.py b/davos/core/parsers.py
similarity index 67%
rename from davos/parsers.py
rename to davos/core/parsers.py
index ec714a82..1b06a9e3 100644
--- a/davos/parsers.py
+++ b/davos/core/parsers.py
@@ -1,9 +1,14 @@
-# ADD DOCSTRING
+"""
+This module defines reimplementations of the command line parsers for
+installer programs supported by `davos`, slightly modified to parse
+arguments supplied via an Onion comment.
+"""
 
 
 __all__ = ['EditableAction', 'OnionParser', 'pip_parser', 'SubtractAction']
 
 
+import sys
 from argparse import (
     Action,
     ArgumentError,
@@ -12,40 +17,134 @@
     SUPPRESS
 )
 
-from davos.exceptions import OnionArgumentError
+from davos.core.exceptions import OnionArgumentError
 
 
 class OnionParser(ArgumentParser):
-    # ADD DOCSTRING
+    """
+    `argparse.ArgumentParser` subclass for parsing Onion comments.
+
+    This class is essentially used to reimplement the installer
+    programs' existing command line parsers with some slight tweaks
+    specific to parsing Onion comments during the notebook's pre-cell
+    execution phase. Since the arguments are eventually passed to the
+    actual installer's parser, this is, admittedly, slightly redundant.
+    However, it affords a number of advantages, such as:
+        - protecting against shell injections (e.g., `# pip: --upgrade
+          numpy && rm -rf /` fails due to the OnionParser, but would
+          otherwise execute successfully)
+        - detecting errors quickly, before spawning a subprocesses
+        - allowing `davos` to adopt per-smuggle-command behaviors based
+          on user-provided arguments (e.g., `--force-reinstall`,
+          `-I/--ignore-installed`, `--no-input`, `-v/--verbose`, etc.)
+
+    See Also
+    --------
+    argparse.ArgumentParser :
+        https://docs.python.org/3/library/argparse.html#argumentparser-objects
+
+    Notes
+    -----
+    Currently supported arguments are based on `pip` v21.1.3, regardless
+    of the user's local `pip` version. However, the vast majority of
+    arguments are consistent across versions.
+    """
+
     def parse_args(self, args, namespace=None):
+        """
+        Parse installer options as command line arguments.
+
+        Parameters
+        ----------
+        args : list of str
+            Command line arguments for the installer program specified
+            in an Onion comment, split into a list of strings.
+        namespace : object, optional
+            An object whose namespace should be populated with the
+            parsed arguments. If `None` (default), creates a new, empty
+            `argparse.Namespace` object.
+
+        Returns
+        -------
+        object
+            The populated object passed as `namespace`
+        """
         self._args = ' '.join(args)
         try:
-            try:
-                ns, extras = super().parse_known_args(args=args,
-                                                      namespace=namespace)
-            except ArgumentError as e:
+            ns, extras = super().parse_known_args(args=args,
+                                                  namespace=namespace)
+        except (ArgumentError, ArgumentTypeError) as e:
+            if isinstance(e, OnionArgumentError):
+                raise
+            elif isinstance(e, ArgumentError):
                 raise OnionArgumentError(msg=e.message,
                                          argument=e.argument_name,
-                                         onion_txt=self._args) from e
-            except (ArgumentTypeError, ValueError) as e:
-                raise OnionArgumentError(msg=e.args[0], onion_txt=self._args)
+                                         onion_txt=self._args) from None
             else:
-                if extras:
-                    msg = f"Unrecognized arguments: {' '.join(extras)}"
-                    raise OnionArgumentError(msg=msg, argument=extras[0],
-                                             onion_txt=self._args)
-                return ns
+                raise OnionArgumentError(msg=e.args[0],
+                                         onion_txt=self._args) from None
+        else:
+            if extras:
+                msg = f"Unrecognized arguments: {' '.join(extras)}"
+                raise OnionArgumentError(msg=msg,
+                                         argument=extras[0],
+                                         onion_txt=self._args)
+            return ns
         finally:
             # noinspection PyAttributeOutsideInit
             self._args = None
 
     def error(self, message):
-        # ADD DOCSTRING
-        raise OnionArgumentError(msg=message, onion_txt=self._args)
+        """
+        Raise an OnionArgumentError with a given message.
+
+        This is needed to override `argparse.ArgumentParser.error()`.
+        `argparse` is  which exits the program when called (in response
+        to an exception being raised) because it is generally intended
+        for command line interfaces. The generally idea is to affect the
+        stack trace displayed for the user as little as possible, while
+        also ensuring all exceptions raised inherit from `SyntaxError`
+        so they can be successfully raised during the notebook cell
+        pre-execution step.
+
+        Parameters
+        ----------
+        message : str
+            The error message to be displayed for the raised exception.
+        """
+        if sys.exc_info()[1] is not None:
+            raise
+        else:
+            if message == 'one of the arguments -e/--editable is required':
+                message = 'Onion comment must specify a package name'
+            raise OnionArgumentError(msg=message, onion_txt=self._args)
 
 
 class EditableAction(Action):
-    # ADD DOCSTRING
+    """
+    `argparse.Action` subclass for pip's `-e/--editable <path/url>` arg.
+
+    The `-e/--editable` argument is implemented in a slightly unusual
+    way in order to allow the OnionParser to mimic the behavior of the
+    real `pip install` command's far more complex parser. The argument
+    is optional (default: `False`), and is mutually exclusive with the
+    positional `spec` argument. If provided, it accepts/requires a
+    single value (metavar: `'<path/url>'`). However, rather than storing
+    the value in the `argparse.Namespace` object's "editable" attribute,
+    the EditableAction stores `editable=True` and assigns the passed
+    value to "`spec`". This way:
+        1. As with the real `pip install` command, the project path/url
+           for an editable install must immediately follow the
+           `-e/--editable` argument (i.e., `/some/file/path -e` is
+           invalid)
+        2. The parser accepts only a single regular or editable package
+           spec (since an Onion comment can only ever refer to a single
+           package).
+        3. After parsing, argument values and types are consistent and
+           easier to handle: `spec` will always be the package spec and
+           `editable` will always be a `bool`.
+    """
+
     def __init__(
             self,
             option_strings,
@@ -54,10 +153,30 @@ def __init__(
             metavar=None,
             help=None
     ):
-        # ADD DOCSTRING
-        # NOTE: `argparse.Action` subclass constructors must contain
-        #  `dest` as a positional arg, but `self.dest` will always be
-        #  `'editable'` for this particular class
+        """
+        Parameters
+        ----------
+        option_strings : sequence of str
+            Command line option strings which should be associated with
+            this action.
+        dest : str
+            The name of the attribute to hold the created object(s).
+        default : multiple types, optional
+            The value to be produced if the option is not specified
+            (default: `None`).
+        metavar : str, optional
+            The name to be used for the option's argument with the help
+            string. If `None` (default), the `dest` value will be used
+            as the name.
+        help : str, optional
+            The help string describing the argument.
+
+        Notes
+        -----
+        `argparse.Action` subclass constructors must take `dest` as a
+        positional argument, but `self.dest` will always be `'editable'`
+        for EditableAction instances.
+        """
         super().__init__(option_strings, dest, default=default,
                          metavar=metavar, help=help)
 
@@ -67,7 +186,23 @@ def __call__(self, parser, namespace, values, option_string=None):
 
 
 class SubtractAction(Action):
-    # ADD DOCSTRING
+    """
+    `argparse.Action` subclass for subtracting from an attribute.
+
+    This action functions as the inverse of the `argparse` module's
+    built-in `'count'` action (`argparse._CountAction`). For each
+    occurrence of a given keyword argument, it *subtracts* 1 from the
+    specified namespace attribute (`dest`). Generally, it can be used to
+    implement an argument whose presence negates that of a different
+    argument. For example, the `pip install` command accepts both
+    `-v`/`--verbose` and `-q`/`--quiet` options, which have opposite
+    effects on the stdout generated during installation, and both of
+    which may be passed multiple times. Assigning the `'count'` action
+    to `-v`/`--verbose`, the `SubtractAction` to `-q`/`--quiet`, and a
+    common `dest` to both of the two allows the ultimate verbosity to be
+    the net value of the two.
+    """
+
     def __init__(
             self,
             option_strings,
@@ -76,7 +211,23 @@ def __init__(
             required=False,
             help=None
     ):
-        # ADD DOCSTRING
+        """
+        Parameters
+        ----------
+        option_strings : sequence of str
+            Command-line option strings which should be associated with
+            this action.
+        dest : str
+            The name of the attribute to subtract from.
+        default : multiple types, optional
+            The value to be produced if the option is not specified
+            (default: `None`).
+        required : bool, optional
+            Whether the option must be specified at the command line
+            (default: `False`).
+        help : str, optional
+            The help string describing the argument.
+        """
         super().__init__(option_strings=option_strings, dest=dest, nargs=0,
                          default=default, required=required, help=help)
 
@@ -415,6 +566,11 @@ def __call__(self, parser, namespace, values, option_string=None):
     metavar='<path>',
     help="Path to a verbose appending log."
 )
+pip_general_opts.add_argument(
+    '--no-input',
+    action='store_true',
+    help="Disable prompting for input."
+)
 pip_general_opts.add_argument(
     '--retries',
     type=int,
@@ -482,11 +638,11 @@ def __call__(self, parser, namespace, values, option_string=None):
     help="Don't periodically check PyPI to determine whether a new version of "
          "pip is available for download. Implied with --no-index."
 )
-# NOTE: I think this would have no effect in Jupyter/Colab environments
+
 pip_general_opts.add_argument(
     '--no-color',
     action='store_true',
-    help="uppress colored output."
+    help="Suppress colored output."
 )
 pip_general_opts.add_argument(
     '--no-python-version-warning',
diff --git a/davos/parsers.pyi b/davos/core/parsers.pyi
similarity index 85%
rename from davos/parsers.pyi
rename to davos/core/parsers.pyi
index 03d98e5e..5dfd77c9 100644
--- a/davos/parsers.pyi
+++ b/davos/core/parsers.pyi
@@ -6,7 +6,11 @@ __all__ = list[Literal['EditableAction', 'OnionParser', 'pip_parser', 'SubtractA
 
 class OnionParser(ArgumentParser):
     _args: Optional[str]
-    def parse_args(self, args: Sequence[str], namespace: Optional[Namespace] = ...) -> Namespace: ...
+    def parse_args(                                              # type: ignore
+            self, 
+            args: Sequence[str], 
+            namespace: Optional[Namespace] = ...
+    ) -> Namespace: ...
     def error(self, message: str) -> NoReturn: ...
 
 class EditableAction(Action):
@@ -44,4 +48,4 @@ class SubtractAction(Action):
     ) -> None: ...
 
 _pip_install_usage: list[str]
-pip_parser: OnionParser
+pip_parser: OnionParser
\ No newline at end of file
diff --git a/davos/core/regexps.py b/davos/core/regexps.py
new file mode 100644
index 00000000..6e99ddf9
--- /dev/null
+++ b/davos/core/regexps.py
@@ -0,0 +1,133 @@
+"""
+This module contains regular expressions used heavily by `davos`,
+pre-compiled as `re.Pattern` objects.
+"""
+
+
+__all__ = ['pip_installed_pkgs_regex', 'smuggle_statement_regex']
+
+
+import re
+
+
+_name_re = r'[a-zA-Z]\w*'
+
+_smuggle_subexprs = {
+    'name_re': _name_re,
+    'qualname_re': fr'{_name_re}(?: *\. *{_name_re})*',
+    'as_re': fr' +as +{_name_re}',
+    'onion_re': r'\# *(?:pip|conda) *: *[^#\n ].+?(?= +\#| *\n| *$)',
+    '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
+    r'(?P<FULL_CMD>'                                                      # capture full text of command in named group
+        r'(?:'                                                            # first valid syntax:
+            r'smuggle +{qualname_re}(?:{as_re})?'                         # match 'smuggle' + pkg/module name + optional alias
+            r'(?:'                                                        # match the following:
+                r' *'                                                     #  - any amount of horizontal whitespace
+                r','                                                      #  - followed by a comma
+                r' *'                                                     #  - followed by any amount of horizontal whitespace
+                r'{qualname_re}(?:{as_re})?'                              #  - followed by another pkg + optional alias
+            r')*'                                                         # ... any number of times
+            r'(?P<SEMICOLON_SEP>(?= *; *(?:smuggle|from)))?'              # check for multiple statements separated by semicolon 
+                                                                          #   (match empty string with positive lookahead assertion 
+                                                                          #   so named group gets defined without consuming)
+            r'(?(SEMICOLON_SEP)|'                                         # if the aren't multiple semicolon-separated statements:
+                r'(?:'
+                    r' *(?={onion_re})'                                   # consume horizontal whitespace only if followed by onion
+                    r'(?P<ONION>{onion_re})?'                             # capture onion comment in named group...
+                r')?'                                                     # ...optionally, if present
+            r')'
+        r')|(?:'                                                          # else (line doesn't match first valid syntax):
+            r'from *{qualname_re} +smuggle +'                             # match 'from' + package[.module[...]] + 'smuggle '
+            r'(?P<OPEN_PARENS>\()?'                                       # capture open parenthesis for later check, if present
+            r'(?(OPEN_PARENS)'                                            # if parentheses opened:
+                r'(?:'                                                    # logic for matching possible multiline statement:
+                    r' *'                                                 # capture any spaces following opening parenthesis
+                    r'(?:'                                                # logic for matching code on *first line*:
+                        r'{name_re}(?:{as_re})?'                          # match a name with optional alias
+                        r' *'                                             # optionally, match any number of spaces
+                        r'(?:'                                            # match the following...:
+                            r','                                          #  - a comma
+                            r' *'                                         #  - optionally followed by any number of spaces
+                            r'{name_re}(?:{as_re})?'                      #  - followed by another name with optional alias
+                            r' *'                                         #  - optionally followed by any number of spaces
+                        r')*'                                             # ...any number of times
+                        r',?'                                             # match optional comma after last name, however many there were
+                        r' *'                                             # finally, match any number of optional spaces
+                    r')?'                                                 # any code on first line (matched by preceding group) is optional
+                    r'(?:'                                                # match 1 of 4 possible ends for first line:
+                        r'(?P<FROM_ONION_1>{onion_re}) *{comment_re}?'    #  1. onion, optionally followed by unrelated comment(s)
+                    r'|'                                                  #
+                        r'{comment_re}'                                   #  2. unrelated, non-onion comment
+                    r'|'                                                  #
+                        r'(?m:$)'                                         #  3. nothing further before EOL
+                    r'|'                                                  #
+                        r'(?P<CLOSE_PARENS_FIRSTLINE>\))'                 #  4. close parenthesis on first line
+                    r')'
+                    r'(?(CLOSE_PARENS_FIRSTLINE)|'                        # if parentheses were NOT closed on first line
+                        r'(?:'                                            # logic for matching subsequent line(s) of multiline smuggle statement:
+                            r'\s*'                                        # match any & all whitespace before 2nd line
+                            r'(?:'                                        # match 1 of 3 possibilities for each additional line:
+                                r'{name_re}(?:{as_re})?'                  #  1. similar to first line, match a name & optional alias...
+                                r' *'                                     #     ...followed by any amount of horizontal whitespace...
+                                r'(?:'                                    #     ...
+                                    r','                                  #     ...followed by comma...
+                                    r' *'                                 #     ...optional whitespace...
+                                    r'{name_re}(?:{as_re})?'              #     ...additional name & optional alias
+                                    r' *'                                 #     ...optional whitespace...
+                                r')*'                                     #     ...repeated any number of times
+                                r'[^)\n]*'                                #     ...plus any other content up to newline or close parenthesis
+                            r'|'
+                                r' *{comment_re}'                         #  2. match full-line comment, indented an arbitrary amount
+                            r'|'
+                                r'\n *'                                   #  3. an empty line (truly empty or only whitespace characters)
+                            r')'
+                        r')*'                                             # and repeat for any number of additional lines
+                        r'\)'                                             # finally, match close parenthesis
+                    r')'
+                r')'
+            r'|'                                                          # else (no open parenthesis, so single line or /-continuation):
+                r'{name_re}(?:{as_re})?'                                  # match name with optional alias
+                r'(?:'                                                    # possibly with additional comma-separated names & aliases ...
+                    r' *'                                                 # ...
+                    r','                                                  # ...
+                    r' *'                                                 # ...
+                    r'{name_re}(?:{as_re})?'                              # ...
+                r')*'                                                     # ...repeated any number of times
+            r')'
+            r'(?P<FROM_SEMICOLON_SEP>(?= *; *(?:smuggle|from)))?'         # check for multiple statements separated by semicolon
+            r'(?(FROM_SEMICOLON_SEP)|'                                    # if there aren't additional ;-separated statements...
+                r'(?(FROM_ONION_1)|'                                      # ...and this isn't a multiline statement with onion on line 1:
+                    r'(?:'
+                        r' *(?={onion_re})'                               # consume horizontal whitespace only if followed by onion
+                        r'(?P<FROM_ONION>{onion_re})'                     # capture onion comment in named group...
+                    r')?'                                                 # ...optionally, if present
+                r')'
+            r')'
+        r')'
+    r')'
+).format_map(_smuggle_subexprs))
+
+# Condensed, fully substituted regex:
+# ^\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
+# >\#+ *(?:pip|conda) *: *[^#\n ].+?(?= +\#| *\n| *$))?)?))|(?:from *[a-
+# zA-Z]\w*(?: *\. *[a-zA-Z]\w*)* +smuggle +(?P<OPEN_PARENS>\()?(?(OPEN_P
+# ARENS)(?: *(?:[a-zA-Z]\w*(?: +as +[a-zA-Z]\w*)? *(?:, *[a-zA-Z]\w*(?:
+# +as +[a-zA-Z]\w*)? *)*,? *)?(?:(?P<FROM_ONION_1>\#+ *(?:pip|conda) *:
+# *[^#\n ].+?(?= +\#| *\n| *$)) *(?m:\#+.*$)?|(?m:\#+.*$)|(?m:$)|(?P<CLO
+# SE_PARENS_FIRSTLINE>\)))(?(CLOSE_PARENS_FIRSTLINE)|(?:\s*(?:[a-zA-Z]\w
+# *(?: +as +[a-zA-Z]\w*)? *(?:, *[a-zA-Z]\w*(?: +as +[a-zA-Z]\w*)? *)*[^
+# )\n]*| *(?m:\#+.*$)|\n *))*\)))|[a-zA-Z]\w*(?: +as +[a-zA-Z]\w*)?(?: *
+# , *[a-zA-Z]\w*(?: +as +[a-zA-Z]\w*)?)*)(?P<FROM_SEMICOLON_SEP>(?= *; *
+# (?:smuggle|from)))?(?(FROM_SEMICOLON_SEP)|(?(FROM_ONION_1)|(?: *(?=\#+
+#  *(?:pip|conda) *: *[^#\n ].+?(?= +\#| *\n| *$))(?P<FROM_ONION>\#+ *(?
+# :pip|conda) *: *[^#\n ].+?(?= +\#| *\n| *$)))?))))
diff --git a/davos/core/regexps.pyi b/davos/core/regexps.pyi
new file mode 100644
index 00000000..64e88226
--- /dev/null
+++ b/davos/core/regexps.pyi
@@ -0,0 +1,17 @@
+from re import Pattern
+from typing import Literal, TypedDict
+
+__all__: list[Literal['pip_installed_pkgs_regex', 'smuggle_statement_regex']]
+
+_name_re: Literal[r'[a-zA-Z]\w*']
+
+# noinspection PyPep8Naming
+class _smuggle_subexprs(TypedDict):
+    as_re: Literal[r' +as +[a-zA-Z]\w*']
+    comment_re: Literal[r'(?m:\#+.*$)']
+    name_re: Literal[r'[a-zA-Z]\w*']
+    onion_re: Literal[r'\# *(?:pip|conda) *: *[^#\n ].+?(?= +\#| *\n| *$)']
+    qualname_re: Literal[r'[a-zA-Z]\w*(?: *\. *[a-zA-Z]\w*)*']
+
+pip_installed_pkgs_regex: Pattern[str]
+smuggle_statement_regex: Pattern[str]
diff --git a/davos/exceptions.py b/davos/exceptions.py
deleted file mode 100644
index 9f7d546a..00000000
--- a/davos/exceptions.py
+++ /dev/null
@@ -1,176 +0,0 @@
-from argparse import ArgumentError
-from subprocess import CalledProcessError
-
-from davos import davos
-
-
-__all__ = [
-    'DavosError',
-    'DavosParserError',
-    'OnionParserError',
-    'OnionArgumentError',
-    'ParserNotImplementedError',
-    'SmugglerError',
-    'InstallerError'
-]
-
-
-class DavosError(Exception):
-    pass
-
-
-class DavosParserError(SyntaxError, DavosError):
-    """
-    Base class for errors raised during the parsing step.
-
-    Since the raw cell contents haven't been passed to the Python
-    compiler yet, IPython doesn't allow input transformers to raise any
-    exceptions other than `SyntaxError` during the input transformation
-    phase.  All others will cause the cell to hang indefinitely, meaning
-    all `davos` library exception classes must inherit from
-    `SyntaxError` in order to work.
-
-    Additionally, when the IPython parser catches a `SyntaxError` raised
-    by an input transformer, it displays it using a custom output
-    formatting class in order to handle `SyntaxError`-specific traceback
-    formatting (line number & position of the offending code, etc.). So
-    since there's no way for the `davos` parser to get around that
-    display format and show exceptions with their "normal" formatting,
-    we may as well use those fields to provide some helpful info rather
-    than just leaving them blank.
-    """
-    def __init__(
-            self,
-            msg=None,
-            target_text=None,
-            target_offset=0,
-            *args
-    ):
-        # ADD DOCSTRING
-        #  - `target_text` is text we're searching for which caused
-        #     error
-        #  - `target_offset` is additional offset from start of
-        #    `target_text` for placing caret (allows us to use longer,
-        #    more specific phrase for `target_text` when needed)
-        #  - `flot` is a 4-tuple of (**f**ilename, **l**ineno,
-        #    **o**ffset, **t**ext) passed to the SyntaxError
-        #    constructor.
-
-        # TODO: this may be a lot easier using sys.exc_info(),
-        #  inspect.stack(), inspect.getframeinfo(), etc.
-        if target_text is None:
-            flot = (None, None, None, None)
-        else:
-            xform_manager = davos.ipython_shell.input_transformer_manager
-            # number of "real" lines in the current cell before the
-            # start of the current "chunk" (potentially multi-line
-            # python statement) being parsed
-            n_prev_lines = len(xform_manager.source.splitlines())
-            all_cell_lines = xform_manager.source_raw.splitlines()
-            # remaining cell lines starting with the beginning of the
-            # current "chunk" (no way to isolate just the current chunk)
-            rest_cell_lines = all_cell_lines[n_prev_lines:]
-            for ix, line in enumerate(rest_cell_lines, start=1):
-                if target_text in line:
-                    lineno = n_prev_lines + ix
-                    offset = line.index(target_text) + target_offset
-                    break
-            else:
-                offset = None
-                lineno = None
-            # leave lineno as None so IPython will fill it in as
-            # "<ipython-input-...>"
-            flot = (None, lineno, offset, target_text)
-        super().__init__(msg, flot, *args)
-
-
-class OnionParserError(DavosParserError):
-    """general class for errors related to the Onion structure/object"""
-    pass
-
-
-class OnionArgumentError(ArgumentError, OnionParserError):
-    def __init__(self, msg=None, argument=None, onion_txt=None, *args):
-        if (
-                msg is not None and
-                argument is None and
-                msg.startswith('argument ')
-        ):
-            split_msg = msg.split()
-            argument = split_msg[1].rstrip(':')
-            msg = ' '.join(split_msg[2:])
-        if (onion_txt is not None) and (argument is not None):
-            # default sorting is alphabetical where '--a' comes before
-            # '-a', so long option name will always be checked first,
-            # which is what we want
-            for aname in sorted(argument.split('/')):
-                if aname in onion_txt:
-                    target_offset = onion_txt.index(aname)
-                    break
-            else:
-                target_offset = 0
-        else:
-            target_offset = 0
-        # both `argparse.ArgumentError` and `SyntaxError` are
-        # non-cooperative, so need to initialize them separately rather
-        # than just running through the MRO via a call to super()
-        ArgumentError.__init__(self, argument=None, message=msg)
-        OnionParserError.__init__(self, msg=msg, target_text=onion_txt,
-                                  target_offset=target_offset, *args)
-        self.argument_name = argument
-
-
-class ParserNotImplementedError(OnionParserError, NotImplementedError):
-    """
-    A version of NotImplementedError that can be raised during IPython
-    input transformation step because it inherits from SyntaxError
-    """
-    pass
-
-
-class SmugglerError(DavosError):
-    """class for errors raised during the smuggle step"""
-    pass
-
-
-class InstallerError(SmugglerError, CalledProcessError):
-    """
-    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):
-        cpe_or_retcode = args[0]
-        if isinstance(cpe_or_retcode, CalledProcessError):
-            returncode = cpe_or_retcode.returncode
-            cmd = cpe_or_retcode.cmd
-            output = output or cpe_or_retcode.output
-            stderr = stderr or cpe_or_retcode.stderr
-        else:
-            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 davos.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)
-
-    def __str__(self):
-        return self.msg + '\n\t' + super().__str__()
diff --git a/davos/implementations/__init__.py b/davos/implementations/__init__.py
new file mode 100644
index 00000000..310ff108
--- /dev/null
+++ b/davos/implementations/__init__.py
@@ -0,0 +1,205 @@
+"""
+This module dynamically imports and defines functions based on the
+environment into which `davos` is imported.
+
+Some `davos` functionality requires (often quite drastically) different
+implementations depending on certain properties of the importing
+environment (e.g., "regular" Python vs IPython, the IPython notebook
+front-end, etc.). To deal with this, environment-dependent parts of core
+features and behaviors (in the `davos.core` subpackage) are isolated and
+abstracted as "helper functions". Multiple, interchangeable
+implementations of each helper function are organized into
+per-environment modules within the `davos.implementations` subpackage.
+At runtime, this module selectively imports a single version of each
+helper function based on the global Python environment. `davos.core`
+modules can then access the correct implementation of each helper
+function regardless of the environment by importing it from the
+top-level `davos.implementations` module. This also allows individual
+`davos.implementations` modules to import packages that aren't
+guaranteed to be installed outside certain environments (e.g., `google`,
+`ipykernel`) without requiring them as dependencies of the overall
+`davos` package.
+
+The importing environment is determined by the value of the
+`environment` field of the global `DavosConfig` instance, so it's
+important that:
+    1. the global `davos.config` object is instantiated before this
+       module is imported
+    2. the top-level namespace of `davos.core.config` does not import
+       this module or any others that do so, recursively.
+    3. any modules imported by this module that in turn rely on
+       implementation-specific functions are loaded *after* those
+       functions are defined, here.
+"""
+
+
+__all__ = [
+    'auto_restart_rerun',
+    'full_parser',
+    'generate_parser_func',
+    'prompt_restart_rerun_buttons'
+]
+
+
+from davos import config
+from davos.core.config import DavosConfig
+from davos.core.exceptions import DavosConfigError
+
+
+import_environment = config.environment
+
+if import_environment == 'Python':
+    from davos.implementations.python import (
+        _activate_helper,
+        _check_conda_avail_helper,
+        _deactivate_helper,
+        _run_shell_command_helper,
+        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,
+        _set_custom_showsyntaxerror,
+        _showsyntaxerror_davos
+    )
+
+    _set_custom_showsyntaxerror()
+
+    if import_environment == 'IPython>=7.0':
+        from davos.implementations.ipython_post7 import (
+            _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,
+            _deactivate_helper,
+            generate_parser_func
+        )
+        if import_environment == 'Colaboratory':
+            from davos.implementations.colab import (
+                auto_restart_rerun,
+                prompt_restart_rerun_buttons
+            )
+        else:
+            from davos.implementations.jupyter import (
+                auto_restart_rerun,
+                prompt_restart_rerun_buttons
+            )
+
+
+from davos.core.core import check_conda, smuggle, parse_line
+
+
+# Implementation-specific wrapper around davos.core.core.parse_line
+full_parser = generate_parser_func(parse_line)
+
+
+########################################
+#  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
+# either:
+#  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
+#     implementation-specific functions are set here.
+
+def _active_fget(conf):
+    """getter for davos.config.active"""
+    return config._active
+
+
+def _active_fset(conf, value):
+    """setter for davos.config.active"""
+    if value is True:
+        _activate_helper(smuggle, full_parser)
+    elif value is False:
+        _deactivate_helper(smuggle, full_parser)
+    else:
+        raise DavosConfigError('active', "field may be 'True' or 'False'")
+
+    conf._active = value
+
+
+def _conda_avail_fget(conf):
+    """getter for davos.config.conda_avail"""
+    if conf._conda_avail is None:
+        check_conda()
+
+    return conf._conda_avail
+
+
+def _conda_avail_fset(conf, _):
+    """setter for davos.config.conda_avail"""
+    raise DavosConfigError('conda_avail', 'field is read-only')
+
+
+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
+        # haven't checked yet
+        check_conda()
+
+    return conf._conda_env
+
+
+def _conda_env_fset(conf, new_env):
+    """setter for davos.config.conda_env"""
+    if conf._conda_avail is None:
+        check_conda()
+
+    if conf._conda_avail is False:
+        raise DavosConfigError(
+            "conda_env",
+            "Cannot set conda environment. No local conda installation found"
+        )
+    elif new_env != conf._conda_env:
+        if (
+                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())}
+            raise DavosConfigError(
+                "conda_env",
+                f"unrecognized environment name: '{new_env}'. Local "
+                f"environments are:\n\t'{local_envs}'"
+            )
+
+        conf._conda_env = new_env
+
+
+def _conda_envs_dirs_fget(conf):
+    """getter for davos.config.conda_envs_dirs"""
+    if conf._conda_avail is None:
+        check_conda()
+
+    return conf._conda_envs_dirs
+
+
+def _conda_envs_dirs_fset(conf, _):
+    """setter for davos.config.conda_envs_dirs"""
+    raise DavosConfigError('conda_envs_dirs', 'field is read-only')
+
+
+DavosConfig.active = property(fget=_active_fget, fset=_active_fset)
+
+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,
+                                       fset=_conda_envs_dirs_fset)
diff --git a/davos/implementations/__init__.pyi b/davos/implementations/__init__.pyi
new file mode 100644
index 00000000..fd00abbd
--- /dev/null
+++ b/davos/implementations/__init__.pyi
@@ -0,0 +1,54 @@
+from collections.abc import Callable
+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
+
+__all__ = list[
+    Literal[
+        'auto_restart_rerun',
+        'full_parser',
+        'generate_parser_func',
+        'prompt_restart_rerun_buttons'
+    ]
+]
+
+LineParserFunc = Callable[[str], str]
+FullParserFunc = Union[IPyPost7FullParserFunc, LineParserFunc]
+
+class SmuggleFunc(Protocol):
+    def __call__(
+            self,
+            name: str,
+            as_: Optional[str] = ...,
+            installer: Literal['conda', 'pip'] = ...,
+            args_str: str = ...,
+            installer_kwargs: Optional[PipInstallerKwargs] = ...
+    ) -> None: ...
+
+_activate_helper: Callable[[SmuggleFunc, FullParserFunc], None]
+_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]
+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] = ...,
+            running_compiled_code: bool = ...
+    ) -> None: ...
+
+def _active_fget(conf: DavosConfig) -> bool: ...
+def _active_fset(conf: DavosConfig, value: bool) -> None: ...
+def _conda_avail_fget(conf: DavosConfig) -> bool: ...
+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: ...
diff --git a/davos/implementations/colab.py b/davos/implementations/colab.py
new file mode 100644
index 00000000..baf12c92
--- /dev/null
+++ b/davos/implementations/colab.py
@@ -0,0 +1,63 @@
+"""
+This modules contains implementations of helper functions specific to
+Google Colaboratory notebooks.
+"""
+
+
+__all__ = ['auto_restart_rerun', 'prompt_rerun_buttons']
+
+
+from IPython.core.display import _display_mimetype
+
+
+def auto_restart_rerun(pkgs):
+    """
+    Colab-specific implementation of `auto_restart_rerun`.
+
+    Raises `NotImplementedError` whenever called, though this should
+    never happen except when done intentionally as trying to set
+    `davos.config.auto_rerun = True` should raise an error. This feature
+    is not available in Colab notebooks because it requires accessing
+    the notebook frontend through the `colab.global.notebook` JavaScript
+    object, which Colab blocks you from doing from the kernel.
+
+    Parameters
+    ----------
+    pkgs : list of str
+        Packages that could not be reloaded without restarting the
+        runtime.
+
+    Raises
+    -------
+    NotImplementedError
+        In all cases.
+    """
+    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):
+    """
+    Colab-specific implementation of `prompt_restart_rerun_buttons`.
+
+    Issues a warning that the notebook runtime must be restarted in
+    order to use the just-smuggled version of one or more `pkgs`, and
+    displays a button the user can click to do so. Uses one of Colab's
+    existing MIME types, since it's one of the few things explicitly
+    allowed to send messages between the frontend and kernel.
+
+    Parameters
+    ----------
+    pkgs : list of str
+        Packages that could not be reloaded without restarting the
+        runtime.
+    """
+    _display_mimetype(
+        "application/vnd.colab-display-data+json",
+        (
+            {'pip_warning': {'packages': ', '.join(pkgs)}},
+        ),
+        raw=True
+    )
diff --git a/davos/implementations/colab.pyi b/davos/implementations/colab.pyi
new file mode 100644
index 00000000..79f6b01a
--- /dev/null
+++ 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/ipython_common.py b/davos/implementations/ipython_common.py
new file mode 100644
index 00000000..d6844470
--- /dev/null
+++ b/davos/implementations/ipython_common.py
@@ -0,0 +1,221 @@
+"""
+This modules contains implementations of helper functions common across
+all IPython versions and front-end interfaces.
+"""
+
+
+import textwrap
+import sys
+from contextlib import redirect_stdout
+from io import StringIO
+from subprocess import CalledProcessError
+
+from IPython.core.error import UsageError
+from IPython.utils.process import system as _run_shell_cmd
+
+from davos import config
+from davos.core.exceptions import DavosParserError
+
+
+def _check_conda_avail_helper():
+    """
+    Check whether the `conda` executable is available.
+
+    `IPython` implementation of the helper function for
+    `davos.core.core.check_conda`. Tries to access the `conda`
+    executable by running `conda list IPython` using the `%conda`
+    IPython line magic. If successful, returns the (suppressed) stdout
+    generated by the command. Otherwise, returns `None`.
+
+    Returns
+    -------
+    str or None
+        If the `%conda` line magic is available and `conda list IPython`
+        runs successfully, the captured stdout. Otherwise, `None`.
+
+    See Also
+    --------
+    davos.core.core.check_conda : core function that calls this helper.
+    """
+    try:
+        with redirect_stdout(StringIO()) as conda_list_output:
+            # this specific line magic seems to be the only reliable way
+            # to *actually* get the kernel environment -- shell (!)
+            # commands and all conda magic (%) commands other than
+            # `conda list` run in the base conda environment. Listed
+            # package is arbitrary, but listing single package is much
+            # faster than listing all, so using IPython because it's
+            # guaranteed to be installed
+            config._ipython_shell.run_line_magic('conda', 'list IPython')
+    except (UsageError, ValueError):
+        # %conda line magic is not available
+        return None
+    return conda_list_output.getvalue()
+
+
+def _run_shell_command_helper(command):
+    """
+    Run a shell command in a subprocess, piping stdout & stderr.
+
+    `IPython` implementation of helper function for
+    `davos.core.core.run_shell_command`. stdout & stderr streams are
+    captured or suppressed by the outer function. If the command runs
+    successfully, return its exit status (`0`). Otherwise, raise an
+    error.
+
+    Parameters
+    ----------
+    command : str
+        The command to execute.
+
+    Returns
+    -------
+    int
+        The exit code of the command. This will always be `0` if the
+        function returns. Otherwise, an error is raised.
+
+    Raises
+    ------
+    subprocess.CalledProcessError :
+        If the command returned a non-zero exit status.
+
+    See Also
+    --------
+    IPython.utils.process.system : `IPython` shell command runner.
+    """
+    retcode = _run_shell_cmd(command)
+    if retcode != 0:
+        raise CalledProcessError(returncode=retcode, cmd=command)
+
+
+def _set_custom_showsyntaxerror():
+    """
+    Overload the `IPython` shell's `.showsyntaxerror()` method.
+
+    Replaces the global `IPython` interactive shell object's
+    `.showsyntaxerror()` method with a custom function that allows
+    `davos`-native exceptions raised during the pre-execution cell
+    parsing phase to display a full traceback. Also:
+        - updates the custom function's docstring to include the
+          original `.showsyntaxerror()` method's docstring and
+          explicitly note that the method was updated by `davos`
+        - stores a reference to the original `.showsyntaxerror()` method
+          in the `davos.config` object so it can be called from the
+          custom version
+        - binds the custom function to the interactive shell object
+          *instance* so it implicitly receives the instance as its first
+          argument when called (like a normal instance method)
+
+    See Also
+    -------
+    davos.implementations.ipython_common._showsyntaxerror_davos :
+        The custom `.showsyntaxerror()` method set by `davos`.
+    IPython.core.interactiveshell.InteractiveShell.showsyntaxerror :
+        The original, overloaded `.showsyntaxerror()` method.
+
+    Notes
+    -----
+    Runs exactly once when `davos` is imported and initialized in an
+    `IPython` environment, and takes no action if run again. This
+    prevents overwriting the reference to the original
+    `.showsyntaxerror()` method stored in the `davos.config` object.
+    """
+    if config._ipy_showsyntaxerror_orig is not None:
+        # function has already been called
+        return
+
+    ipy_shell = config.ipython_shell
+    new_doc = textwrap.dedent(f"""\
+        {' METHOD UPDATED BY DAVOS PACKAGE '.center(72, '=')}
+        
+        {textwrap.indent(_showsyntaxerror_davos.__doc__, '    ')}
+
+        {' ORIGINAL DOCSTRING: '.center(72, '=')}
+        
+        
+        {ipy_shell.showsyntaxerror.__doc__}""")
+
+    _showsyntaxerror_davos.__doc__ = new_doc
+    config._ipy_showsyntaxerror_orig = ipy_shell.showsyntaxerror
+    ipy_shell.showsyntaxerror = _showsyntaxerror_davos.__get__(ipy_shell)
+
+
+# noinspection PyUnusedLocal
+def _showsyntaxerror_davos(
+        ipy_shell,
+        filename=None,
+        running_compiled_code=False
+):
+    """
+    Show `davos` library `SyntaxError` subclasses with full tracebacks.
+
+
+    Replaces global IPython interactive shell object's
+    `.showsyntaxerror()` method during initialization as a way to hook
+    into `IPython`'s exception handling machinery for errors raised
+    during the pre-execution cell parsing phase.
+
+    Because cell content is parsed as text rather than actually executed
+    during this stage, the only exceptions `IPython` expects input
+    transformers (such as the `davos` parser) to raise are
+    `SyntaxError`s. Thus, all `davos` library exceptions that may be
+    raised by the parser inherit from `SyntaxError`). And because
+    `IPython` assumes any `SyntaxError`s raised during parsing were
+    caused by issues with the cell content itself, it expects their
+    stack traces to comprise only a single frame, and displays them in a
+    format that does not include a full traceback. This function
+    excludes `davos` library errors from this behavior, and displays
+    them in full using the standard, more readable & informative format.
+
+    Parameters
+    ----------
+    ipy_shell : IPython.core.interactiveshell.InteractiveShell
+        The global `IPython` shell instance. Because the function is
+        bound as a method of the shell instance, this is passed
+        implicitly (i.e., equivalent to `self`).
+    filename : str, optional
+        The name of the file the `SyntaxError` occurred in. If `None`
+        (default), the name of the cell's entry in `linecache.cache`
+        will be used.
+    running_compiled_code : bool, optional
+        Whether the `SyntaxError` occurred while running compiled code
+        (see **Notes** below).
+
+    See Also
+    --------
+    davos.implementations.ipython_common._set_custom_showsyntaxerror :
+        Replaces the `.showsyntaxerror()` method with this function.
+    IPython.core.compilerop.code_name :
+        Generates unique names for each cell used in `linecache.cache`.
+    IPython.core.interactiveshell.InteractiveShell.showsyntaxerror :
+        The original `.showsyntaxerror()` method this function replaces.
+
+
+    Notes
+    -----
+    The `running_compiled_code` argument was added in `IPython` 6.1.0,
+    and setting it to `True` accomplishes (something close to) the same
+    thing this workaround does. However, since `davos` needs to support
+    `IPython` versions back to v5.5.0 (which is used by Colab), we can't
+    rely on it being available.
+    """
+    etype, value, tb = ipy_shell._get_exc_info()
+    if issubclass(etype, DavosParserError):
+        try:
+            # noinspection PyBroadException
+            try:
+                stb = value._render_traceback_()
+            except:
+                stb = ipy_shell.InteractiveTB.structured_traceback(
+                    etype, value, tb, tb_offset=ipy_shell.InteractiveTB.tb_offset
+                )
+            ipy_shell._showtraceback(etype, value, stb)
+            if ipy_shell.call_pdb:
+                ipy_shell.debugger(force=True)
+            return
+        except KeyboardInterrupt:
+            print('\n' + ipy_shell.get_exception_only(), file=sys.stderr)
+    else:
+        # original method is stored in Davos instance, but still bound
+        # IPython.core.interactiveshell.InteractiveShell instance
+        return config._ipy_showsyntaxerror_orig(filename=filename)
diff --git a/davos/implementations/ipython_common.pyi b/davos/implementations/ipython_common.pyi
new file mode 100644
index 00000000..fff6756a
--- /dev/null
+++ b/davos/implementations/ipython_common.pyi
@@ -0,0 +1,11 @@
+from typing import Optional
+from davos.core.config import IpythonShell
+
+def _check_conda_avail_helper() -> Optional[str]: ...
+def _run_shell_command_helper(command: str) -> None: ...
+def _set_custom_showsyntaxerror() -> None: ...
+def _showsyntaxerror_davos(
+        ipy_shell: IpythonShell,
+        filename: Optional[str] = ...,
+        running_compiled_code: bool = ...
+) -> None: ...
diff --git a/davos/implementations/ipython_post7.py b/davos/implementations/ipython_post7.py
new file mode 100644
index 00000000..aee5ece0
--- /dev/null
+++ b/davos/implementations/ipython_post7.py
@@ -0,0 +1,224 @@
+"""
+This modules contains implementations of helper functions specific to
+newer versions of `IPython`, starting with v7.0.0.
+"""
+
+
+__all__ = ['generate_parser_func']
+
+
+from IPython.core.inputtransformer import assemble_python_lines
+
+from davos import config
+
+
+def _activate_helper(smuggle_func, parser_func):
+    """
+    `IPython>=7.0.0`-specific implementation of `_activate_helper`.
+
+    Helper function called when setting `davos.config.active = True` or
+    running `davos.activate()`. Registers the `davos` parser
+    (`parser_func`) as an `IPython` input transformer (in the
+    `input_transformers_post` group) if it isn't one already. Injects
+    `smuggle_func` into the `IPython` user namespace as `"smuggle"`.
+
+    Parameters
+    ----------
+    smuggle_func : callable
+        Function to be added to the `IPython` user namespace under the
+        name "`smuggle`" (typically, `davos.core.core.smuggle`).
+    parser_func : callable
+        Function to be registered as an `IPython` input transformer (for
+        `IPython>=7.0.0`, the return value of
+        `davos.implementations.ipython_post7.generate_parser_func()`).
+
+    See Also
+    --------
+    davos.core.core.smuggle : The `smuggle` function.
+    generate_parser_func : Function that creates the `davos` parser.
+    """
+    ipy_shell = config._ipython_shell
+    input_xforms = ipy_shell.input_transformers_post
+    if parser_func not in input_xforms:
+        input_xforms.append(parser_func)
+
+    # insert "smuggle" into notebook namespace
+    ipy_shell.user_ns['smuggle'] = smuggle_func
+
+
+def _deactivate_helper(smuggle_func, parser_func):
+    """
+    `IPython>=7.0.0`-specific implementation of `_deactivate_helper`.
+
+    Helper function called when setting `davos.config.active = False` or
+    running `davos.deactivate()`. Removes the `davos` parser
+    (`parser_func`) from the set of `IPython` input transformers and
+    deletes the variable named "`smuggle`" from the `IPython` user
+    namespace if it (a) exists and (b) holds a reference to
+    `smuggle_func`, rather than some other value.
+
+    Parameters
+    ----------
+    smuggle_func : callable
+        Function expected to be the value of "`smuggle`" in the
+        `IPython` user namespace. Used to confirm that variable should
+        be deleted (typically, `davos.core.core.smuggle`).
+    parser_func : callable
+        Function that should be removed from the set of `IPython` input
+        transformers (for `IPython>=7.0.0`, the return value of
+        `davos.implementations.ipython_post7.generate_parser_func()`).
+
+    See Also
+    --------
+    davos.core.core.smuggle : The `smuggle` function.
+    generate_parser_func : Function that creates the `davos` parser.
+
+    Notes
+    -----
+    1. Any `smuggle` statements following a call to `davos.deactivate()`
+       will result in `SyntaxError`s unless the parser is reactivated
+       first.
+    2. The `davos` parser adds minimal overhead to cell execution.
+       However, deactivating it once it is no longer needed (i.e., after
+       the last `smuggle` statement) may be useful when measuring
+       precise runtimes (e.g. profiling code), as the amount of overhead
+       added is a function of the number of lines rather than
+       complexity.
+    """
+    ipy_shell = config._ipython_shell
+    try:
+        ipy_shell.input_transformers_post.remove(parser_func)
+    except ValueError:
+        pass
+
+    if ipy_shell.user_ns.get('smuggle') is smuggle_func:
+        del ipy_shell.user_ns['smuggle']
+
+
+def generate_parser_func(line_parser):
+    """
+    `IPython>=7.0.0`-specific implementation of `generate_parser_func`.
+
+    Given a function that parses a single line of code, returns the full
+    `davos` parser to be registered as an `IPython` input transformer.
+
+    Parameters
+    ----------
+    line_parser : callable
+        Function that parses a single line of user code (typically,
+        `davos.core.core.parse_line`).
+
+    Returns
+    -------
+    callable
+        The `davos` parser for use as an `IPython` input transformer.
+        Given user input consisting of one or more lines (e.g., a
+        notebook cell), returns the input with all lines parsed.
+
+    See Also
+    --------
+    davos.core.core.parse_line : Single-line parser function.
+
+    Notes
+    -----
+    1. In order to handle multiline `smuggle` statements, the
+       `line_parser` function (`davos.core.core.parse_line`) works on
+       "*logical*"/"*assembled*" lines, rather than "*physical*" lines.
+       A logical line may consist of a single physical line, or multiple
+       physical lines joined by explicit (i.e., backslash-based) or
+       implicit (i.e., parenthesis/bracket/brace/etc.-based). For
+       example, each of the following comprise multiple physical lines,
+       but a single logical line:
+       ```python
+       from foo import bar \
+                       baz \
+                       qux \
+                       quux
+
+       spam = [ham,
+               eggs,
+               ni]
+
+       the_night = {
+           'dark': True,
+           'full_of_terrors': True
+       }
+       ```
+    2. The API for input transformations was completely overhauled in
+       `IPython` v7.0. Among other changes, there is no longer a hook
+       exposed for input transformers that work on fully assembled
+       multiline statements (previously called
+       `python_line_transforms`). Additionally, all input transformer
+       functions are now called once per input area (i.e., notebook cell
+       or interactive shell prompt) and are passed the full input,
+       rather being called on each individual line. The `IPython>=7.0.0`
+       implementation of the `davos` parser handles this by tokenizing
+       the and assembling logical lines from the full input before
+       passing each to the `line_parser` function. While this adds some
+       additional overhead compared to the `IPython<7.0.0` `davos`
+       parser, the difference is functionally de minimis, and in fact
+       outweighed by the new parser's ability to skip parsing cells that
+       don't contain `smuggle` statements altogether.
+    3. Before it is returned, the full `davos` parser function is
+       assigned an attribute "`has_side_effects`", which is set to
+       `True`. In `IPython>=7.17`, this will prevent the parser from
+       being run when `IPython` checks whether or not the user input is
+       complete (i.e., after pressing enter in the `IPython` shell, is
+       the existing input a full statement, or part of a multiline
+       statement/code block?). In `IPython<7.17`, this will have no
+       effect.
+    """
+    pyline_assembler = assemble_python_lines()
+
+    def full_parser(lines):
+        if 'smuggle ' not in ''.join(lines):
+            # if cell contains no potential smuggle statements, don't
+            # bother parsing line-by-line
+            return lines
+
+        parsed_lines = []
+        curr_buff = []
+        for ix, raw_line in enumerate(lines):
+            # don't include trailing '\n'
+            python_line = pyline_assembler.push(raw_line[:-1])
+            if python_line is None:
+                # currently accumulating multiline logical line
+                curr_buff.append(raw_line)
+                continue
+
+            # pass single-line parser full logical lines -- may be
+            # single physical line or fully accumulated multiline
+            # statement
+            parsed_line = line_parser(python_line)
+            if curr_buff:
+                # logical line consists of multiple physical lines
+                if parsed_line == python_line:
+                    # multiline statement is not a smuggle statement;
+                    # don't combine physical lines in output
+                    parsed_lines.extend(curr_buff)
+                    # last line isn't in curr_buff; add it separately
+                    parsed_lines.append(raw_line)
+                else:
+                    # logical line is a multiline smuggle statement
+                    parsed_lines.append(f'{parsed_line}\n')
+                # reset partially accumulated lines
+                curr_buff.clear()
+            else:
+                # logical line consists of a single physical line
+                parsed_lines.append(f'{parsed_line}\n')
+
+        # .reset() clears pyline_assembler's .buf & .tokenizer for next
+        # cell. Returns ''.join(pyline_assembler.buf) if .buf list is
+        # not empty, otherwise None
+        if pyline_assembler.reset():
+            # Presence of an incomplete logical line after parsing the
+            # last physical line means there's a SyntaxError somewhere.
+            # Include remaining physical lines to let IPython/Python
+            # deal with raising the SyntaxError from the proper location
+            parsed_lines.extend(curr_buff)
+        return parsed_lines
+
+    # prevents transformer from being run multiple times when IPython
+    # parses partial line to determine whether input is complete
+    full_parser.has_side_effects = True
+    return full_parser
diff --git a/davos/implementations/ipython_post7.pyi b/davos/implementations/ipython_post7.pyi
new file mode 100644
index 00000000..9a735fc5
--- /dev/null
+++ b/davos/implementations/ipython_post7.pyi
@@ -0,0 +1,12 @@
+from typing import Literal, Protocol
+from davos.implementations import LineParserFunc, SmuggleFunc
+
+__all__ = list[Literal['generate_parser_func']]
+
+class IPyPost7FullParserFunc(Protocol):
+    has_side_effects: Literal[True]
+    def __call__(self, line_parser: list[str]) -> list[str]: ...
+
+def _activate_helper(smuggle_func: SmuggleFunc, parser_func: IPyPost7FullParserFunc) -> None: ...
+def _deactivate_helper(smuggle_func: SmuggleFunc, parser_func: IPyPost7FullParserFunc) -> None: ...
+def generate_parser_func(line_parser: LineParserFunc) -> IPyPost7FullParserFunc: ...
\ No newline at end of file
diff --git a/davos/implementations/ipython_pre7.py b/davos/implementations/ipython_pre7.py
new file mode 100644
index 00000000..8b6e82e5
--- /dev/null
+++ b/davos/implementations/ipython_pre7.py
@@ -0,0 +1,168 @@
+"""
+This modules contains implementations of helper functions specific to
+versions of `IPython` before v7.0.0 (the oldest officially supported
+version is v5.5.0).
+"""
+
+
+__all__ = ['generate_parser_func']
+
+
+from IPython.core.inputtransformer import StatelessInputTransformer
+
+from davos import config
+
+
+def _activate_helper(smuggle_func, parser_func):
+    """
+    `IPython<7.0.0`-specific implementation of `_activate_helper`.
+
+    Helper function called when setting `davos.config.active = True` or
+    running `davos.activate()`. Wraps the the `davos` parser
+    (`parser_func`) in an
+    `IPython/core.inputtransformer.StatelessInputTransformer` instance
+    and registers it as both an `IPython` input transformer *and* input
+    splitter (in the `python_line_transforms` group) if it isn't one
+    already. Injects `smuggle_func` into the `IPython` user namespace as
+    `"smuggle"`.
+
+    Parameters
+    ----------
+    smuggle_func : callable
+        Function to be added to the `IPython` user namespace under the
+        name "`smuggle`" (typically, `davos.core.core.smuggle`).
+    parser_func : callable
+        Function to be registered as an `IPython` input transformer
+        and input splitter (for `IPython<7.0.0`, the return value of
+        `davos.implementations.ipython_pre7.generate_parser_func()`).
+
+    See Also
+    --------
+    davos.core.core.smuggle : The `smuggle` function.
+    generate_parser_func : Function that creates the `davos` parser.
+    IPython.core.inputtransformer.StatelessInputTransformer :
+        Wrapper class for stateless input transformer functions.
+
+    Notes
+    -----
+    1. `IPython<7.0.0` allows input transformer functions to hook into
+       three different steps of the `IPython` parser during the
+       pre-execution phase, and transform user input at various stages
+       before it's sent to the Python parser. The `davos` parser runs as
+       a "`python_line_transform`", which is the last group of
+       transforms run on the raw input. By this stage, the `IPython`
+       parser has reassembled groups of "*physical*" lines joined by
+       both explicit (backslash-based) and implicit
+       (parenthesis/bracket/brace/etc.-based) line continuations into
+       full Python statements so the `davos` parser will receive
+       multiline `smuggle` statements will be as a single unit.
+    2. `IPython<7.0.0` requires that custom input transformers be
+       added to both the `IPython` shell's `input_splitter` attribute
+       and its `input_transformer_manager`. The former set of functions
+       is run by `IPython` when checking whether or not the user input
+       is complete (i.e., after pressing enter in the `IPython` shell,
+       is the existing input a full statement, or part of a multiline
+       statement/code block?). The latter is run when parsing complete
+       blocks of user input that will be executed as Python code.
+    """
+    ipy_shell = config._ipython_shell
+    smuggle_transformer = StatelessInputTransformer.wrap(parser_func)
+    # noinspection PyDeprecation
+    splitter_xforms = ipy_shell.input_splitter.python_line_transforms
+    manager_xforms = ipy_shell.input_transformer_manager.python_line_transforms
+
+    if not any(t.func is parser_func for t in splitter_xforms):
+        splitter_xforms.append(smuggle_transformer())
+
+    if not any(t.func is parser_func for t in manager_xforms):
+        manager_xforms.append(smuggle_transformer())
+
+    # insert "smuggle" into notebook namespace
+    ipy_shell.user_ns['smuggle'] = smuggle_func
+
+
+# noinspection PyDeprecation
+def _deactivate_helper(smuggle_func, parser_func):
+    """
+    `IPython<7.0.0`-specific implementation of `_deactivate_helper`.
+
+    Helper function called when setting `davos.config.active = False` or
+    running `davos.deactivate()`. Removes the
+    `IPython/core.inputtransformer.StatelessInputTransformer` instance
+    whose `.func` is the `davos` parser (`parser_func`) from both the
+    `.input_splitter` and `input_transformer_manager` attributes the
+    `IPython` shell. Deletes the variable named "`smuggle`" from the
+    `IPython` user namespace if it (a) exists and (b) holds a reference
+    to `smuggle_func`, rather than some other value.
+
+    Parameters
+    ----------
+    smuggle_func : callable
+        Function expected to be the value of "`smuggle`" in the
+        `IPython` user namespace. Used to confirm that variable should
+        be deleted (typically, `davos.core.core.smuggle`).
+    parser_func : callable
+        Function that should be removed from the set of `IPython` input
+        transformers (for `IPython<7.0.0`, `davos.core.core.parse_line`
+        or the return value of
+        `davos.implementations.ipython_pre7.generate_parser_func()`,
+        which are equivalent).
+
+    See Also
+    --------
+    davos.core.core.smuggle : The `smuggle` function.
+    generate_parser_func : Function that creates the `davos` parser.
+
+    Notes
+    -----
+    1. Any `smuggle` statements following a call to `davos.deactivate()`
+       will result in `SyntaxError`s unless the parser is reactivated
+       first.
+    2. The `davos` parser adds minimal overhead to cell execution.
+       However, deactivating it once it is no longer needed (i.e., after
+       the last `smuggle` statement) may be useful when measuring
+       precise runtimes (e.g. profiling code), as the amount of overhead
+       added is a function of the number of lines rather than
+       complexity.
+    """
+    ipy_shell = config._ipython_shell
+    splitter_xforms = ipy_shell.input_splitter.python_line_transforms
+    manager_xforms = ipy_shell.input_transformer_manager.python_line_transforms
+    for xform in splitter_xforms:
+        if xform.func is parser_func:
+            splitter_xforms.remove(xform)
+            break
+
+    for xform in manager_xforms:
+        if xform.func is parser_func:
+            manager_xforms.remove(xform)
+            break
+
+    if ipy_shell.user_ns.get('smuggle') is smuggle_func:
+        del ipy_shell.user_ns['smuggle']
+
+
+def generate_parser_func(line_parser):
+    """
+    `IPython<7.0.0`-specific implementation of `generate_parser_func`.
+
+    Given a function that parses a single line of code, returns the full
+    `davos` parser to be wrapped in an
+    `IPython.core.inputtransformer.StatelessInputTransformer` and
+    registered as both an input transformer and input splitter.
+    Unlike more recent versions, `IPython<7.0.0` expects transformer
+    functions to accept a single (reassembled) line of input at a time,
+    so the full parser is simply the single-line parser.
+
+    Parameters
+    ----------
+    line_parser : callable
+        Function that parses a single line of user code (typically,
+        `davos.core.core.parse_line`).
+
+    Returns
+    -------
+    callable
+        The function passed to `line_parser`.
+    """
+    return line_parser
diff --git a/davos/implementations/ipython_pre7.pyi b/davos/implementations/ipython_pre7.pyi
new file mode 100644
index 00000000..1c149652
--- /dev/null
+++ b/davos/implementations/ipython_pre7.pyi
@@ -0,0 +1,8 @@
+from typing import Literal
+from davos.implementations import LineParserFunc, SmuggleFunc
+
+__all__ = list[Literal['generate_parser_func']]
+
+def _activate_helper(smuggle_func: SmuggleFunc, parser_func: LineParserFunc) -> None: ...
+def _deactivate_helper(smuggle_func: SmuggleFunc, parser_func: LineParserFunc) -> None: ...
+def generate_parser_func(line_parser: LineParserFunc) -> LineParserFunc: ...
\ No newline at end of file
diff --git a/davos/implementations/js_functions.py b/davos/implementations/js_functions.py
new file mode 100644
index 00000000..bd86e9f7
--- /dev/null
+++ b/davos/implementations/js_functions.py
@@ -0,0 +1,206 @@
+"""
+This modules contains JavaScript code for various `davos` features that
+require manipulating the notebook JS object in the browser.
+
+**Note**: currently, these features are implemented for Jupyter
+notebooks only, as Colaboratory heavily restricts communication between
+the Python kernel and notebook frontend. However, within Jupyter, they
+are implemented for all `IPython` versions supported by `davos`.
+
+JavaScript functions in this module are organized in a `DotDict`
+instance (see class docstring below) whose keys are function names and
+whose values are function definitions.  Two functions are currently
+implemented:
+
+`JS_FUNCTIONS.jupyter.restartRunCellsAbove`
+    Restarts the notebook kernel and queues all cells above for
+    execution, including the current (highlighted) cell. In `davos`,
+    this is generally useful when a smuggled package that was previously
+    imported cannot be reloaded by the current interpreter.
+
+`JS_FUNCTIONS.jupyter.displayButtonPrompt`
+    Displays any number of buttons in the output area of the current
+    cell for user selection and (together with
+    `davos.implementations.jupyter.prompt_restart_rerun_buttons`) blocks
+    further code execution until one is clicked. Each button can
+    optionally trigger a JavaScript-based callback when clicked, and/or
+    send a "result" value from the notebook frontend to the Python
+    kernel, which is automatically converted from a JavaScript type to a
+    Python type. Buttons can also specify text labels and unique `id`s
+    for their HTML elements. All button elements are removed when one is
+    clicked, which prevents multiple user selections, removes their
+    event listeners, and makes their `id`s available for reuse. See
+    function JSDoc for type info and more details.
+"""
+
+
+__all__ = ['DotDict', 'JS_FUNCTIONS']
+
+
+from textwrap import dedent
+
+
+class DotDict(dict):
+    """Simple `dict` subclass that can be accessed like a JS object"""
+
+    __delattr__ = dict.__delitem__
+    __getattr__ = dict.__getitem__
+    __setattr__ = dict.__setitem__
+
+    # noinspection PyMissingConstructor
+    def __init__(self, d):
+        """
+        Parameters
+        ----------
+        d : `collections.Mapping`
+            A `dict`-like object to be converted to a `DotDict`,
+            recursively.
+        """
+        for k, v in d.items():
+            if isinstance(v, dict):
+                v = DotDict(v)
+            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': dedent("""
+            const restartRunCellsAbove = function() {
+                const outputArea = this,
+                    notebook = Jupyter.notebook,
+                    // first cell currently selected, if multiple
+                    anchorCellIndex = notebook.get_anchor_index(),
+                    // most recently selected cell, if multiple
+                    selectedCellIndex = notebook.get_selected_index(),
+                    runningCell = outputArea.element.parents('.cell'),
+                    allCells = notebook.get_cell_elements(),
+                    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) {
+                        // select multiple cells without moving anchor
+                        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)
+        """),
+        'displayButtonPrompt': dedent("""
+            /**
+             * 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, 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';
+                    buttonElement.classList.add('davos', 'prompt-button');
+                    if (typeof buttonObj.id !== 'undefined') {
+                        buttonElement.id = buttonObj.id;
+                    }
+                    if (typeof buttonObj.text === 'undefined') {
+                        buttonElement.textContent = `Button ${ix}`;
+                    } else {
+                        buttonElement.textContent = buttonObj.text;
+                    }
+                    if (typeof buttonObj.onClick === 'undefined') {
+                        // 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
+                        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, await 
+                // callbackPromise resolution (any button clicked)
+                await callbackPromise.then(() => {
+                    // remove element in output area containing buttons
+                    outputDisplayArea.remove();
+                    // execute clicked button's callback, store return value 
+                    // (if any)
+                    const CbReturnVal = clickedButtonCallback();
+                    
+                    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;
+                        }
+                        // 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/js_functions.pyi b/davos/implementations/js_functions.pyi
new file mode 100644
index 00000000..adef45d9
--- /dev/null
+++ b/davos/implementations/js_functions.pyi
@@ -0,0 +1,12 @@
+from collections.abc import MutableMapping
+from typing import Any, Literal
+
+__all__ = list[Literal['DotDict', 'JS_FUNCTIONS']]
+
+class DotDict(dict):
+    def __init__(self, d: MutableMapping) -> None: ...
+    def __delattr__(self, key: str) -> None: ...
+    def __getattr__(self, item: str) -> Any: ...
+    def __setattr__(self, key: str, value: Any) -> None: ...
+
+JS_FUNCTIONS: DotDict
\ No newline at end of file
diff --git a/davos/implementations/jupyter.py b/davos/implementations/jupyter.py
new file mode 100644
index 00000000..1b4d7c32
--- /dev/null
+++ b/davos/implementations/jupyter.py
@@ -0,0 +1,231 @@
+"""
+This modules contains implementations of helper functions specific to
+Jupyter notebooks.
+"""
+
+
+__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):
+    """
+    Jupyter-specific implementation of `auto_restart_rerun`.
+
+    Automatically restarts the notebook kernel and reruns all cells
+    above, *including the current cell*. Called when one or more
+    smuggled `pkgs` that were previously imported cannot be reloaded by
+    the current interpreter, and `davos.config.auto_rerun` is set to
+    `True`. Displays a message in the cell output area with the
+    package(s) that required the kernel restart, calls
+    `JS_FUNCTIONS.jupyter.restartRunCellsAbove`, and then blocks until
+    the kernel is restarted.
+
+    Parameters
+    ----------
+    pkgs : list of str
+        Packages that could not be reloaded without restarting the
+        kernel.
+
+    See Also
+    --------
+    JS_FUNCTIONS.jupyter.restartRunCellsAbove :
+        JavaScript function that restarts kernel and reruns cells above
+
+    Notes
+    -----
+    1. The message displayed before restarting the kernel can be
+       silenced by setting `davos.config.suppress_stdout` to `True`.
+    2. After calling `JS_FUNCTIONS.jupyter.restartRunCellsAbove`, this
+       function sleeps until until the kernel restarts to prevent any
+       further code in the current cell or other queued cells from
+       executing. Restarting the kernel is often not instantaneous;
+       there's generally a 1-2s delay while the kernel sends & receives
+       various shutdown messages, but it can take significantly
+       longer on a slow machine, with older Python/Jupyter/ipykernel
+       versions, if a large amount of data was loaded into memory, if
+       multiple notebook kernels are running at once, etc. If this
+       function returned immediately, it's likely subsequent lines of
+       code would be run before the kernel disconnected. This can cause
+       problems if those lines of code use the package(s) that prompted
+       the restart, or have effects that persist across kernel sessions.
+    """
+    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):
+    """
+    Jupyter-specific implementation of `prompt_restart_rerun_buttons`.
+
+    Displays a warning that the notebook kernel must be restarted in
+    order to use the just-smuggled version of one or more previously
+    imported `pkgs`, and displays a pair of buttons (via
+    `JS_FUNCTIONS.jupyter.displayButtonPrompt`) that prompt the user to
+    either (a) restart the kernel and rerun all cells up to the current
+    point, or (b) ignore the warning and continue running. Then, polls
+    the kernel's stdin socket until it receives a reply from the
+    notebook frontend, or the kernel is restarted.
+
+    Parameters
+    ----------
+    pkgs : list of str
+        Packages that could not be reloaded without restarting the
+        kernel.
+
+    Returns
+    -------
+    None
+        If the user clicks the "Continue Running" button, returns
+        `None`. Otherwise, restarts the kernel and therefore never
+        returns.
+
+    See Also
+    --------
+    JS_FUNCTIONS.jupyter.displayButtonPrompt :
+        JavaScript function for prompting user input with buttons.
+    ipykernel.kernelbase.Kernel._input_request :
+        Kernel method that replaces the built-in `input` in notebooks.
+
+    Notes
+    -----
+    1. This method of blocking and waiting for user input is based on
+       `ipykernel`'s replacement for the built-in `input` function used
+       in notebook environments.
+
+    """
+    # TODO: remove warning message when button is clicked
+    msg = (
+        "WARNING: The following packages were previously imported by the "
+        "interpreter and could not be reloaded because their compiled modules "
+        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 (zmq.sugar.poll.select) args:
+            #   - list of sockets/FDs to be polled for read events
+            #   - list of sockets/FDs to be polled for write events
+            #   - list of sockets/FDs to be polled for error events
+            #   - timeout (in seconds; None implies no timeout)
+            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 Exception as e:
+            if isinstance(e, KeyboardInterrupt):
+                # re-raise KeyboardInterrupt with simplified traceback
+                # (excludes some convoluted calls to internal
+                # IPython/zmq machinery)
+                raise KeyboardInterrupt("Interrupted by user") from None
+            else:
+                kernel.log.warning("Invalid Message:", exc_info=True)
+
+    # noinspection PyBroadException
+    try:
+        value = py3compat.unicode_to_str(reply['content']['value'])
+    except:
+        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
new file mode 100644
index 00000000..cc94fd56
--- /dev/null
+++ b/davos/implementations/python.py
@@ -0,0 +1,222 @@
+"""
+This modules contains implementations of helper functions specific to
+"pure" (i.e., non-interactive) Python scripts.
+
+**Note**: `davos` does not currently support pure Python, but will begin
+to do so in a near-future version.
+"""
+
+__all__ = [
+    'auto_restart_rerun',
+    'generate_parser_func',
+    'prompt_restart_rerun_buttons'
+]
+
+
+import locale
+import shlex
+import signal
+import sys
+from contextlib import redirect_stdout
+from io import StringIO
+from subprocess import CalledProcessError, PIPE, Popen
+
+
+def _activate_helper(smuggle_func, parser_func):
+    """
+    Pure Python implementation of `_activate_helper`.
+
+    Raises `NotImplementedError` whenever called, as `davos` does not
+    yet support non-interactive Python environments.
+
+    Parameters
+    ----------
+    smuggle_func : callable
+        Function to be injected into the module namespace under the
+        name "`smuggle`" (typically, `davos.core.core.smuggle`).
+    parser_func : callable
+        Function called to parse the Python module as plain text and
+        replace `smuggle` statements with the `smuggle()` function.
+
+    Raises
+    -------
+    NotImplementedError
+        In all cases.
+    """
+    raise NotImplementedError(
+        "davos does not yet support non-interactive Python environments"
+    )
+
+
+def _check_conda_avail_helper():
+    """
+    Check whether the `conda` executable is available.
+
+    Pure Python implementation of helper function for
+    `davos.core.core.check_conda`. Tries to access the `conda`
+    executable by running `conda list IPython`. If successful, returns
+    the (suppressed) stdout generated by the command. Otherwise, returns
+    `None`.
+
+    Returns
+    -------
+    str or None
+        If `conda list IPython` executes successfully, the captured
+        stdout. Otherwise, `None`.
+
+    Raises
+    ------
+    subprocess.CalledProcessError
+        If the `conda` executable is not available.
+
+    See Also
+    --------
+    davos.core.core.check_conda : core function that calls this helper.
+    """
+    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
+            # implementation (which must use `conda list`)
+            _run_shell_command_helper('conda list Python')
+    except CalledProcessError:
+        return None
+    return conda_list_output.getvalue()
+
+
+def _deactivate_helper(smuggle_func, parser_func):
+    """
+    Pure Python implementation of `_activate_helper`.
+
+    Raises `NotImplementedError` whenever called, as `davos` does not
+    yet support non-interactive Python environments.
+
+    Parameters
+    ----------
+    smuggle_func : callable
+    parser_func : callable
+
+    Raises
+    -------
+    NotImplementedError
+        In all cases.
+    """
+    raise NotImplementedError(
+        "davos does not yet support non-interactive Python environments"
+    )
+
+
+def _run_shell_command_helper(command):
+    """
+    Run a shell command in a subprocess, piping stdout & stderr.
+
+    Pure Python implementation of helper function for
+    `davos.core.core.run_shell_command`. stdout & stderr streams are
+    captured or suppressed by the outer function. If the command runs
+    successfully, return its exit status (`0`). Otherwise, raise an
+    error.
+
+    Parameters
+    ----------
+    command : str
+        The command to execute.
+
+    Returns
+    -------
+    int
+        The exit code of the command. This will always be `0` if the
+        function returns. Otherwise, an error is raised.
+
+    Raises
+    ------
+    subprocess.CalledProcessError :
+        If the command returned a non-zero exit status.
+    """
+    cmd = shlex.split(command)
+    process = Popen(cmd, stdout=PIPE, stderr=PIPE,
+                    encoding=locale.getpreferredencoding())
+    try:
+        while True:
+            retcode = process.poll()
+            if retcode is None:
+                output = process.stdout.readline()
+                if output:
+                    sys.stdout.write(output)
+            elif retcode != 0:
+                # processed returned with non-zero exit status
+                raise CalledProcessError(returncode=retcode, cmd=cmd)
+    except KeyboardInterrupt:
+        # forward CTRL + C to process before raising
+        process.send_signal(signal.SIGINT)
+        raise
+
+
+def auto_restart_rerun(pkgs):
+    """
+    Pure Python implementation of `auto_restart_rerun`.
+
+    Raises `NotImplementedError` whenever called, as `davos` does not
+    yet support non-interactive Python environments.
+
+    Parameters
+    ----------
+    pkgs : list of str
+        Packages that could not be reloaded without restarting the
+        runtime.
+
+    Raises
+    -------
+    NotImplementedError
+        In all cases.
+    """
+    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):
+    """
+    Pure Python implementation of `generate_parser_func`.
+
+    Raises `NotImplementedError` whenever called, as `davos` does not
+    yet support non-interactive Python environments.
+
+    Parameters
+    ----------
+    line_parser : callable
+        Function that parses a single line of user code (typically,
+        `davos.core.core.parse_line`).
+
+    Raises
+    -------
+    NotImplementedError
+        In all cases.
+    """
+    raise NotImplementedError(
+        "davos does not yet support non-interactive Python environments"
+    )
+
+
+def prompt_restart_rerun_buttons(pkgs):
+    """
+    Pure Python implementation of `prompt_restart_rerun_buttons`.
+
+    Raises `NotImplementedError` whenever called, as button-based input
+    prompts are not available outside notebook environments.
+
+    Parameters
+    ----------
+    pkgs : list of str
+        Packages that could not be reloaded without restarting the
+        runtime.
+
+    Raises
+    -------
+    NotImplementedError
+        In all cases.
+    """
+    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
new file mode 100644
index 00000000..9590546d
--- /dev/null
+++ b/davos/implementations/python.pyi
@@ -0,0 +1,17 @@
+from typing import Literal, NoReturn, Optional
+from davos.implementations import LineParserFunc, SmuggleFunc
+
+__all__ = list[
+    Literal[
+        'auto_restart_rerun',
+        'generate_parser_func',
+        'prompt_restart_rerun_buttons'
+    ]
+]
+
+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) -> NoReturn: ...
+def _run_shell_command_helper(command: str) -> None: ...
+def auto_restart_rerun(pkgs: list[str]) -> NoReturn: ...
+def generate_parser_func(line_parser: LineParserFunc) -> NoReturn: ...
diff --git a/davos/jupyter.py b/davos/jupyter.py
deleted file mode 100644
index ab8a8adf..00000000
--- a/davos/jupyter.py
+++ /dev/null
@@ -1,50 +0,0 @@
-# ADD DOCSTRING
-
-
-__all__ = [
-    'activate_parser_jupyter',
-    'check_parser_active_jupyter',
-    'deactivate_parser_jupyter',
-    'run_shell_command_jupyter',
-    'smuggle_jupyter',
-    'smuggle_parser_jupyter'
-]
-
-
-from davos.exceptions import ParserNotImplementedError
-
-
-def activate_parser_jupyter():
-    raise NotImplementedError(
-        "davos does not currently support Jupyter notebooks"
-    )
-
-
-def check_parser_active_jupyter():
-    raise NotImplementedError(
-        "davos does not currently support Jupyter notebooks"
-    )
-
-
-def deactivate_parser_jupyter():
-    raise NotImplementedError(
-        "davos does not currently support Jupyter notebooks"
-    )
-
-
-def run_shell_command_jupyter(command):
-    raise ParserNotImplementedError(
-        "davos does not currently support Jupyter notebooks"
-    )
-
-
-def smuggle_jupyter():
-    raise NotImplementedError(
-        "davos does not currently support Jupyter notebooks"
-    )
-
-
-def smuggle_parser_jupyter(line):
-    raise ParserNotImplementedError(
-        "davos does not currently support Jupyter notebooks"
-    )
diff --git a/davos/jupyter.pyi b/davos/jupyter.pyi
deleted file mode 100644
index 72fe8438..00000000
--- a/davos/jupyter.pyi
+++ /dev/null
@@ -1,19 +0,0 @@
-from typing import Literal, NoReturn
-
-__all__: list[
-    Literal[
-        'activate_parser_jupyter',
-        'check_parser_active_jupyter',
-        'deactivate_parser_jupyter',
-        'run_shell_command_jupyter',
-        'smuggle_jupyter',
-        'smuggle_parser_jupyter'
-    ]
-]
-
-def activate_parser_jupyter() -> NoReturn: ...
-def check_parser_active_jupyter() -> NoReturn: ...
-def deactivate_parser_jupyter() -> NoReturn: ...
-def run_shell_command_jupyter(command: str) -> NoReturn: ...
-def smuggle_jupyter() -> NoReturn: ...
-def smuggle_parser_jupyter(line: str) -> NoReturn: ...
diff --git a/davos/python.py b/davos/python.py
deleted file mode 100644
index b4c76073..00000000
--- a/davos/python.py
+++ /dev/null
@@ -1,71 +0,0 @@
-# ADD DOCSTRING
-
-
-__all__ = [
-    'activate_parser_python',
-    'check_parser_active_python',
-    'deactivate_parser_python',
-    'run_shell_command_python',
-    'smuggle_python',
-    'smuggle_parser_python'
-]
-
-
-import locale
-import shlex
-import signal
-import sys
-from subprocess import CalledProcessError, PIPE, Popen
-
-
-def activate_parser_python():
-    raise NotImplementedError(
-        "davos does not currently support pure Python interpreters"
-    )
-
-
-def check_parser_active_python():
-    raise NotImplementedError(
-        "davos does not currently support pure Python interpreters"
-    )
-
-
-def deactivate_parser_python():
-    raise NotImplementedError(
-        "davos does not currently support pure Python interpreters"
-    )
-
-
-def run_shell_command_python(command):
-    # ADD DOCSTRING
-    cmd = shlex.split(command)
-    process = Popen(cmd, stdout=PIPE, stderr=PIPE,
-                    encoding=locale.getpreferredencoding())
-    try:
-        while True:
-            retcode = process.poll()
-            if retcode is None:
-                output = process.stdout.readline()
-                if output:
-                    sys.stdout.write(output)
-            elif retcode != 0:
-                # processed returned with non-zero exit status
-                raise CalledProcessError(returncode=retcode, cmd=cmd)
-            else:
-                return retcode
-    except KeyboardInterrupt:
-        # forward CTRL + C to process before raising
-        process.send_signal(signal.SIGINT)
-        raise
-
-
-def smuggle_python():
-    raise NotImplementedError(
-        "davos does not currently support pure Python interpreters"
-    )
-
-
-def smuggle_parser_python(script_text):
-    raise NotImplementedError(
-        "davos does not currently support pure Python interpreters"
-    )
diff --git a/davos/python.pyi b/davos/python.pyi
deleted file mode 100644
index 5c792d22..00000000
--- a/davos/python.pyi
+++ /dev/null
@@ -1,19 +0,0 @@
-from typing import Literal, NoReturn
-
-__all__: list[
-    Literal[
-        'activate_parser_python',
-        'check_parser_active_python',
-        'deactivate_parser_python',
-        'run_shell_command_python',
-        'smuggle_python',
-        'smuggle_parser_python'
-    ]
-]
-
-def activate_parser_python() -> NoReturn: ...
-def check_parser_active_python() -> NoReturn: ...
-def deactivate_parser_python() -> NoReturn: ...
-def run_shell_command_python() -> Literal[0]: ...
-def smuggle_python() -> NoReturn: ...
-def smuggle_parser_python(script_text: str) -> NoReturn: ...
diff --git a/pyproject.toml b/pyproject.toml
index 805b1c1a..b36b7869 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -3,4 +3,14 @@ requires = [
     "setuptools>=46.4",
     "wheel"
 ]
-build-backend = "setuptools.build_meta"
\ No newline at end of file
+build-backend = "setuptools.build_meta"
+
+[tool.pytest.ini_options]
+addopts = "--capture=no --strict-markers --verbose"
+markers = [
+    "colab: marks tests that should run only on Google Colab",
+    "jupyter: marks tests that should run only in Jupyter notebooks",
+    "ipython_pre7: marks tests that should run only if IPython<7.0.0",
+    "ipython_post7: marks tests that should run only if IPython>=7.0.0",
+    "timeout: marks tests that should fail after a certain amount of time"
+]
diff --git a/setup.cfg b/setup.cfg
index 60bf2570..b76f6616 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -11,7 +11,7 @@ url = https://github.com/ContextLab/davos
 download_url = https://github.com/ContextLab/davos
 license = MIT
 license_file = LICENSE
-keywords = import module pip install automatic davos smuggle
+keywords = import install package module automatic davos smuggle pip conda
 classifiers =
     Intended Audience :: Developers
     Intended Audience :: Science/Research
@@ -30,15 +30,25 @@ classifiers =
 
 [options]
 python_requires = >=3.6
-tests_require =
-    google-colab==1.0.0
-    IPython>=5.5.0
-    pytest>=6.0.1
-    typing_extensions;python_version<"3.7"
+install_requires = packaging
+setup_requires = setuptools>=42.0.2
 packages = find:
 include_package_data = true
 zip_safe = false
 
+[options.extras_require]
+tests =
+    google-colab
+    IPython>=7.15;python_version>="3.9"
+    IPython>=7.3.0;python_version>="3.8"
+    IPython>=5.5.0
+    ipykernel>=5.0.0
+    mypy>=0.812
+    pytest==6.2
+    requests
+    selenium>=3.141
+    typing_extensions;python_version<"3.7"
+
 [options.package_data]
 * = py.typed, *.pyi
 
@@ -48,4 +58,17 @@ universal = 0
 
 [mypy]
 python_version = 3.9
-ignore_missing_imports = true
+disable_error_code = override
+disallow_any_expr = true
+disallow_any_generics = true
+disallow_untyped_calls = true
+disallow_untyped_defs = true
+disallow_incomplete_defs = true
+disallow_untyped_decorators = true
+no_implicit_optional = true
+warn_redundant_casts = true
+warn_unused_ignores = true
+warn_return_any = true
+warn_unreachable = true
+show_error_codes = true
+show_absolute_path = true


Test patch:
diff --git a/.github/workflows/ci-tests-colab.yml b/.github/workflows/ci-tests-colab.yml
new file mode 100644
index 00000000..a5421de9
--- /dev/null
+++ b/.github/workflows/ci-tests-colab.yml
@@ -0,0 +1,118 @@
+name: CI Tests (Colab)
+
+on:
+  push:
+    paths-ignore:
+      - 'paper/**'
+      - '.gitattributes'
+      - 'LICENSE'
+      - 'README.md'
+  workflow_dispatch:
+    inputs:
+      fork:
+        description: 'The fork on which to run the tests'
+        required: false
+        default: 'ContextLab'
+      commit:
+        description: 'The SHA to checkout (defaults to ref that triggered workflow)'
+        required: false
+        default: 'default'
+      debug_enabled:
+        description: 'Pause before tests for tmate debugging'
+        required: false
+        default: 'false'
+
+defaults:
+  run:
+    shell: bash -leo pipefail {0}
+
+jobs:
+  run-tests:
+    name: "Run Colab CI Tests (Python 3.7, IPython 5.5.0)"
+    runs-on: ubuntu-latest
+    # run if triggered by any of the following:
+    #   - a workflow_dispatch event (manual or from trigger-colab-tests-pr workflow)
+    #   - a push to paxtonfitzpatrick/davos
+    #   - a push to ContextLab/davos
+    # don't run on any pull requests because:
+    #   - pull requests between branches duplicate checks run by pushing to the head branch
+    #   - pull requests between forks are handled by the workflow_dispatch setup
+    if: >
+      github.event_name == 'workflow_dispatch'
+      || (github.event_name == 'push' && github.repository_owner == 'paxtonfitzpatrick')
+      || (github.event_name == 'push' && github.repository_owner == 'ContextLab')
+    env:
+      ARTIFACTS_DIR: ${{ github.workspace }}/artifacts
+      GMAIL_ADDRESS: ${{ secrets.DAVOS_GMAIL_ADDRESS }}
+      GMAIL_PASSWORD: ${{ secrets.DAVOS_GMAIL_PASSWORD }}
+      HEAD_FORK: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.fork || github.repository_owner }}
+      IPYTHON_VERSION: 5.5.0
+      NOTEBOOK_TYPE: colab
+      PYTHON_VERSION: 3.7
+      RECOVERY_GMAIL_ADDRESS: ${{ secrets.DAVOS_RECOVERY_GMAIL_ADDRESS }}
+    steps:
+      - name: Determine fork and commit SHA
+        run: |
+          if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then
+              head_sha=${{ github.event.inputs.commit }}
+              [[ "$head_sha" == "default" ]] && head_sha="$GITHUB_SHA"
+          else
+              head_sha="$GITHUB_SHA"
+          fi
+          echo "HEAD_SHA=$head_sha" >> $GITHUB_ENV
+
+      - uses: actions/checkout@v2
+        with:
+          repository: ${{ env.HEAD_FORK }}/davos
+          ref: ${{ env.HEAD_SHA }}
+
+      - name: install miniconda
+        uses: conda-incubator/setup-miniconda@v2
+        with:
+          auto-update-conda: true
+          auto-activate-base: true
+          activate-environment: ""
+
+      - name: setup base environment
+        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
+
+      - name: debug runner
+        if: github.event_name == 'workflow_dispatch' && github.event.inputs.debug_enabled == 'true'
+        uses: mxschmitt/action-tmate@v3
+
+      - name: run pytest
+        id: run-pytest
+        run: pytest -sv tests/
+
+      - name: upload selenium error artifacts
+        if: failure() && steps.run-pytest.outcome == 'failure'
+        uses: actions/upload-artifact@v2
+        with:
+          path: ${{ env.ARTIFACTS_DIR }}
+          if-no-files-found: ignore
diff --git a/.github/workflows/ci-tests-jupyter.yml b/.github/workflows/ci-tests-jupyter.yml
new file mode 100644
index 00000000..fac55c27
--- /dev/null
+++ b/.github/workflows/ci-tests-jupyter.yml
@@ -0,0 +1,137 @@
+name: CI Tests (Jupyter)
+
+on:
+  push:
+    paths-ignore:
+      - 'paper/**'
+      - '.gitattributes'
+      - 'LICENSE'
+      - 'README.md'
+  pull_request:
+    paths-ignore:
+      - 'paper/**'
+      - '.gitattributes'
+      - 'LICENSE'
+      - 'README.md'
+  workflow_dispatch:
+    inputs:
+      debug_enabled:
+        description: 'Pause before tests for tmate debugging'
+        required: false
+        default: 'false'
+
+defaults:
+  run:
+    shell: bash -leo pipefail {0}
+
+jobs:
+  run-tests:
+    name: "Run Jupyter CI tests (Python ${{ matrix.python-version }}, IPython ${{ matrix.ipython-version }})"
+    runs-on: ubuntu-latest
+    # only run on pull requests between forks to avoid duplicate runs with 'push' event
+    if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository
+    strategy:
+      fail-fast: false
+      matrix:
+        include:
+          # for each Python version, test with earliest and latest supported IPython version
+          - python-version: 3.6
+            ipython-version: 5.5.0    # earliest version supported by davos
+          - python-version: 3.6
+            ipython-version: 7.16     # latest version to support Python 3.6
+          - python-version: 3.7
+            ipython-version: 5.5.0    # earliest version supported by davos
+          - python-version: 3.7
+            ipython-version: latest
+          - python-version: 3.8
+            ipython-version: 7.3.0     # earliest version to support Python 3.8
+          - python-version: 3.8
+            ipython-version: latest
+          - python-version: 3.9
+            ipython-version: 7.15      # earliest version to support Python 3.9
+          - python-version: 3.9
+            ipython-version: latest
+    env:
+      HEAD_FORK: ${{ github.repository_owner }}
+      HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
+      IPYTHON_VERSION: ${{ matrix.ipython-version }}
+      NOTEBOOK_TYPE: jupyter
+      PYTHON_VERSION: ${{ matrix.python-version }}
+    steps:
+      - uses: actions/checkout@v2
+        with:
+          ref: ${{ env.HEAD_SHA }}
+
+      - name: install miniconda
+        uses: conda-incubator/setup-miniconda@v2
+        with:
+          auto-update-conda: true
+          auto-activate-base: true
+          activate-environment: ""
+
+      - name: setup base environment
+        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
+
+      - name: setup notebook kernel environment
+        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
+
+          # install davos & various test requirements in kernel environment
+          conda activate kernel-env
+          if [[ "$PYTHON_VERSION" =~ ^3.(6|7)$ ]]; then
+              pip install typing-extensions
+          fi
+          pip install "ipykernel==5.0.0" requests scipy fastdtw==0.3.4 tqdm==4.41.1
+          if [[ "$IPYTHON_VERSION" == "latest" ]]; then
+              pip install --upgrade IPython
+          else
+              pip install IPython==$IPYTHON_VERSION
+          fi
+          pip install .
+
+          # make environment available as a jupyter kernel
+          python -m ipykernel install --prefix=/usr/share/miniconda --name=kernel-env
+          conda deactivate
+
+      - name: launch Jupyter server
+        run: jupyter notebook --no-browser --port=8888 --NotebookApp.token= &
+
+      - name: debug runner
+        if: |
+          github.event_name == 'workflow_dispatch'
+          && github.event.inputs.debug_enabled == 'true'
+        uses: mxschmitt/action-tmate@v3
+
+      - name: run pytest
+        id: run-pytest
+        run: pytest -sv tests/
diff --git a/.github/workflows/request-colab-tests.yml b/.github/workflows/request-colab-tests.yml
new file mode 100644
index 00000000..d4c4fc05
--- /dev/null
+++ b/.github/workflows/request-colab-tests.yml
@@ -0,0 +1,37 @@
+name: Request Colab Tests for PR
+
+on:
+  pull_request_target:
+    paths-ignore:
+      - 'paper/**'
+      - '.gitattributes'
+      - 'LICENSE'
+      - 'README.md'
+
+defaults:
+  run:
+    shell: bash -leo pipefail {0}
+
+jobs:
+  request-colab-tests:
+    name: "Request approval to run Colab tests"
+    runs-on: ubuntu-latest
+    # run on pull requests from forks to the base repository
+    if: github.repository_owner == 'ContextLab' && github.event.pull_request.head.user.login != 'ContextLab'
+    steps:
+      - name: Comment on PR
+        uses: actions/github-script@v4
+        with:
+          script: |
+            const headFork = context.payload.pull_request.head.user.login,
+                headSha = context.payload.pull_request.head.sha,
+                headShaShort = headSha.substr(0, 7),
+                headCommitUrl = `https://github.com/${headFork}/davos/tree/${headSha}`,
+                commentBody = `@paxtonfitzpatrick Run Colab CI tests on [${headFork}/davos@${headShaShort}](${headCommitUrl})?`;
+
+            github.issues.createComment({
+              issue_number: context.issue.number,
+              owner: context.repo.owner,
+              repo: context.repo.repo,
+              body: commentBody
+            });
diff --git a/.github/workflows/trigger-colab-tests-pr.yml b/.github/workflows/trigger-colab-tests-pr.yml
new file mode 100644
index 00000000..f8bf2dcb
--- /dev/null
+++ b/.github/workflows/trigger-colab-tests-pr.yml
@@ -0,0 +1,73 @@
+name: Trigger Colab CI Tests for Pull Request
+
+on:
+  issue_comment:
+    types: [created]
+
+defaults:
+  run:
+    shell: bash -leo pipefail {0}
+
+jobs:
+  trigger-tests-from-comment:
+    name: "Trigger Colab CI Tests from PR Comment"
+    runs-on: ubuntu-latest
+    # run if all of the following are true:
+    #   - the comment was made on a pull request (rather than an issue)
+    #   - the base of the pull request is ContextLab/davos
+    #   - the head of the pull request is a different fork (rather than just a branch)
+    #   - the comment was made by paxtonfitzpatrick
+    #   - the github-actions bot was tagged at the beginning of the comment
+    if: >
+      github.event.issue.pull_request
+      && github.repository == 'ContextLab/davos'
+      && github.event.issue.pull_request.head.repo.full_name != 'ContextLab/davos'
+      && github.event.comment.user.login == 'paxtonfitzpatrick'
+      && startsWith(github.event.comment.body, '@github-actions ')
+    steps:
+      - name: Check Whether Comment Triggers Workflow
+        id: check-comment
+        uses: actions/github-script@v4
+        with:
+          script: |
+            // keywords that trigger Colab CI tests
+            const approveStrings = ['approve', 'run', 'yes', '👍'],
+            // comment body with @github-actions bot tag removed
+                commentBody = context.payload.comment.body.replace(/@github-actions\s+/, '');
+
+            // if the comment contains any of the keyword strings
+            if (approveStrings.some(str => commentBody.includes(str))) {
+                // set outputs for use in next step
+                core.setOutput('trigger_tests', 'true');
+                core.setOutput('head_fork', context.payload.issue.user.login);
+                core.setOutput('head_sha', context.payload.issue.pull_request.head.sha);
+                // react to comment with thumbs-up to confirm
+                github.reactions.createforIssueComment({
+                    owner: context.repo.owner,
+                    repo: context.repo.repo,
+                    comment_id: context.payload.comment.id,
+                    content: '+1'
+                });
+            } else {
+                core.setOutput('trigger_tests', 'false');
+            }
+
+      - name: Dispatch Colab CI Test Workflow
+        if: steps.check-comment.outputs.trigger_tests == 'true'
+        env:
+          HEAD_FORK: steps.check-comment.outputs.head_fork
+          HEAD_SHA: steps.check-comment.outputs.head_sha
+        uses: actions/github-script@v4
+        with:
+          script: |
+            github.actions.createWorkflowDispatch({
+                owner: context.repo.owner,
+                repo: context.repo.repo,
+                workflow_id: 'ci-tests-colab.yml',
+                ref: context.sha,
+                inputs: {
+                    fork: process.env.HEAD_FORK,
+                    commit: process.env.HEAD_SHA,
+                    debug_enabled: 'false'
+                }
+            });
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 00000000..62526442
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,594 @@
+from __future__ import annotations
+
+import ast
+import html
+import json
+import re
+import time
+import traceback
+from collections.abc import Iterable, Iterator
+from os import getenv
+from pathlib import Path
+from typing import (
+    Any,
+    Literal,
+    Optional,
+    overload,
+    Type,
+    TYPE_CHECKING,
+    TypedDict,
+    TypeVar,
+    Union
+)
+
+import pytest
+from selenium import webdriver
+from selenium.common.exceptions import (
+    ElementNotInteractableException,
+    TimeoutException,
+    WebDriverException
+)
+from selenium.webdriver.common.action_chains import ActionChains
+from selenium.webdriver.common.by import By
+from selenium.webdriver.common.keys import Keys
+from selenium.webdriver.firefox.options import Options
+from selenium.webdriver.remote.webelement import WebElement
+# noinspection PyPep8Naming
+from selenium.webdriver.support import expected_conditions as EC
+from selenium.webdriver.support.ui import WebDriverWait
+
+if TYPE_CHECKING:
+    import _pytest
+    import pluggy
+    import py
+
+
+######################## TYPING-RELATED OBJECTS ########################    # noqa: E266
+_ByOpts = Literal[
+    'class name',
+    'css selector',
+    'id',
+    'link text',
+    'name',
+    'partial link text',
+    'tag name',
+    'xpath'
+]
+_E = TypeVar('_E', bound=BaseException)
+_IpyVersions = Literal['5.5.0', '7.3.0', '7.15', '7.16', 'latest']
+_NbTypes = Literal['colab', 'jupyter']
+_PyVersions = Literal['3.6', '3.7', '3.8', '3.9']
+
+
+class _SharedVars(TypedDict):
+    GITHUB_USERNAME: str
+    GITHUB_REF: str
+    NOTEBOOK_TYPE: _NbTypes
+    IPYTHON_VERSION: _IpyVersions
+    PYTHON_VERSION: _PyVersions
+
+
+# could type-hint notebook JSON many levels deep, but unnecessary
+class _CellJson(TypedDict, total=False):
+    cell_type: Literal['code', 'markdown']
+    execution_count: int
+    metadata: dict[str, Union[Any, dict[str, Any]]]
+    outputs: list[dict[str, Union[int, str, dict[str, Any], list[dict[str, Any]]]]]
+    source: list[str]
+
+
+class _DecoratorData(TypedDict):
+    name: str
+    args: tuple
+    kwargs: dict[str, Any]
+
+
+class _NotebookJson(TypedDict):
+    cells: list[_CellJson]
+    metadata: dict[str, Union[dict[str, dict[str, Any]]]]
+    nbformat: int
+    nbformat_minor: int
+
+
+########################################################################
+
+
+GITHUB_USERNAME: str = getenv("HEAD_FORK")
+GITHUB_REF: str = getenv("HEAD_SHA")
+REPO_ROOT: Path = Path(getenv("GITHUB_WORKSPACE")).resolve(strict=True)
+# noinspection PyTypeChecker
+IPYTHON_VERSION: _IpyVersions = getenv('IPYTHON_VERSION')
+# noinspection PyTypeChecker
+NOTEBOOK_TYPE: _NbTypes = getenv('NOTEBOOK_TYPE')
+# noinspection PyTypeChecker
+PYTHON_VERSION: _PyVersions = getenv('PYTHON_VERSION')
+SHARED_VARS: _SharedVars = {
+    'GITHUB_USERNAME': GITHUB_USERNAME,
+    'GITHUB_REF': GITHUB_REF,
+    'NOTEBOOK_TYPE': NOTEBOOK_TYPE,
+    'IPYTHON_VERSION': IPYTHON_VERSION,
+    'PYTHON_VERSION': PYTHON_VERSION
+}
+
+
+# noinspection PyPep8Naming
+class element_has_class:
+    def __init__(self, locator: tuple[_ByOpts, str], cls_name: str) -> None:
+        self.locator = locator
+        self.cls_name = cls_name
+
+    def __call__(self, driver: webdriver.Firefox) -> Union[WebElement, Literal[False]]:
+        element = driver.find_element(*self.locator)
+        if self.cls_name in element.get_attribute('class').split():
+            return element
+        return False
+
+
+class NotebookTestFailed(Exception):
+    pass
+
+
+class NotebookTestSkipped(Exception):
+    pass
+
+
+class NotebookDriver:
+    def __init__(self, url: str) -> None:
+        self.url = url
+        options = Options()
+        options.headless = True
+        self.driver = webdriver.Firefox(
+            options=options, executable_path=getenv('DRIVER_PATH')
+        )
+        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,
+            __locator_or_el: Union[str, WebElement],
+            /,
+            by: _ByOpts = By.CSS_SELECTOR,
+            timeout: int = 10,
+            poll_frequency: float = 0.5,
+            ignored_exceptions: Optional[Union[_E, Iterable[_E]]] = None
+    ) -> WebElement:
+        if isinstance(__locator_or_el, WebElement):
+            if isinstance(ignored_exceptions, BaseException):
+                ignored_exceptions: tuple[_E] = (ignored_exceptions,)
+            start_time = time.time()
+            while True:
+                try:
+                    __locator_or_el.click()
+                except (ElementNotInteractableException, *ignored_exceptions) as e:
+                    if time.time() - start_time >= timeout:
+                        raise TimeoutException(
+                            f"{__locator_or_el} was not clickable after "
+                            f"{timeout} seconds"
+                        ) from e
+                    else:
+                        pass
+                else:
+                    return __locator_or_el
+        else:
+            wait = WebDriverWait(self.driver, timeout=timeout,
+                                 poll_frequency=poll_frequency,
+                                 ignored_exceptions=ignored_exceptions)
+            locator = (by, __locator_or_el)
+            element_is_visible = EC.visibility_of_element_located(locator)
+            element_is_clickable = EC.element_to_be_clickable(locator)
+            wait.until(element_is_visible)
+            element: WebElement = wait.until(element_is_clickable)
+            element.click()
+            return element
+
+    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
+            # was created and when it was populated
+            time.sleep(3)
+            result_element = self.driver.find_element_by_id(f"{func_name}_result")
+            result = result_element.text.split(maxsplit=2)[1:]
+        return result
+
+    def get_test_runner_cell(self) -> WebElement:
+        # 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]
+
+    def quit(self) -> None:
+        self.driver.quit()
+
+    def set_max_timeout(self, timeout: float) -> None:
+        self.driver.implicitly_wait(timeout)
+
+
+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)
+        try:
+            self.sign_in_google()
+            self.factory_reset_runtime()
+        except WebDriverException:
+            self.capture_error_artifacts()
+            traceback.print_exc()
+            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()")
+
+    def factory_reset_runtime(self):
+        self.driver.execute_script("colab.global.notebook.kernel.unassignCurrentVm({skipConfirm: 1})")
+
+    def set_template_vars(
+            self,
+            extra_vars: Optional[dict[str, str]] = None
+    ) -> None:
+        if extra_vars is None:
+            to_replace = SHARED_VARS
+        else:
+            to_replace = SHARED_VARS | extra_vars
+        # assumes variables are set in first code cell
+        template_cell = self.driver.find_elements_by_class_name('code')[0]
+        cell_contents: str = template_cell.get_property('innerText')
+        # replace Latin1 non-breaking spaces with UTF-8 spaces
+        cell_contents = cell_contents.replace(u'\xa0', u' ')
+        for key, val in to_replace.items():
+            template = f"${key}$"
+            cell_contents = cell_contents.replace(template, val)
+        set_cell_text_js = f"arguments[0].setText(`{cell_contents}`)"
+        self.driver.execute_script(set_cell_text_js, template_cell)
+
+    def sign_in_google(self) -> None:
+        # click "Sign in" button
+        self.click("#gb > div > div.gb_Se > a")
+        # enter email
+        email_input_box = self.driver.find_element_by_id("identifierId")
+        email_input_box.send_keys(getenv("GMAIL_ADDRESS"))
+        self.click("identifierNext", By.ID)
+        # screen takes a moment to progress
+        time.sleep(3)
+        # enter password
+        pwd_input_box = self.driver.find_element_by_name("password")
+        pwd_input_box.send_keys(getenv("GMAIL_PASSWORD"))
+        self.click("passwordNext", By.ID)
+        time.sleep(3)
+        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(1) > div"
+            )
+            time.sleep(3)
+            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)
+
+    def run_all_cells(self, pre_approved: bool = False) -> None:
+        keyboard_shortcut = ActionChains(self.driver) \
+            .key_down(Keys.META).send_keys() \
+            .send_keys(Keys.F9) \
+            .key_up(Keys.META)
+        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
+            # ElementClickInterceptedException)
+            time.sleep(3)
+            self.driver.find_element_by_id("ok").click()
+
+    def wait_for_test_start(self) -> None:
+        test_runner_cell = self.get_test_runner_cell()
+        test_runner_cell_id: str = test_runner_cell.get_attribute("id")
+        # # wait for runtime to connect and queue all cells
+        # wait = WebDriverWait(self.driver, 10)
+        # is_queued = element_has_class((By.ID, test_runner_cell_id), "pending")
+        # wait.until(is_queued)
+        # # wait for cell that runs test functions to start execution
+        # # needs a longer timeout due to davos installation
+        wait = WebDriverWait(self.driver, 60)
+        is_running_tests = element_has_class((By.ID, test_runner_cell_id), "code-has-output")
+        wait.until(is_running_tests)
+        # switch focus to iframe containing cell output
+        self.driver.switch_to.frame(test_runner_cell.find_element_by_tag_name("iframe"))
+
+
+class JupyterDriver(NotebookDriver):
+    @staticmethod
+    def set_kernel(notebook_path: str) -> None:
+        notebook_path: Path = REPO_ROOT.joinpath(notebook_path)
+        with notebook_path.open() as nb:
+            notebook_json = json.load(nb)
+        notebook_json['metadata']['kernelspec'] = {
+            'display_name': 'kernel-env',
+            'language': 'python',
+            'name': 'kernel-env'
+        }
+        with notebook_path.open('w') as nb:
+            json.dump(notebook_json, nb)
+
+    def __init__(
+            self,
+            notebook_path: Union[Path, str],
+            ip: str = '127.0.0.1',
+            port: str = '8888'
+    ) -> None:
+        self.set_kernel(notebook_path)
+        # noinspection HttpUrlsUsage
+        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)
+        self.driver.execute_script("Jupyter.notebook.clear_all_output()")
+
+    def run_all_cells(self) -> None:
+        # wait up to 10 seconds for "Cell" menu item to be clickable
+        self.click("#menus > div > div > ul > li:nth-child(5) > a")
+        self.click("run_all_cells", By.ID)
+
+    def set_template_vars(
+            self,
+            extra_vars: Optional[dict[str, str]] = None
+    ) -> None:
+        if extra_vars is None:
+            to_replace = SHARED_VARS
+        else:
+            to_replace = SHARED_VARS | extra_vars
+        cell_contents = self.driver.execute_script("return Jupyter.notebook.get_cell(0).get_text()")
+        for key, val in to_replace.items():
+            template = f"${key}$"
+            cell_contents = cell_contents.replace(template, val)
+        set_cell_text_js = "Jupyter.notebook.get_cell(0).set_text(arguments[0])"
+        self.driver.execute_script(set_cell_text_js, cell_contents)
+
+    def wait_for_test_start(self) -> None:
+        wait = WebDriverWait(self.driver, 60)
+        locator = (By.CSS_SELECTOR, "#notebook-container > div:last-child .output_area")
+        first_test_executed = EC.presence_of_element_located(locator)
+        wait.until(first_test_executed)
+
+
+class NotebookFile(pytest.File):
+    test_func_pattern = re.compile(r'(?:^@.+\n)*def test_[^(]+\(.*\):', re.M)
+
+    @staticmethod
+    def process_decorators(
+            decs_asts: list[Union[ast.Attribute, ast.Call]]
+    ) -> list[_pytest.mark.structures.MarkDecorator]:
+        pytest_markers = []
+        # reverse so multiple decorators are added from inside, out
+        for dec in reversed(decs_asts):
+            if isinstance(dec, ast.Call):
+                # noinspection PyUnresolvedReferences
+                # (expects dec.func to be ast.expr, but will always be
+                # ast.Attribute here)
+                mark_name: str = dec.func.attr
+                if mark_name == 'skipif':
+                    # ignore mark.skipif decorator so condition is
+                    # evaluated in notebook namespace
+                    continue
+                args = [ast.literal_eval(arg) for arg in dec.args]
+                kwargs = {arg.arg: ast.literal_eval(arg.value) for arg in dec.keywords}
+            else:
+                mark_name = dec.attr
+                args = []
+                kwargs = {}
+            marker = getattr(pytest.mark, mark_name)(*args, **kwargs)
+            pytest_markers.append(marker)
+        return pytest_markers
+
+    def __init__(
+            self,
+            fspath: str,
+            *,
+            driver_cls: Type[Union[ColabDriver, JupyterDriver]],
+            parent: Optional[_pytest.nodes.Node] = None,
+            config: Optional[_pytest.config.Config] = None,
+            session: Optional[_pytest.main.Session] = None,
+            nodeid: Optional[str] = None
+    ):
+        super().__init__(fspath=fspath, parent=parent, config=config,
+                         session=session, nodeid=nodeid)
+        self.driver_cls = driver_cls
+        self.driver: Optional[Union[ColabDriver, JupyterDriver]] = None
+        notebook_abspath = Path(self.fspath).resolve(strict=True)
+        self.notebook_path = str(notebook_abspath.relative_to(REPO_ROOT))
+        self._test_names: list[str] = []
+
+    def collect(self) -> Iterator[NotebookTest]:
+        with self.fspath.open() as nb:
+            notebook_json: _NotebookJson = json.load(nb)
+        for cell in notebook_json['cells']:
+            if cell['cell_type'] == 'code':
+                cell_contents = ''.join(cell['source'])
+                match_groups: list[str] = self.test_func_pattern.findall(cell_contents)
+                for match in match_groups:
+                    # add body to function signature so it can be parsed
+                    valid_funcdef = match + ' pass'
+                    # noinspection PyTypeChecker
+                    # (expects ast.stmt, will always be ast.FunctionDef)
+                    test_ast: ast.FunctionDef = ast.parse(valid_funcdef).body[0]
+                    test_name = test_ast.name
+                    test_obj: NotebookTest = NotebookTest.from_parent(self, name=test_name)
+                    if test_ast.decorator_list:
+                        # noinspection PyTypeChecker
+                        # (expects list[ast.stmt], will always be
+                        # list[Union[ast.Attribute, ast.Call]])
+                        pytest_marks = self.process_decorators(test_ast.decorator_list)
+                        for mark in pytest_marks:
+                            test_obj.add_marker(mark)
+                    self._test_names.append(test_name)
+                    yield test_obj
+
+    def setup(self) -> None:
+        # TODO: refactor this to call self.driver.<setup_func>()?
+        super().setup()
+        self.driver = self.driver_cls(self.notebook_path)
+        self.driver.clear_all_outputs()
+        self.driver.set_template_vars()
+        # give DOM a moment to update before running & waiting
+        time.sleep(3)
+        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
+        # specify pytest-style timeouts via the @mark.timeout()
+        # decorator in the notebook
+        self.driver.set_max_timeout(300)
+
+    def teardown(self) -> None:
+        if self.driver is not None:
+            self.driver.quit()
+        return super().teardown()
+
+
+# noinspection PyUnresolvedReferences
+class NotebookTest(pytest.Item):
+    parent: NotebookFile
+
+    def reportinfo(self) -> Tuple[Union[py.path.local, str], Optional[int], str]:
+        return self.fspath, 0, ""
+
+    def runtest(self) -> None:
+        result_info = self.parent.driver.get_test_result(self.name)
+        outcome = result_info[0]
+        if outcome == 'PASSED':
+            return None
+        elif outcome == 'SKIPPED':
+            # test was skipped due to mark.skipif/mark.xfail conditions
+            # evaluating to True within the notebook context.
+            # result_info[1] is the "reason" passed to the decorator
+            raise NotebookTestSkipped(result_info[1])
+        elif outcome == 'FAILED':
+            # test failed, result_info[1] is the pre-formatted traceback to
+            # be displayed
+            raise NotebookTestFailed(result_info[1])
+        else:
+            raise ValueError(f"received unexpected test outcome: {outcome}")
+
+    @overload
+    def repr_failure(
+            self,
+            excinfo: _pytest._code.code.ExceptionInfo[NotebookTestFailed],
+            style: None
+    ) -> str:
+        ...
+
+    @overload
+    def repr_failure(
+            self,
+            excinfo: _pytest._code.code.ExceptionInfo[BaseException],
+            style: Optional[_pytest._code.code._TracebackStyle]
+    ) -> _pytest._code.code.TerminalRepr:
+        ...
+
+    def repr_failure(
+            self,
+            excinfo: _pytest._code.code.ExceptionInfo[Union[NotebookTestFailed, BaseException]],
+            style: Optional[_pytest._code.code._TracebackStyle] = None
+    ) -> Union[str, _pytest._code.code.TerminalRepr]:
+        if isinstance(excinfo.value, NotebookTestFailed):
+            return html.unescape(str(excinfo.value))
+        return super().repr_failure(excinfo=excinfo, style=style)
+
+
+def pytest_collect_file(
+        path: py.path.local,
+        parent: pytest.Collector
+) -> Optional[NotebookFile]:
+    if NOTEBOOK_TYPE == 'colab':
+        driver_cls = ColabDriver
+    else:
+        driver_cls = JupyterDriver
+
+    if path.basename.startswith('test_') and path.ext == ".ipynb":
+        return NotebookFile.from_parent(parent, fspath=path, driver_cls=driver_cls)
+    return None
+
+
+def pytest_markeval_namespace(config: _pytest.config.Config):
+    return SHARED_VARS
+
+
+@pytest.hookimpl(hookwrapper=True)
+def pytest_runtest_call(item: pytest.Item):
+    """
+    handles @mark.skipif and @mark.xfail decorators whose conditions
+    need to be evaluated inside the notebook
+    """
+    outcome: pluggy.callers._Result = yield
+    excinfo = outcome.excinfo
+    if excinfo is not None and excinfo[0] is NotebookTestSkipped:
+        msg = html.unescape(str(excinfo[1]))
+        pytest.skip(msg)
+
+
+def pytest_runtest_setup(item: pytest.Item):
+    missing_reqs = []
+    for mark in item.iter_markers():
+        if mark.name == 'colab' and NOTEBOOK_TYPE != 'colab':
+            missing_reqs.append('Google Colab')
+        elif mark.name == 'jupyter' and NOTEBOOK_TYPE != 'jupyter':
+            missing_reqs.append('Jupyter notebooks')
+        elif (mark.name == 'ipython_pre7' and
+                (IPYTHON_VERSION == 'latest' or
+                 int(IPYTHON_VERSION.split('.')[0]) >= 7)):
+            missing_reqs.append('IPython<7.0.0')
+        elif (mark.name == 'ipython_post7' and
+              IPYTHON_VERSION != 'latest' and
+              int(IPYTHON_VERSION.split('.')[0]) < 7):
+            missing_reqs.append('IPython>=7.0.0')
+
+    if missing_reqs:
+        pytest.skip(f"Test requires {', '.join(missing_reqs)}")
diff --git a/tests/test__environment_and_init.ipynb b/tests/test__environment_and_init.ipynb
new file mode 100644
index 00000000..fb86d8ba
--- /dev/null
+++ b/tests/test__environment_and_init.ipynb
@@ -0,0 +1,335 @@
+{
+ "cells": [
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-06T01:39:18.077407Z",
+     "start_time": "2021-07-06T01:39:18.069414Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "GITHUB_USERNAME = \"$GITHUB_USERNAME$\"\n",
+    "GITHUB_REF = \"$GITHUB_REF$\"\n",
+    "NOTEBOOK_TYPE = \"$NOTEBOOK_TYPE$\"\n",
+    "PYTHON_VERSION = \"$PYTHON_VERSION$\"\n",
+    "IPYTHON_VERSION = \"$IPYTHON_VERSION$\""
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-06T01:42:49.171770Z",
+     "start_time": "2021-07-06T01:42:49.036805Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "import warnings\n",
+    "from pathlib import Path\n",
+    "\n",
+    "import requests\n",
+    "\n",
+    "\n",
+    "warnings.filterwarnings('error', module='davos')\n",
+    "\n",
+    "if NOTEBOOK_TYPE == 'colab':\n",
+    "    # utils module doesn't exist on colab VM, so get current version from GitHub\n",
+    "    utils_module = Path('utils.py').resolve()\n",
+    "    response = requests.get(f'https://raw.githubusercontent.com/{GITHUB_USERNAME}/davos/{GITHUB_REF}/tests/utils.py')\n",
+    "    utils_module.write_text(response.text)\n",
+    "    # also need to install davos locally\n",
+    "    from utils import install_davos\n",
+    "    install_davos(source='github', ref=GITHUB_REF, fork=GITHUB_USERNAME)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-06T02:12:59.681734Z",
+     "start_time": "2021-07-06T02:12:59.679070Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "import inspect\n",
+    "import json\n",
+    "import subprocess\n",
+    "import sys\n",
+    "\n",
+    "import davos\n",
+    "import IPython\n",
+    "import pkg_resources\n",
+    "\n",
+    "from utils import (\n",
+    "    is_imported, \n",
+    "    is_installed, \n",
+    "    mark, \n",
+    "    raises, \n",
+    "    run_tests, \n",
+    "    TestingEnvironmentError\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "IPYTHON_SHELL = get_ipython()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# tests for general testing environment & package initialization\n",
+    "tests GitHub runner itself, as well as contents of `__init__.py` & `implementations.__init__.py`"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-06T03:20:05.668719Z",
+     "start_time": "2021-07-06T03:20:05.666297Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_import_davos():\n",
+    "    global davos\n",
+    "    import davos\n",
+    "    assert is_imported('davos')"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "def test_expected_python_version():\n",
+    "    installed_version = '.'.join(map(str, sys.version_info[:2]))\n",
+    "    expected_version = PYTHON_VERSION\n",
+    "    if installed_version != expected_version:\n",
+    "        raise TestingEnvironmentError(\n",
+    "            f\"Test environment has Python {sys.version.split()[0]}, expected \"\n",
+    "            \"{PYTHON_VERSION}\"\n",
+    "        )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "@mark.jupyter\n",
+    "def test_notebook_using_kernel_python():\n",
+    "    if not sys.executable.endswith('envs/kernel-env/bin/python'):\n",
+    "        raise TestingEnvironmentError(\n",
+    "            \"Notebook does not appear to be using the correct python \"\n",
+    "            \"executable. Expected a path ending in \"\n",
+    "            f\"'envs/kernel-env/bin/python', found {sys.executable}\"\n",
+    "        )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "@mark.skipif(IPYTHON_VERSION == 'latest', reason=\"runs when IPYTHON_VERSION != 'latest'\")\n",
+    "def test_expected_ipython_version():\n",
+    "    try:\n",
+    "        pkg_resources.get_distribution(f\"IPython=={IPYTHON_VERSION}\")\n",
+    "    except pkg_resources.VersionConflict as e:\n",
+    "        raise TestingEnvironmentError(\n",
+    "            f\"Test environment has IPython=={IPython.__version__}, expected \"\n",
+    "            f\"{IPYTHON_VERSION}\") from e"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "@mark.skipif(IPYTHON_VERSION != 'latest', reason=\"runs when IPYTHON_VERSION == 'latest'\")\n",
+    "def test_latest_ipython_version():\n",
+    "    pip_exe = davos.config.pip_executable\n",
+    "    outdated_pkgs = subprocess.check_output(\n",
+    "            [pip_exe, 'list', '--outdated', '--format', 'json'], encoding='utf-8'\n",
+    "        )\n",
+    "    outdated_pkgs_json = json.loads(outdated_pkgs)\n",
+    "    for pkg in outdated_pkgs_json:\n",
+    "        if pkg['name'] == 'ipython':\n",
+    "            raise TestingEnvironmentError(\n",
+    "                f\"Test environment has IPython=={pkg['version']}, expected \"\n",
+    "                f\"latest version (IPython=={pkg['latest_version']})\"\n",
+    "            )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "def test_scipy_installed():\n",
+    "    \"\"\"used as an example package for some tests\"\"\"\n",
+    "    assert is_installed('scipy')"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "def test_fastdtw_installed():\n",
+    "    \"\"\"used as an example package for some tests\"\"\"\n",
+    "    assert is_installed('fastdtw==0.3.4')"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "def test_tqdm_installed():\n",
+    "    \"\"\"used as an example package for some tests\"\"\"\n",
+    "    assert is_installed('tqdm==4.41.1')"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "def test_smuggle_in_namespace():\n",
+    "    assert 'smuggle' in globals()\n",
+    "    assert 'smuggle' in IPYTHON_SHELL.user_ns\n",
+    "    assert globals()['smuggle'] is IPYTHON_SHELL.user_ns['smuggle']"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "def test_activated_on_import():\n",
+    "    assert davos.is_active()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "def test_deactivate_reactivate_toplevel():\n",
+    "    assert davos.is_active()\n",
+    "    \n",
+    "    davos.deactivate()\n",
+    "    assert not davos.is_active()\n",
+    "    \n",
+    "    with raises(NameError, match=\"name 'smuggle' is not defined\"):\n",
+    "        smuggle ast\n",
+    "        \n",
+    "    davos.activate()\n",
+    "    assert davos.is_active()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "def test_all_configurable_fields_settable_via_configure():\n",
+    "    all_properties = []\n",
+    "    for name, val in davos.core.config.DavosConfig.__dict__.items():\n",
+    "        if isinstance(val, property):\n",
+    "            all_properties.append(name)\n",
+    "    read_only_fields = {\n",
+    "        'conda_avail', \n",
+    "        'conda_envs_dirs', \n",
+    "        'environment', \n",
+    "        'ipython_shell', \n",
+    "        'smuggled'\n",
+    "    }\n",
+    "    configurable_fields = set(all_properties) - read_only_fields\n",
+    "    configure_func_kwargs = set(inspect.signature(davos.configure).parameters)\n",
+    "    assert not configurable_fields.symmetric_difference(\n",
+    "        configure_func_kwargs\n",
+    "    ), (\n",
+    "        f\"configurable fields: {configurable_fields}\\ndavos.configure kwargs: \"\n",
+    "        f\"{configure_func_kwargs}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "def test_configure_resets_fields_on_fail():\n",
+    "    active_before = davos.config.active\n",
+    "    confirm_install_before = davos.config.confirm_install\n",
+    "    with raises(davos.core.exceptions.DavosConfigError):\n",
+    "        davos.configure(\n",
+    "            active=False, \n",
+    "            confirm_install=True, \n",
+    "            suppress_stdout='BAD VALUE'\n",
+    "        )\n",
+    "    assert davos.config.active is active_before\n",
+    "    assert davos.config.confirm_install is confirm_install_before"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "run_tests()"
+   ]
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "kernel-env",
+   "language": "python",
+   "name": "kernel-env"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.8.10"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 2
+}
diff --git a/tests/test_config.ipynb b/tests/test_config.ipynb
new file mode 100644
index 00000000..ec25798f
--- /dev/null
+++ b/tests/test_config.ipynb
@@ -0,0 +1,476 @@
+{
+ "cells": [
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-13T00:52:12.660439Z",
+     "start_time": "2021-07-13T00:52:12.652124Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "GITHUB_USERNAME = \"$GITHUB_USERNAME$\"\n",
+    "GITHUB_REF = \"$GITHUB_REF$\"\n",
+    "NOTEBOOK_TYPE = \"$NOTEBOOK_TYPE$\"\n",
+    "PYTHON_VERSION = \"$PYTHON_VERSION$\"\n",
+    "IPYTHON_VERSION = \"$IPYTHON_VERSION$\""
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-13T00:52:12.728784Z",
+     "start_time": "2021-07-13T00:52:12.663310Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "import warnings\n",
+    "from pathlib import Path\n",
+    "\n",
+    "import requests\n",
+    "\n",
+    "\n",
+    "warnings.filterwarnings('error', module='davos')\n",
+    "\n",
+    "if NOTEBOOK_TYPE == 'colab':\n",
+    "    # utils module doesn't exist on colab VM, so get current version from GitHub\n",
+    "    utils_module = Path('utils.py').resolve()\n",
+    "    response = requests.get(f'https://raw.githubusercontent.com/{GITHUB_USERNAME}/davos/{GITHUB_REF}/tests/utils.py')\n",
+    "    utils_module.write_text(response.text)\n",
+    "    # also need to install davos locally\n",
+    "    from utils import install_davos\n",
+    "    install_davos(source='github', ref=GITHUB_REF, fork=GITHUB_USERNAME)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-13T00:52:12.839660Z",
+     "start_time": "2021-07-13T00:52:12.731068Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "import re\n",
+    "from contextlib import redirect_stdout\n",
+    "from io import StringIO\n",
+    "\n",
+    "import davos\n",
+    "import IPython\n",
+    "import pkg_resources\n",
+    "from davos.core.core import run_shell_command\n",
+    "from davos.core.exceptions import DavosConfigError\n",
+    "\n",
+    "from utils import mark, raises, run_tests"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-13T00:52:12.845860Z",
+     "start_time": "2021-07-13T00:52:12.842655Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "IPYTHON_SHELL = get_ipython()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# tests for `davos.core.config`"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-13T00:52:12.851873Z",
+     "start_time": "2021-07-13T00:52:12.848448Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_config_is_singleton():\n",
+    "    assert davos.core.config.DavosConfig() is davos.config"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-13T00:52:12.860218Z",
+     "start_time": "2021-07-13T00:52:12.854377Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_deactivate_reactivate_configlevel():\n",
+    "    assert davos.config.active\n",
+    "    \n",
+    "    davos.config.active = False\n",
+    "    assert not davos.config.active\n",
+    "    \n",
+    "    with raises(NameError, match=re.escape(\"name 'smuggle' is not defined\")):\n",
+    "        smuggle ast\n",
+    "        \n",
+    "    davos.config.active = True\n",
+    "    assert davos.config.active"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-13T00:52:12.865982Z",
+     "start_time": "2021-07-13T00:52:12.862608Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "@mark.colab\n",
+    "def test_auto_rerun_disabled_colab():\n",
+    "    with raises(DavosConfigError):\n",
+    "        davos.config.auto_rerun = True"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-13T00:52:12.876024Z",
+     "start_time": "2021-07-13T00:52:12.871755Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "@mark.jupyter\n",
+    "def test_confirm_install_disabled_noninteractive():\n",
+    "    match = re.escape(\n",
+    "        \"'davos.config.confirm_install': field may not be 'True' in \"\n",
+    "        \"noninteractive mode\"\n",
+    "    )\n",
+    "    try:\n",
+    "        davos.config.noninteractive = True\n",
+    "        with raises(DavosConfigError, match=match):\n",
+    "            davos.config.confirm_install = True\n",
+    "    finally:\n",
+    "        # ensure noninteractive mode & prompting are disabled after test\n",
+    "        davos.config.noninteractive = False\n",
+    "        davos.config.confirm_install = False"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-13T00:52:12.885437Z",
+     "start_time": "2021-07-13T00:52:12.880085Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_environment():\n",
+    "    msg = f\"davos.config.environment is '{davos.config.environment}'\"\n",
+    "    if NOTEBOOK_TYPE == 'colab':\n",
+    "        assert davos.config.environment == 'Colaboratory', msg\n",
+    "    elif IPython.version_info[0] < 7:\n",
+    "        assert davos.config.environment == 'IPython<7.0', msg\n",
+    "    else:\n",
+    "        assert davos.config.environment == 'IPython>=7.0', msg"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-13T00:52:12.891943Z",
+     "start_time": "2021-07-13T00:52:12.888888Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_ipython_shell_is_global_obj():\n",
+    "    assert davos.config.ipython_shell is IPYTHON_SHELL"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-13T00:52:12.905396Z",
+     "start_time": "2021-07-13T00:52:12.894091Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_ipython_shell_is_read_only():\n",
+    "    match = re.escape(\"'davos.config.ipython_shell': field is read-only\")\n",
+    "    with raises(DavosConfigError, match=match):\n",
+    "        davos.config.ipython_shell = None"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-13T00:52:12.912325Z",
+     "start_time": "2021-07-13T00:52:12.908427Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "@mark.colab\n",
+    "def test_ipython_shell_is_colab_shell():\n",
+    "    import google\n",
+    "    google_version = pkg_resources.get_distribution('google').version\n",
+    "    assert isinstance(IPYTHON_SHELL, google.colab._shell.Shell), (\n",
+    "        \"Qualified name for Colab interactive shell class has changed (google \"\n",
+    "        \"module may have been recently updated).\\n\\tShell type:\\t\"\n",
+    "        f\"{type(IPYTHON_SHELL)}\\n\\tgoogle version:\\t{google_version}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-13T00:52:12.918398Z",
+     "start_time": "2021-07-13T00:52:12.914539Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "@mark.jupyter\n",
+    "def test_noninteractive_warns_if_confirm_install():\n",
+    "    match = re.escape(\n",
+    "        \"noninteractive mode enabled, setting `confirm_install = False`\"\n",
+    "    )\n",
+    "    try:\n",
+    "        davos.config.confirm_install = True\n",
+    "        with raises(UserWarning, match=match):\n",
+    "            davos.config.noninteractive = True\n",
+    "    finally:\n",
+    "        # ensure prompting & noninteractive mode are disabled after test\n",
+    "        davos.config.noninteractive = False\n",
+    "        davos.config.confirm_install = False"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-13T00:52:12.923567Z",
+     "start_time": "2021-07-13T00:52:12.920348Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "@mark.colab\n",
+    "def test_noninteractive_mode_disabled_colab():\n",
+    "    match = re.escape(\n",
+    "        \"'davos.config.noninteractive': noninteractive mode not available \"\n",
+    "        \"in Colaboratory\"\n",
+    "    )\n",
+    "    with raises(DavosConfigError, match=match):\n",
+    "        davos.config.noninteractive = True"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-13T00:52:12.930567Z",
+     "start_time": "2021-07-13T00:52:12.926391Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_pip_executable_exists():\n",
+    "    pip_exe_path = Path(davos.config.pip_executable)\n",
+    "    assert pip_exe_path.is_file(), f\"{pip_exe_path} does not exist\""
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-13T00:52:12.936989Z",
+     "start_time": "2021-07-13T00:52:12.933023Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "@mark.jupyter\n",
+    "def test_pip_executable_in_kernel_env():\n",
+    "    pip_exe = davos.config.pip_executable\n",
+    "    assert pip_exe.endswith('/envs/kernel-env/bin/pip'), (\n",
+    "        \"expected davos.config.pip_executable path to end in \"\n",
+    "        f\"'/envs/kernel-env/bin/pip'. Path is '{pip_exe}'\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-13T00:52:12.942258Z",
+     "start_time": "2021-07-13T00:52:12.938969Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_set_pip_executable_fails_no_exist():\n",
+    "    bad_path = '/fake/path/to/bin/pip'\n",
+    "    match = re.escape(\n",
+    "        \"'davos.config.pip_executable': No such file or directory: \"\n",
+    "        \"'/fake/path/to/bin/pip'\"\n",
+    "    )\n",
+    "    with raises(DavosConfigError, match=match):\n",
+    "        davos.config.pip_executable = bad_path"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-13T00:52:12.950236Z",
+     "start_time": "2021-07-13T00:52:12.944606Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_set_pip_executable_fails_is_dir():\n",
+    "    tmpdir = Path.cwd().joinpath('tmpdir')\n",
+    "    tmpdir.mkdir(parents=False, exist_ok=False)\n",
+    "    match = re.escape(\n",
+    "        f\"'davos.config.pip_executable': '{tmpdir}' is not an executable file\"\n",
+    "    )\n",
+    "    try:\n",
+    "        with raises(DavosConfigError, match=match):\n",
+    "            davos.config.pip_executable = tmpdir\n",
+    "    finally:\n",
+    "        tmpdir.rmdir()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-13T00:52:12.956129Z",
+     "start_time": "2021-07-13T00:52:12.953186Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_smuggled_dict_initially_empty():\n",
+    "    assert davos.config.smuggled == {}, (\n",
+    "        \"expected davos.config.smuggled to be empty. Found: \"\n",
+    "        f\"'{davos.config.smuggled}'\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-13T00:52:12.961030Z",
+     "start_time": "2021-07-13T00:52:12.958085Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_smuggled_is_read_only():\n",
+    "    match = re.escape(\"'davos.config.smuggled': field is read-only\")\n",
+    "    with raises(DavosConfigError, match=match):\n",
+    "        davos.config.smuggled = {'foo': 'bar'}"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-13T00:52:12.967814Z",
+     "start_time": "2021-07-13T00:52:12.963275Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_suppress_stdout():\n",
+    "    try:\n",
+    "        davos.config.suppress_stdout = True\n",
+    "        with redirect_stdout(StringIO()) as tmp_stdout:\n",
+    "            run_shell_command('echo \"hello world\"', live_stdout=None)\n",
+    "            tmp_stdout = tmp_stdout.getvalue()\n",
+    "        assert tmp_stdout == '', (\n",
+    "            f\"expected stdout to be suppressed. Found: '{tmp_stdout}'\"\n",
+    "        )\n",
+    "    finally:\n",
+    "        davos.config.suppress_stdout = False"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-13T00:52:13.111287Z",
+     "start_time": "2021-07-13T00:52:12.969975Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "run_tests()"
+   ]
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "kernel-env",
+   "language": "python",
+   "name": "kernel-env"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.8.10"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 2
+}
diff --git a/tests/test_core.ipynb b/tests/test_core.ipynb
new file mode 100644
index 00000000..5b253619
--- /dev/null
+++ b/tests/test_core.ipynb
@@ -0,0 +1,2886 @@
+{
+ "cells": [
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:14.827895Z",
+     "start_time": "2021-07-21T23:36:14.819055Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "GITHUB_USERNAME = \"$GITHUB_USERNAME$\"\n",
+    "GITHUB_REF = \"$GITHUB_REF$\"\n",
+    "NOTEBOOK_TYPE = \"$NOTEBOOK_TYPE$\"\n",
+    "PYTHON_VERSION = \"$PYTHON_VERSION$\"\n",
+    "IPYTHON_VERSION = \"$IPYTHON_VERSION$\""
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:14.896413Z",
+     "start_time": "2021-07-21T23:36:14.830609Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "import warnings\n",
+    "from pathlib import Path\n",
+    "\n",
+    "import requests\n",
+    "\n",
+    "\n",
+    "warnings.filterwarnings('error', module='davos')\n",
+    "\n",
+    "if NOTEBOOK_TYPE == 'colab':\n",
+    "    # utils module doesn't exist on colab VM, so get current version from GitHub\n",
+    "    utils_module = Path('utils.py').resolve()\n",
+    "    response = requests.get(f'https://raw.githubusercontent.com/{GITHUB_USERNAME}/davos/{GITHUB_REF}/tests/utils.py')\n",
+    "    utils_module.write_text(response.text)\n",
+    "    # also need to install davos locally\n",
+    "    from utils import install_davos\n",
+    "    install_davos(source='github', ref=GITHUB_REF, fork=GITHUB_USERNAME)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.016165Z",
+     "start_time": "2021-07-21T23:36:14.899449Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "import builtins\n",
+    "import inspect\n",
+    "import sys\n",
+    "import types\n",
+    "from contextlib import redirect_stdout\n",
+    "from io import StringIO\n",
+    "from pathlib import Path\n",
+    "from subprocess import CalledProcessError\n",
+    "from textwrap import dedent\n",
+    "\n",
+    "import davos\n",
+    "import IPython\n",
+    "import pkg_resources\n",
+    "from IPython.utils.io import capture_output as capture_ipython_display\n",
+    "\n",
+    "from utils import (\n",
+    "    expected_onion_parser_output, \n",
+    "    expected_parser_output,\n",
+    "    is_imported, \n",
+    "    is_installed,\n",
+    "    mark, \n",
+    "    matches_expected_output,\n",
+    "    raises, \n",
+    "    run_tests\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.021856Z",
+     "start_time": "2021-07-21T23:36:15.018321Z"
+    },
+    "code_folding": []
+   },
+   "outputs": [],
+   "source": [
+    "IPYTHON_SHELL = get_ipython()\n",
+    "\n",
+    "_parse_onion = davos.core.core.Onion.parse_onion\n",
+    "\n",
+    "\n",
+    "def _parse_line(line):\n",
+    "    if IPYTHON_VERSION == 'latest' or int(IPYTHON_VERSION.split('.')[0]) >= 7:\n",
+    "        line = [f\"{line}\\n\"]\n",
+    "    return davos.implementations.full_parser(line)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# tests for `davos.core.core`\n",
+    "**Note**: regular expressions defined in `davos.core.regexps` but used in `davos.core.core` are tested in `test_regexps.ipynb`"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.030402Z",
+     "start_time": "2021-07-21T23:36:15.023535Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_capture_stdout_multistream():\n",
+    "    \"\"\"\n",
+    "    should write correct output to stdout (captured for test), \n",
+    "    in-memory object, and file streams simultaneously\n",
+    "    \"\"\"\n",
+    "    msg = (\"Strictly speaking, I didn't do the theiving. That would be the \"\n",
+    "           \"pirates. I just moved what they stole from one place to another\")\n",
+    "    tmpfile = Path('tmpfile.txt')\n",
+    "    try:\n",
+    "        with redirect_stdout(StringIO()) as mock_stdout:\n",
+    "            with davos.core.core.capture_stdout(\n",
+    "                StringIO(), tmpfile.open('w')\n",
+    "            ) as (mem_stream, file_stream):\n",
+    "                print(msg, end='')\n",
+    "                in_mem = mem_stream.getvalue()\n",
+    "            \n",
+    "            in_stdout = mock_stdout.getvalue()\n",
+    "        \n",
+    "        in_file = tmpfile.read_text()\n",
+    "        \n",
+    "        assert mem_stream.closed, \"StringIO object was not closed\"\n",
+    "        assert file_stream.closed, \"file stream was not closed\"\n",
+    "\n",
+    "        assert in_stdout == msg, (\n",
+    "            \"content written to stdout doesn't match original. Expected:\\n\"\n",
+    "            f\"\\\"{msg}\\\"\\n\\nFound:\\n\\\"{in_stdout}\\\"\"\n",
+    "        )\n",
+    "        assert in_mem == msg, (\n",
+    "            \"content written to StringIO object doesn't match original. \"\n",
+    "            f\"Expected:\\n\\\"{msg}\\\"\\n\\nFound:\\n\\\"{in_stdout}\\\"\"\n",
+    "        )\n",
+    "        assert in_file == msg, (\n",
+    "            \"content written to file doesn't match original. Expected:\\n\"\n",
+    "            f\"\\\"{msg}\\\"\\n\\nFound:\\n\\\"{in_file}\\\"\"\n",
+    "        )\n",
+    "    finally:\n",
+    "        if not mem_stream.closed:\n",
+    "            mem_stream.close()\n",
+    "        if not file_stream.closed:\n",
+    "            file_stream.close()\n",
+    "        if tmpfile.is_file():\n",
+    "            tmpfile.unlink()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.036456Z",
+     "start_time": "2021-07-21T23:36:15.032537Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_capture_stdout_not_closing():\n",
+    "    \"\"\"\n",
+    "    passing 'closing=True' should prevent streams from closing when \n",
+    "    exiting context block\n",
+    "    \"\"\"\n",
+    "    msg = (\"Strictly speaking, I didn't do the theiving. That would be the \"\n",
+    "           \"pirates. I just moved what they stole from one place to another\")\n",
+    "    try:\n",
+    "        with davos.core.core.capture_stdout(StringIO(), closing=False) as mem_stream:\n",
+    "            print(msg, end='')\n",
+    "                \n",
+    "        assert not mem_stream.closed, (\n",
+    "            \"StringIO stream was closed despite passing 'closing=False'\"\n",
+    "        )\n",
+    "    finally:\n",
+    "        mem_stream.close()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.041990Z",
+     "start_time": "2021-07-21T23:36:15.038641Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "@mark.ipython_pre7\n",
+    "def test_check_conda_ipython_pre7():\n",
+    "    \"\"\"\n",
+    "    conda is not available on Google Colaboratory and not yet supported \n",
+    "    for IPython<7.0.0\n",
+    "    \"\"\"\n",
+    "    davos.core.core.check_conda()\n",
+    "    assert davos.config.conda_avail is False\n",
+    "    assert davos.config.conda_env is None\n",
+    "    assert davos.config.conda_envs_dirs is None"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.048789Z",
+     "start_time": "2021-07-21T23:36:15.045510Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "@mark.ipython_post7\n",
+    "def test_check_conda_ipython_post7():\n",
+    "    \"\"\"basic test for expected values\"\"\"\n",
+    "    davos.core.core.check_conda()\n",
+    "    assert davos.config.conda_avail is True\n",
+    "    assert davos.config.conda_env == 'kernel-env'\n",
+    "    assert isinstance(davos.config.conda_envs_dirs, dict)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.059438Z",
+     "start_time": "2021-07-21T23:36:15.051624Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_check_conda_stdout_parsing():\n",
+    "    \"\"\"\n",
+    "    check that 'conda_avail', 'conda_env', and 'conda_envs_dirs' config \n",
+    "    fields are set correctly. Temporarily overloads \n",
+    "    '_check_conda_avail_helper' and 'run_shell_command' functions with \n",
+    "    functions defined below to compare expected values & results.\n",
+    "    \"\"\"\n",
+    "    old_conda_avail = davos.config._conda_avail\n",
+    "    old_conda_env = davos.config._conda_env\n",
+    "    old_conda_envs_dirs = davos.config._conda_envs_dirs\n",
+    "    old__check_conda_avail_helper = davos.core.core._check_conda_avail_helper\n",
+    "    old_run_shell_command = davos.core.core.run_shell_command\n",
+    "    \n",
+    "    mock_conda_list_output = dedent(\"\"\"\\\n",
+    "    # packages in environment at /path/to/anaconda3/envs/mock-current-env:\n",
+    "    #\n",
+    "    # Name                    Version                   Build  Channel\n",
+    "    ipython                   X.X.X                    YYYYYY    ZZZZ\n",
+    "    etc...\"\"\")\n",
+    "    mock_conda_info_output = dedent(\"\"\"\\\n",
+    "    base /path/to/anaconda3\n",
+    "    mock-current-env /path/to/anaconda3/envs/mock-current-env\n",
+    "    mock-other-env /path/to/anaconda3/envs/mock-other-env\"\"\")\n",
+    "    \n",
+    "    expected_conda_envs_dirs = {\n",
+    "        'base': '/path/to/anaconda3',\n",
+    "        'mock-current-env': '/path/to/anaconda3/envs/mock-current-env',\n",
+    "        'mock-other-env': '/path/to/anaconda3/envs/mock-other-env'\n",
+    "    }\n",
+    "    \n",
+    "    def _mock_check_conda_avail_helper():\n",
+    "        return mock_conda_list_output\n",
+    "    \n",
+    "    def _mock_run_shell_command(command, live_stdout=None):\n",
+    "        if command == \"conda info --envs | grep -E '^\\w' | sed -E 's/ +\\*? +/ /g'\":\n",
+    "            return mock_conda_info_output\n",
+    "        else:\n",
+    "            return old_run_shell_command(command, live_stdout=live_stdout)\n",
+    "    \n",
+    "    try:\n",
+    "        davos.core.core._check_conda_avail_helper = _mock_check_conda_avail_helper\n",
+    "        davos.core.core.run_shell_command = _mock_run_shell_command\n",
+    "        \n",
+    "        davos.core.core.check_conda()\n",
+    "        \n",
+    "        assert davos.config.conda_avail is True\n",
+    "        assert davos.config.conda_env == 'mock-current-env', (\n",
+    "            f\"Found: {davos.config.conda_env}\"\n",
+    "        )\n",
+    "        assert davos.config.conda_envs_dirs == expected_conda_envs_dirs, (\n",
+    "            f\"Expected: {expected_conda_envs_dirs}\\nFound: \"\n",
+    "            f\"{davos.config.conda_envs_dirs}\"\n",
+    "        )\n",
+    "    finally:\n",
+    "        davos.config._conda_avail = old_conda_avail\n",
+    "        davos.config._conda_env = old_conda_env\n",
+    "        davos.config._conda_envs_dirs = old_conda_envs_dirs\n",
+    "        davos.core.core._check_conda_avail_helper = old__check_conda_avail_helper\n",
+    "        davos.core.core.run_shell_command = old_run_shell_command"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.069679Z",
+     "start_time": "2021-07-21T23:36:15.061505Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_check_conda_bad_info_cmd():\n",
+    "    \"\"\"\n",
+    "    when parsing 'conda list ipython' output is successful but parsing \n",
+    "    'conda info --envs' output fails, 'conda_avail' and 'conda_env' \n",
+    "    config fields should still be assigned correctly, but \n",
+    "    'conda_envs_dirs' field should be 'None'\n",
+    "    \"\"\"\n",
+    "    old_conda_avail = davos.config._conda_avail\n",
+    "    old_conda_env = davos.config._conda_env\n",
+    "    old_conda_envs_dirs = davos.config._conda_envs_dirs\n",
+    "    old__check_conda_avail_helper = davos.core.core._check_conda_avail_helper\n",
+    "    old_run_shell_command = davos.core.core.run_shell_command\n",
+    "    \n",
+    "    mock_conda_list_output = dedent(\"\"\"\\\n",
+    "    # packages in environment at /path/to/anaconda3/envs/mock-current-env:\n",
+    "    #\n",
+    "    # Name                    Version                   Build  Channel\n",
+    "    ipython                   X.X.X                    YYYYYY    ZZZZ\n",
+    "    etc...\"\"\")\n",
+    "    \n",
+    "    def _mock_check_conda_avail_helper():\n",
+    "        return mock_conda_list_output\n",
+    "    \n",
+    "    def _mock_run_shell_command(command, live_stdout=None):\n",
+    "        if command == \"conda info --envs | grep -E '^\\w' | sed -E 's/ +\\*? +/ /g'\":\n",
+    "            raise Exception\n",
+    "        else:\n",
+    "            return old_run_shell_command(command, live_stdout=live_stdout)\n",
+    "    \n",
+    "    try:\n",
+    "        davos.core.core._check_conda_avail_helper = _mock_check_conda_avail_helper\n",
+    "        davos.core.core.run_shell_command = _mock_run_shell_command\n",
+    "        \n",
+    "        davos.core.core.check_conda()\n",
+    "        \n",
+    "        assert davos.config.conda_avail is True\n",
+    "        assert davos.config.conda_env == 'mock-current-env', (\n",
+    "            f\"Found: {davos.config.conda_env}\"\n",
+    "        )\n",
+    "        assert davos.config.conda_envs_dirs is None\n",
+    "    finally:\n",
+    "        davos.config._conda_avail = old_conda_avail\n",
+    "        davos.config._conda_env = old_conda_env\n",
+    "        davos.config._conda_envs_dirs = old_conda_envs_dirs\n",
+    "        davos.core.core._check_conda_avail_helper = old__check_conda_avail_helper\n",
+    "        davos.core.core.run_shell_command = old_run_shell_command"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.079884Z",
+     "start_time": "2021-07-21T23:36:15.071916Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_check_conda_bad_list_cmd_raises():\n",
+    "    \"\"\"\n",
+    "    when 'conda list ipython' output is parsed successfully, but yields \n",
+    "    an environment name not in the dict parsed from the output of \n",
+    "    'conda info --envs', 'check_conda' function should raise a \n",
+    "    'DavosError'\n",
+    "    \"\"\"\n",
+    "    old_conda_avail = davos.config._conda_avail\n",
+    "    old_conda_env = davos.config._conda_env\n",
+    "    old_conda_envs_dirs = davos.config._conda_envs_dirs\n",
+    "    old__check_conda_avail_helper = davos.core.core._check_conda_avail_helper\n",
+    "    old_run_shell_command = davos.core.core.run_shell_command\n",
+    "    \n",
+    "    mock_conda_list_output = dedent(\"\"\"\\\n",
+    "    # packages in environment at bad-env-path:\n",
+    "    #\n",
+    "    # Name                    Version                   Build  Channel\n",
+    "    ipython                   X.X.X                    YYYYYY    ZZZZ\n",
+    "    etc...\"\"\")\n",
+    "    mock_conda_info_output = dedent(\"\"\"\\\n",
+    "    base /path/to/anaconda3\n",
+    "    mock-current-env /path/to/anaconda3/envs/mock-current-env\n",
+    "    mock-other-env /path/to/anaconda3/envs/mock-other-env\"\"\")\n",
+    "    \n",
+    "    expected_conda_envs_dirs = {\n",
+    "        'base': '/path/to/anaconda3',\n",
+    "        'mock-current-env': '/path/to/anaconda3/envs/mock-current-env',\n",
+    "        'mock-other-env': '/path/to/anaconda3/envs/mock-other-env'\n",
+    "    }\n",
+    "    \n",
+    "    def _mock_check_conda_avail_helper():\n",
+    "        return mock_conda_list_output\n",
+    "    \n",
+    "    def _mock_run_shell_command(command, live_stdout=None):\n",
+    "        if command == \"conda info --envs | grep -E '^\\w' | sed -E 's/ +\\*? +/ /g'\":\n",
+    "            return mock_conda_info_output\n",
+    "        else:\n",
+    "            return old_run_shell_command(command, live_stdout=live_stdout)\n",
+    "    \n",
+    "    try:\n",
+    "        davos.core.core._check_conda_avail_helper = _mock_check_conda_avail_helper\n",
+    "        davos.core.core.run_shell_command = _mock_run_shell_command\n",
+    "        \n",
+    "        with raises(davos.core.exceptions.DavosError):\n",
+    "            davos.core.core.check_conda()\n",
+    "\n",
+    "    finally:\n",
+    "        davos.config._conda_avail = old_conda_avail\n",
+    "        davos.config._conda_env = old_conda_env\n",
+    "        davos.config._conda_envs_dirs = old_conda_envs_dirs\n",
+    "        davos.core.core._check_conda_avail_helper = old__check_conda_avail_helper\n",
+    "        davos.core.core.run_shell_command = old_run_shell_command"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.085553Z",
+     "start_time": "2021-07-21T23:36:15.081974Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "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, 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 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}'\""
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.090549Z",
+     "start_time": "2021-07-21T23:36:15.087694Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_get_previously_imported_pkgs_conda_raises():\n",
+    "    \"\"\"passing 'installer-\"conda\"' should raise 'NotImplementedError'\"\"\"\n",
+    "    with raises(NotImplementedError):\n",
+    "        davos.core.core.get_previously_imported_pkgs('', 'conda')"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.097116Z",
+     "start_time": "2021-07-21T23:36:15.092533Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_import_name_pkg():\n",
+    "    \"\"\"test importing a top-level package.\"\"\"\n",
+    "    try:\n",
+    "        assert not is_imported('scipy')\n",
+    "        scipy = davos.core.core.import_name('scipy')\n",
+    "        assert isinstance(scipy, types.ModuleType), (\n",
+    "            f\"type(scipy): {type(scipy)}\\nMRO: {type(scipy).mro()}\"\n",
+    "        )\n",
+    "        assert not hasattr(scipy, 'stats'), (\n",
+    "            \"scipy.stats submodule should not be loaded\"\n",
+    "        )\n",
+    "        assert 'scipy' in sys.modules\n",
+    "    finally:\n",
+    "        for mod_name in tuple(sys.modules.keys()):\n",
+    "            if 'scipy' in mod_name:\n",
+    "                sys.modules.pop(mod_name)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.104894Z",
+     "start_time": "2021-07-21T23:36:15.099724Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_import_name_module():\n",
+    "    \"\"\"test importing a module from a package.\"\"\"\n",
+    "    try:\n",
+    "        assert not is_imported('scipy')\n",
+    "        stats = davos.core.core.import_name('scipy.stats')\n",
+    "        assert isinstance(stats, types.ModuleType), (\n",
+    "            f\"type(stats): {type(stats)}\\nMRO: {type(stats).mro()}\"\n",
+    "        )\n",
+    "        assert not hasattr(stats, 'tests'), (\n",
+    "            \"scipy.stats.tests submodule should not be loaded\"\n",
+    "        )\n",
+    "        assert 'scipy' in sys.modules\n",
+    "        assert 'scipy.stats' in sys.modules\n",
+    "    finally:\n",
+    "        for mod_name in tuple(sys.modules.keys()):\n",
+    "            if 'scipy' in mod_name:\n",
+    "                sys.modules.pop(mod_name)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.115816Z",
+     "start_time": "2021-07-21T23:36:15.107966Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_import_name_function():\n",
+    "    \"\"\"test importing a function by its qualified name\"\"\"\n",
+    "    try:\n",
+    "        assert not is_imported('scipy')\n",
+    "        ttest_ind = davos.core.core.import_name('scipy.stats.ttest_ind')\n",
+    "        assert isinstance(ttest_ind, types.FunctionType), (\n",
+    "            f\"type(scipy): {type(ttest_ind)}\\nMRO: {type(ttest_ind).mro()}\"\n",
+    "        )\n",
+    "        assert ttest_ind.__module__ == 'scipy.stats.stats', (\n",
+    "            f\"Expected: 'scipy.stats.stats'\\nFound: '{ttest_ind.__module__}'\"\n",
+    "        )\n",
+    "        assert 'scipy' in sys.modules\n",
+    "        assert 'scipy.stats' in sys.modules\n",
+    "        assert 'scipy.stats.stats' in sys.modules\n",
+    "    finally:\n",
+    "        for mod_name in tuple(sys.modules.keys()):\n",
+    "            if 'scipy' in mod_name:\n",
+    "                sys.modules.pop(mod_name)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.122621Z",
+     "start_time": "2021-07-21T23:36:15.118201Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_parse_onion_simple():\n",
+    "    \"\"\"simplest case\"\"\"\n",
+    "    onion = '# pip: foo==0.0.1'\n",
+    "    expected = expected_onion_parser_output('foo==0.0.1')\n",
+    "    result = _parse_onion(onion)\n",
+    "    assert result == expected, f\"Expected:\\n{expected}\\nResult:\\n{result}\""
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.128037Z",
+     "start_time": "2021-07-21T23:36:15.124863Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_parse_onion_conda_raises():\n",
+    "    \"\"\"installing with conda is not yet supported\"\"\"\n",
+    "    onion = '# conda: foo==0.0.1'\n",
+    "    with raises(davos.core.exceptions.ParserNotImplementedError):\n",
+    "        _parse_onion(onion)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.134846Z",
+     "start_time": "2021-07-21T23:36:15.130599Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_parse_onion_bad_installer_raises():\n",
+    "    \"\"\"should throw an error if an unrecognized installer is specified\"\"\"\n",
+    "    onion = '# apt: foo==0.0.1'\n",
+    "    with raises(davos.core.exceptions.OnionParserError):\n",
+    "        _parse_onion(onion)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.140842Z",
+     "start_time": "2021-07-21T23:36:15.137237Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_parse_onion_whitespace():\n",
+    "    \"\"\"onion comment should be whitespace-insensitive\"\"\"\n",
+    "    onion = '#             pip:            foo==0.0.1'\n",
+    "    expected = expected_onion_parser_output('foo==0.0.1')\n",
+    "    result = _parse_onion(onion)\n",
+    "    assert result == expected, f\"Expected:\\n{expected}\\nResult:\\n{result}\""
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.146587Z",
+     "start_time": "2021-07-21T23:36:15.143347Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_parse_onion_github():\n",
+    "    \"\"\"test parsing a VCS (specifically GitHub) url\"\"\"\n",
+    "    onion = '# pip: git+https://github.com/foo/bar.git@branch-name#egg=foo&subdirectory=baz'\n",
+    "    expected = expected_onion_parser_output('git+https://github.com/foo/bar.git@branch-name#egg=foo&subdirectory=baz')\n",
+    "    result = _parse_onion(onion)\n",
+    "    assert result == expected, f\"Expected:\\n{expected}\\nResult:\\n{result}\""
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.152101Z",
+     "start_time": "2021-07-21T23:36:15.148571Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_parse_onion_editable():\n",
+    "    \"\"\"GitHub URL like above, but with the --editable flag supplied\"\"\"\n",
+    "    onion = '# pip: --editable git+https://github.com/foo/bar.git'\n",
+    "    expected = expected_onion_parser_output('--editable git+https://github.com/foo/bar.git', \n",
+    "                                            editable=True, \n",
+    "                                            spec='git+https://github.com/foo/bar.git')\n",
+    "    result = _parse_onion(onion)\n",
+    "    assert result == expected, f\"Expected:\\n{expected}\\nResult:\\n{result}\""
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.165040Z",
+     "start_time": "2021-07-21T23:36:15.160398Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_parse_onion_joined_short_args():\n",
+    "    onion = '# pip: -Ive git+https://github.com/foo/bar.git'\n",
+    "    expected = expected_onion_parser_output('-Ive git+https://github.com/foo/bar.git', \n",
+    "                                            editable=True, \n",
+    "                                            ignore_installed=True, \n",
+    "                                            verbosity=1,\n",
+    "                                            spec='git+https://github.com/foo/bar.git')\n",
+    "    result = _parse_onion(onion)\n",
+    "    assert result == expected, f\"Expected:\\n{expected}\\nResult:\\n{result}\""
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.181033Z",
+     "start_time": "2021-07-21T23:36:15.171680Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_onion_simple():\n",
+    "    \"\"\"\n",
+    "    simple Onion object created from 'smuggle' statement with no onion \n",
+    "    comment.\n",
+    "        `smuggle foo`\n",
+    "    \"\"\"\n",
+    "    onion = davos.core.core.Onion('foo', installer='pip', args_str=\"\"\"\"\"\")\n",
+    "    assert onion.install_package == onion._pip_install_package, (\n",
+    "        f\"Expected:\\n{onion._pip_install_package}\\nFound:\\n\"\n",
+    "        f\"{onion.install_package}\"\n",
+    "    )\n",
+    "    assert onion.build is None, f\"Expected: None\\nFound: {onion.build}\"\n",
+    "    assert onion.cache_key == 'pip;', (\n",
+    "        f\"Expected:\\npip;\\nFound:\\n{onion.cache_key}\"\n",
+    "    )\n",
+    "    assert onion.verbosity == 0, f\"Expected: 0\\nFound: {onion.verbosity}\"\n",
+    "    assert onion.install_name == 'foo', (\n",
+    "        f\"Expected\\nfoo\\nFound:\\n{onion.install_name}\"\n",
+    "    )\n",
+    "    assert onion.version_spec == '', (\n",
+    "        f\"Expected: ''\\nFound: {onion.version_spec}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.194840Z",
+     "start_time": "2021-07-21T23:36:15.183258Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_onion_simple_onion():\n",
+    "    \"\"\"\n",
+    "    like above, but with a relatively simple onion comment\n",
+    "        `smuggle foo    # pip: foo==0.0.1 -vv`\n",
+    "    \"\"\"\n",
+    "    installer_kwargs = {\n",
+    "        'editable': False, \n",
+    "        'spec': 'foo==0.0.1', \n",
+    "        'verbosity': 2\n",
+    "    }\n",
+    "    onion = davos.core.core.Onion('foo', \n",
+    "                                  installer='pip', \n",
+    "                                  args_str=\"\"\"foo==0.0.1 -vv\"\"\", \n",
+    "                                  **installer_kwargs)\n",
+    "    assert onion.install_package == onion._pip_install_package, (\n",
+    "        f\"Expected:\\n{onion._pip_install_package}\\nFound:\\n\"\n",
+    "        f\"{onion.install_package}\"\n",
+    "    )\n",
+    "    assert onion.build is None, f\"Expected: None\\nFound: {onion.build}\"\n",
+    "    assert onion.cache_key == 'pip;foo==0.0.1;-vv', (\n",
+    "        f\"Expected:\\npip;foo==0.0.1;-vv\\nFound:\\n{onion.cache_key}\"\n",
+    "    )\n",
+    "    assert onion.verbosity == 2, f\"Expected: 2\\nFound: {onion.verbosity}\"\n",
+    "    assert onion.install_name == 'foo', (\n",
+    "        f\"Expected\\nfoo\\nFound:\\n{onion.install_name}\"\n",
+    "    )\n",
+    "    assert onion.version_spec == '==0.0.1', (\n",
+    "        f\"Expected: '==0.0.1'\\nFound: {onion.version_spec}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.207819Z",
+     "start_time": "2021-07-21T23:36:15.196871Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_onion_different_install_name():\n",
+    "    \"\"\"\n",
+    "    a package imported under a different name than is used to install it\n",
+    "        `smuggle foo    # pip: --editable foo-pkg==0.0.1`\n",
+    "    \"\"\"\n",
+    "    installer_kwargs = {\n",
+    "        'editable': True, \n",
+    "        'spec': 'foo-pkg==0.0.1'\n",
+    "    }\n",
+    "    onion = davos.core.core.Onion('foo', \n",
+    "                                  installer='pip', \n",
+    "                                  args_str=\"\"\"--editable foo-pkg==0.0.1\"\"\", \n",
+    "                                  **installer_kwargs)\n",
+    "    assert onion.install_package == onion._pip_install_package, (\n",
+    "        f\"Expected:\\n{onion._pip_install_package}\\nFound:\\n\"\n",
+    "        f\"{onion.install_package}\"\n",
+    "    )\n",
+    "    assert onion.build is None, f\"Expected: None\\nFound: {onion.build}\"\n",
+    "    assert onion.cache_key == 'pip;--editable;foo-pkg==0.0.1', (\n",
+    "        f\"Expected:\\npip;--editable;foo==0.0.1\\nFound:\\n{onion.cache_key}\"\n",
+    "    )\n",
+    "    assert onion.verbosity == 0, f\"Expected: 2\\nFound: {onion.verbosity}\"\n",
+    "    assert onion.install_name == 'foo-pkg', (\n",
+    "        f\"Expected\\nfoo-pkg\\nFound:\\n{onion.install_name}\"\n",
+    "    )\n",
+    "    assert onion.version_spec == '==0.0.1', (\n",
+    "        f\"Expected: '==0.0.1'\\nFound: {onion.version_spec}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.214532Z",
+     "start_time": "2021-07-21T23:36:15.210439Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_onion_conda_raises():\n",
+    "    \"\"\"passing 'installer=\"conda\"' should raise NotImplementedError\"\"\"\n",
+    "    with raises(NotImplementedError):\n",
+    "        onion = davos.core.core.Onion('foo', installer='conda', args_str=\"\"\"\"\"\")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.220797Z",
+     "start_time": "2021-07-21T23:36:15.217071Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_onion_bad_installer_raises():\n",
+    "    \"\"\"should throw an error if passed an unrecognized installer\"\"\"\n",
+    "    with raises(davos.core.exceptions.OnionParserError):\n",
+    "        onion = davos.core.core.Onion('foo', installer='apt', args_str=\"\"\"\"\"\")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.236140Z",
+     "start_time": "2021-07-21T23:36:15.223628Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_onion_vcs_simple():\n",
+    "    \"\"\"\n",
+    "    relatively simple VCS (GitHub) URL without modifiers\n",
+    "        `smuggle bar    # pip: git+https://github.com/foo/bar.git`\n",
+    "    \"\"\"\n",
+    "    installer_kwargs = {\n",
+    "        'editable': False,\n",
+    "        'spec': 'git+https://github.com/foo/bar.git'\n",
+    "    }\n",
+    "    onion = davos.core.core.Onion('bar', \n",
+    "                                  installer='pip', \n",
+    "                                  args_str=\"\"\"git+https://github.com/foo/bar.git\"\"\", \n",
+    "                                  **installer_kwargs)\n",
+    "    assert onion.install_package == onion._pip_install_package, (\n",
+    "        f\"Expected:\\n{onion._pip_install_package}\\nFound:\\n\"\n",
+    "        f\"{onion.install_package}\"\n",
+    "    )\n",
+    "    assert onion.build is None, f\"Expected: None\\nFound: {onion.build}\"\n",
+    "    assert onion.cache_key == 'pip;git+https://github.com/foo/bar.git', (\n",
+    "        \"Expected:\\npip;git+https://github.com/foo/bar.git\\nFound:\\n\"\n",
+    "        f\"{onion.cache_key}\"\n",
+    "    )\n",
+    "    assert onion.verbosity == 0, f\"Expected: 0\\nFound: {onion.verbosity}\"\n",
+    "    assert onion.install_name == 'git+https://github.com/foo/bar.git', (\n",
+    "        \"Expected\\ngit+https://github.com/foo/bar.git\\nFound:\\n\"\n",
+    "        f\"{onion.install_name}\"\n",
+    "    )\n",
+    "    assert onion.version_spec == '', (\n",
+    "        f\"Expected: ''\\nFound: {onion.version_spec}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.251854Z",
+     "start_time": "2021-07-21T23:36:15.238630Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_onion_vcs_ref():\n",
+    "    \"\"\"\n",
+    "    same as above, but the GitHub URL contains a reference to a specific \n",
+    "    revision (commit, branch, tag, etc.)\n",
+    "        `smuggle bar    # pip: git+https://github.com/foo/bar.git@ref`\n",
+    "    \"\"\"\n",
+    "    installer_kwargs = {\n",
+    "        'editable': False,\n",
+    "        'spec': 'git+https://github.com/foo/bar.git@ref'\n",
+    "    }\n",
+    "    onion = davos.core.core.Onion('bar', \n",
+    "                                  installer='pip', \n",
+    "                                  args_str=\"\"\"git+https://github.com/foo/bar.git@ref\"\"\", \n",
+    "                                  **installer_kwargs)\n",
+    "    assert onion.install_package == onion._pip_install_package, (\n",
+    "        f\"Expected:\\n{onion._pip_install_package}\\nFound:\\n\"\n",
+    "        f\"{onion.install_package}\"\n",
+    "    )\n",
+    "    assert onion.build is None, f\"Expected: None\\nFound: {onion.build}\"\n",
+    "    assert onion.cache_key == 'pip;git+https://github.com/foo/bar.git@ref', (\n",
+    "        \"Expected:\\npip;git+https://github.com/foo/bar.git@ref\\nFound:\\n\"\n",
+    "        f\"{onion.cache_key}\"\n",
+    "    )\n",
+    "    assert onion.verbosity == 0, f\"Expected: 0\\nFound: {onion.verbosity}\"\n",
+    "    assert onion.install_name == 'git+https://github.com/foo/bar.git', (\n",
+    "        \"Expected\\ngit+https://github.com/foo/bar.git\\nFound:\\n\"\n",
+    "        f\"{onion.install_name}\"\n",
+    "    )\n",
+    "    assert onion.version_spec == '@ref', (\n",
+    "        f\"Expected: '@ref'\\nFound: {onion.version_spec}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.267486Z",
+     "start_time": "2021-07-21T23:36:15.254087Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_onion_vcs_ref_egg():\n",
+    "    \"\"\"\n",
+    "    same as above, but the GitHub URL also specifies the project name \n",
+    "    via the '#egg=' component of the URL\n",
+    "        `smuggle bar    # pip: git+https://github.com/foo/bar.git@ref#bar`\n",
+    "    \"\"\"\n",
+    "    installer_kwargs = {\n",
+    "        'editable': False,\n",
+    "        'spec': 'git+https://github.com/foo/bar.git@ref#bar'\n",
+    "    }\n",
+    "    onion = davos.core.core.Onion('bar', \n",
+    "                                  installer='pip', \n",
+    "                                  args_str=\"\"\"git+https://github.com/foo/bar.git@ref#bar\"\"\", \n",
+    "                                  **installer_kwargs)\n",
+    "    assert onion.install_package == onion._pip_install_package, (\n",
+    "        f\"Expected:\\n{onion._pip_install_package}\\nFound:\\n\"\n",
+    "        f\"{onion.install_package}\"\n",
+    "    )\n",
+    "    assert onion.build is None, f\"Expected: None\\nFound: {onion.build}\"\n",
+    "    assert onion.cache_key == 'pip;git+https://github.com/foo/bar.git@ref#bar', (\n",
+    "        \"Expected:\\npip;git+https://github.com/foo/bar.git@ref#bar\\nFound:\\n\"\n",
+    "        f\"{onion.cache_key}\"\n",
+    "    )\n",
+    "    assert onion.verbosity == 0, f\"Expected: 0\\nFound: {onion.verbosity}\"\n",
+    "    assert onion.install_name == 'git+https://github.com/foo/bar.git#bar', (\n",
+    "        \"Expected\\ngit+https://github.com/foo/bar.git#bar\\nFound:\\n\"\n",
+    "        f\"{onion.install_name}\"\n",
+    "    )\n",
+    "    assert onion.version_spec == '@ref', (\n",
+    "        f\"Expected: '@ref'\\nFound: {onion.version_spec}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.284556Z",
+     "start_time": "2021-07-21T23:36:15.270481Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_onion_vcs_ref_egg_subdir():\n",
+    "    \"\"\"\n",
+    "    same as above, but the GitHub URL also specifies the subdirectory \n",
+    "    path of the package within the repository via the '&subdirectory=' \n",
+    "    component of the URL\n",
+    "        `smuggle bar    # pip: git+https://github.com/foo/bar.git@ref#bar&subdirectory=/baz/qux`\n",
+    "    \"\"\"\n",
+    "    installer_kwargs = {\n",
+    "        'editable': False,\n",
+    "        'spec': 'git+https://github.com/foo/bar.git@ref#bar&subdirectory=/baz/qux'\n",
+    "    }\n",
+    "    onion = davos.core.core.Onion('bar', \n",
+    "                                  installer='pip', \n",
+    "                                  args_str=\"\"\"git+https://github.com/foo/bar.git@ref#bar&subdirectory=/baz/qux\"\"\", \n",
+    "                                  **installer_kwargs)\n",
+    "    assert onion.install_package == onion._pip_install_package, (\n",
+    "        f\"Expected:\\n{onion._pip_install_package}\\nFound:\\n\"\n",
+    "        f\"{onion.install_package}\"\n",
+    "    )\n",
+    "    assert onion.build is None, f\"Expected: None\\nFound: {onion.build}\"\n",
+    "    assert onion.cache_key == 'pip;git+https://github.com/foo/bar.git@ref#bar&subdirectory=/baz/qux', (\n",
+    "        \"Expected:\\npip;git+https://github.com/foo/bar.git@ref#bar&subdirectory=/baz/qux\\nFound:\\n\"\n",
+    "        f\"{onion.cache_key}\"\n",
+    "    )\n",
+    "    assert onion.verbosity == 0, f\"Expected: 0\\nFound: {onion.verbosity}\"\n",
+    "    assert onion.install_name == 'git+https://github.com/foo/bar.git#bar&subdirectory=/baz/qux', (\n",
+    "        \"Expected\\ngit+https://github.com/foo/bar.git#bar\\nFound:\\n\"\n",
+    "        f\"{onion.install_name}\"\n",
+    "    )\n",
+    "    assert onion.version_spec == '@ref', (\n",
+    "        f\"Expected: '@ref'\\nFound: {onion.version_spec}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.300473Z",
+     "start_time": "2021-07-21T23:36:15.286833Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_onion_local():\n",
+    "    \"\"\"\n",
+    "    package installed (in editable mode) from a local directory\n",
+    "        `smuggle foo    # pip: -e /path/to/foo`\n",
+    "    \"\"\"\n",
+    "    installer_kwargs = {\n",
+    "        'editable': True,\n",
+    "        'spec': '/path/to/foo'\n",
+    "    }\n",
+    "    onion = davos.core.core.Onion('foo', \n",
+    "                                  installer='pip', \n",
+    "                                  args_str=\"\"\"-e /path/to/foo\"\"\", \n",
+    "                                  **installer_kwargs)\n",
+    "    assert onion.install_package == onion._pip_install_package, (\n",
+    "        f\"Expected:\\n{onion._pip_install_package}\\nFound:\\n\"\n",
+    "        f\"{onion.install_package}\"\n",
+    "    )\n",
+    "    assert onion.build is None, f\"Expected: None\\nFound: {onion.build}\"\n",
+    "    assert onion.cache_key == 'pip;-e;/path/to/foo', (\n",
+    "        \"Expected:\\npip;-e;/path/to/foo\\nFound:\\n\"\n",
+    "        f\"{onion.cache_key}\"\n",
+    "    )\n",
+    "    assert onion.verbosity == 0, f\"Expected: 0\\nFound: {onion.verbosity}\"\n",
+    "    assert onion.install_name == '/path/to/foo', (\n",
+    "        \"Expected\\n/path/to/foo\\nFound:\\n\"\n",
+    "        f\"{onion.install_name}\"\n",
+    "    )\n",
+    "    assert onion.version_spec == '', (\n",
+    "        f\"Expected: ''\\nFound: {onion.version_spec}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.316198Z",
+     "start_time": "2021-07-21T23:36:15.302721Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_onion_pep440_direct_reference():\n",
+    "    \"\"\"\n",
+    "    package installed from a remote source archive file. See \n",
+    "    https://www.python.org/dev/peps/pep-0440/\n",
+    "        `smuggle foo    # pip: -v foo@https://bar.repo/foo-0.0.1-non-any.whl`\n",
+    "    \"\"\"\n",
+    "    installer_kwargs = {\n",
+    "        'editable': False,\n",
+    "        'spec': 'foo@https://bar.repo/foo-0.0.1-non-any.whl',\n",
+    "        'verbosity': 1\n",
+    "    }\n",
+    "    onion = davos.core.core.Onion('foo', \n",
+    "                                  installer='pip', \n",
+    "                                  args_str=\"\"\"-v foo@https://bar.repo/foo-0.0.1-non-any.whl\"\"\", \n",
+    "                                  **installer_kwargs)\n",
+    "    assert onion.install_package == onion._pip_install_package, (\n",
+    "        f\"Expected:\\n{onion._pip_install_package}\\nFound:\\n\"\n",
+    "        f\"{onion.install_package}\"\n",
+    "    )\n",
+    "    assert onion.build is None, f\"Expected: None\\nFound: {onion.build}\"\n",
+    "    assert onion.cache_key == 'pip;-v;foo@https://bar.repo/foo-0.0.1-non-any.whl', (\n",
+    "        \"Expected:\\npip;-v;foo@https://bar.repo/foo-0.0.1-non-any.whl\\nFound:\\n\"\n",
+    "        f\"{onion.cache_key}\"\n",
+    "    )\n",
+    "    assert onion.verbosity == 1, f\"Expected: 1\\nFound: {onion.verbosity}\"\n",
+    "    assert onion.install_name == 'foo@https://bar.repo/foo-0.0.1-non-any.whl', (\n",
+    "        \"Expected\\nfoo@https://bar.repo/foo-0.0.1-non-any.whl\\nFound:\\n{onion.install_name}\"\n",
+    "    )\n",
+    "    assert onion.version_spec == '', (\n",
+    "        f\"Expected: ''\\nFound: {onion.version_spec}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.332404Z",
+     "start_time": "2021-07-21T23:36:15.318520Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_onion_complex_version():\n",
+    "    \"\"\"\n",
+    "    onion comment that constrains the smuggled package version using \n",
+    "    multiple specifiers. -v/--verbose are passed in a somewhat \n",
+    "    unrealistic way, but it's meant to test isolating the version \n",
+    "    specifier from other arugments both before and after it\n",
+    "        `smuggle foo    # pip: -v foo-pkg>=1.0.1,<2 --verbose`\n",
+    "    \"\"\"\n",
+    "    installer_kwargs = {\n",
+    "        'editable': False,\n",
+    "        'spec': 'foo-pkg>=1.0.1,<2',\n",
+    "        'verbosity': 2\n",
+    "    }\n",
+    "    onion = davos.core.core.Onion('foo', \n",
+    "                                  installer='pip', \n",
+    "                                  args_str=\"\"\"-v foo-pkg>=1.0.1,<2 --verbose\"\"\", \n",
+    "                                  **installer_kwargs)\n",
+    "    assert onion.install_package == onion._pip_install_package, (\n",
+    "        f\"Expected:\\n{onion._pip_install_package}\\nFound:\\n\"\n",
+    "        f\"{onion.install_package}\"\n",
+    "    )\n",
+    "    assert onion.build is None, f\"Expected: None\\nFound: {onion.build}\"\n",
+    "    assert onion.cache_key == 'pip;-v;foo-pkg>=1.0.1,<2;--verbose', (\n",
+    "        f\"Expected:\\npip;-v;foo-pkg>=1.0.1,<2;--verbose\\nFound:\\n{onion.cache_key}\"\n",
+    "    )\n",
+    "    assert onion.verbosity == 2, f\"Expected: 1\\nFound: {onion.verbosity}\"\n",
+    "    assert onion.install_name == 'foo-pkg', (\n",
+    "        f\"Expected\\nfoo-pkg\\nFound:\\n{onion.install_name}\"\n",
+    "    )\n",
+    "    assert onion.version_spec == '>=1.0.1,<2', (\n",
+    "        f\"Expected: '>=1.0.1,<2'\\nFound: {onion.version_spec}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.338601Z",
+     "start_time": "2021-07-21T23:36:15.334869Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_onion_is_installed_stdlib():\n",
+    "    \"\"\"\n",
+    "    Onion.is_installed property should always return True for standard \n",
+    "    library modules\n",
+    "    \"\"\"\n",
+    "    test_mods = ('ast', 'base64', 'collections', 'datetime', 'enum')\n",
+    "    for mod in test_mods:\n",
+    "        onion = davos.core.core.Onion(mod, installer='pip', args_str=\"\"\"\"\"\")\n",
+    "        assert onion.is_installed, (\n",
+    "            f\"Expected standard library module '{mod}' to be installed\"\n",
+    "        )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.345869Z",
+     "start_time": "2021-07-21T23:36:15.340741Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_onion_is_installed_args_false():\n",
+    "    \"\"\"\n",
+    "    Onion.is_installed property should always return False when these \n",
+    "    arguments are supplied. Test using a package that is definitely \n",
+    "    installed to make sure the presence of these arguments is actually \n",
+    "    responsible for the False return value\n",
+    "    \"\"\"\n",
+    "    always_false_args = ('--force-reinstall', '--ignore-installed', '--upgrade')\n",
+    "    for arg in always_false_args:\n",
+    "        kwarg_form = arg.lstrip('-').replace('-', '_')\n",
+    "        installer_kwargs = {\n",
+    "            'editable': False, \n",
+    "            kwarg_form: True, \n",
+    "            'spec': 'IPython'\n",
+    "        }\n",
+    "        onion = davos.core.core.Onion('IPython', \n",
+    "                                      installer='pip', \n",
+    "                                      args_str=f\"\"\"{arg} IPython\"\"\", \n",
+    "                                      **installer_kwargs)\n",
+    "        assert not onion.is_installed, (\n",
+    "            f\"Passing {arg} should cause onion.is_installed to return False\"\n",
+    "        )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.354092Z",
+     "start_time": "2021-07-21T23:36:15.348617Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_onion_is_installed_vcs_false():\n",
+    "    \"\"\"\n",
+    "    Onion.is_installed property should always return False when the \n",
+    "    onion comment specifies a VCS URL, since there's no way to compare \n",
+    "    the state of a local installation to the remote repository (e.g., by \n",
+    "    commit hash). Like above, test using a package that's definitely \n",
+    "    installed, for which onion.is_installed would otherwise be True.\n",
+    "    \"\"\"\n",
+    "    installer_kwargs = {\n",
+    "        'editable': False,\n",
+    "        'spec': 'git+https://github.com/ipython/ipython.git'\n",
+    "    }\n",
+    "    onion = davos.core.core.Onion('bar', \n",
+    "                                  installer='pip', \n",
+    "                                  args_str=\"\"\"git+https://github.com/ipython/ipython.git\"\"\", \n",
+    "                                  **installer_kwargs)\n",
+    "    \n",
+    "    assert not onion.is_installed, (\n",
+    "        \"specifying a VCS URL should always cause onion.is_installed to \"\n",
+    "        \"return False\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.360263Z",
+     "start_time": "2021-07-21T23:36:15.356146Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_onion_is_installed_nonmatching_package():\n",
+    "    \"\"\"\n",
+    "    Test result of Onion.is_installed searching installed package \n",
+    "    distributions for a package that *IS NOT* installed locally\n",
+    "    \"\"\"\n",
+    "    installer_kwargs = {\n",
+    "        'editable': False,\n",
+    "        'spec': 'FakePackage'\n",
+    "    }\n",
+    "    onion = davos.core.core.Onion('FakePackage', \n",
+    "                                  installer='pip', \n",
+    "                                  args_str=\"\"\"\"\"\", \n",
+    "                                  **installer_kwargs)\n",
+    "    \n",
+    "    assert not onion.is_installed, (\n",
+    "        \"Should not have found local installation of 'FakePackage'\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.366651Z",
+     "start_time": "2021-07-21T23:36:15.362473Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_onion_is_installed_matching_package():\n",
+    "    \"\"\"\n",
+    "    Test result of Onion.is_installed searching installed package \n",
+    "    distributions for a package that *IS* installed locally, with no \n",
+    "    version constraints\n",
+    "    \"\"\"\n",
+    "    installer_kwargs = {\n",
+    "        'editable': False,\n",
+    "        'spec': 'IPython'\n",
+    "    }\n",
+    "    onion = davos.core.core.Onion('IPython', \n",
+    "                                  installer='pip', \n",
+    "                                  args_str=\"\"\"IPython\"\"\", \n",
+    "                                  **installer_kwargs)\n",
+    "    \n",
+    "    assert onion.is_installed, \"Failed to find local IPython installation\""
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.374454Z",
+     "start_time": "2021-07-21T23:36:15.369565Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_onion_is_installed_nonmatching_version():\n",
+    "    \"\"\"\n",
+    "    Test result of Onion.is_installed searching installed package \n",
+    "    distributions for a package that *IS* installed locally, but whose \n",
+    "    local installation *DOES NOT* satisfy the requested version \n",
+    "    constraint\n",
+    "    \"\"\"\n",
+    "    installer_kwargs = {\n",
+    "        'editable': False,\n",
+    "        'spec': 'IPython<=2.3.4'\n",
+    "    }\n",
+    "    onion = davos.core.core.Onion('IPython', \n",
+    "                                  installer='pip', \n",
+    "                                  args_str=\"\"\"IPython<=2.3.4\"\"\", \n",
+    "                                  **installer_kwargs)\n",
+    "    \n",
+    "    assert not onion.is_installed, (\n",
+    "        \"Should not have found local installation satisfying \"\n",
+    "        f\"'IPython<=2.3.4'. Local version is '{IPython.__version__}'\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.380939Z",
+     "start_time": "2021-07-21T23:36:15.376932Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_onion_is_installed_matching_version():\n",
+    "    \"\"\"\n",
+    "    Test result of Onion.is_installed searching installed package \n",
+    "    distributions for a package that *IS* installed locally, and whose \n",
+    "    local installation *DOES* satisfy the requested version constraint\n",
+    "    \"\"\"\n",
+    "    \n",
+    "    installer_kwargs = {\n",
+    "        'editable': False,\n",
+    "        'spec': f'IPython=={IPython.__version__}'\n",
+    "    }\n",
+    "    onion = davos.core.core.Onion('IPython', \n",
+    "                                  installer='pip', \n",
+    "                                  args_str=f\"\"\"IPython=={IPython.__version__}\"\"\", \n",
+    "                                  **installer_kwargs)\n",
+    "    \n",
+    "    assert onion.is_installed, (\n",
+    "        \"Failed to find local IPython installation matching locally \"\n",
+    "        f\"installed version ({IPython.__version__})\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.393525Z",
+     "start_time": "2021-07-21T23:36:15.383177Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_pip_install_package_fail():\n",
+    "    \"\"\"test handling of install shell command that fails\"\"\"\n",
+    "    onion = davos.core.core.Onion('foo', installer='pip', args_str=\"\"\"\"\"\")\n",
+    "    \n",
+    "    expected_returncode = 1\n",
+    "    expected_cmd = onion.install_cmd\n",
+    "    expected_output = \"Expected stdout string\"\n",
+    "    expected_stderr = \"Expected stderr string\"\n",
+    "    \n",
+    "    old_run_shell_command = davos.core.core.run_shell_command\n",
+    "    \n",
+    "    def _mock_run_shell_command(command, live_stdout=None):\n",
+    "        if command == onion.install_cmd:\n",
+    "            raise CalledProcessError(returncode=expected_returncode, \n",
+    "                                     cmd=expected_cmd, \n",
+    "                                     output=expected_output, \n",
+    "                                     stderr=expected_stderr)\n",
+    "        else:\n",
+    "            return old_run_shell_command(command, live_stdout=live_stdout)\n",
+    "        \n",
+    "    try:\n",
+    "        davos.core.core.run_shell_command = _mock_run_shell_command\n",
+    "        with raises(davos.core.exceptions.InstallerError) as excinfo:\n",
+    "            onion._pip_install_package()\n",
+    "        \n",
+    "        exc_value = excinfo.value\n",
+    "        result_returncode = exc_value.returncode\n",
+    "        result_cmd = exc_value.cmd\n",
+    "        result_output = exc_value.output\n",
+    "        result_stderr = exc_value.stderr\n",
+    "        \n",
+    "        assert result_returncode == expected_returncode, result_returncode\n",
+    "        assert result_cmd == expected_cmd, result_cmd\n",
+    "        assert result_output == expected_output, result_output\n",
+    "        assert result_stderr == expected_stderr, result_stderr\n",
+    "        \n",
+    "    finally:\n",
+    "        davos.core.core.run_shell_command = old_run_shell_command"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.403668Z",
+     "start_time": "2021-07-21T23:36:15.395659Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_pip_install_package_target_prepend_syspath():\n",
+    "    \"\"\"\n",
+    "    when installing a package in a specified --target directory that is \n",
+    "    not already in sys.path, the directory should be prepended to \n",
+    "    sys.path\n",
+    "    \"\"\"\n",
+    "    old_run_shell_command = davos.core.core.run_shell_command\n",
+    "    old_syspath = sys.path[:]\n",
+    "    tmpdir = Path('tmpdir')\n",
+    "    installer_kwargs = {\n",
+    "        'editable': False,\n",
+    "        'spec': 'foo',\n",
+    "        'target': 'tmpdir'\n",
+    "    }\n",
+    "    onion = davos.core.core.Onion('foo', \n",
+    "                                  installer='pip', \n",
+    "                                  args_str=\"\"\"foo --target tmpdir\"\"\", \n",
+    "                                  **installer_kwargs)\n",
+    "    mock_stdout = \"stdout from pip-installing 'foo' in tmpdir/\"\n",
+    "        \n",
+    "    def _mock_run_shell_command(command, live_stdout=None):\n",
+    "        if command == onion.install_cmd:\n",
+    "            return mock_stdout\n",
+    "        else:\n",
+    "            return old_run_shell_command(command, live_stdout=live_stdout)\n",
+    "\n",
+    "    try:\n",
+    "        tmpdir.mkdir()\n",
+    "        assert str(tmpdir) not in sys.path, f\"{tmpdir} already in sys.path\"\n",
+    "        davos.core.core.run_shell_command = _mock_run_shell_command\n",
+    "        onion._pip_install_package()\n",
+    "        assert sys.path[0] == str(tmpdir), (\n",
+    "            f\"{tmpdir} was not prepended to sys.path\\nsys.path:\\n{sys.path}\")\n",
+    "        \n",
+    "    finally:\n",
+    "        davos.core.core.run_shell_command = old_run_shell_command\n",
+    "        sys.path = old_syspath\n",
+    "        if tmpdir.is_dir():\n",
+    "            tmpdir.rmdir()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.409811Z",
+     "start_time": "2021-07-21T23:36:15.405672Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_parser_handles_basic_line():\n",
+    "    \"\"\"simplest use case\"\"\"\n",
+    "    line = \"smuggle foo\"\n",
+    "    expected = expected_parser_output('foo')\n",
+    "    assert matches_expected_output(expected, _parse_line(line))"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.417146Z",
+     "start_time": "2021-07-21T23:36:15.412820Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_parser_handles_basic_line_alias():\n",
+    "    \"\"\"simplest use case, plus alias\"\"\"\n",
+    "    line = \"smuggle foo as bar\"\n",
+    "    expected = expected_parser_output('foo', as_='bar')\n",
+    "    assert matches_expected_output(expected, _parse_line(line))"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.424408Z",
+     "start_time": "2021-07-21T23:36:15.420136Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_parser_handles_basic_line_onion():\n",
+    "    \"\"\"simplest use case, with onion comment\"\"\"\n",
+    "    line = \"smuggle foo as bar    # pip: foo==0.0.1\"\n",
+    "    expected = expected_parser_output('foo', as_='bar', args_str='foo==0.0.1')\n",
+    "    assert matches_expected_output(expected, _parse_line(line))"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.431400Z",
+     "start_time": "2021-07-21T23:36:15.426631Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_parser_handles_smuggle_qualname():\n",
+    "    line = \"smuggle foo.bar as baz\"\n",
+    "    expected = expected_parser_output('foo.bar', as_='baz')\n",
+    "    assert matches_expected_output(expected, _parse_line(line))"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.441054Z",
+     "start_time": "2021-07-21T23:36:15.434111Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_parser_handles_indented_line():\n",
+    "    \"\"\"\n",
+    "    needs to handle, e.g.\n",
+    "        ```\n",
+    "        def foo():\n",
+    "            smuggle numpy as np\n",
+    "        ```\n",
+    "    Note: unless `davos.deactivate() is run, the above line will \n",
+    "    actually be parsed (though it will have no effect)\n",
+    "    \"\"\"\n",
+    "    line = \"    smuggle foo as bar\"\n",
+    "    expected = expected_parser_output('foo', as_='bar')\n",
+    "    if IPython.version_info[0] >= 7:\n",
+    "        expected[0] = \"    \" + expected[0]\n",
+    "    else:\n",
+    "        expected = \"    \" + expected\n",
+    "    assert matches_expected_output(expected, _parse_line(line))"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.446580Z",
+     "start_time": "2021-07-21T23:36:15.443804Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "test_parser_handles_indented_line()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.454228Z",
+     "start_time": "2021-07-21T23:36:15.449173Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_parser_handles_different_install_name():\n",
+    "    \"\"\"different names used to install and import package\"\"\"\n",
+    "    line = \"smuggle foo as bar    # pip: foo-package==0.0.1\"\n",
+    "    expected = expected_parser_output('foo', as_='bar', \n",
+    "                                       args_str='foo-package==0.0.1')\n",
+    "    assert matches_expected_output(expected, _parse_line(line))"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.461806Z",
+     "start_time": "2021-07-21T23:36:15.456382Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "@mark.ipython_pre7\n",
+    "def test_parser_handles_multi_pkgs_pre7():\n",
+    "    line = \"smuggle foo as bar, baz as qux, spam, ham, eggs\"\n",
+    "    expected = expected_parser_output('foo', as_='bar')\n",
+    "    expected += f\"; {expected_parser_output('baz', as_='qux')}\"\n",
+    "    expected += f\"; {expected_parser_output('spam')}\"\n",
+    "    expected += f\"; {expected_parser_output('ham')}\"\n",
+    "    expected += f\"; {expected_parser_output('eggs')}\"\n",
+    "    assert matches_expected_output(expected, _parse_line(line))"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.472427Z",
+     "start_time": "2021-07-21T23:36:15.464407Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "@mark.ipython_post7\n",
+    "def test_parser_handles_multi_pkgs_post7():\n",
+    "    line = \"smuggle foo as bar, baz as qux, spam, ham, eggs\"\n",
+    "    expected = expected_parser_output('foo', as_='bar')\n",
+    "    expected[0] = expected[0][:-1] + f\"; {expected_parser_output('baz', as_='qux')[0]}\"\n",
+    "    expected[0] = expected[0][:-1] + f\"; {expected_parser_output('spam')[0]}\"\n",
+    "    expected[0] = expected[0][:-1] + f\"; {expected_parser_output('ham')[0]}\"\n",
+    "    expected[0] = expected[0][:-1] + f\"; {expected_parser_output('eggs')[0]}\"\n",
+    "    assert matches_expected_output(expected, _parse_line(line))"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.479729Z",
+     "start_time": "2021-07-21T23:36:15.474457Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "@mark.ipython_pre7\n",
+    "def test_parser_handles_multi_pkgs_onion_pre7():\n",
+    "    \"\"\"\n",
+    "    onion info should be passed to smuggle function for FIRST package\n",
+    "    \"\"\"\n",
+    "    line = \"smuggle foo as bar, baz as qux, spam, ham, eggs    # pip: foo==0.0.1\"\n",
+    "    expected = expected_parser_output('foo', as_='bar', args_str='foo==0.0.1')\n",
+    "    expected += f\"; {expected_parser_output('baz', as_='qux')}\"\n",
+    "    expected += f\"; {expected_parser_output('spam')}\"\n",
+    "    expected += f\"; {expected_parser_output('ham')}\"\n",
+    "    expected += f\"; {expected_parser_output('eggs')}\"\n",
+    "    assert matches_expected_output(expected, _parse_line(line))"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.491312Z",
+     "start_time": "2021-07-21T23:36:15.482206Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "@mark.ipython_post7\n",
+    "def test_parser_handles_multi_pkgs_onion_post7():\n",
+    "    \"\"\"\n",
+    "    onion info should be passed to smuggle function for FIRST package\n",
+    "    \"\"\"\n",
+    "    line = \"smuggle foo as bar, baz as qux, spam, ham, eggs    # pip: foo==0.0.1\"\n",
+    "    expected = expected_parser_output('foo', as_='bar', args_str='foo==0.0.1')\n",
+    "    expected[0] = expected[0][:-1] + f\"; {expected_parser_output('baz', as_='qux')[0]}\"\n",
+    "    expected[0] = expected[0][:-1] + f\"; {expected_parser_output('spam')[0]}\"\n",
+    "    expected[0] = expected[0][:-1] + f\"; {expected_parser_output('ham')[0]}\"\n",
+    "    expected[0] = expected[0][:-1] + f\"; {expected_parser_output('eggs')[0]}\"\n",
+    "    assert matches_expected_output(expected, _parse_line(line))"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.498552Z",
+     "start_time": "2021-07-21T23:36:15.493297Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "@mark.ipython_pre7\n",
+    "def test_parser_handles_backslash_pre7():\n",
+    "    line = \"\"\"smuggle foo as bar, \\\n",
+    "                      baz as qux, \\\n",
+    "                      spam, ham, \\\n",
+    "                      eggs\"\"\"\n",
+    "    expected = expected_parser_output('foo', as_='bar')\n",
+    "    expected += f\"; {expected_parser_output('baz', as_='qux')}\"\n",
+    "    expected += f\"; {expected_parser_output('spam')}\"\n",
+    "    expected += f\"; {expected_parser_output('ham')}\"\n",
+    "    expected += f\"; {expected_parser_output('eggs')}\"\n",
+    "    assert matches_expected_output(expected, _parse_line(line))"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.508852Z",
+     "start_time": "2021-07-21T23:36:15.500904Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "@mark.ipython_post7\n",
+    "def test_parser_handles_backslash_post7():\n",
+    "    line = \"\"\"smuggle foo as bar, \\\n",
+    "                      baz as qux, \\\n",
+    "                      spam, ham, \\\n",
+    "                      eggs\"\"\"\n",
+    "    expected = expected_parser_output('foo', as_='bar')\n",
+    "    expected[0] = expected[0][:-1] + f\"; {expected_parser_output('baz', as_='qux')[0]}\"\n",
+    "    expected[0] = expected[0][:-1] + f\"; {expected_parser_output('spam')[0]}\"\n",
+    "    expected[0] = expected[0][:-1] + f\"; {expected_parser_output('ham')[0]}\"\n",
+    "    expected[0] = expected[0][:-1] + f\"; {expected_parser_output('eggs')[0]}\"\n",
+    "    assert matches_expected_output(expected, _parse_line(line))"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.518163Z",
+     "start_time": "2021-07-21T23:36:15.512276Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "@mark.ipython_pre7\n",
+    "def test_parser_handles_backslash_onion_pre7():\n",
+    "    \"\"\"\n",
+    "    again, onion info should be passed to smuggle function for FIRST \n",
+    "    package\n",
+    "    \"\"\"\n",
+    "    line = \"\"\"smuggle foo as bar, \\\n",
+    "                      baz as qux, \\\n",
+    "                      spam, ham, \\\n",
+    "                      eggs    # pip: foo==0.0.1\"\"\"\n",
+    "    expected = expected_parser_output('foo', as_='bar', args_str='foo==0.0.1')\n",
+    "    expected += f\"; {expected_parser_output('baz', as_='qux')}\"\n",
+    "    expected += f\"; {expected_parser_output('spam')}\"\n",
+    "    expected += f\"; {expected_parser_output('ham')}\"\n",
+    "    expected += f\"; {expected_parser_output('eggs')}\"\n",
+    "    assert matches_expected_output(expected, _parse_line(line))"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.527759Z",
+     "start_time": "2021-07-21T23:36:15.520240Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "@mark.ipython_post7\n",
+    "def test_parser_handles_backslash_onion_post7():\n",
+    "    \"\"\"\n",
+    "    again, onion info should be passed to smuggle function for FIRST \n",
+    "    package\n",
+    "    \"\"\"\n",
+    "    line = \"\"\"smuggle foo as bar, \\\n",
+    "                      baz as qux, \\\n",
+    "                      spam, ham, \\\n",
+    "                      eggs    # pip: foo==0.0.1\"\"\"\n",
+    "    expected = expected_parser_output('foo', as_='bar', args_str='foo==0.0.1')\n",
+    "    expected[0] = expected[0][:-1] + f\"; {expected_parser_output('baz', as_='qux')[0]}\"\n",
+    "    expected[0] = expected[0][:-1] + f\"; {expected_parser_output('spam')[0]}\"\n",
+    "    expected[0] = expected[0][:-1] + f\"; {expected_parser_output('ham')[0]}\"\n",
+    "    expected[0] = expected[0][:-1] + f\"; {expected_parser_output('eggs')[0]}\"\n",
+    "    assert matches_expected_output(expected, _parse_line(line))"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.536867Z",
+     "start_time": "2021-07-21T23:36:15.530683Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "@mark.ipython_pre7\n",
+    "def test_parser_handles_inconsistent_whitespace_pre7():\n",
+    "    \"\"\"should handle weird amounts of whitespace that are *technically* valid\"\"\"\n",
+    "    line = \"\"\"smuggle               foo     as    bar    \\\n",
+    ",baz as qux  , \\\n",
+    "          spam  .  ham    as    eggs                #   pip   :       foo==0.0.1    \"\"\"\n",
+    "    expected = expected_parser_output('foo', as_='bar', args_str='foo==0.0.1')\n",
+    "    expected += f\"; {expected_parser_output('baz', as_='qux')}\"\n",
+    "    expected += f\"; {expected_parser_output('spam.ham', as_='eggs')}\"\n",
+    "    # real parser adds back leading & trailing characters (including whitespace)\n",
+    "    expected += \"    \"\n",
+    "    assert matches_expected_output(expected, _parse_line(line))"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.547286Z",
+     "start_time": "2021-07-21T23:36:15.539487Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "@mark.ipython_post7\n",
+    "def test_parser_handles_inconsistent_whitespace_post7():\n",
+    "    \"\"\"should handle weird amounts of whitespace that are *technically* valid\"\"\"\n",
+    "    line = \"\"\"smuggle               foo     as    bar    \\\n",
+    ",baz as qux  , \\\n",
+    "          spam  .  ham    as    eggs                #   pip   :       foo==0.0.1    \"\"\"\n",
+    "    expected = expected_parser_output('foo', as_='bar', args_str='foo==0.0.1')\n",
+    "    expected[0] = expected[0][:-1] + f\"; {expected_parser_output('baz', as_='qux')[0]}\"\n",
+    "    expected[0] = expected[0][:-1] + f\"; {expected_parser_output('spam.ham', as_='eggs')[0]}\"\n",
+    "    # real parser adds back leading & trailing characters (including whitespace)\n",
+    "    expected[0] = expected[0][:-1] + \"    \" + '\\n'\n",
+    "    assert matches_expected_output(expected, _parse_line(line))"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.555123Z",
+     "start_time": "2021-07-21T23:36:15.549990Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_parser_handles_smuggle_from():\n",
+    "    \"\"\"second possible broad syntax class\"\"\"\n",
+    "    line = \"from foo smuggle bar\"\n",
+    "    expected = expected_parser_output('foo.bar', as_='bar')\n",
+    "    assert matches_expected_output(expected, _parse_line(line))"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.562134Z",
+     "start_time": "2021-07-21T23:36:15.557682Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_parser_handles_smuggle_from_onion():\n",
+    "    \"\"\"second possible broad syntax class\"\"\"\n",
+    "    line = \"from foo smuggle bar    # pip: foo==0.0.1\"\n",
+    "    expected = expected_parser_output('foo.bar', as_='bar', args_str='foo==0.0.1')\n",
+    "    assert matches_expected_output(expected, _parse_line(line))"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.570219Z",
+     "start_time": "2021-07-21T23:36:15.564603Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "@mark.ipython_pre7\n",
+    "def test_parser_handles_smuggle_from_multi_pre7():\n",
+    "    line = \"from foo smuggle bar, baz as spam, qux\"\n",
+    "    expected = expected_parser_output('foo.bar', as_='bar')\n",
+    "    expected += f\"; {expected_parser_output('foo.baz', as_='spam')}\"\n",
+    "    expected += f\"; {expected_parser_output('foo.qux', as_='qux')}\"\n",
+    "    assert matches_expected_output(expected, _parse_line(line))"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.578614Z",
+     "start_time": "2021-07-21T23:36:15.572825Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "@mark.ipython_post7\n",
+    "def test_parser_handles_smuggle_from_multi_post7():\n",
+    "    line = \"from foo smuggle bar, baz as spam, qux\"\n",
+    "    expected = expected_parser_output('foo.bar', as_='bar')\n",
+    "    expected[0] = expected[0][:-1] + f\"; {expected_parser_output('foo.baz', as_='spam')[0]}\"\n",
+    "    expected[0] = expected[0][:-1] + f\"; {expected_parser_output('foo.qux', as_='qux')[0]}\"\n",
+    "    assert matches_expected_output(expected, _parse_line(line))"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.587235Z",
+     "start_time": "2021-07-21T23:36:15.581105Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "@mark.ipython_pre7\n",
+    "def test_parser_handles_smuggle_from_multi_onion_pre7():\n",
+    "    \"\"\"onion info should be passed to FIRST smuggle function\"\"\"\n",
+    "    line = \"from foo smuggle bar, baz as spam, qux    # pip: foo==0.0.1\"\n",
+    "    expected = expected_parser_output('foo.bar', as_='bar', args_str='foo==0.0.1')\n",
+    "    expected += f\"; {expected_parser_output('foo.baz', as_='spam')}\"\n",
+    "    expected += f\"; {expected_parser_output('foo.qux', as_='qux')}\"\n",
+    "    assert matches_expected_output(expected, _parse_line(line))"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.597010Z",
+     "start_time": "2021-07-21T23:36:15.590410Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "@mark.ipython_post7\n",
+    "def test_parser_handles_smuggle_from_multi_onion_post7():\n",
+    "    \"\"\"onion info should be passed to FIRST smuggle function\"\"\"\n",
+    "    line = \"from foo smuggle bar, baz as spam, qux    # pip: foo==0.0.1\"\n",
+    "    expected = expected_parser_output('foo.bar', as_='bar', args_str='foo==0.0.1')\n",
+    "    expected[0] = expected[0][:-1] + f\"; {expected_parser_output('foo.baz', as_='spam')[0]}\"\n",
+    "    expected[0] = expected[0][:-1] + f\"; {expected_parser_output('foo.qux', as_='qux')[0]}\"\n",
+    "    assert matches_expected_output(expected, _parse_line(line))"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.617808Z",
+     "start_time": "2021-07-21T23:36:15.612008Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "@mark.ipython_pre7\n",
+    "def test_parser_handles_smuggle_from_backslash():\n",
+    "    \"\"\"onion info should be passed to FIRST smuggle function\"\"\"\n",
+    "    line = \"\"\"from foo smuggle bar, \\\n",
+    "                               baz as spam, \\\n",
+    "                               qux    # pip: foo==0.0.1\"\"\"\n",
+    "    expected = expected_parser_output('foo.bar', as_='bar', args_str='foo==0.0.1')\n",
+    "    expected += f\"; {expected_parser_output('foo.baz', as_='spam')}\"\n",
+    "    expected += f\"; {expected_parser_output('foo.qux', as_='qux')}\"\n",
+    "    assert matches_expected_output(expected, _parse_line(line))"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.626530Z",
+     "start_time": "2021-07-21T23:36:15.621859Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "@mark.ipython_pre7\n",
+    "def test_parser_handles_smuggle_from_parentheses():\n",
+    "    line = \"\"\"from foo smuggle (bar, baz as spam, qux,)\"\"\"\n",
+    "    # also tests trailing comma inside parentheses, which is valid\n",
+    "    expected = expected_parser_output('foo.bar', as_='bar')\n",
+    "    expected += f\"; {expected_parser_output('foo.baz', as_='spam')}\"\n",
+    "    expected += f\"; {expected_parser_output('foo.qux', as_='qux')}\"\n",
+    "    assert matches_expected_output(expected, _parse_line(line))"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.635477Z",
+     "start_time": "2021-07-21T23:36:15.629321Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "@mark.ipython_pre7\n",
+    "def test_parser_handles_smuggle_from_parentheses_multiline_1():\n",
+    "    line = \"\"\"from foo smuggle (bar, \n",
+    "                                baz as spam, \n",
+    "                                qux)\"\"\"\n",
+    "    expected = expected_parser_output('foo.bar', as_='bar')\n",
+    "    expected += f\"; {expected_parser_output('foo.baz', as_='spam')}\"\n",
+    "    expected += f\"; {expected_parser_output('foo.qux', as_='qux')}\"\n",
+    "    assert matches_expected_output(expected, _parse_line(line))"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.643872Z",
+     "start_time": "2021-07-21T23:36:15.638499Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "@mark.ipython_pre7\n",
+    "def test_parser_handles_smuggle_from_parentheses_multiline_1_onion_1():\n",
+    "    line = \"\"\"from foo smuggle (bar,    # pip: foo==0.0.1\n",
+    "                                baz as spam, \n",
+    "                                qux)\"\"\"\n",
+    "    expected = expected_parser_output('foo.bar', as_='bar', args_str='foo==0.0.1')\n",
+    "    expected += f\"; {expected_parser_output('foo.baz', as_='spam')}\"\n",
+    "    expected += f\"; {expected_parser_output('foo.qux', as_='qux')}\"\n",
+    "    assert matches_expected_output(expected, _parse_line(line))"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.652188Z",
+     "start_time": "2021-07-21T23:36:15.646154Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "@mark.ipython_pre7\n",
+    "def test_parser_handles_smuggle_from_parentheses_multiline_1_onion_2():\n",
+    "    line = \"\"\"from foo smuggle (bar, \n",
+    "                                baz as spam, \n",
+    "                                qux)    # pip: foo==0.0.1\"\"\"\n",
+    "    expected = expected_parser_output('foo.bar', as_='bar', args_str='foo==0.0.1')\n",
+    "    expected += f\"; {expected_parser_output('foo.baz', as_='spam')}\"\n",
+    "    expected += f\"; {expected_parser_output('foo.qux', as_='qux')}\"\n",
+    "    assert matches_expected_output(expected, _parse_line(line))"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.660367Z",
+     "start_time": "2021-07-21T23:36:15.654909Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "@mark.ipython_pre7\n",
+    "def test_parser_handles_smuggle_from_parentheses_multiline_2():\n",
+    "    line = \"\"\"from foo smuggle (\n",
+    "                  bar, \n",
+    "                  baz as spam, \n",
+    "                  qux\n",
+    "              )\"\"\"\n",
+    "    expected = expected_parser_output('foo.bar', as_='bar')\n",
+    "    expected += f\"; {expected_parser_output('foo.baz', as_='spam')}\"\n",
+    "    expected += f\"; {expected_parser_output('foo.qux', as_='qux')}\"\n",
+    "    assert matches_expected_output(expected, _parse_line(line))"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.668724Z",
+     "start_time": "2021-07-21T23:36:15.663200Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "@mark.ipython_pre7\n",
+    "def test_parser_handles_smuggle_from_parentheses_multiline_2_onion_1():\n",
+    "    line = \"\"\"from foo smuggle (    # pip: foo==0.0.1\n",
+    "                  bar, \n",
+    "                  baz as spam, \n",
+    "                  qux\n",
+    "              )\"\"\"\n",
+    "    expected = expected_parser_output('foo.bar', as_='bar', args_str='foo==0.0.1')\n",
+    "    expected += f\"; {expected_parser_output('foo.baz', as_='spam')}\"\n",
+    "    expected += f\"; {expected_parser_output('foo.qux', as_='qux')}\"\n",
+    "    assert matches_expected_output(expected, _parse_line(line))"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.679009Z",
+     "start_time": "2021-07-21T23:36:15.672400Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "@mark.ipython_pre7\n",
+    "def test_parser_handles_smuggle_from_parentheses_multiline_2_onion_2():\n",
+    "    line = \"\"\"from foo smuggle (\n",
+    "                  bar, \n",
+    "                  baz as spam, \n",
+    "                  qux\n",
+    "              )    # pip: foo==0.0.1\"\"\"\n",
+    "    expected = expected_parser_output('foo.bar', as_='bar', args_str='foo==0.0.1')\n",
+    "    expected += f\"; {expected_parser_output('foo.baz', as_='spam')}\"\n",
+    "    expected += f\"; {expected_parser_output('foo.qux', as_='qux')}\"\n",
+    "    assert matches_expected_output(expected, _parse_line(line))"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.692354Z",
+     "start_time": "2021-07-21T23:36:15.683305Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "@mark.ipython_pre7\n",
+    "def test_parser_handles_smuggle_from_parentheses_multiline_2_onion_2_comments():\n",
+    "    line = \"\"\"from foo smuggle (    # unrelated comment on first line\n",
+    "                  bar,    # unrelated comment on package name line\n",
+    "                  baz as spam, \n",
+    "                  # unrelated comment on its own line\n",
+    "                  qux\n",
+    "              )    # pip: foo==0.0.1    # unrelated comment after onion\"\"\"\n",
+    "    expected = expected_parser_output('foo.bar', as_='bar', args_str='foo==0.0.1')\n",
+    "    expected += f\"; {expected_parser_output('foo.baz', as_='spam')}\"\n",
+    "    expected += f\"; {expected_parser_output('foo.qux', as_='qux')}\"\n",
+    "    expected += \"    # unrelated comment after onion\"\n",
+    "    assert matches_expected_output(expected, _parse_line(line))"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.699832Z",
+     "start_time": "2021-07-21T23:36:15.694935Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "@mark.ipython_pre7\n",
+    "def test_parser_handles_smuggle_semicolons():\n",
+    "    \"\"\"also combines multiple elements from prior tests\"\"\"\n",
+    "    line = \"\"\"smuggle foo; smuggle bar as baz; \\\n",
+    "              from spam smuggle eggs; \\\n",
+    "              from qux smuggle quux as corge    # pip: qux-package==0.0.1\"\"\"\n",
+    "    expected = expected_parser_output('foo')\n",
+    "    expected += f\"; {expected_parser_output('bar', as_='baz')}\"\n",
+    "    expected += f\"; {expected_parser_output('spam.eggs', as_='eggs')}\"\n",
+    "    expected += f\"; {expected_parser_output('qux.quux', as_='corge', args_str='qux-package==0.0.1')}\"\n",
+    "    assert matches_expected_output(expected, _parse_line(line))"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.707658Z",
+     "start_time": "2021-07-21T23:36:15.702100Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_prompt_input_yes():\n",
+    "    \"\"\"mock user input of 'yes'\"\"\"\n",
+    "    prompt_text = ''\n",
+    "    \n",
+    "    def _mock_input(prompt=None):\n",
+    "        nonlocal prompt_text\n",
+    "        prompt_text = prompt\n",
+    "        return 'yes'\n",
+    "    \n",
+    "    try:\n",
+    "        davos.core.core.input = _mock_input\n",
+    "        result = davos.core.core.prompt_input('prompt question?')\n",
+    "        assert result is True, f\"Expected: True\\nFound: {result}\"\n",
+    "        assert prompt_text.strip().endswith('[y/n]'), (\n",
+    "            f\"Expected prompt to end with '[y/n]'\\nFound: {prompt_text}\"\n",
+    "        )\n",
+    "    finally:\n",
+    "        davos.core.core.input = builtins.input"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.716927Z",
+     "start_time": "2021-07-21T23:36:15.710716Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_prompt_input_no():\n",
+    "    \"\"\"mock user input of 'no'\"\"\"\n",
+    "    prompt_text = ''\n",
+    "    \n",
+    "    def _mock_input(prompt=None):\n",
+    "        nonlocal prompt_text\n",
+    "        prompt_text = prompt\n",
+    "        return 'no'\n",
+    "    \n",
+    "    try:\n",
+    "        davos.core.core.input = _mock_input\n",
+    "        result = davos.core.core.prompt_input('prompt question?')\n",
+    "        assert result is False, f\"Expected: False\\nFound: {result}\"\n",
+    "        assert prompt_text.strip().endswith('[y/n]'), (\n",
+    "            f\"Expected prompt to end with '[y/n]'\\nFound: {prompt_text}\"\n",
+    "        )\n",
+    "    finally:\n",
+    "        davos.core.core.input = builtins.input"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.724106Z",
+     "start_time": "2021-07-21T23:36:15.719239Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_prompt_input_default_accept():\n",
+    "    \"\"\"\n",
+    "    mock user pressing return without entering any text when default \n",
+    "    value *IS* set\n",
+    "    \"\"\"\n",
+    "    prompt_text = ''\n",
+    "    \n",
+    "    def _mock_input(prompt=None):\n",
+    "        nonlocal prompt_text\n",
+    "        prompt_text = prompt\n",
+    "        return ''\n",
+    "    \n",
+    "    try:\n",
+    "        davos.core.core.input = _mock_input\n",
+    "        result = davos.core.core.prompt_input('prompt question?', default='y')\n",
+    "        assert result is True, f\"Expected: True\\nFound: {result}\"\n",
+    "        assert prompt_text.strip().endswith('[Y/n]'), (\n",
+    "            f\"Expected prompt to end with '[Y/n]'\\nFound: {prompt_text}\"\n",
+    "        )\n",
+    "    finally:\n",
+    "        davos.core.core.input = builtins.input"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.732855Z",
+     "start_time": "2021-07-21T23:36:15.726089Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_prompt_input_default_reject():\n",
+    "    \"\"\"\n",
+    "    mock user pressing return without entering any text, when default \n",
+    "    value *IS NOT* set\n",
+    "    \"\"\"\n",
+    "    prompt_text = ''\n",
+    "    inputs = ['', '', '', 'y']\n",
+    "    input_ix = -1\n",
+    "    \n",
+    "    def _mock_input(prompt=None):\n",
+    "        nonlocal prompt_text, input_ix\n",
+    "        prompt_text = prompt\n",
+    "        input_ix += 1\n",
+    "        return inputs[input_ix]\n",
+    "    \n",
+    "    try:\n",
+    "        davos.core.core.input = _mock_input\n",
+    "        result = davos.core.core.prompt_input('prompt question?', default=None)\n",
+    "        assert result is True, f\"Expected: True\\nFound: {result}\"\n",
+    "        assert prompt_text.strip().endswith('[y/n]'), (\n",
+    "            f\"Expected prompt to end with '[y/n]'\\nFound: {prompt_text}\"\n",
+    "        )\n",
+    "        assert input_ix == 3, (\n",
+    "            \"Expected to cycle through empty inputs until reaching non-empty \"\n",
+    "            f\"string. Input was accepted when input_ix=={input_ix}\"\n",
+    "        )\n",
+    "    finally:\n",
+    "        davos.core.core.input = builtins.input"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.739943Z",
+     "start_time": "2021-07-21T23:36:15.735232Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_prompt_input_default_invalid():\n",
+    "    \"\"\"\n",
+    "    should raise ValueError if the value passed to 'default' is not \n",
+    "    valid\n",
+    "    \"\"\"\n",
+    "    def _mock_input(prompt=None):\n",
+    "        return 'y'\n",
+    "    try:\n",
+    "        davos.core.core.input = _mock_input\n",
+    "        with raises(ValueError):\n",
+    "            davos.core.core.prompt_input('prompt question?', default='Q')\n",
+    "    finally:\n",
+    "        davos.core.core.input = builtins.input"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.748221Z",
+     "start_time": "2021-07-21T23:36:15.742316Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_prompt_input_interrupt_accept():\n",
+    "    \"\"\"\n",
+    "    mock user pressing raising KeyboardInterrupt when interrupt value \n",
+    "    *IS* set\n",
+    "    \"\"\"\n",
+    "    prompt_text = ''\n",
+    "    \n",
+    "    def _mock_input(prompt=None):\n",
+    "        nonlocal prompt_text\n",
+    "        prompt_text = prompt\n",
+    "        raise KeyboardInterrupt()\n",
+    "    \n",
+    "    try:\n",
+    "        davos.core.core.input = _mock_input\n",
+    "        result = davos.core.core.prompt_input('prompt question?', \n",
+    "                                              default='no', \n",
+    "                                              interrupt='y')\n",
+    "        assert result is True, f\"Expected: True\\nFound: {result}\"\n",
+    "        assert prompt_text.strip().endswith('[y/N]'), (\n",
+    "            f\"Expected prompt to end with '[y/N]'\\nFound: {prompt_text}\"\n",
+    "        )\n",
+    "    finally:\n",
+    "        davos.core.core.input = builtins.input"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.756514Z",
+     "start_time": "2021-07-21T23:36:15.750767Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_prompt_input_interrupt_raise():\n",
+    "    \"\"\"\n",
+    "    mock user pressing raising KeyboardInterrupt when interrupt value \n",
+    "    *IS NOT* set\n",
+    "    \"\"\"\n",
+    "    prompt_text = ''\n",
+    "    \n",
+    "    def _mock_input(prompt=None):\n",
+    "        nonlocal prompt_text\n",
+    "        prompt_text = prompt\n",
+    "        raise KeyboardInterrupt()\n",
+    "    \n",
+    "    try:\n",
+    "        davos.core.core.input = _mock_input\n",
+    "        with raises(KeyboardInterrupt):\n",
+    "            result = davos.core.core.prompt_input('prompt question?', \n",
+    "                                                  default='yes')\n",
+    "        assert prompt_text.strip().endswith('[Y/n]'), (\n",
+    "            f\"Expected prompt to end with '[Y/n]'\\nFound: {prompt_text}\"\n",
+    "        )\n",
+    "    finally:\n",
+    "        davos.core.core.input = builtins.input"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.763331Z",
+     "start_time": "2021-07-21T23:36:15.758525Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_prompt_input_interrupt_invalid():\n",
+    "    \"\"\"\n",
+    "    should raise ValueError if the value passed to 'interrupt' is not \n",
+    "    valid\n",
+    "    \"\"\"\n",
+    "    def _mock_input(prompt=None):\n",
+    "        return 'y'\n",
+    "    try:\n",
+    "        davos.core.core.input = _mock_input\n",
+    "        with raises(ValueError):\n",
+    "            davos.core.core.prompt_input('prompt question?', interrupt='Z')\n",
+    "    finally:\n",
+    "        davos.core.core.input = builtins.input"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.769121Z",
+     "start_time": "2021-07-21T23:36:15.765525Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_run_shell_command_simple():\n",
+    "    stdout = davos.core.core.run_shell_command('echo \"hello world!\"', \n",
+    "                                               live_stdout=False)\n",
+    "    stdout = stdout.strip()\n",
+    "    assert stdout == 'hello world!', f\"Expected: hello world!\\nFound: {stdout}\""
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.775948Z",
+     "start_time": "2021-07-21T23:36:15.771583Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_run_shell_command_multiword():\n",
+    "    quote = (\"Strictly speaking, I didn't do the theiving. That would be the \"\n",
+    "             \"pirates. I just moved what they stole from one place to another\")\n",
+    "    stdout = davos.core.core.run_shell_command(f'echo \"{quote}\"', \n",
+    "                                               live_stdout=False)\n",
+    "    stdout = stdout.strip()\n",
+    "    assert stdout == quote, f'Expected:\\n\"{quote}\"\\nFound:\\n\"{stdout}\"'"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.783098Z",
+     "start_time": "2021-07-21T23:36:15.778081Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_run_shell_command_failure():\n",
+    "    expected_output = '/bin/bash: blahblahblah: command not found'\n",
+    "    with raises(CalledProcessError) as excinfo:\n",
+    "        davos.core.core.run_shell_command('/bin/bash -c \"blahblahblah\"', \n",
+    "                                          live_stdout=True)\n",
+    "    retcode = excinfo.value.returncode\n",
+    "    output = excinfo.value.output.strip()\n",
+    "    assert retcode == 127, f\"Expected return code 127, found {retcode}\"\n",
+    "    assert output == expected_output, (\n",
+    "        f'Expected output: \"{expected_output}\"\\nFound: \"{output}\"'\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.794623Z",
+     "start_time": "2021-07-21T23:36:15.785593Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "@mark.timeout(30)\n",
+    "def test_smuggle_pip_new():\n",
+    "    \"\"\"\n",
+    "    smuggle a package that does't exist locally, using 'pip' as the \n",
+    "    installer program\n",
+    "    \"\"\"\n",
+    "    assert not is_installed('ppca')\n",
+    "    smuggle ppca    # pip: ppca>=0.0.4\n",
+    "    assert isinstance(ppca, types.ModuleType), (\n",
+    "        \"Expected smuggled 'ppca' object to be a module. type(ppca) is \"\n",
+    "        f\"{type(ppca)}\"\n",
+    "    )\n",
+    "    assert hasattr(ppca, 'PPCA')\n",
+    "    assert is_installed('ppca>=0.0.4'), (\n",
+    "        \"installed 'ppca' version does not match version specified in onion \"\n",
+    "        \"comment\"\n",
+    "    )\n",
+    "    assert davos.config.smuggled['ppca'] == 'pip;ppca>=0.0.4', (\n",
+    "        f\"Expected: 'pip;ppca>=0.0.4'\\nFound: {davos.config.smuggled['ppca']}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.806486Z",
+     "start_time": "2021-07-21T23:36:15.797133Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "@mark.timeout(30)\n",
+    "def test_smuggle_from_pip_new():\n",
+    "    \"\"\"\n",
+    "    smuggle a single name from a package that doesn't exist locally, \n",
+    "    using 'pip' as the installer program\n",
+    "    \"\"\"\n",
+    "    assert not is_installed('paramiko')\n",
+    "    from paramiko smuggle SSHClient    # pip: paramiko==2.7.2\n",
+    "    assert inspect.isclass(SSHClient), (\n",
+    "        \"Expected 'paramiko.SSHClient' object to be a class. \"\n",
+    "        f\"type(SSHClient) is {type(SSHClient)}\"\n",
+    "    )\n",
+    "    assert is_installed('paramiko==2.7.2'), (\n",
+    "        \"installed 'paramiko' version does not match version specified in \"\n",
+    "        \"onion comment\"\n",
+    "    )\n",
+    "    assert davos.config.smuggled['paramiko'] == 'pip;paramiko==2.7.2', (\n",
+    "        \"Expected: 'pip;paramiko==2.7.2'\\nFound: \"\n",
+    "        f\"{davos.config.smuggled['paramiko']}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.817423Z",
+     "start_time": "2021-07-21T23:36:15.808825Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "@mark.timeout(30)\n",
+    "def test_smuggle_previously_installed():\n",
+    "    \"\"\"\n",
+    "    smuggle a package that was already installed locally, but whose \n",
+    "    local version does not match the requested version\n",
+    "    \"\"\"\n",
+    "    assert is_installed('fastdtw==0.3.4')\n",
+    "    assert not is_imported('fastdtw')\n",
+    "    smuggle fastdtw    # pip: fastdtw==0.3.2\n",
+    "    assert isinstance(fastdtw, types.ModuleType), (\n",
+    "        \"Expected smuggled 'fastdtw' object to be a module. type(fastdtw) is \"\n",
+    "        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",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.826353Z",
+     "start_time": "2021-07-21T23:36:15.819450Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "@mark.timeout(30)\n",
+    "def test_smuggle_previously_imported():\n",
+    "    \"\"\"\n",
+    "    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",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.840858Z",
+     "start_time": "2021-07-21T23:36:15.829349Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "@mark.timeout(60)\n",
+    "def test_smuggle_github_ref():\n",
+    "    \"\"\"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",
+    "    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",
+    "        f\"{davos.config.smuggled['hypertools']}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.855074Z",
+     "start_time": "2021-07-21T23:36:15.842939Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "@mark.timeout(30)\n",
+    "def test_smuggle_github_editable():\n",
+    "    \"\"\"\n",
+    "    smuggle a package from a VCS URL in 'editable' mode, installing its \n",
+    "    source into $PWD/gh_clones\n",
+    "    \"\"\"\n",
+    "    assert not is_installed('quail')\n",
+    "    dest_path = Path('gh_clones/quail').resolve()\n",
+    "    assert not dest_path.is_dir()\n",
+    "    smuggle quail    # pip: -e git+https://github.com/ContextLab/quail.git@v0.2.0#egg=quail --src gh_clones\n",
+    "    assert dest_path.is_dir()\n",
+    "    assert str(dest_path) in sys.path\n",
+    "    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",
+    "        f\"{davos.config.smuggled['quail']}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T23:36:15.867224Z",
+     "start_time": "2021-07-21T23:36:15.858069Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "@mark.timeout(300)\n",
+    "def test_smuggle_github_subdirectory():\n",
+    "    \"\"\"\n",
+    "    smuggle a package tthat exists as a subdirectory of a larger GitHub \n",
+    "    repository\n",
+    "    \"\"\"\n",
+    "    assert not is_installed('sherlock_helpers')\n",
+    "    with capture_ipython_display():\n",
+    "        # displays an IPython object on import, which \n",
+    "        # contextlib.redirect_stdout doesn't catch\n",
+    "        smuggle sherlock_helpers    # pip: git+https://github.com/ContextLab/sherlock-topic-model-paper.git@v1.0#subdirectory=code/sherlock_helpers\n",
+    "    assert isinstance(sherlock_helpers, types.ModuleType)\n",
+    "    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",
+    "        f\"{davos.config.smuggled['sherlock_helpers']}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "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": {
+     "end_time": "2021-07-21T23:36:29.346578Z",
+     "start_time": "2021-07-21T23:36:15.871668Z"
+    },
+    "scrolled": false
+   },
+   "outputs": [],
+   "source": [
+    "run_tests()"
+   ]
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "kernel-env",
+   "language": "python",
+   "name": "kernel-env"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.8.10"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 2
+}
diff --git a/tests/test_davos_colab.ipynb b/tests/test_davos_colab.ipynb
deleted file mode 100644
index 1f15e504..00000000
--- a/tests/test_davos_colab.ipynb
+++ /dev/null
@@ -1,1788 +0,0 @@
-{
-  "nbformat": 4,
-  "nbformat_minor": 0,
-  "metadata": {
-    "colab": {
-      "name": "test-davos-colab.ipynb",
-      "provenance": [],
-      "collapsed_sections": [],
-      "toc_visible": true
-    },
-    "kernelspec": {
-      "display_name": "Python 3",
-      "name": "python3"
-    },
-    "language_info": {
-      "name": "python"
-    }
-  },
-  "cells": [
-    {
-      "cell_type": "markdown",
-      "metadata": {
-        "id": "J2ClQflyITTI"
-      },
-      "source": [
-        "<p align=\"center\"\n",
-        "    <a href=\"https://colab.research.google.com/github/ContextLab/psyc32-hello-world/blob/master/hello_world_assignment.ipynb\" target=\"_parent\">\n",
-        "        <img src=\"https://colab.research.google.com/assets/colab-badge.svg\" alt=\"Open In Colab\">\n",
-        "    </a>\n",
-        "</p>"
-      ]
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "sXPlvCvRsuLE"
-      },
-      "source": [
-        "import ast\n",
-        "import contextlib\n",
-        "import functools\n",
-        "import os\n",
-        "import signal\n",
-        "import subprocess\n",
-        "import sys\n",
-        "import types\n",
-        "from pathlib import Path\n",
-        "\n",
-        "import pkg_resources\n",
-        "from IPython.core.ultratb import AutoFormattedTB"
-      ],
-      "execution_count": 1,
-      "outputs": []
-    },
-    {
-      "cell_type": "markdown",
-      "metadata": {
-        "id": "73wNblHUaT8s"
-      },
-      "source": [
-        "# **Test helpers**"
-      ]
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "dSVO_4asVNe6"
-      },
-      "source": [
-        "setup_funcs = []\n",
-        "tests = []"
-      ],
-      "execution_count": 2,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "ufPkHItMyqB4"
-      },
-      "source": [
-        "class DavosTestingError:\n",
-        "    \"\"\"Base class for Davos testing-related errors\"\"\"\n",
-        "    pass\n",
-        "\n",
-        "\n",
-        "class DavosAssertionError(DavosTestingError, AssertionError):\n",
-        "    \"\"\"Subclasses AssertionError for pytest-specific handling\"\"\"\n",
-        "    pass\n",
-        "\n",
-        "\n",
-        "class TestingEnvironmentError(DavosAssertionError, OSError):\n",
-        "    \"\"\"Raised due to issues with the testing environment\"\"\"\n",
-        "    pass\n",
-        "\n",
-        "\n",
-        "class NonColabEnvironmentError(TestingEnvironmentError):\n",
-        "    \"\"\"Raised if this notebook is run outside of Colaboratory\"\"\"\n",
-        "    def __init__(self, *args):\n",
-        "        msg = \"Tests in this notebook are meant to be run in a Colaboratory environment\"\n",
-        "        super().__init__(msg, *args)\n",
-        "\n",
-        "\n",
-        "class TestTimeoutError(DavosAssertionError):\n",
-        "    \"\"\"Raised if a test exceeds its timeout limit\"\"\"\n",
-        "    pass"
-      ],
-      "execution_count": 3,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "1X6xLIDjLvH8"
-      },
-      "source": [
-        "def _expected_onion_parser_output(args_str, **installer_kwargs):\n",
-        "    _installer_kwargs = {'editable': False, 'spec': args_str}\n",
-        "    _installer_kwargs.update(installer_kwargs)\n",
-        "    return '\"pip\"', f'\"\"\"{args_str}\"\"\"', _installer_kwargs"
-      ],
-      "execution_count": 4,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "aC6WMKyNxtSl"
-      },
-      "source": [
-        "def _expected_parser_output(name, as_=None, args_str=None, **installer_kwargs):\n",
-        "    if as_ is not None:\n",
-        "        as_ = f'\"{as_}\"'\n",
-        "    expected = f'smuggle(name=\"{name}\", as_={as_}'\n",
-        "    if args_str is not None or any(installer_kwargs):\n",
-        "        installer, args_str, inst_kwargs = _expected_onion_parser_output(args_str, **installer_kwargs)\n",
-        "        expected += f', installer={installer}, args_str={args_str}, installer_kwargs={inst_kwargs}'\n",
-        "    return expected + ')'"
-      ],
-      "execution_count": 5,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "lTOnvuyrM487"
-      },
-      "source": [
-        "def _is_imported(package):\n",
-        "    if package in sys.modules:\n",
-        "        return True\n",
-        "    elif any(pkg.startswith(f'{package}.') for pkg in sys.modules):\n",
-        "        return True\n",
-        "    return False"
-      ],
-      "execution_count": 6,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "9Alh4UZEP2Ac"
-      },
-      "source": [
-        "def _is_installed(package):\n",
-        "    try:\n",
-        "        dist = pkg_resources.get_distribution(package)\n",
-        "    except pkg_resources.DistributionNotFound:\n",
-        "        return False\n",
-        "    else:\n",
-        "        return True"
-      ],
-      "execution_count": 7,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "LuXE3Dvny_wo"
-      },
-      "source": [
-        "def _matches_expected_output(expected, result):\n",
-        "    all_match = True\n",
-        "    expected_chunks = expected.split(';')\n",
-        "    result_chunks = result.split(';')\n",
-        "    assert len(expected_chunks) == len(result_chunks)\n",
-        "    for expected_chunk, result_chunk in zip(expected_chunks, result_chunks):\n",
-        "        if 'installer_kwargs=' in expected_chunk and 'installer_kwargs' in result_chunk:\n",
-        "            exp_main, exp_kwargs = expected_chunk.strip().split('installer_kwargs=')\n",
-        "            res_main, res_kwargs = result_chunk.strip().split('installer_kwargs=')\n",
-        "            # remove ')'\n",
-        "            exp_kwargs = ast.literal_eval(exp_kwargs[:-1])\n",
-        "            res_kwargs = ast.literal_eval(res_kwargs[:-1])\n",
-        "            all_match = all_match and exp_main == res_main and exp_kwargs == res_kwargs\n",
-        "        else:\n",
-        "            all_match = all_match and expected_chunk == result_chunk\n",
-        "    return all_match"
-      ],
-      "execution_count": 8,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "1CeVqSKle5zT"
-      },
-      "source": [
-        "def _parse_line(line):\n",
-        "    return davos.colab.smuggle_parser_colab(line)"
-      ],
-      "execution_count": 9,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "pHqLqdn0e51X"
-      },
-      "source": [
-        "def _parse_onion(onion):\n",
-        "    return davos.core.Onion.parse_onion(onion)"
-      ],
-      "execution_count": 10,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "J6F_6mB6VAOI"
-      },
-      "source": [
-        "def setup_func(func):\n",
-        "    @functools.wraps(func)\n",
-        "    def _wrapped():\n",
-        "        return func()\n",
-        "        \n",
-        "    setup_funcs.append(func)\n",
-        "    return _wrapped"
-      ],
-      "execution_count": 11,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "pbaCKBNeVAjR"
-      },
-      "source": [
-        "def test(func=None, *, timeout=100):\n",
-        "    def decorator(func):\n",
-        "        def handler(signum, frame):\n",
-        "            raise TestTimeoutError(\n",
-        "                f\"'{func.__name__}' timed out after {timeout} seconds\"\n",
-        "            )\n",
-        "        @functools.wraps(func)\n",
-        "        def wrapper():\n",
-        "            # print(f'timeout is {timeout}')\n",
-        "            signal.signal(signal.SIGALRM, handler)\n",
-        "            signal.alarm(timeout)\n",
-        "            try:\n",
-        "                return func()\n",
-        "            finally:\n",
-        "                signal.alarm(0)\n",
-        "\n",
-        "        tests.append(wrapper)\n",
-        "        return wrapper\n",
-        "    \n",
-        "    if func is not None:\n",
-        "        return decorator(func)\n",
-        "    return decorator"
-      ],
-      "execution_count": 12,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "eujmFjaFWwVm"
-      },
-      "source": [
-        "def run_setup(source='github', ref=None, fork=None):\n",
-        "    n_funcs = len(setup_funcs)\n",
-        "    for i, sf in enumerate(setup_funcs):\n",
-        "        name = sf.__name__\n",
-        "        prefix = f\"{i+1}/{n_funcs} {name}:\"\n",
-        "        print(f\"{prefix}{' '*(60-len(prefix))}\", end='')\n",
-        "        try:\n",
-        "            if name == 'test_install_davos':\n",
-        "                sf(source=source, ref=ref, fork=fork)\n",
-        "            else:\n",
-        "                sf()\n",
-        "        except:\n",
-        "            print('failed')\n",
-        "            raise\n",
-        "        else:\n",
-        "            print('passed')"
-      ],
-      "execution_count": 13,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "8TThnBUIy3h7"
-      },
-      "source": [
-        "def show_failures(names_excs):\n",
-        "    tb_displayer = AutoFormattedTB('Context', 'LightBG')\n",
-        "    for name, exc in names_excs:\n",
-        "        n_pre = (85 - len(name) - 2) // 2\n",
-        "        n_post = 85 - len(name) - 2 - n_pre\n",
-        "        print(f\"{'='*n_pre} {name} {'='*n_post}\")\n",
-        "        print(tb_displayer.text(type(exc), exc, exc.__traceback__))\n",
-        "        print('\\n\\n\\n')"
-      ],
-      "execution_count": 14,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "LRdPqhZ0WwX4"
-      },
-      "source": [
-        "def run_tests(live_stdout=False):\n",
-        "    if live_stdout:\n",
-        "        stdout_context = contextlib.nullcontext\n",
-        "    else:\n",
-        "        stdout_context = contextlib.redirect_stdout\n",
-        "    names_excs = []\n",
-        "    n_tests = len(tests)\n",
-        "    for i, tf in enumerate(tests, start=1):\n",
-        "        name = tf.__name__\n",
-        "        prefix = f\"{i}/{n_tests}{' ' if i > 9 else '  '}{name}:\"\n",
-        "        print(f\"{prefix}{' '*(85-len(prefix))}\", end='')\n",
-        "        try:\n",
-        "            with open(os.devnull, 'w') as devnull, stdout_context(devnull):\n",
-        "                tf()\n",
-        "        except Exception as e:\n",
-        "            names_excs.append((name, e))\n",
-        "            if isinstance(e, TestTimeoutError):\n",
-        "                print('timed out')\n",
-        "            else:\n",
-        "                print('failed')\n",
-        "        else:\n",
-        "            print('passed')\n",
-        "    \n",
-        "    print('\\n\\n')\n",
-        "    n_failed = len(names_excs)\n",
-        "    if n_failed == 0:\n",
-        "        print('All tests passed!')\n",
-        "    else:\n",
-        "        print(f'{n_tests-n_failed}/{n_tests} passed')\n",
-        "        print('Displaying errors from failing tests:')\n",
-        "        print('\\n\\n')\n",
-        "    show_failures(names_excs)"
-      ],
-      "execution_count": 15,
-      "outputs": []
-    },
-    {
-      "cell_type": "markdown",
-      "metadata": {
-        "id": "TTnx8Ux83ugP"
-      },
-      "source": [
-        "# **Setup tests**"
-      ]
-    },
-    {
-      "cell_type": "markdown",
-      "metadata": {
-        "id": "Jamfm0pP4LpR"
-      },
-      "source": [
-        "### Testing environment validation & installation tests"
-      ]
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "PuVsfmse6GQ8"
-      },
-      "source": [
-        "@setup_func\n",
-        "def test_import_ipython():\n",
-        "    global IPython\n",
-        "    try:\n",
-        "        import IPython\n",
-        "    except ModuleNotFoundError as e:\n",
-        "        raise NonColabEnvironmentError() from e"
-      ],
-      "execution_count": 16,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "J4V4sMqS4LBM"
-      },
-      "source": [
-        "@setup_func\n",
-        "def test_import_google():\n",
-        "    global google\n",
-        "    try:\n",
-        "        import google\n",
-        "    except ModuleNotFoundError as e:\n",
-        "        raise NonColabEnvironmentError() from e"
-      ],
-      "execution_count": 17,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "vimIIlfT5hdu"
-      },
-      "source": [
-        "@setup_func\n",
-        "def test_get_ipython_shell():\n",
-        "    global IPYTHON_SHELL\n",
-        "    try:\n",
-        "        IPYTHON_SHELL = get_ipython()\n",
-        "    except NameError as e:\n",
-        "        raise NonColabEnvironmentError() from e"
-      ],
-      "execution_count": 18,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "0aMa2x7n5-cg"
-      },
-      "source": [
-        "@setup_func\n",
-        "def test_ipython_shell_is_colab_shell():\n",
-        "    try:\n",
-        "        assert isinstance(IPYTHON_SHELL, google.colab._shell.Shell)\n",
-        "    except AssertionError as e:\n",
-        "        raise NonColabEnvironmentError() from e\n",
-        "    except AttributeError as e:\n",
-        "        google_version = pkg_resources.get_distribution('google').version\n",
-        "        raise TestingEnvironmentError(\n",
-        "            \"Qualified name for Colab shell class has changed (google module \"\n",
-        "            \"may have been recently updated).\\n\\tShell type:\\t\"\n",
-        "            f\"{type(IPYTHON_SHELL)}\\n\\tgoogle version:\\t{google_version}\"\n",
-        "        ) from e"
-      ],
-      "execution_count": 19,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "Jq9BX8VC9XaZ"
-      },
-      "source": [
-        "@setup_func\n",
-        "def test_ipython_version():\n",
-        "    ipy_version = IPython.__version__\n",
-        "    try:\n",
-        "        assert ipy_version == '5.5.0'\n",
-        "    except AssertionError as e:\n",
-        "        raise TestingEnvironmentError(\n",
-        "            f\"Found unexpected IPython version: {ipy_version}\\nExpected \"\n",
-        "            \"IPython==5.5.0\"\n",
-        "        ) from e"
-      ],
-      "execution_count": 20,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "nVW4u29OT4Uj"
-      },
-      "source": [
-        "@setup_func\n",
-        "def test_no_transforms_registered():\n",
-        "    assert len(IPYTHON_SHELL.input_splitter.python_line_transforms) == 0\n",
-        "    assert len(IPYTHON_SHELL.input_transformer_manager.python_line_transforms) == 0"
-      ],
-      "execution_count": 21,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "w_m4bwUBtI6S"
-      },
-      "source": [
-        "@setup_func\n",
-        "def test_install_davos(source='github', ref=None, fork=None):\n",
-        "    source = source.lower()\n",
-        "    if source == 'github':\n",
-        "        if fork is None:\n",
-        "            fork = 'ContextLab'\n",
-        "        name = f'git+https://github.com/{fork}/davos.git'\n",
-        "        if ref is not None:\n",
-        "            name += f'@{ref}'\n",
-        "        name += '#egg=davos'\n",
-        "    elif source in ('pip', 'pypi', 'testpypi'):\n",
-        "        name = 'davos'\n",
-        "        if source == 'testpypi':\n",
-        "            name = '--index-url https://test.pypi.org/simple/ ' + name\n",
-        "        if ref is not None:\n",
-        "            name += f'=={ref}'\n",
-        "    elif source == 'conda':\n",
-        "        raise NotImplementedError(\n",
-        "            \"conda installation is not supported in Colaboratory\"\n",
-        "        )\n",
-        "    else:\n",
-        "        raise ValueError(f\"Invalid source '{source}'\")\n",
-        "    \n",
-        "    return_code = IPython.core.interactiveshell.system(f'pip install {name}')\n",
-        "    if return_code != 0:\n",
-        "        raise DavosTestingError(\n",
-        "            f\"Failed to install 'davos'. Ran command:\\n\\t`pip install {name}`\"\n",
-        "            )"
-      ],
-      "execution_count": 22,
-      "outputs": []
-    },
-    {
-      "cell_type": "markdown",
-      "metadata": {
-        "id": "3akA2h2zSwI7"
-      },
-      "source": [
-        "### Import & initialization tests"
-      ]
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "t7rW8ymXKCI6"
-      },
-      "source": [
-        "@setup_func\n",
-        "def test_import_davos():\n",
-        "    global davos\n",
-        "    try:\n",
-        "        import davos\n",
-        "    except ModuleNotFoundError as e:\n",
-        "        raise DavosTestingError(\"Failed to import 'davos' after installation\")"
-      ],
-      "execution_count": 23,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "OeB54gfIU0pH"
-      },
-      "source": [
-        "@setup_func\n",
-        "def test_smuggler_transform_registered():\n",
-        "    splitter_transforms = IPYTHON_SHELL.input_splitter.python_line_transforms\n",
-        "    line_transforms = IPYTHON_SHELL.input_transformer_manager.python_line_transforms\n",
-        "    # transform be registered once (and only once) in both places\n",
-        "    assert len(splitter_transforms) == 1\n",
-        "    assert len(line_transforms) == 1\n",
-        "    smuggler_splitter_transform = splitter_transforms[0]\n",
-        "    smuggler_line_transform = line_transforms[0]\n",
-        "    # both should be StatelessInputTransformers that wrap the \n",
-        "    # `smuggle_parser_colab` function\n",
-        "    assert isinstance(smuggler_splitter_transform, \n",
-        "                      IPython.core.inputtransformer.StatelessInputTransformer)\n",
-        "    assert isinstance(smuggler_line_transform, \n",
-        "                      IPython.core.inputtransformer.StatelessInputTransformer)\n",
-        "    assert (smuggler_splitter_transform.func \n",
-        "            is smuggler_line_transform.func \n",
-        "            is davos.colab.smuggle_parser_colab)"
-      ],
-      "execution_count": 24,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "wjDdDkyHXft-"
-      },
-      "source": [
-        "@setup_func\n",
-        "def test_smuggle_function_in_namespace():\n",
-        "    assert 'smuggle' in IPYTHON_SHELL.user_ns"
-      ],
-      "execution_count": 25,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "954ebNTeYxUq"
-      },
-      "source": [
-        "@setup_func\n",
-        "def test_correct_functions_chosen():\n",
-        "    assert smuggle is davos.colab.smuggle_colab"
-      ],
-      "execution_count": 26,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "scwvd0G_aMsx"
-      },
-      "source": [
-        "@setup_func\n",
-        "def test_Davos_object_initialization():\n",
-        "    davos_obj = davos.davos\n",
-        "    assert davos_obj.confirm_install is False\n",
-        "    assert davos_obj.suppress_stdout is False\n",
-        "    assert davos_obj.smuggled == {}\n",
-        "    assert davos_obj.smuggler is davos.colab.smuggle_colab\n",
-        "    assert davos.activate is davos_obj.activate_parser is davos.colab.activate_parser_colab\n",
-        "    assert davos.deactivate is davos_obj.deactivate_parser is davos.colab.deactivate_parser_colab\n",
-        "    assert davos_obj._shell_cmd_helper is davos.colab.run_shell_command_colab\n",
-        "    assert davos_obj.parser_environment == 'IPY_OLD'"
-      ],
-      "execution_count": 27,
-      "outputs": []
-    },
-    {
-      "cell_type": "markdown",
-      "metadata": {
-        "id": "cTOksxgP3nSG"
-      },
-      "source": [
-        "# **Run setup tests**"
-      ]
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "colab": {
-          "base_uri": "https://localhost:8080/"
-        },
-        "id": "QMvGVihj4l-D",
-        "outputId": "1c945267-f2e3-4832-b340-bdcb127204d1"
-      },
-      "source": [
-        "run_setup(source='github', fork='paxtonfitzpatrick')"
-      ],
-      "execution_count": 28,
-      "outputs": [
-        {
-          "output_type": "stream",
-          "text": [
-            "1/12 test_import_ipython:                                   passed\n",
-            "2/12 test_import_google:                                    passed\n",
-            "3/12 test_get_ipython_shell:                                passed\n",
-            "4/12 test_ipython_shell_is_colab_shell:                     passed\n",
-            "5/12 test_ipython_version:                                  passed\n",
-            "6/12 test_no_transforms_registered:                         passed\n",
-            "7/12 test_install_davos:                                    Collecting davos\n",
-            "  Cloning https://github.com/paxtonfitzpatrick/davos.git to /tmp/pip-install-sgjxfs1o/davos\n",
-            "  Running command git clone -q https://github.com/paxtonfitzpatrick/davos.git /tmp/pip-install-sgjxfs1o/davos\n",
-            "  Installing build dependencies ... \u001b[?25l\u001b[?25hdone\n",
-            "  Getting requirements to build wheel ... \u001b[?25l\u001b[?25hdone\n",
-            "    Preparing wheel metadata ... \u001b[?25l\u001b[?25hdone\n",
-            "Building wheels for collected packages: davos\n",
-            "  Building wheel for davos (PEP 517) ... \u001b[?25l\u001b[?25hdone\n",
-            "  Created wheel for davos: filename=davos-0.0.1-cp37-none-any.whl size=34545 sha256=847e52e1511283ba9deb9a24ca4a34c33d05cbfa4e0637509782881cb5161422\n",
-            "  Stored in directory: /tmp/pip-ephem-wheel-cache-al_oeui3/wheels/6d/b5/22/60574bf4074226d4d9c357fbfe356cb75b8eda276caa67e403\n",
-            "Successfully built davos\n",
-            "Installing collected packages: davos\n",
-            "Successfully installed davos-0.0.1\n",
-            "passed\n",
-            "8/12 test_import_davos:                                     passed\n",
-            "9/12 test_smuggler_transform_registered:                    passed\n",
-            "10/12 test_smuggle_function_in_namespace:                   passed\n",
-            "11/12 test_correct_functions_chosen:                        passed\n",
-            "12/12 test_Davos_object_initialization:                     passed\n"
-          ],
-          "name": "stdout"
-        }
-      ]
-    },
-    {
-      "cell_type": "markdown",
-      "metadata": {
-        "id": "eeYkZiDL4Vrr"
-      },
-      "source": [
-        "# **Main tests**"
-      ]
-    },
-    {
-      "cell_type": "markdown",
-      "metadata": {
-        "id": "h8zmDNgMgLGk"
-      },
-      "source": [
-        "## Unit tests"
-      ]
-    },
-    {
-      "cell_type": "markdown",
-      "metadata": {
-        "id": "AjpH754ElGt3"
-      },
-      "source": [
-        "### shell command runner tests"
-      ]
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "oBF5TmvOhFSl"
-      },
-      "source": [
-        "@test\n",
-        "def test_run_shell_command_simple():\n",
-        "    stdout, retcode = davos.davos.run_shell_command('whoami', live_stdout=False)\n",
-        "    assert retcode == 0\n",
-        "    assert stdout == 'root\\r\\n'"
-      ],
-      "execution_count": 29,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "MF0REQDfh5UF"
-      },
-      "source": [
-        "@test\n",
-        "def test_run_shell_command_multiword():\n",
-        "    quote = (\"Strictly speaking, I didn't do the theiving. That would be the \"\n",
-        "             \"pirates. I just moved what they stole from one place to another\")\n",
-        "    stdout, retcode = davos.davos.run_shell_command(f'echo \"{quote}\"', \n",
-        "                                                    live_stdout=False)\n",
-        "    assert retcode == 0\n",
-        "    assert stdout == quote + '\\r\\n'"
-      ],
-      "execution_count": 30,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "w7uenDGZcsuP"
-      },
-      "source": [
-        "@test\n",
-        "def test_run_shell_command_failure():\n",
-        "    try:\n",
-        "        davos.davos.run_shell_command('blahblahblah', live_stdout=True)\n",
-        "    except subprocess.CalledProcessError as e:\n",
-        "        assert e.returncode == 127\n",
-        "        assert e.output == '/bin/sh: 1: blahblahblah: not found\\r\\n', e.output.getvalue()"
-      ],
-      "execution_count": 31,
-      "outputs": []
-    },
-    {
-      "cell_type": "markdown",
-      "metadata": {
-        "id": "E4U8QOFolgpS"
-      },
-      "source": [
-        "### smuggle command parser tests"
-      ]
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "VzF2OhxZkxnl"
-      },
-      "source": [
-        "@test\n",
-        "def test_parser_ignores_line_no_smuggle():\n",
-        "    line = \"def foo(bar, baz=qux):\"\n",
-        "    assert _parse_line(line) == line"
-      ],
-      "execution_count": 32,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "-2e0f8r7mJNE"
-      },
-      "source": [
-        "@test\n",
-        "def test_parser_ignores_line_decoy_smuggle():\n",
-        "    \"\"\"a line that has \"smuggle\" in it, but should be ignored\"\"\"\n",
-        "    line = \"def smuggle_something(foo):\"\n",
-        "    assert _parse_line(line) == line"
-      ],
-      "execution_count": 33,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "i_8gCTv2mJQI"
-      },
-      "source": [
-        "@test\n",
-        "def test_parser_ignores_commented_line():\n",
-        "    \"\"\"parser should ignore commented-out smuggle statements\"\"\"\n",
-        "    line = \"# smuggle foo as bar\"\n",
-        "    assert _parse_line(line) == line"
-      ],
-      "execution_count": 34,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "oUOlI4yEiaeW"
-      },
-      "source": [
-        "@test\n",
-        "def test_parser_handles_basic_line():\n",
-        "    \"\"\"simplest use case\"\"\"\n",
-        "    line = \"smuggle foo\"\n",
-        "    expected = _expected_parser_output('foo')\n",
-        "    assert _matches_expected_output(expected, _parse_line(line))"
-      ],
-      "execution_count": 35,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "D4Vi1ay4ly4g"
-      },
-      "source": [
-        "@test\n",
-        "def test_parser_handles_basic_line_alias():\n",
-        "    \"\"\"simplest use case, plus alias\"\"\"\n",
-        "    line = \"smuggle foo as bar\"\n",
-        "    expected = _expected_parser_output('foo', as_='bar')\n",
-        "    assert _matches_expected_output(expected, _parse_line(line))"
-      ],
-      "execution_count": 36,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "QQqY5lFWia1U"
-      },
-      "source": [
-        "@test\n",
-        "def test_parser_handles_basic_line_onion():\n",
-        "    \"\"\"simplest use case, with onion comment\"\"\"\n",
-        "    line = \"smuggle foo as bar    # pip: foo==0.0.1\"\n",
-        "    expected = _expected_parser_output('foo', as_='bar', args_str='foo==0.0.1')\n",
-        "    assert _matches_expected_output(expected, _parse_line(line))"
-      ],
-      "execution_count": 37,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "f7laAO9UkU55"
-      },
-      "source": [
-        "@test\n",
-        "def test_parser_ignores_non_onion_comment():\n",
-        "    \"\"\"looks similar to the preivous one, but the inline comment is NOT an onion spec\"\"\"\n",
-        "    line = \"smuggle foo as bar    # omg foo is my FAVORITE package\"\n",
-        "    expected = _expected_parser_output('foo', as_='bar') + \"    # omg foo is my FAVORITE package\"\n",
-        "    assert _matches_expected_output(expected, _parse_line(line))"
-      ],
-      "execution_count": 38,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "SDTuBZL9JGrs"
-      },
-      "source": [
-        "@test\n",
-        "def test_parser_ignores_imposter_onion_comment():\n",
-        "    \"\"\"VERY close to an onion comment, but doesn't start with \"pip:\" (note colon)\"\"\"\n",
-        "    line = \"smuggle foo as bar    # pip is a [redacted] package manager than conda\"\n",
-        "    expected = _expected_parser_output('foo', as_='bar') + \"    # pip is a [redacted] package manager than conda\"\n",
-        "    assert _matches_expected_output(expected, _parse_line(line))"
-      ],
-      "execution_count": 39,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "8eUcnzCnrjH2"
-      },
-      "source": [
-        "@test\n",
-        "def test_parser_handles_smuggle_qualname():\n",
-        "    line = \"smuggle foo.bar as baz\"\n",
-        "    expected = _expected_parser_output('foo.bar', as_='baz')\n",
-        "    assert _matches_expected_output(expected, _parse_line(line))"
-      ],
-      "execution_count": 40,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "tYyTHhq6mJTB"
-      },
-      "source": [
-        "@test\n",
-        "def test_parser_handles_indented_line():\n",
-        "    \"\"\"\n",
-        "    needs to handle, e.g.\n",
-        "        ```\n",
-        "        def foo():\n",
-        "            smuggle numpy as np\n",
-        "        ```\n",
-        "    Note: unless `davos.deactivate() is run, the above line will \n",
-        "    actually be parsed (though it will have no effect)\n",
-        "    \"\"\"\n",
-        "    line = \"    smuggle foo as bar\"\n",
-        "    expected = \"    \" + _expected_parser_output('foo', as_='bar')\n",
-        "    assert _matches_expected_output(expected, _parse_line(line))"
-      ],
-      "execution_count": 41,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "vXFaqV8l4cba"
-      },
-      "source": [
-        "@test\n",
-        "def test_parser_handles_different_install_name():\n",
-        "    \"\"\"\n",
-        "    sometimes, name used for pip-installation is different \n",
-        "    from name used to import\n",
-        "    \"\"\"\n",
-        "    line = \"smuggle foo as bar    # pip: foo-package==0.0.1\"\n",
-        "    expected = _expected_parser_output('foo', as_='bar', \n",
-        "                                       args_str='foo-package==0.0.1')\n",
-        "    assert _matches_expected_output(expected, _parse_line(line))"
-      ],
-      "execution_count": 42,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "bAXICxNor4a9"
-      },
-      "source": [
-        "@test\n",
-        "def test_parser_handles_multiple_packages():\n",
-        "    line = \"smuggle foo as bar, baz as qux, spam, ham, eggs\"\n",
-        "    expected = _expected_parser_output('foo', as_='bar')\n",
-        "    expected += f\"; {_expected_parser_output('baz', as_='qux')}\"\n",
-        "    expected += f\"; {_expected_parser_output('spam')}\"\n",
-        "    expected += f\"; {_expected_parser_output('ham')}\"\n",
-        "    expected += f\"; {_expected_parser_output('eggs')}\"\n",
-        "    assert _matches_expected_output(expected, _parse_line(line))"
-      ],
-      "execution_count": 43,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "4Xm-d7dYhjuR"
-      },
-      "source": [
-        "@test\n",
-        "def test_parser_handles_multiple_packages_onion():\n",
-        "    \"\"\"onion info should be passed to smuggle function for FIRST package\"\"\"\n",
-        "    line = \"smuggle foo as bar, baz as qux, spam, ham, eggs    # pip: foo==0.0.1\"\n",
-        "    expected = _expected_parser_output('foo', as_='bar', args_str='foo==0.0.1')\n",
-        "    expected += f\"; {_expected_parser_output('baz', as_='qux')}\"\n",
-        "    expected += f\"; {_expected_parser_output('spam')}\"\n",
-        "    expected += f\"; {_expected_parser_output('ham')}\"\n",
-        "    expected += f\"; {_expected_parser_output('eggs')}\"\n",
-        "    assert _matches_expected_output(expected, _parse_line(line))"
-      ],
-      "execution_count": 44,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "UFRPxgIer4dc"
-      },
-      "source": [
-        "@test\n",
-        "def test_parser_handles_backslash():\n",
-        "    line = \"\"\"smuggle foo as bar, \\\n",
-        "                      baz as qux, \\\n",
-        "                      spam, ham, \\\n",
-        "                      eggs\"\"\"\n",
-        "    expected = _expected_parser_output('foo', as_='bar')\n",
-        "    expected += f\"; {_expected_parser_output('baz', as_='qux')}\"\n",
-        "    expected += f\"; {_expected_parser_output('spam')}\"\n",
-        "    expected += f\"; {_expected_parser_output('ham')}\"\n",
-        "    expected += f\"; {_expected_parser_output('eggs')}\"\n",
-        "    assert _matches_expected_output(expected, _parse_line(line))"
-      ],
-      "execution_count": 45,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "wturHFrIr4fi"
-      },
-      "source": [
-        "@test\n",
-        "def test_parser_handles_backslash_onion():\n",
-        "    \"\"\"again, onion info should be passed to smuggle function for FIRST package\"\"\"\n",
-        "    line = \"\"\"smuggle foo as bar, \\\n",
-        "                      baz as qux, \\\n",
-        "                      spam, ham, \\\n",
-        "                      eggs    # pip: foo==0.0.1\"\"\"\n",
-        "    expected = _expected_parser_output('foo', as_='bar', args_str='foo==0.0.1')\n",
-        "    expected += f\"; {_expected_parser_output('baz', as_='qux')}\"\n",
-        "    expected += f\"; {_expected_parser_output('spam')}\"\n",
-        "    expected += f\"; {_expected_parser_output('ham')}\"\n",
-        "    expected += f\"; {_expected_parser_output('eggs')}\"\n",
-        "    assert _matches_expected_output(expected, _parse_line(line))"
-      ],
-      "execution_count": 46,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "wSFAB-8AgKi6"
-      },
-      "source": [
-        "@test\n",
-        "def test_parser_handles_inconsistent_whitespace():\n",
-        "    \"\"\"should handle weird amounts of whitespace that are *technically* valid\"\"\"\n",
-        "    line = \"\"\"smuggle               foo     as    bar    \\\n",
-        ",baz as qux  , \\\n",
-        "          spam  .  ham    as    eggs                #   pip   :       foo==0.0.1    \"\"\"\n",
-        "    expected = _expected_parser_output('foo', as_='bar', args_str='foo==0.0.1')\n",
-        "    expected += f\"; {_expected_parser_output('baz', as_='qux')}\"\n",
-        "    expected += f\"; {_expected_parser_output('spam.ham', as_='eggs')}\"\n",
-        "    # real parser adds back leading & trailing characters (including whitespace)\n",
-        "    expected += \"    \"\n",
-        "    assert _matches_expected_output(expected, _parse_line(line))"
-      ],
-      "execution_count": 47,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "iiynLAjLgKnn"
-      },
-      "source": [
-        "@test\n",
-        "def test_parser_handles_smuggle_from():\n",
-        "    \"\"\"second possible broad syntax class\"\"\"\n",
-        "    line = \"from foo smuggle bar\"\n",
-        "    expected = _expected_parser_output('foo.bar', as_='bar')\n",
-        "    assert _matches_expected_output(expected, _parse_line(line))"
-      ],
-      "execution_count": 48,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "8mPAq2AXsnS9"
-      },
-      "source": [
-        "@test\n",
-        "def test_parser_handles_smuggle_from_onion():\n",
-        "    \"\"\"second possible broad syntax class\"\"\"\n",
-        "    line = \"from foo smuggle bar    # pip: foo==0.0.1\"\n",
-        "    expected = _expected_parser_output('foo.bar', as_='bar', args_str='foo==0.0.1')\n",
-        "    assert _matches_expected_output(expected, _parse_line(line))"
-      ],
-      "execution_count": 49,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "GGvdTMMgr3dp"
-      },
-      "source": [
-        "@test\n",
-        "def test_parser_handles_smuggle_from_multi():\n",
-        "    line = \"from foo smuggle bar, baz as spam, qux\"\n",
-        "    expected = _expected_parser_output('foo.bar', as_='bar')\n",
-        "    expected += f\"; {_expected_parser_output('foo.baz', as_='spam')}\"\n",
-        "    expected += f\"; {_expected_parser_output('foo.qux', as_='qux')}\"\n",
-        "    assert _matches_expected_output(expected, _parse_line(line))"
-      ],
-      "execution_count": 50,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "EjOyycpCQFm-"
-      },
-      "source": [
-        "@test\n",
-        "def test_parser_handles_smuggle_from_multi_onion():\n",
-        "    \"\"\"onion info should be passed to FIRST smuggle function\"\"\"\n",
-        "    line = \"from foo smuggle bar, baz as spam, qux    # pip: foo==0.0.1\"\n",
-        "    expected = _expected_parser_output('foo.bar', as_='bar', args_str='foo==0.0.1')\n",
-        "    expected += f\"; {_expected_parser_output('foo.baz', as_='spam')}\"\n",
-        "    expected += f\"; {_expected_parser_output('foo.qux', as_='qux')}\"\n",
-        "    assert _matches_expected_output(expected, _parse_line(line))"
-      ],
-      "execution_count": 51,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "grUaxSdCtXKQ"
-      },
-      "source": [
-        "@test\n",
-        "def test_parser_handles_smuggle_from_backslash():\n",
-        "    \"\"\"onion info should be passed to FIRST smuggle function\"\"\"\n",
-        "    line = \"\"\"from foo smuggle bar, \\\n",
-        "                               baz as spam, \\\n",
-        "                               qux    # pip: foo==0.0.1\"\"\"\n",
-        "    expected = _expected_parser_output('foo.bar', as_='bar', args_str='foo==0.0.1')\n",
-        "    expected += f\"; {_expected_parser_output('foo.baz', as_='spam')}\"\n",
-        "    expected += f\"; {_expected_parser_output('foo.qux', as_='qux')}\"\n",
-        "    assert _matches_expected_output(expected, _parse_line(line))"
-      ],
-      "execution_count": 52,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "sCcYGt_AQFpb"
-      },
-      "source": [
-        "@test\n",
-        "def test_parser_handles_smuggle_from_parentheses():\n",
-        "    line = \"\"\"from foo smuggle (bar, baz as spam, qux,)\"\"\"\n",
-        "    # also tests trailing comma inside parentheses, which is valid\n",
-        "    expected = _expected_parser_output('foo.bar', as_='bar')\n",
-        "    expected += f\"; {_expected_parser_output('foo.baz', as_='spam')}\"\n",
-        "    expected += f\"; {_expected_parser_output('foo.qux', as_='qux')}\"\n",
-        "    assert _matches_expected_output(expected, _parse_line(line))"
-      ],
-      "execution_count": 53,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "gLCfWNofQFrb"
-      },
-      "source": [
-        "@test\n",
-        "def test_parser_handles_smuggle_from_parentheses_multiline_1():\n",
-        "    line = \"\"\"from foo smuggle (bar, \n",
-        "                                baz as spam, \n",
-        "                                qux)\"\"\"\n",
-        "    expected = _expected_parser_output('foo.bar', as_='bar')\n",
-        "    expected += f\"; {_expected_parser_output('foo.baz', as_='spam')}\"\n",
-        "    expected += f\"; {_expected_parser_output('foo.qux', as_='qux')}\"\n",
-        "    assert _matches_expected_output(expected, _parse_line(line))"
-      ],
-      "execution_count": 54,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "f4RxjFx_vNyM"
-      },
-      "source": [
-        "@test\n",
-        "def test_parser_handles_smuggle_from_parentheses_multiline_1_onion_1():\n",
-        "    line = \"\"\"from foo smuggle (bar,    # pip: foo==0.0.1\n",
-        "                                baz as spam, \n",
-        "                                qux)\"\"\"\n",
-        "    expected = _expected_parser_output('foo.bar', as_='bar', args_str='foo==0.0.1')\n",
-        "    expected += f\"; {_expected_parser_output('foo.baz', as_='spam')}\"\n",
-        "    expected += f\"; {_expected_parser_output('foo.qux', as_='qux')}\"\n",
-        "    assert _matches_expected_output(expected, _parse_line(line))"
-      ],
-      "execution_count": 55,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "wYlMwMwPvN7r"
-      },
-      "source": [
-        "@test\n",
-        "def test_parser_handles_smuggle_from_parentheses_multiline_1_onion_2():\n",
-        "    line = \"\"\"from foo smuggle (bar, \n",
-        "                                baz as spam, \n",
-        "                                qux)    # pip: foo==0.0.1\"\"\"\n",
-        "    expected = _expected_parser_output('foo.bar', as_='bar', args_str='foo==0.0.1')\n",
-        "    expected += f\"; {_expected_parser_output('foo.baz', as_='spam')}\"\n",
-        "    expected += f\"; {_expected_parser_output('foo.qux', as_='qux')}\"\n",
-        "    assert _matches_expected_output(expected, _parse_line(line))"
-      ],
-      "execution_count": 56,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "pmrunSGUu6LA"
-      },
-      "source": [
-        "@test\n",
-        "def test_parser_handles_smuggle_from_parentheses_multiline_2():\n",
-        "    line = \"\"\"from foo smuggle (\n",
-        "                  bar, \n",
-        "                  baz as spam, \n",
-        "                  qux\n",
-        "              )\"\"\"\n",
-        "    expected = _expected_parser_output('foo.bar', as_='bar')\n",
-        "    expected += f\"; {_expected_parser_output('foo.baz', as_='spam')}\"\n",
-        "    expected += f\"; {_expected_parser_output('foo.qux', as_='qux')}\"\n",
-        "    assert _matches_expected_output(expected, _parse_line(line))"
-      ],
-      "execution_count": 57,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "dHRTaLVevfBc"
-      },
-      "source": [
-        "@test\n",
-        "def test_parser_handles_smuggle_from_parentheses_multiline_2_onion_1():\n",
-        "    line = \"\"\"from foo smuggle (    # pip: foo==0.0.1\n",
-        "                  bar, \n",
-        "                  baz as spam, \n",
-        "                  qux\n",
-        "              )\"\"\"\n",
-        "    expected = _expected_parser_output('foo.bar', as_='bar', args_str='foo==0.0.1')\n",
-        "    expected += f\"; {_expected_parser_output('foo.baz', as_='spam')}\"\n",
-        "    expected += f\"; {_expected_parser_output('foo.qux', as_='qux')}\"\n",
-        "    assert _matches_expected_output(expected, _parse_line(line))"
-      ],
-      "execution_count": 58,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "bwg_NQNrvfET"
-      },
-      "source": [
-        "@test\n",
-        "def test_parser_handles_smuggle_from_parentheses_multiline_2_onion_2():\n",
-        "    line = \"\"\"from foo smuggle (\n",
-        "                  bar, \n",
-        "                  baz as spam, \n",
-        "                  qux\n",
-        "              )    # pip: foo==0.0.1\"\"\"\n",
-        "    expected = _expected_parser_output('foo.bar', as_='bar', args_str='foo==0.0.1')\n",
-        "    expected += f\"; {_expected_parser_output('foo.baz', as_='spam')}\"\n",
-        "    expected += f\"; {_expected_parser_output('foo.qux', as_='qux')}\"\n",
-        "    assert _matches_expected_output(expected, _parse_line(line))"
-      ],
-      "execution_count": 59,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "rYyjDKl6u6ND"
-      },
-      "source": [
-        "@test\n",
-        "def test_parser_handles_smuggle_from_parentheses_multiline_2_onion_2_comments():\n",
-        "    line = \"\"\"from foo smuggle (    # unrelated comment on first line\n",
-        "                  bar,    # unrelated comment on package name line\n",
-        "                  baz as spam, \n",
-        "                  # unrelated comment on its own line\n",
-        "                  qux\n",
-        "              )    # pip: foo==0.0.1    # unrelated comment after onion\"\"\"\n",
-        "    expected = _expected_parser_output('foo.bar', as_='bar', args_str='foo==0.0.1')\n",
-        "    expected += f\"; {_expected_parser_output('foo.baz', as_='spam')}\"\n",
-        "    expected += f\"; {_expected_parser_output('foo.qux', as_='qux')}\"\n",
-        "    expected += \"    # unrelated comment after onion\"\n",
-        "    assert _matches_expected_output(expected, _parse_line(line))"
-      ],
-      "execution_count": 60,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "d7Jm83r9DJol"
-      },
-      "source": [
-        "@test\n",
-        "def test_parser_handles_smuggle_semicolons():\n",
-        "    \"\"\"also combines multiple elements from prior tests\"\"\"\n",
-        "    line = \"\"\"smuggle foo; smuggle bar as baz; \\\n",
-        "              from spam smuggle eggs; \\\n",
-        "              from qux smuggle quux as corge    # pip: qux-package==0.0.1\"\"\"\n",
-        "    expected = _expected_parser_output('foo')\n",
-        "    expected += f\"; {_expected_parser_output('bar', as_='baz')}\"\n",
-        "    expected += f\"; {_expected_parser_output('spam.eggs', as_='eggs')}\"\n",
-        "    expected += f\"; {_expected_parser_output('qux.quux', as_='corge', args_str='qux-package==0.0.1')}\"\n",
-        "    assert _matches_expected_output(expected, _parse_line(line))"
-      ],
-      "execution_count": 61,
-      "outputs": []
-    },
-    {
-      "cell_type": "markdown",
-      "metadata": {
-        "id": "zirSJGjJHV-V"
-      },
-      "source": [
-        "### onion comment parser tests"
-      ]
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "0XhK1r5ou6PI"
-      },
-      "source": [
-        "@test\n",
-        "def test_onion_parser_simple():\n",
-        "    onion = '# pip: foo==0.0.1'\n",
-        "    expected = _expected_onion_parser_output('foo==0.0.1')\n",
-        "    assert _parse_onion(onion) == expected"
-      ],
-      "execution_count": 62,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "HVZceFWkRZA7"
-      },
-      "source": [
-        "@test\n",
-        "def test_onion_parser_whitespace():\n",
-        "    onion = '#             pip:            foo==0.0.1'\n",
-        "    expected = _expected_onion_parser_output('foo==0.0.1')\n",
-        "    assert _parse_onion(onion) == expected"
-      ],
-      "execution_count": 63,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "d0IOshgRQvnk"
-      },
-      "source": [
-        "@test\n",
-        "def test_onion_parser_github():\n",
-        "    onion = '# pip: git+https://github.com/foo/bar.git@branch-name#egg=foo&subdirectory=baz'\n",
-        "    expected = _expected_onion_parser_output('git+https://github.com/foo/bar.git@branch-name#egg=foo&subdirectory=baz')\n",
-        "    assert _parse_onion(onion) == expected"
-      ],
-      "execution_count": 64,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "M4gBOhCGu6Q1"
-      },
-      "source": [
-        "@test\n",
-        "def test_onion_parser_editable():\n",
-        "    onion = '# pip: --editable git+https://github.com/foo/bar.git'\n",
-        "    expected = _expected_onion_parser_output('--editable git+https://github.com/foo/bar.git', \n",
-        "                                             editable=True, \n",
-        "                                             spec='git+https://github.com/foo/bar.git')\n",
-        "    assert _parse_onion(onion) == expected"
-      ],
-      "execution_count": 65,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "J8wQPpzzwXdc"
-      },
-      "source": [
-        "@test\n",
-        "def test_onion_parser_joined_short_args():\n",
-        "    onion = '# pip: -Ive git+https://github.com/foo/bar.git'\n",
-        "    expected = _expected_onion_parser_output('-Ive git+https://github.com/foo/bar.git', \n",
-        "                                             editable=True, \n",
-        "                                             ignore_installed=True, \n",
-        "                                             verbosity=1,\n",
-        "                                             spec='git+https://github.com/foo/bar.git')\n",
-        "    assert _parse_onion(onion) == expected"
-      ],
-      "execution_count": 66,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "BM6uJdjyw7Ne"
-      },
-      "source": [
-        "@test\n",
-        "def test_onion_parser_fails_mutually_exclusive_args():\n",
-        "    onion = '# pip: --use-pep517 --no-use-pep517 foo'\n",
-        "    try:\n",
-        "        _parse_onion(onion)    # SHOULD FAIL\n",
-        "    except davos.exceptions.OnionArgumentError as e:\n",
-        "        # has both 'msg' and 'message' attrs because it inherits from both \n",
-        "        # SyntaxError and argparse.ArgumentError\n",
-        "        assert e.msg == e.message == \"not allowed with argument --use-pep517\"\n",
-        "        assert e.argument_name == \"--no-use-pep517\"\n",
-        "        assert str(e) == \"argument --no-use-pep517: not allowed with argument --use-pep517\"\n",
-        "    else:\n",
-        "        raise DavosAssertionError(\"test should've raised 'davos.exceptions.OnionArgumentError'\")"
-      ],
-      "execution_count": 67,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "AUC0oBgORDgT"
-      },
-      "source": [
-        "@test\n",
-        "def test_onion_parser_fails_editable_after_spec():\n",
-        "    onion = '# pip: git+https://github.com/foo/bar.git#egg=foo --editable'\n",
-        "    try:\n",
-        "        _parse_onion(onion)    # SHOULD FAIL\n",
-        "    except davos.exceptions.OnionArgumentError as e:\n",
-        "        # has both 'msg' and 'message' attrs because it inherits from both \n",
-        "        # SyntaxError and argparse.ArgumentError\n",
-        "        assert e.msg == e.message == \"expected one argument\"\n",
-        "        assert e.argument_name == \"-e/--editable\"\n",
-        "        assert str(e) == \"argument -e/--editable: expected one argument\"\n",
-        "    else:\n",
-        "        raise DavosAssertionError(\"test should've raised 'davos.exceptions.OnionArgumentError'\")"
-      ],
-      "execution_count": 68,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "CBWpL3xPqy62"
-      },
-      "source": [
-        "@test\n",
-        "def test_onion_parser_fails_arg_requires_value():\n",
-        "    onion = '# pip: foo==0.0.1 --platform '\n",
-        "    try:\n",
-        "        _parse_onion(onion)    # SHOULD FAIL\n",
-        "    except davos.exceptions.OnionArgumentError as e:\n",
-        "        # has both 'msg' and 'message' attrs because it inherits from both \n",
-        "        # SyntaxError and argparse.ArgumentError\n",
-        "        assert e.msg == e.message == \"expected one argument\"\n",
-        "        assert e.argument_name == \"--platform\"\n",
-        "        assert str(e) == \"argument --platform: expected one argument\"\n",
-        "    else:\n",
-        "        raise DavosAssertionError(\"test should've raised 'davos.exceptions.OnionArgumentError'\")"
-      ],
-      "execution_count": 69,
-      "outputs": []
-    },
-    {
-      "cell_type": "markdown",
-      "metadata": {
-        "id": "mobuRXLIzSYI"
-      },
-      "source": [
-        "### miscellaneous tests"
-      ]
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "Ik7dWA_eKxZ1"
-      },
-      "source": [
-        "@test\n",
-        "def test_does_not_register_multiple_transformers():\n",
-        "    splitter_xforms = IPYTHON_SHELL.input_splitter.python_line_transforms\n",
-        "    manager_xforms = IPYTHON_SHELL.input_transformer_manager.python_line_transforms\n",
-        "    initial_n_splitter_xforms = len(splitter_xforms)\n",
-        "    initial_n_manager_xforms = len(manager_xforms)\n",
-        "    # record whether parser is active before running test to restore \n",
-        "    # initial state afterward\n",
-        "    start_active = davos.is_active()\n",
-        "    if not start_active:\n",
-        "        # ensure these are the number of registered transforms \n",
-        "        # *including the davos parser*\n",
-        "        initial_n_splitter_xforms += 1\n",
-        "        initial_n_manager_xforms += 1\n",
-        "\n",
-        "    try:\n",
-        "        for _ in range(5):\n",
-        "            davos.activate()\n",
-        "            assert len(splitter_xforms) == initial_n_splitter_xforms\n",
-        "            assert len(manager_xforms) == initial_n_manager_xforms\n",
-        "    finally:\n",
-        "        if not start_active:\n",
-        "            davos.deactivate()"
-      ],
-      "execution_count": 70,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "5MaNzcxkKIxW"
-      },
-      "source": [
-        "@test\n",
-        "def test_deactivate_reactivate():\n",
-        "    start_active = davos.is_active()\n",
-        "    try:\n",
-        "        # start with the parser active\n",
-        "        if not start_active:\n",
-        "            davos.activate()\n",
-        "        \n",
-        "        assert davos.is_active()\n",
-        "        davos.deactivate()\n",
-        "        assert not davos.is_active()\n",
-        "        davos.activate()\n",
-        "        assert davos.is_active()\n",
-        "    finally:\n",
-        "        # return to state before function\n",
-        "        if not start_active:\n",
-        "            davos.deactivate()"
-      ],
-      "execution_count": 71,
-      "outputs": []
-    },
-    {
-      "cell_type": "markdown",
-      "metadata": {
-        "id": "f6KNObdccm0Z"
-      },
-      "source": [
-        "## Integration tests"
-      ]
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "dAFOjVHdclYr"
-      },
-      "source": [
-        "@test(timeout=30)\n",
-        "def test_smuggle_pypi_new():\n",
-        "    assert not _is_installed('ppca')\n",
-        "    smuggle ppca    # pip: ppca>=0.0.4\n",
-        "    assert isinstance(ppca, types.ModuleType)\n",
-        "    assert hasattr(ppca, 'PPCA')"
-      ],
-      "execution_count": 72,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "xSmyvDGGez-g"
-      },
-      "source": [
-        "@test(timeout=30)\n",
-        "def test_smuggle_from_pypi_new():\n",
-        "    assert not _is_installed('umap-learn')\n",
-        "    from umap smuggle UMAP    # pip: umap-learn==0.4.6\n",
-        "    assert hasattr(UMAP, 'fit')\n",
-        "    assert _is_installed('umap-learn==0.4.6')"
-      ],
-      "execution_count": 73,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "VZcVIiqw2FNH"
-      },
-      "source": [
-        "@test(timeout=30)\n",
-        "def test_smuggle_previously_installed():\n",
-        "    assert _is_installed('fastdtw==0.3.4')\n",
-        "    smuggle fastdtw    # pip: fastdtw==0.3.2\n",
-        "    assert _is_installed('fastdtw==0.3.2')"
-      ],
-      "execution_count": 74,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "3i_NEWou3GJd"
-      },
-      "source": [
-        "@test(timeout=30)\n",
-        "def test_smuggle_previously_imported():\n",
-        "    import tqdm\n",
-        "    assert tqdm.__version__ == '4.41.1'\n",
-        "    smuggle tqdm    # pip: tqdm==4.45.0\n",
-        "    assert tqdm.__version__ == '4.45.0'"
-      ],
-      "execution_count": 75,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "CAwx5l_V3hfu"
-      },
-      "source": [
-        "@test(timeout=30)\n",
-        "def test_smuggle_github_ref():\n",
-        "    assert not _is_installed('hypertools')\n",
-        "    smuggle hypertools as hyp    # pip: git+https://github.com/ContextLab/hypertools.git@36c12fd#egg=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')"
-      ],
-      "execution_count": 76,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "WjesOMYTzhap"
-      },
-      "source": [
-        "@test(timeout=30)\n",
-        "def test_smuggle_github_editable():\n",
-        "    assert not _is_installed('quail')\n",
-        "    smuggle quail    # pip: -e git+https://github.com/ContextLab/quail.git@v0.2.0#egg=quail --src /content/gh_clones\n",
-        "    assert '/content/gh_clones/quail' in sys.path\n",
-        "    assert isinstance(quail, types.ModuleType)\n",
-        "    assert hasattr(quail, 'Egg')\n",
-        "    assert _is_installed('quail==0.2.0')\n",
-        "    assert Path('/content/gh_clones/quail').is_dir()"
-      ],
-      "execution_count": 77,
-      "outputs": []
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "id": "4fzZ0fFL9bmF"
-      },
-      "source": [
-        "@test(timeout=300)\n",
-        "def test_smuggle_github_subdirectory():\n",
-        "    assert not _is_installed('sherlock_helpers')\n",
-        "    smuggle sherlock_helpers    # pip: git+https://github.com/ContextLab/sherlock-topic-model-paper.git@v1.0#subdirectory=code/sherlock_helpers\n",
-        "    assert isinstance(sherlock_helpers, types.ModuleType)\n",
-        "    assert hasattr(sherlock_helpers, 'github_url')\n",
-        "    assert sherlock_helpers.__version__ == '0.0.1'\n",
-        "    assert _is_installed('sherlock_helpers==0.0.1')"
-      ],
-      "execution_count": 78,
-      "outputs": []
-    },
-    {
-      "cell_type": "markdown",
-      "metadata": {
-        "id": "SgQcCroC4gqI"
-      },
-      "source": [
-        "# **Run main tests**"
-      ]
-    },
-    {
-      "cell_type": "code",
-      "metadata": {
-        "colab": {
-          "base_uri": "https://localhost:8080/",
-          "height": 1000
-        },
-        "id": "HW5I9hyX3Nv9",
-        "outputId": "fa075155-f720-4e59-d577-d04a937dd453"
-      },
-      "source": [
-        "run_tests()"
-      ],
-      "execution_count": 79,
-      "outputs": [
-        {
-          "output_type": "stream",
-          "text": [
-            "1/50  test_run_shell_command_simple:                                                 passed\n",
-            "2/50  test_run_shell_command_multiword:                                              passed\n",
-            "3/50  test_run_shell_command_failure:                                                passed\n",
-            "4/50  test_parser_ignores_line_no_smuggle:                                           passed\n",
-            "5/50  test_parser_ignores_line_decoy_smuggle:                                        passed\n",
-            "6/50  test_parser_ignores_commented_line:                                            passed\n",
-            "7/50  test_parser_handles_basic_line:                                                passed\n",
-            "8/50  test_parser_handles_basic_line_alias:                                          passed\n",
-            "9/50  test_parser_handles_basic_line_onion:                                          passed\n",
-            "10/50 test_parser_ignores_non_onion_comment:                                         passed\n",
-            "11/50 test_parser_ignores_imposter_onion_comment:                                    passed\n",
-            "12/50 test_parser_handles_smuggle_qualname:                                          passed\n",
-            "13/50 test_parser_handles_indented_line:                                             passed\n",
-            "14/50 test_parser_handles_different_install_name:                                    passed\n",
-            "15/50 test_parser_handles_multiple_packages:                                         passed\n",
-            "16/50 test_parser_handles_multiple_packages_onion:                                   passed\n",
-            "17/50 test_parser_handles_backslash:                                                 passed\n",
-            "18/50 test_parser_handles_backslash_onion:                                           passed\n",
-            "19/50 test_parser_handles_inconsistent_whitespace:                                   passed\n",
-            "20/50 test_parser_handles_smuggle_from:                                              passed\n",
-            "21/50 test_parser_handles_smuggle_from_onion:                                        passed\n",
-            "22/50 test_parser_handles_smuggle_from_multi:                                        passed\n",
-            "23/50 test_parser_handles_smuggle_from_multi_onion:                                  passed\n",
-            "24/50 test_parser_handles_smuggle_from_backslash:                                    passed\n",
-            "25/50 test_parser_handles_smuggle_from_parentheses:                                  passed\n",
-            "26/50 test_parser_handles_smuggle_from_parentheses_multiline_1:                      passed\n",
-            "27/50 test_parser_handles_smuggle_from_parentheses_multiline_1_onion_1:              passed\n",
-            "28/50 test_parser_handles_smuggle_from_parentheses_multiline_1_onion_2:              passed\n",
-            "29/50 test_parser_handles_smuggle_from_parentheses_multiline_2:                      passed\n",
-            "30/50 test_parser_handles_smuggle_from_parentheses_multiline_2_onion_1:              passed\n",
-            "31/50 test_parser_handles_smuggle_from_parentheses_multiline_2_onion_2:              passed\n",
-            "32/50 test_parser_handles_smuggle_from_parentheses_multiline_2_onion_2_comments:     passed\n",
-            "33/50 test_parser_handles_smuggle_semicolons:                                        passed\n",
-            "34/50 test_onion_parser_simple:                                                      passed\n",
-            "35/50 test_onion_parser_whitespace:                                                  passed\n",
-            "36/50 test_onion_parser_github:                                                      passed\n",
-            "37/50 test_onion_parser_editable:                                                    passed\n",
-            "38/50 test_onion_parser_joined_short_args:                                           passed\n",
-            "39/50 test_onion_parser_fails_mutually_exclusive_args:                               passed\n",
-            "40/50 test_onion_parser_fails_editable_after_spec:                                   passed\n",
-            "41/50 test_onion_parser_fails_arg_requires_value:                                    passed\n",
-            "42/50 test_does_not_register_multiple_transformers:                                  passed\n",
-            "43/50 test_deactivate_reactivate:                                                    passed\n",
-            "44/50 test_smuggle_pypi_new:                                                         passed\n",
-            "45/50 test_smuggle_from_pypi_new:                                                    passed\n",
-            "46/50 test_smuggle_previously_installed:                                             passed\n",
-            "47/50 test_smuggle_previously_imported:                                              passed\n",
-            "48/50 test_smuggle_github_ref:                                                       passed\n",
-            "49/50 test_smuggle_github_editable:                                                  passed\n",
-            "50/50 test_smuggle_github_subdirectory:                                              "
-          ],
-          "name": "stdout"
-        },
-        {
-          "output_type": "display_data",
-          "data": {
-            "text/markdown": "Helper functions and variables used across multiple notebooks can be found in `/usr/local/lib/python3.7/dist-packages/sherlock_helpers`, or on GitHub, [here](https://github.com/ContextLab/sherlock-topic-model-paper/tree/master/code/sherlock_helpers).<br />You can also view source code directly from the notebook with:<br /><pre>    from sherlock_helpers.functions import show_source<br />    show_source(foo)</pre>",
-            "text/plain": [
-              "<IPython.core.display.Markdown object>"
-            ]
-          },
-          "metadata": {
-            "tags": []
-          }
-        },
-        {
-          "output_type": "stream",
-          "text": [
-            "passed\n",
-            "\n",
-            "\n",
-            "\n",
-            "All tests passed!\n"
-          ],
-          "name": "stdout"
-        }
-      ]
-    }
-  ]
-}
\ No newline at end of file
diff --git a/tests/test_regexps.ipynb b/tests/test_regexps.ipynb
new file mode 100644
index 00000000..f84866d2
--- /dev/null
+++ b/tests/test_regexps.ipynb
@@ -0,0 +1,1632 @@
+{
+ "cells": [
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T22:54:18.091773Z",
+     "start_time": "2021-07-21T22:54:18.079372Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "GITHUB_USERNAME = \"$GITHUB_USERNAME$\"\n",
+    "GITHUB_REF = \"$GITHUB_REF$\"\n",
+    "NOTEBOOK_TYPE = \"$NOTEBOOK_TYPE$\"\n",
+    "PYTHON_VERSION = \"$PYTHON_VERSION$\"\n",
+    "IPYTHON_VERSION = \"$IPYTHON_VERSION$\""
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T22:54:18.225003Z",
+     "start_time": "2021-07-21T22:54:18.094255Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "import warnings\n",
+    "from pathlib import Path\n",
+    "\n",
+    "import requests\n",
+    "\n",
+    "\n",
+    "warnings.filterwarnings('error', module='davos')\n",
+    "\n",
+    "if NOTEBOOK_TYPE == 'colab':\n",
+    "    # utils module doesn't exist on colab VM, so get current version from GitHub\n",
+    "    utils_module = Path('utils.py').resolve()\n",
+    "    response = requests.get(f'https://raw.githubusercontent.com/{GITHUB_USERNAME}/davos/{GITHUB_REF}/tests/utils.py')\n",
+    "    utils_module.write_text(response.text)\n",
+    "    # also need to install davos locally\n",
+    "    from utils import install_davos\n",
+    "    install_davos(source='github', ref=GITHUB_REF, fork=GITHUB_USERNAME)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T22:54:18.550822Z",
+     "start_time": "2021-07-21T22:54:18.228854Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "from pprint import pformat\n",
+    "from textwrap import dedent\n",
+    "\n",
+    "import davos\n",
+    "from davos.core.regexps import pip_installed_pkgs_regex, smuggle_statement_regex\n",
+    "\n",
+    "from utils import run_tests"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T22:54:18.556147Z",
+     "start_time": "2021-07-21T22:54:18.082Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "IPYTHON_SHELL = get_ipython()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# tests for `davos.core.regexps`"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T22:54:18.557817Z",
+     "start_time": "2021-07-21T22:54:18.085Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_pip_installed_regex_single():\n",
+    "    \"\"\"\n",
+    "    should match a single package name when no dependencies needed to be \n",
+    "    installed. stdout used for test comes from running\n",
+    "    '!pip install git+https://github.com/ContextLab/davos.git' in a \n",
+    "    Colab notebook.\n",
+    "    \"\"\"\n",
+    "    stdout = dedent(\"\"\"\\\n",
+    "    Collecting git+https://github.com/ContextLab/davos.git\n",
+    "      Cloning https://github.com/ContextLab/davos.git to /tmp/pip-req-build-4272zzqa\n",
+    "      Running command git clone -q https://github.com/ContextLab/davos.git /tmp/pip-req-build-4272zzqa\n",
+    "      Installing build dependencies ... done\n",
+    "      Getting requirements to build wheel ... done\n",
+    "        Preparing wheel metadata ... done\n",
+    "    Building wheels for collected packages: davos\n",
+    "      Building wheel for davos (PEP 517) ... done\n",
+    "      Created wheel for davos: filename=davos-0.0.1-cp37-none-any.whl size=37733 sha256=d02890824aa9e9ae1b0c7e801113c4120a43f4e9ba7220db3227d46411c7b45c\n",
+    "      Stored in directory: /tmp/pip-ephem-wheel-cache-y28fng42/wheels/62/30/bc/79958ce75e105bdcf95c737091e62372429850960d6628e277\n",
+    "    Successfully built davos\n",
+    "    Installing collected packages: davos\n",
+    "    Successfully installed davos-0.0.1\"\"\")\n",
+    "    expected = ['davos-0.0.1']\n",
+    "    result = pip_installed_pkgs_regex.findall(stdout)\n",
+    "    assert result == expected, (\n",
+    "        f\"Expected: {expected}\\nFound: {result}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T22:54:18.559943Z",
+     "start_time": "2021-07-21T22:54:18.086Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_pip_installed_regex_multi():\n",
+    "    \"\"\"\n",
+    "    should match names of installed package and all just-installed \n",
+    "    dependencies when when any dependencies needed to be installed. \n",
+    "    stdout used for test comes from running '!pip install hypertools' in \n",
+    "    a Colab notebook.\n",
+    "    \"\"\"\n",
+    "    stdout = dedent(\"\"\"\\\n",
+    "    Collecting hypertools\n",
+    "      Downloading https://files.pythonhosted.org/packages/09/8d/54736bd23e346fcf33fa15d63c2403c05a191a0c1047ed01a24b68794fc6/hypertools-0.7.0-py3-none-any.whl (59kB)\n",
+    "         |████████████████████████████████| 61kB 2.5MB/s \n",
+    "    Requirement already satisfied: matplotlib>=1.5.1 in /usr/local/lib/python3.7/dist-packages (from hypertools) (3.2.2)\n",
+    "    Requirement already satisfied: future in /usr/local/lib/python3.7/dist-packages (from hypertools) (0.16.0)\n",
+    "    Collecting deepdish\n",
+    "      Downloading https://files.pythonhosted.org/packages/6e/39/2a47c852651982bc5eb39212ac110284dd20126bdc7b49bde401a0139f5d/deepdish-0.3.6-py2.py3-none-any.whl\n",
+    "    Requirement already satisfied: numpy>=1.10.4 in /usr/local/lib/python3.7/dist-packages (from hypertools) (1.19.5)\n",
+    "    Requirement already satisfied: scikit-learn!=0.22,<0.24,>=0.19.1 in /usr/local/lib/python3.7/dist-packages (from hypertools) (0.22.2.post1)\n",
+    "    Requirement already satisfied: requests in /usr/local/lib/python3.7/dist-packages (from hypertools) (2.23.0)\n",
+    "    Requirement already satisfied: six in /usr/local/lib/python3.7/dist-packages (from hypertools) (1.15.0)\n",
+    "    Requirement already satisfied: seaborn>=0.8.1 in /usr/local/lib/python3.7/dist-packages (from hypertools) (0.11.1)\n",
+    "    Requirement already satisfied: scipy>=1.0.0 in /usr/local/lib/python3.7/dist-packages (from hypertools) (1.4.1)\n",
+    "    Collecting PPCA>=0.0.2\n",
+    "      Downloading https://files.pythonhosted.org/packages/16/7f/7195bf3742e19076a21a9c3250a4f11c87153bb4ea3dcaf1077678383b76/ppca-0.0.4-py3-none-any.whl\n",
+    "    Requirement already satisfied: pandas>=0.18.0 in /usr/local/lib/python3.7/dist-packages (from hypertools) (1.1.5)\n",
+    "    Collecting umap-learn>=0.4.6\n",
+    "      Downloading https://files.pythonhosted.org/packages/75/69/85e7f950bb75792ad5d666d86c5f3e62eedbb942848e7e3126513af9999c/umap-learn-0.5.1.tar.gz (80kB)\n",
+    "         |████████████████████████████████| 81kB 4.9MB/s \n",
+    "    Requirement already satisfied: cycler>=0.10 in /usr/local/lib/python3.7/dist-packages (from matplotlib>=1.5.1->hypertools) (0.10.0)\n",
+    "    Requirement already satisfied: python-dateutil>=2.1 in /usr/local/lib/python3.7/dist-packages (from matplotlib>=1.5.1->hypertools) (2.8.1)\n",
+    "    Requirement already satisfied: pyparsing!=2.0.4,!=2.1.2,!=2.1.6,>=2.0.1 in /usr/local/lib/python3.7/dist-packages (from matplotlib>=1.5.1->hypertools) (2.4.7)\n",
+    "    Requirement already satisfied: kiwisolver>=1.0.1 in /usr/local/lib/python3.7/dist-packages (from matplotlib>=1.5.1->hypertools) (1.3.1)\n",
+    "    Requirement already satisfied: tables in /usr/local/lib/python3.7/dist-packages (from deepdish->hypertools) (3.4.4)\n",
+    "    Requirement already satisfied: joblib>=0.11 in /usr/local/lib/python3.7/dist-packages (from scikit-learn!=0.22,<0.24,>=0.19.1->hypertools) (1.0.1)\n",
+    "    Requirement already satisfied: chardet<4,>=3.0.2 in /usr/local/lib/python3.7/dist-packages (from requests->hypertools) (3.0.4)\n",
+    "    Requirement already satisfied: certifi>=2017.4.17 in /usr/local/lib/python3.7/dist-packages (from requests->hypertools) (2021.5.30)\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->hypertools) (1.24.3)\n",
+    "    Requirement already satisfied: idna<3,>=2.5 in /usr/local/lib/python3.7/dist-packages (from requests->hypertools) (2.10)\n",
+    "    Requirement already satisfied: pytz>=2017.2 in /usr/local/lib/python3.7/dist-packages (from pandas>=0.18.0->hypertools) (2018.9)\n",
+    "    Requirement already satisfied: numba>=0.49 in /usr/local/lib/python3.7/dist-packages (from umap-learn>=0.4.6->hypertools) (0.51.2)\n",
+    "    Collecting pynndescent>=0.5\n",
+    "      Downloading https://files.pythonhosted.org/packages/b1/8d/44bf1c9e69dd9bf0697a3b9375b0729942525c0eee7b7859f563439d676a/pynndescent-0.5.4.tar.gz (1.1MB)\n",
+    "         |████████████████████████████████| 1.1MB 10.1MB/s \n",
+    "    Requirement already satisfied: numexpr>=2.5.2 in /usr/local/lib/python3.7/dist-packages (from tables->deepdish->hypertools) (2.7.3)\n",
+    "    Requirement already satisfied: llvmlite<0.35,>=0.34.0.dev0 in /usr/local/lib/python3.7/dist-packages (from numba>=0.49->umap-learn>=0.4.6->hypertools) (0.34.0)\n",
+    "    Requirement already satisfied: setuptools in /usr/local/lib/python3.7/dist-packages (from numba>=0.49->umap-learn>=0.4.6->hypertools) (57.0.0)\n",
+    "    Building wheels for collected packages: umap-learn, pynndescent\n",
+    "      Building wheel for umap-learn (setup.py) ... done\n",
+    "      Created wheel for umap-learn: filename=umap_learn-0.5.1-cp37-none-any.whl size=76569 sha256=9724e07d61b5a5b9f10255907b8612fd2a445bbda2812801a47378be1026ae83\n",
+    "      Stored in directory: /root/.cache/pip/wheels/ad/df/d5/a3691296ff779f25cd1cf415a3af954b987fb53111e3392cf4\n",
+    "      Building wheel for pynndescent (setup.py) ... done\n",
+    "      Created wheel for pynndescent: filename=pynndescent-0.5.4-cp37-none-any.whl size=52374 sha256=5b61db3df31eb793b253bd1a7fb0330841623781450a9a95ceb0ae682331206f\n",
+    "      Stored in directory: /root/.cache/pip/wheels/42/4b/8c/f6f119c67cf6583bb192431fa8f7278cf95e5b943055077d94\n",
+    "    Successfully built umap-learn pynndescent\n",
+    "    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_regex.findall(stdout)\n",
+    "    assert result == expected, (\n",
+    "        f\"Expected: {expected}\\nFound: {result}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T22:54:18.561983Z",
+     "start_time": "2021-07-21T22:54:18.088Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_pip_installed_regex_none():\n",
+    "    \"\"\"\n",
+    "    shouldn't match any package names when the package was already \n",
+    "    installed. stdout used for test comes from running \n",
+    "    '!pip install requests' in a Colab notebook.\n",
+    "    \"\"\"\n",
+    "    stdout = dedent(\"\"\"\\\n",
+    "    Requirement already satisfied: requests in /usr/local/lib/python3.7/dist-packages (2.23.0)\n",
+    "    Requirement already satisfied: certifi>=2017.4.17 in /usr/local/lib/python3.7/dist-packages (from requests) (2021.5.30)\n",
+    "    Requirement already satisfied: idna<3,>=2.5 in /usr/local/lib/python3.7/dist-packages (from requests) (2.10)\n",
+    "    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_regex.findall(stdout)\n",
+    "    assert result == expected, (\n",
+    "        f\"Expected: {expected}\\nFound: {result}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T22:54:18.563495Z",
+     "start_time": "2021-07-21T22:54:18.090Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_smuggle_regex_ignores_none():\n",
+    "    \"\"\"should not match simple, unrelated line\"\"\"\n",
+    "    line = 'def foo(bar, baz=qux):'\n",
+    "    match = smuggle_statement_regex.match(line)\n",
+    "    assert match is None, f\"matched: '{match.group()}'\""
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T22:54:18.564756Z",
+     "start_time": "2021-07-21T22:54:18.091Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_smuggle_regex_ignores_decoy():\n",
+    "    \"\"\"should not match line containing \"smuggle\" in unrelated context\"\"\"\n",
+    "    line = 'def smuggle_something(foo):'\n",
+    "    match = smuggle_statement_regex.match(line)\n",
+    "    assert match is None, f\"matched: '{match.group()}'\""
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T22:54:18.565717Z",
+     "start_time": "2021-07-21T22:54:18.093Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_smuggle_regex_ignores_comment():\n",
+    "    \"\"\"should not match commented-out smuggle statement\"\"\"\n",
+    "    line = '# smuggle foo as bar'\n",
+    "    match = smuggle_statement_regex.match(line)\n",
+    "    assert match is None, f\"matched: '{match.group()}'\""
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T22:54:18.566815Z",
+     "start_time": "2021-07-21T22:54:18.095Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_smuggle_regex_ignores_non_leading():\n",
+    "    \"\"\"should not match smuggle statements not at beginning of line\"\"\"\n",
+    "    line = 'foo() and bar() and baz() smuggle qux()'\n",
+    "    match = smuggle_statement_regex.match(line)\n",
+    "    assert match is None, f\"matched: '{match.group()}'\""
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T22:54:18.567946Z",
+     "start_time": "2021-07-21T22:54:18.096Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_smuggle_regex_basic():\n",
+    "    \"\"\"simplest use case\"\"\"\n",
+    "    line = 'smuggle foo'\n",
+    "    expected_groupdict = {\n",
+    "        'FULL_CMD': line,\n",
+    "        'SEMICOLON_SEP': None,\n",
+    "        'ONION': None,\n",
+    "        'OPEN_PARENS': None,\n",
+    "        'FROM_ONION_1': None,\n",
+    "        'CLOSE_PARENS_FIRSTLINE': None,\n",
+    "        'FROM_SEMICOLON_SEP': None,\n",
+    "        'FROM_ONION': None\n",
+    "    }\n",
+    "    match = smuggle_statement_regex.match(line)\n",
+    "    assert match is not None, f\"failed to match: '{line}'\"\n",
+    "    \n",
+    "    result_groupdict = match.groupdict()\n",
+    "    assert result_groupdict == expected_groupdict, (\n",
+    "        \"bad values in match.groupdict(). Expected:\\n\"\n",
+    "        f\"{pformat(expected_groupdict)}\\nFound:\\n{pformat(result_groupdict)}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T22:54:18.569132Z",
+     "start_time": "2021-07-21T22:54:18.098Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_smuggle_regex_alias():\n",
+    "    \"\"\"simplest use case, plus alias\"\"\"\n",
+    "    line = 'smuggle foo as bar'\n",
+    "    expected_groupdict = {\n",
+    "        'FULL_CMD': line,\n",
+    "        'SEMICOLON_SEP': None,\n",
+    "        'ONION': None,\n",
+    "        'OPEN_PARENS': None,\n",
+    "        'FROM_ONION_1': None,\n",
+    "        'CLOSE_PARENS_FIRSTLINE': None,\n",
+    "        'FROM_SEMICOLON_SEP': None,\n",
+    "        'FROM_ONION': None\n",
+    "    }\n",
+    "    match = smuggle_statement_regex.match(line)\n",
+    "    assert match is not None, f\"failed to match: '{line}'\"\n",
+    "    \n",
+    "    result_groupdict = match.groupdict()\n",
+    "    assert result_groupdict == expected_groupdict, (\n",
+    "        \"bad values in match.groupdict(). Expected:\\n\"\n",
+    "        f\"{pformat(expected_groupdict)}\\nFound:\\n{pformat(result_groupdict)}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T22:54:18.570405Z",
+     "start_time": "2021-07-21T22:54:18.100Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_smuggle_regex_basic_onion():\n",
+    "    \"\"\"simplest use case, plus onion comment\"\"\"\n",
+    "    line = 'smuggle foo    # pip: foo==0.0.1'\n",
+    "    expected_groupdict = {\n",
+    "        'FULL_CMD': line,\n",
+    "        'SEMICOLON_SEP': None,\n",
+    "        'ONION': '# pip: foo==0.0.1',\n",
+    "        'OPEN_PARENS': None,\n",
+    "        'FROM_ONION_1': None,\n",
+    "        'CLOSE_PARENS_FIRSTLINE': None,\n",
+    "        'FROM_SEMICOLON_SEP': None,\n",
+    "        'FROM_ONION': None\n",
+    "    }\n",
+    "    match = smuggle_statement_regex.match(line)\n",
+    "    assert match is not None, f\"failed to match: '{line}'\"\n",
+    "    \n",
+    "    result_groupdict = match.groupdict()\n",
+    "    assert result_groupdict == expected_groupdict, (\n",
+    "        \"bad values in match.groupdict(). Expected:\\n\"\n",
+    "        f\"{pformat(expected_groupdict)}\\nFound:\\n{pformat(result_groupdict)}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T22:54:18.571994Z",
+     "start_time": "2021-07-21T22:54:18.102Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_smuggle_regex_alias_onion():\n",
+    "    \"\"\"simplest use case, plus alias, plus onion comment\"\"\"\n",
+    "    line = 'smuggle foo as bar    # pip: foo==0.0.1'\n",
+    "    expected_groupdict = {\n",
+    "        'FULL_CMD': line,\n",
+    "        'SEMICOLON_SEP': None,\n",
+    "        'ONION': '# pip: foo==0.0.1',\n",
+    "        'OPEN_PARENS': None,\n",
+    "        'FROM_ONION_1': None,\n",
+    "        'CLOSE_PARENS_FIRSTLINE': None,\n",
+    "        'FROM_SEMICOLON_SEP': None,\n",
+    "        'FROM_ONION': None\n",
+    "    }\n",
+    "    match = smuggle_statement_regex.match(line)\n",
+    "    assert match is not None, f\"failed to match: '{line}'\"\n",
+    "    \n",
+    "    result_groupdict = match.groupdict()\n",
+    "    assert result_groupdict == expected_groupdict, (\n",
+    "        \"bad values in match.groupdict(). Expected:\\n\"\n",
+    "        f\"{pformat(expected_groupdict)}\\nFound:\\n{pformat(result_groupdict)}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T22:54:18.573132Z",
+     "start_time": "2021-07-21T22:54:18.103Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_smuggle_regex_conda_onion():\n",
+    "    \"\"\"same as above, but with conda specified as the installer\"\"\"\n",
+    "    line = 'smuggle foo as bar    # conda: foo==0.0.1'\n",
+    "    expected_groupdict = {\n",
+    "        'FULL_CMD': line,\n",
+    "        'SEMICOLON_SEP': None,\n",
+    "        'ONION': '# conda: foo==0.0.1',\n",
+    "        'OPEN_PARENS': None,\n",
+    "        'FROM_ONION_1': None,\n",
+    "        'CLOSE_PARENS_FIRSTLINE': None,\n",
+    "        'FROM_SEMICOLON_SEP': None,\n",
+    "        'FROM_ONION': None\n",
+    "    }\n",
+    "    match = smuggle_statement_regex.match(line)\n",
+    "    assert match is not None, f\"failed to match: '{line}'\"\n",
+    "    \n",
+    "    result_groupdict = match.groupdict()\n",
+    "    assert result_groupdict == expected_groupdict, (\n",
+    "        \"bad values in match.groupdict(). Expected:\\n\"\n",
+    "        f\"{pformat(expected_groupdict)}\\nFound:\\n{pformat(result_groupdict)}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T22:54:18.574309Z",
+     "start_time": "2021-07-21T22:54:18.105Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_smuggle_regex_alias_ignores_non_onion():\n",
+    "    \"\"\"same as above, but with a *non-onion* inline comment\"\"\"\n",
+    "    line = 'smuggle foo as bar    # load the module that does the thing'\n",
+    "    expected_groupdict = {\n",
+    "        'FULL_CMD': 'smuggle foo as bar',\n",
+    "        'SEMICOLON_SEP': None,\n",
+    "        'ONION': None,\n",
+    "        'OPEN_PARENS': None,\n",
+    "        'FROM_ONION_1': None,\n",
+    "        'CLOSE_PARENS_FIRSTLINE': None,\n",
+    "        'FROM_SEMICOLON_SEP': None,\n",
+    "        'FROM_ONION': None\n",
+    "    }\n",
+    "    match = smuggle_statement_regex.match(line)\n",
+    "    assert match is not None, f\"failed to match: '{line}'\"\n",
+    "    \n",
+    "    result_groupdict = match.groupdict()\n",
+    "    assert result_groupdict == expected_groupdict, (\n",
+    "        \"bad values in match.groupdict(). Expected:\\n\"\n",
+    "        f\"{pformat(expected_groupdict)}\\nFound:\\n{pformat(result_groupdict)}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T22:54:18.575401Z",
+     "start_time": "2021-07-21T22:54:18.106Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_smuggle_regex_alias_ignores_decoy_onion():\n",
+    "    \"\"\"same as above, but with a *just barely non-onion* comment\"\"\"\n",
+    "    line = 'smuggle foo as bar    # pip; \"a [redacted] package manager than conda\"'\n",
+    "    expected_groupdict = {\n",
+    "        'FULL_CMD': 'smuggle foo as bar',\n",
+    "        'SEMICOLON_SEP': None,\n",
+    "        'ONION': None,\n",
+    "        'OPEN_PARENS': None,\n",
+    "        'FROM_ONION_1': None,\n",
+    "        'CLOSE_PARENS_FIRSTLINE': None,\n",
+    "        'FROM_SEMICOLON_SEP': None,\n",
+    "        'FROM_ONION': None\n",
+    "    }\n",
+    "    match = smuggle_statement_regex.match(line)\n",
+    "    assert match is not None, f\"failed to match: '{line}'\"\n",
+    "    \n",
+    "    result_groupdict = match.groupdict()\n",
+    "    assert result_groupdict == expected_groupdict, (\n",
+    "        \"bad values in match.groupdict(). Expected:\\n\"\n",
+    "        f\"{pformat(expected_groupdict)}\\nFound:\\n{pformat(result_groupdict)}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T22:54:18.576515Z",
+     "start_time": "2021-07-21T22:54:18.108Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_smuggle_regex_qualname():\n",
+    "    \"\"\"should match qualified names of packages/modules\"\"\"\n",
+    "    line = 'smuggle foo.bar as baz'\n",
+    "    expected_groupdict = {\n",
+    "        'FULL_CMD': line,\n",
+    "        'SEMICOLON_SEP': None,\n",
+    "        'ONION': None,\n",
+    "        'OPEN_PARENS': None,\n",
+    "        'FROM_ONION_1': None,\n",
+    "        'CLOSE_PARENS_FIRSTLINE': None,\n",
+    "        'FROM_SEMICOLON_SEP': None,\n",
+    "        'FROM_ONION': None\n",
+    "    }\n",
+    "    match = smuggle_statement_regex.match(line)\n",
+    "    assert match is not None, f\"failed to match: '{line}'\"\n",
+    "    \n",
+    "    result_groupdict = match.groupdict()\n",
+    "    assert result_groupdict == expected_groupdict, (\n",
+    "        \"bad values in match.groupdict(). Expected:\\n\"\n",
+    "        f\"{pformat(expected_groupdict)}\\nFound:\\n{pformat(result_groupdict)}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T22:54:18.577627Z",
+     "start_time": "2021-07-21T22:54:18.109Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_smuggle_regex_indented():\n",
+    "    \"\"\"\n",
+    "    should match smuggle statements inside try/except blocks, loops, \n",
+    "    if blocks, function definitions, etc.\n",
+    "    \"\"\"\n",
+    "    line = '    smuggle foo as bar    # pip: foo==0.0.1'\n",
+    "    expected_groupdict = {\n",
+    "        'FULL_CMD': line.strip(),\n",
+    "        'SEMICOLON_SEP': None,\n",
+    "        'ONION': '# pip: foo==0.0.1',\n",
+    "        'OPEN_PARENS': None,\n",
+    "        'FROM_ONION_1': None,\n",
+    "        'CLOSE_PARENS_FIRSTLINE': None,\n",
+    "        'FROM_SEMICOLON_SEP': None,\n",
+    "        'FROM_ONION': None\n",
+    "    }\n",
+    "    match = smuggle_statement_regex.match(line)\n",
+    "    assert match is not None, f\"failed to match: '{line}'\"\n",
+    "    \n",
+    "    result_groupdict = match.groupdict()\n",
+    "    assert result_groupdict == expected_groupdict, (\n",
+    "        \"bad values in match.groupdict(). Expected:\\n\"\n",
+    "        f\"{pformat(expected_groupdict)}\\nFound:\\n{pformat(result_groupdict)}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T22:54:18.578616Z",
+     "start_time": "2021-07-21T22:54:18.111Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_smuggle_regex_multiple_pkgs():\n",
+    "    \"\"\"\n",
+    "    should match line with multiple packages (with and without aliases, \n",
+    "    with and without qualnames) smuggled using a single 'smuggle' \n",
+    "    statement\n",
+    "    \"\"\"\n",
+    "    line = 'smuggle foo as bar, baz as qux, spam.ham, eggs'\n",
+    "    expected_groupdict = {\n",
+    "        'FULL_CMD': line,\n",
+    "        'SEMICOLON_SEP': None,\n",
+    "        'ONION': None,\n",
+    "        'OPEN_PARENS': None,\n",
+    "        'FROM_ONION_1': None,\n",
+    "        'CLOSE_PARENS_FIRSTLINE': None,\n",
+    "        'FROM_SEMICOLON_SEP': None,\n",
+    "        'FROM_ONION': None\n",
+    "    }\n",
+    "    match = smuggle_statement_regex.match(line)\n",
+    "    assert match is not None, f\"failed to match: '{line}'\"\n",
+    "    \n",
+    "    result_groupdict = match.groupdict()\n",
+    "    assert result_groupdict == expected_groupdict, (\n",
+    "        \"bad values in match.groupdict(). Expected:\\n\"\n",
+    "        f\"{pformat(expected_groupdict)}\\nFound:\\n{pformat(result_groupdict)}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T22:54:18.580055Z",
+     "start_time": "2021-07-21T22:54:18.113Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_smuggle_regex_multiple_pkgs_onion():\n",
+    "    \"\"\"same as above, with onion comment\"\"\"\n",
+    "    line = 'smuggle foo as bar, baz as qux, spam.ham, eggs    # pip: foo==0.0.1'\n",
+    "    expected_groupdict = {\n",
+    "        'FULL_CMD': line,\n",
+    "        'SEMICOLON_SEP': None,\n",
+    "        'ONION': '# pip: foo==0.0.1',\n",
+    "        'OPEN_PARENS': None,\n",
+    "        'FROM_ONION_1': None,\n",
+    "        'CLOSE_PARENS_FIRSTLINE': None,\n",
+    "        'FROM_SEMICOLON_SEP': None,\n",
+    "        'FROM_ONION': None\n",
+    "    }\n",
+    "    match = smuggle_statement_regex.match(line)\n",
+    "    assert match is not None, f\"failed to match: '{line}'\"\n",
+    "    \n",
+    "    result_groupdict = match.groupdict()\n",
+    "    assert result_groupdict == expected_groupdict, (\n",
+    "        \"bad values in match.groupdict(). Expected:\\n\"\n",
+    "        f\"{pformat(expected_groupdict)}\\nFound:\\n{pformat(result_groupdict)}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T22:54:18.581743Z",
+     "start_time": "2021-07-21T22:54:18.115Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_smuggle_regex_multiline_explicit():\n",
+    "    \"\"\"\n",
+    "    same as above, but split across lines with explicit \n",
+    "    (backslash-based) line continuations\n",
+    "    \"\"\"\n",
+    "    line = \"\"\"smuggle foo as bar, \\\n",
+    "              baz as qux, \\\n",
+    "              spam.ham, \\\n",
+    "              eggs    # pip: foo==0.0.1\"\"\"\n",
+    "    expected_groupdict = {\n",
+    "        'FULL_CMD': line,\n",
+    "        'SEMICOLON_SEP': None,\n",
+    "        'ONION': '# pip: foo==0.0.1',\n",
+    "        'OPEN_PARENS': None,\n",
+    "        'FROM_ONION_1': None,\n",
+    "        'CLOSE_PARENS_FIRSTLINE': None,\n",
+    "        'FROM_SEMICOLON_SEP': None,\n",
+    "        'FROM_ONION': None\n",
+    "    }\n",
+    "    match = smuggle_statement_regex.match(line)\n",
+    "    assert match is not None, f\"failed to match: '{line}'\"\n",
+    "    \n",
+    "    result_groupdict = match.groupdict()\n",
+    "    assert result_groupdict == expected_groupdict, (\n",
+    "        \"bad values in match.groupdict(). Expected:\\n\"\n",
+    "        f\"{pformat(expected_groupdict)}\\nFound:\\n{pformat(result_groupdict)}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T22:54:18.582735Z",
+     "start_time": "2021-07-21T22:54:18.117Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_smuggle_regex_whitespace_insensitive():\n",
+    "    \"\"\"\n",
+    "    same as above, with inconsistent but syntactically valid whitespace, \n",
+    "    including explicit (backslash-based) line continuations\n",
+    "    \"\"\"\n",
+    "    line = \"\"\"smuggle               foo     as    bar    \\\n",
+    ",baz as   qux  , \\\n",
+    "          spam  .  ham    ,    eggs                #   pip   :       foo==0.0.1    \"\"\"\n",
+    "    expected_groupdict = {\n",
+    "        'FULL_CMD': line.strip(),\n",
+    "        'SEMICOLON_SEP': None,\n",
+    "        'ONION': '#   pip   :       foo==0.0.1',\n",
+    "        'OPEN_PARENS': None,\n",
+    "        'FROM_ONION_1': None,\n",
+    "        'CLOSE_PARENS_FIRSTLINE': None,\n",
+    "        'FROM_SEMICOLON_SEP': None,\n",
+    "        'FROM_ONION': None\n",
+    "    }\n",
+    "    match = smuggle_statement_regex.match(line)\n",
+    "    assert match is not None, f\"failed to match: '{line}'\"\n",
+    "    \n",
+    "    result_groupdict = match.groupdict()\n",
+    "    assert result_groupdict == expected_groupdict, (\n",
+    "        \"bad values in match.groupdict(). Expected:\\n\"\n",
+    "        f\"{pformat(expected_groupdict)}\\nFound:\\n{pformat(result_groupdict)}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T22:54:18.583891Z",
+     "start_time": "2021-07-21T22:54:18.119Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_smuggle_regex_from():\n",
+    "    \"\"\"simplest use case from second syntactic category\"\"\"\n",
+    "    line = 'from foo smuggle bar'\n",
+    "    expected_groupdict = {\n",
+    "        'FULL_CMD': line,\n",
+    "        'SEMICOLON_SEP': None,\n",
+    "        'ONION': None,\n",
+    "        'OPEN_PARENS': None,\n",
+    "        'FROM_ONION_1': None,\n",
+    "        'CLOSE_PARENS_FIRSTLINE': None,\n",
+    "        'FROM_SEMICOLON_SEP': None,\n",
+    "        'FROM_ONION': None\n",
+    "    }\n",
+    "    match = smuggle_statement_regex.match(line)\n",
+    "    assert match is not None, f\"failed to match: '{line}'\"\n",
+    "    \n",
+    "    result_groupdict = match.groupdict()\n",
+    "    assert result_groupdict == expected_groupdict, (\n",
+    "        \"bad values in match.groupdict(). Expected:\\n\"\n",
+    "        f\"{pformat(expected_groupdict)}\\nFound:\\n{pformat(result_groupdict)}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T22:54:18.584977Z",
+     "start_time": "2021-07-21T22:54:18.121Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_smuggle_regex_from_alias():\n",
+    "    \"\"\"same as above, with alias\"\"\"\n",
+    "    line = 'from foo smuggle bar as baz'\n",
+    "    expected_groupdict = {\n",
+    "        'FULL_CMD': line,\n",
+    "        'SEMICOLON_SEP': None,\n",
+    "        'ONION': None,\n",
+    "        'OPEN_PARENS': None,\n",
+    "        'FROM_ONION_1': None,\n",
+    "        'CLOSE_PARENS_FIRSTLINE': None,\n",
+    "        'FROM_SEMICOLON_SEP': None,\n",
+    "        'FROM_ONION': None\n",
+    "    }\n",
+    "    match = smuggle_statement_regex.match(line)\n",
+    "    assert match is not None, f\"failed to match: '{line}'\"\n",
+    "    \n",
+    "    result_groupdict = match.groupdict()\n",
+    "    assert result_groupdict == expected_groupdict, (\n",
+    "        \"bad values in match.groupdict(). Expected:\\n\"\n",
+    "        f\"{pformat(expected_groupdict)}\\nFound:\\n{pformat(result_groupdict)}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T22:54:18.586131Z",
+     "start_time": "2021-07-21T22:54:18.123Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_smuggle_regex_from_onion():\n",
+    "    \"\"\"same as above, with onion comment\"\"\"\n",
+    "    line = 'from foo smuggle bar    # pip: foo==0.0.1'\n",
+    "    expected_groupdict = {\n",
+    "        'FULL_CMD': line,\n",
+    "        'SEMICOLON_SEP': None,\n",
+    "        'ONION': None,\n",
+    "        'OPEN_PARENS': None,\n",
+    "        'FROM_ONION_1': None,\n",
+    "        'CLOSE_PARENS_FIRSTLINE': None,\n",
+    "        'FROM_SEMICOLON_SEP': None,\n",
+    "        'FROM_ONION': '# pip: foo==0.0.1'\n",
+    "    }\n",
+    "    match = smuggle_statement_regex.match(line)\n",
+    "    assert match is not None, f\"failed to match: '{line}'\"\n",
+    "    \n",
+    "    result_groupdict = match.groupdict()\n",
+    "    assert result_groupdict == expected_groupdict, (\n",
+    "        \"bad values in match.groupdict(). Expected:\\n\"\n",
+    "        f\"{pformat(expected_groupdict)}\\nFound:\\n{pformat(result_groupdict)}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T22:54:18.587014Z",
+     "start_time": "2021-07-21T22:54:18.125Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_smuggle_regex_from_alias_onion():\n",
+    "    \"\"\"same as above, with alias, with onion comment\"\"\"\n",
+    "    line = 'from foo smuggle bar as baz    # pip: foo==0.0.1'\n",
+    "    expected_groupdict = {\n",
+    "        'FULL_CMD': line,\n",
+    "        'SEMICOLON_SEP': None,\n",
+    "        'ONION': None,\n",
+    "        'OPEN_PARENS': None,\n",
+    "        'FROM_ONION_1': None,\n",
+    "        'CLOSE_PARENS_FIRSTLINE': None,\n",
+    "        'FROM_SEMICOLON_SEP': None,\n",
+    "        'FROM_ONION': '# pip: foo==0.0.1'\n",
+    "    }\n",
+    "    match = smuggle_statement_regex.match(line)\n",
+    "    assert match is not None, f\"failed to match: '{line}'\"\n",
+    "    \n",
+    "    result_groupdict = match.groupdict()\n",
+    "    assert result_groupdict == expected_groupdict, (\n",
+    "        \"bad values in match.groupdict(). Expected:\\n\"\n",
+    "        f\"{pformat(expected_groupdict)}\\nFound:\\n{pformat(result_groupdict)}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T22:54:18.588371Z",
+     "start_time": "2021-07-21T22:54:18.126Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_smuggle_regex_from_qualname():\n",
+    "    \"\"\"adds qualname to above setup\"\"\"\n",
+    "    line = 'from foo.bar.baz smuggle qux as quux    # pip: foo==0.0.1'\n",
+    "    expected_groupdict = {\n",
+    "        'FULL_CMD': line,\n",
+    "        'SEMICOLON_SEP': None,\n",
+    "        'ONION': None,\n",
+    "        'OPEN_PARENS': None,\n",
+    "        'FROM_ONION_1': None,\n",
+    "        'CLOSE_PARENS_FIRSTLINE': None,\n",
+    "        'FROM_SEMICOLON_SEP': None,\n",
+    "        'FROM_ONION': '# pip: foo==0.0.1'\n",
+    "    }\n",
+    "    match = smuggle_statement_regex.match(line)\n",
+    "    assert match is not None, f\"failed to match: '{line}'\"\n",
+    "    \n",
+    "    result_groupdict = match.groupdict()\n",
+    "    assert result_groupdict == expected_groupdict, (\n",
+    "        \"bad values in match.groupdict(). Expected:\\n\"\n",
+    "        f\"{pformat(expected_groupdict)}\\nFound:\\n{pformat(result_groupdict)}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T22:54:18.589588Z",
+     "start_time": "2021-07-21T22:54:18.127Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_smuggle_regex_from_multiple_names_onion():\n",
+    "    \"\"\"\n",
+    "    should match line with multiple names (with and without aliases, \n",
+    "    with and without qualnames) being smuggled from the same package \n",
+    "    using a single 'smuggle' statement, followed by an onion comment\n",
+    "    \"\"\"\n",
+    "    line = 'from foo.bar smuggle baz, qux as quux, spam, ham as eggs    # pip: foo==0.0.1'\n",
+    "    expected_groupdict = {\n",
+    "        'FULL_CMD': line,\n",
+    "        'SEMICOLON_SEP': None,\n",
+    "        'ONION': None,\n",
+    "        'OPEN_PARENS': None,\n",
+    "        'FROM_ONION_1': None,\n",
+    "        'CLOSE_PARENS_FIRSTLINE': None,\n",
+    "        'FROM_SEMICOLON_SEP': None,\n",
+    "        'FROM_ONION': '# pip: foo==0.0.1'\n",
+    "    }\n",
+    "    match = smuggle_statement_regex.match(line)\n",
+    "    assert match is not None, f\"failed to match: '{line}'\"\n",
+    "    \n",
+    "    result_groupdict = match.groupdict()\n",
+    "    assert result_groupdict == expected_groupdict, (\n",
+    "        \"bad values in match.groupdict(). Expected:\\n\"\n",
+    "        f\"{pformat(expected_groupdict)}\\nFound:\\n{pformat(result_groupdict)}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T22:54:18.590555Z",
+     "start_time": "2021-07-21T22:54:18.128Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_smuggle_from_parentheses():\n",
+    "    \"\"\"\n",
+    "    same as above, but names are enclosed in parentheses (even though \n",
+    "    they are not split across multiple lines). Also tests trailing comma \n",
+    "    inside parentheses, which is syntactically valid for import \n",
+    "    statements.\n",
+    "    \"\"\"\n",
+    "    line = 'from foo.bar smuggle (baz, qux as quux, spam, ham as eggs,)    # pip: foo==0.0.1'\n",
+    "    expected_groupdict = {\n",
+    "        'FULL_CMD': line,\n",
+    "        'SEMICOLON_SEP': None,\n",
+    "        'ONION': None,\n",
+    "        'OPEN_PARENS': '(',\n",
+    "        'FROM_ONION_1': None,\n",
+    "        'CLOSE_PARENS_FIRSTLINE': ')',\n",
+    "        'FROM_SEMICOLON_SEP': None,\n",
+    "        'FROM_ONION': '# pip: foo==0.0.1'\n",
+    "    }\n",
+    "    match = smuggle_statement_regex.match(line)\n",
+    "    assert match is not None, f\"failed to match: '{line}'\"\n",
+    "    \n",
+    "    result_groupdict = match.groupdict()\n",
+    "    assert result_groupdict == expected_groupdict, (\n",
+    "        \"bad values in match.groupdict(). Expected:\\n\"\n",
+    "        f\"{pformat(expected_groupdict)}\\nFound:\\n{pformat(result_groupdict)}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T22:54:18.591556Z",
+     "start_time": "2021-07-21T22:54:18.130Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_smuggle_from_multiline_explicit():\n",
+    "    \"\"\"\n",
+    "    similar to above, but split across multiple lines with explicit \n",
+    "    (backslash-based) line continuations\n",
+    "    \"\"\"\n",
+    "    line = \"\"\"from foo.bar smuggle baz, \\\n",
+    "                                   qux as quux, \\\n",
+    "                                   spam, \\\n",
+    "                                   ham as eggs    # pip: foo==0.0.1\"\"\"\n",
+    "    expected_groupdict = {\n",
+    "        'FULL_CMD': line,\n",
+    "        'SEMICOLON_SEP': None,\n",
+    "        'ONION': None,\n",
+    "        'OPEN_PARENS': None,\n",
+    "        'FROM_ONION_1': None,\n",
+    "        'CLOSE_PARENS_FIRSTLINE': None,\n",
+    "        'FROM_SEMICOLON_SEP': None,\n",
+    "        'FROM_ONION': '# pip: foo==0.0.1'\n",
+    "    }\n",
+    "    match = smuggle_statement_regex.match(line)\n",
+    "    assert match is not None, f\"failed to match: '{line}'\"\n",
+    "    \n",
+    "    result_groupdict = match.groupdict()\n",
+    "    assert result_groupdict == expected_groupdict, (\n",
+    "        \"bad values in match.groupdict(). Expected:\\n\"\n",
+    "        f\"{pformat(expected_groupdict)}\\nFound:\\n{pformat(result_groupdict)}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T22:54:18.592696Z",
+     "start_time": "2021-07-21T22:54:18.132Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "def test_smuggle_from_multiline_implicit_1_onion_1():\n",
+    "    \"\"\"\n",
+    "    similar to above, but 'smuggle' statement is split across multiple \n",
+    "    lines with implicit (parentheses-based) line continuation. First of \n",
+    "    multiple variations on multiline syntax.\n",
+    "    \n",
+    "    Notes:\n",
+    "    - implicit_1: first/last smuggled names occupy same lines as \n",
+    "                  opening/closing parentheses\n",
+    "    - onion_1: onion comment appears on first line\n",
+    "    \"\"\"\n",
+    "    line = \"\"\"from foo.bar smuggle (baz,    # pip: foo==0.0.1\n",
+    "                                    qux as quux,\n",
+    "                                    spam, \n",
+    "                                    ham as eggs)\"\"\"\n",
+    "    expected_groupdict = {\n",
+    "        'FULL_CMD': line,\n",
+    "        'SEMICOLON_SEP': None,\n",
+    "        'ONION': None,\n",
+    "        'OPEN_PARENS': '(',\n",
+    "        'FROM_ONION_1': '# pip: foo==0.0.1',\n",
+    "        'CLOSE_PARENS_FIRSTLINE': None,\n",
+    "        'FROM_SEMICOLON_SEP': None,\n",
+    "        'FROM_ONION': None\n",
+    "    }\n",
+    "    match = smuggle_statement_regex.match(line)\n",
+    "    assert match is not None, f\"failed to match: '{line}'\"\n",
+    "    \n",
+    "    result_groupdict = match.groupdict()\n",
+    "    assert result_groupdict == expected_groupdict, (\n",
+    "        \"bad values in match.groupdict(). Expected:\\n\"\n",
+    "        f\"{pformat(expected_groupdict)}\\nFound:\\n{pformat(result_groupdict)}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "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",
+    "    \"\"\"\n",
+    "    Variation on above.\n",
+    "    \n",
+    "    Notes:\n",
+    "    - implicit_1: first/last smuggled names occupy same lines as \n",
+    "                  opening/closing parentheses\n",
+    "    - onion_2: onion comment appears on last line\n",
+    "    \"\"\"\n",
+    "    line = \"\"\"from foo.bar smuggle (baz,\n",
+    "                                    qux as quux,\n",
+    "                                    spam, \n",
+    "                                    ham as eggs)    # pip: foo==0.0.1\"\"\"\n",
+    "    expected_groupdict = {\n",
+    "        'FULL_CMD': line,\n",
+    "        'SEMICOLON_SEP': None,\n",
+    "        'ONION': None,\n",
+    "        'OPEN_PARENS': '(',\n",
+    "        'FROM_ONION_1': None,\n",
+    "        'CLOSE_PARENS_FIRSTLINE': None,\n",
+    "        'FROM_SEMICOLON_SEP': None,\n",
+    "        'FROM_ONION': '# pip: foo==0.0.1'\n",
+    "    }\n",
+    "    match = smuggle_statement_regex.match(line)\n",
+    "    assert match is not None, f\"failed to match: '{line}'\"\n",
+    "    \n",
+    "    result_groupdict = match.groupdict()\n",
+    "    assert result_groupdict == expected_groupdict, (\n",
+    "        \"bad values in match.groupdict(). Expected:\\n\"\n",
+    "        f\"{pformat(expected_groupdict)}\\nFound:\\n{pformat(result_groupdict)}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "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",
+    "    \"\"\"\n",
+    "    Variation on above.\n",
+    "    \n",
+    "    Notes:\n",
+    "    - implicit_1: first/last smuggled names occupy same lines as \n",
+    "                  opening/closing parentheses\n",
+    "    - onion_3: onion comment appears on line with name, but not first or \n",
+    "               last, so not parsed\n",
+    "    \"\"\"\n",
+    "    line = \"\"\"from foo.bar smuggle (baz,\n",
+    "                                    qux as quux,    # pip: foo==0.0.1\n",
+    "                                    spam, \n",
+    "                                    ham as eggs)\"\"\"\n",
+    "    expected_groupdict = {\n",
+    "        'FULL_CMD': line,\n",
+    "        'SEMICOLON_SEP': None,\n",
+    "        'ONION': None,\n",
+    "        'OPEN_PARENS': '(',\n",
+    "        'FROM_ONION_1': None,\n",
+    "        'CLOSE_PARENS_FIRSTLINE': None,\n",
+    "        'FROM_SEMICOLON_SEP': None,\n",
+    "        'FROM_ONION': None\n",
+    "    }\n",
+    "    match = smuggle_statement_regex.match(line)\n",
+    "    assert match is not None, f\"failed to match: '{line}'\"\n",
+    "    \n",
+    "    result_groupdict = match.groupdict()\n",
+    "    assert result_groupdict == expected_groupdict, (\n",
+    "        \"bad values in match.groupdict(). Expected:\\n\"\n",
+    "        f\"{pformat(expected_groupdict)}\\nFound:\\n{pformat(result_groupdict)}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "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",
+    "    \"\"\"\n",
+    "    Variation on above.\n",
+    "    \n",
+    "    Notes:\n",
+    "    - implicit_1: first/last smuggled names occupy same lines as \n",
+    "                  opening/closing parentheses\n",
+    "    - onion_4: onion comment appears on separate line, but not first or \n",
+    "               last, so not parsed\n",
+    "    \"\"\"\n",
+    "    line = \"\"\"from foo.bar smuggle (baz,\n",
+    "                                    # pip: foo==0.0.1\n",
+    "                                    qux as quux,\n",
+    "                                    spam, \n",
+    "                                    ham as eggs)\"\"\"\n",
+    "    expected_groupdict = {\n",
+    "        'FULL_CMD': line,\n",
+    "        'SEMICOLON_SEP': None,\n",
+    "        'ONION': None,\n",
+    "        'OPEN_PARENS': '(',\n",
+    "        'FROM_ONION_1': None,\n",
+    "        'CLOSE_PARENS_FIRSTLINE': None,\n",
+    "        'FROM_SEMICOLON_SEP': None,\n",
+    "        'FROM_ONION': None\n",
+    "    }\n",
+    "    match = smuggle_statement_regex.match(line)\n",
+    "    assert match is not None, f\"failed to match: '{line}'\"\n",
+    "    \n",
+    "    result_groupdict = match.groupdict()\n",
+    "    assert result_groupdict == expected_groupdict, (\n",
+    "        \"bad values in match.groupdict(). Expected:\\n\"\n",
+    "        f\"{pformat(expected_groupdict)}\\nFound:\\n{pformat(result_groupdict)}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "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",
+    "    \"\"\"\n",
+    "    Variation on above.\n",
+    "    \n",
+    "    Notes:\n",
+    "    - implicit_1: first/last smuggled names occupy same lines as \n",
+    "                  opening/closing parentheses\n",
+    "    - onion_5: onion comment appears on last line, with non-onion \n",
+    "               comments on various lines above it\n",
+    "    \"\"\"\n",
+    "    line = \"\"\"from foo.bar smuggle (baz,    # unrelated first line comment\n",
+    "                                    qux as quux,    # unrelated middle line comment\n",
+    "                                    spam, \n",
+    "                                    # unrelated separate line comment\n",
+    "                                    ham as eggs)    # pip: foo==0.0.1\"\"\"\n",
+    "    expected_groupdict = {\n",
+    "        'FULL_CMD': line,\n",
+    "        'SEMICOLON_SEP': None,\n",
+    "        'ONION': None,\n",
+    "        'OPEN_PARENS': '(',\n",
+    "        'FROM_ONION_1': None,\n",
+    "        'CLOSE_PARENS_FIRSTLINE': None,\n",
+    "        'FROM_SEMICOLON_SEP': None,\n",
+    "        'FROM_ONION': '# pip: foo==0.0.1'\n",
+    "    }\n",
+    "    match = smuggle_statement_regex.match(line)\n",
+    "    assert match is not None, f\"failed to match: '{line}'\"\n",
+    "    \n",
+    "    result_groupdict = match.groupdict()\n",
+    "    assert result_groupdict == expected_groupdict, (\n",
+    "        \"bad values in match.groupdict(). Expected:\\n\"\n",
+    "        f\"{pformat(expected_groupdict)}\\nFound:\\n{pformat(result_groupdict)}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "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",
+    "    \"\"\"\n",
+    "    Variation on above.\n",
+    "    \n",
+    "    Notes:\n",
+    "    - implicit_2: first/last smuggled names occupy different lines from\n",
+    "                  opening/closing parentheses\n",
+    "    - onion_1: onion comment appears on first line\n",
+    "    \"\"\"\n",
+    "    line = \"\"\"from foo.bar smuggle (    # pip: foo==0.0.1\n",
+    "                  baz,\n",
+    "                  qux as quux,\n",
+    "                  spam, \n",
+    "                  ham as eggs\n",
+    "              )\"\"\"\n",
+    "    expected_groupdict = {\n",
+    "        'FULL_CMD': line,\n",
+    "        'SEMICOLON_SEP': None,\n",
+    "        'ONION': None,\n",
+    "        'OPEN_PARENS': '(',\n",
+    "        'FROM_ONION_1': '# pip: foo==0.0.1',\n",
+    "        'CLOSE_PARENS_FIRSTLINE': None,\n",
+    "        'FROM_SEMICOLON_SEP': None,\n",
+    "        'FROM_ONION': None\n",
+    "    }\n",
+    "    match = smuggle_statement_regex.match(line)\n",
+    "    assert match is not None, f\"failed to match: '{line}'\"\n",
+    "    \n",
+    "    result_groupdict = match.groupdict()\n",
+    "    assert result_groupdict == expected_groupdict, (\n",
+    "        \"bad values in match.groupdict(). Expected:\\n\"\n",
+    "        f\"{pformat(expected_groupdict)}\\nFound:\\n{pformat(result_groupdict)}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "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",
+    "    \"\"\"\n",
+    "    Variation on above.\n",
+    "    \n",
+    "    Notes:\n",
+    "    - implicit_2: first/last smuggled names occupy different lines from\n",
+    "                  opening/closing parentheses\n",
+    "    - onion_2: onion comment appears on last line\n",
+    "    \"\"\"\n",
+    "    line = \"\"\"from foo.bar smuggle (\n",
+    "                  baz,\n",
+    "                  qux as quux,\n",
+    "                  spam, \n",
+    "                  ham as eggs\n",
+    "              )    # pip: foo==0.0.1\"\"\"\n",
+    "    expected_groupdict = {\n",
+    "        'FULL_CMD': line,\n",
+    "        'SEMICOLON_SEP': None,\n",
+    "        'ONION': None,\n",
+    "        'OPEN_PARENS': '(',\n",
+    "        'FROM_ONION_1': None,\n",
+    "        'CLOSE_PARENS_FIRSTLINE': None,\n",
+    "        'FROM_SEMICOLON_SEP': None,\n",
+    "        'FROM_ONION': '# pip: foo==0.0.1'\n",
+    "    }\n",
+    "    match = smuggle_statement_regex.match(line)\n",
+    "    assert match is not None, f\"failed to match: '{line}'\"\n",
+    "    \n",
+    "    result_groupdict = match.groupdict()\n",
+    "    assert result_groupdict == expected_groupdict, (\n",
+    "        \"bad values in match.groupdict(). Expected:\\n\"\n",
+    "        f\"{pformat(expected_groupdict)}\\nFound:\\n{pformat(result_groupdict)}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "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",
+    "    \"\"\"\n",
+    "    Variation on above.\n",
+    "    \n",
+    "    Notes:\n",
+    "    - implicit_2: first/last smuggled names occupy different lines from\n",
+    "                  opening/closing parentheses\n",
+    "    - onion_3: onion comment appears on line with name, but not first or \n",
+    "               last, so not parsed\n",
+    "    \"\"\"\n",
+    "    line = \"\"\"from foo.bar smuggle (\n",
+    "                  baz, \n",
+    "                  qux as quux,\n",
+    "                  spam,    # pip: foo==0.0.1\n",
+    "                  ham as eggs\n",
+    "              )\"\"\"\n",
+    "    expected_groupdict = {\n",
+    "        'FULL_CMD': line,\n",
+    "        'SEMICOLON_SEP': None,\n",
+    "        'ONION': None,\n",
+    "        'OPEN_PARENS': '(',\n",
+    "        'FROM_ONION_1': None,\n",
+    "        'CLOSE_PARENS_FIRSTLINE': None,\n",
+    "        'FROM_SEMICOLON_SEP': None,\n",
+    "        'FROM_ONION': None\n",
+    "    }\n",
+    "    match = smuggle_statement_regex.match(line)\n",
+    "    assert match is not None, f\"failed to match: '{line}'\"\n",
+    "    \n",
+    "    result_groupdict = match.groupdict()\n",
+    "    assert result_groupdict == expected_groupdict, (\n",
+    "        \"bad values in match.groupdict(). Expected:\\n\"\n",
+    "        f\"{pformat(expected_groupdict)}\\nFound:\\n{pformat(result_groupdict)}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "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",
+    "    \"\"\"\n",
+    "    Variation on above.\n",
+    "    \n",
+    "    Notes:\n",
+    "    - implicit_2: first/last smuggled names occupy different lines from\n",
+    "                  opening/closing parentheses\n",
+    "    - onion_4: onion comment appears on separate line, but not first or \n",
+    "               last, so not parsed\n",
+    "    \"\"\"\n",
+    "    line = \"\"\"from foo.bar smuggle (\n",
+    "                  baz, \n",
+    "                  qux as quux,\n",
+    "                  # pip: foo==0.0.1\n",
+    "                  spam,\n",
+    "                  ham as eggs\n",
+    "              )\"\"\"\n",
+    "    expected_groupdict = {\n",
+    "        'FULL_CMD': line,\n",
+    "        'SEMICOLON_SEP': None,\n",
+    "        'ONION': None,\n",
+    "        'OPEN_PARENS': '(',\n",
+    "        'FROM_ONION_1': None,\n",
+    "        'CLOSE_PARENS_FIRSTLINE': None,\n",
+    "        'FROM_SEMICOLON_SEP': None,\n",
+    "        'FROM_ONION': None\n",
+    "    }\n",
+    "    match = smuggle_statement_regex.match(line)\n",
+    "    assert match is not None, f\"failed to match: '{line}'\"\n",
+    "    \n",
+    "    result_groupdict = match.groupdict()\n",
+    "    assert result_groupdict == expected_groupdict, (\n",
+    "        \"bad values in match.groupdict(). Expected:\\n\"\n",
+    "        f\"{pformat(expected_groupdict)}\\nFound:\\n{pformat(result_groupdict)}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "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",
+    "    \"\"\"\n",
+    "    Variation on above.\n",
+    "    \n",
+    "    Notes:\n",
+    "    - implicit_2: first/last smuggled names occupy different lines from\n",
+    "                  opening/closing parentheses\n",
+    "    - onion_5: onion comment appears on last line, with non-onion \n",
+    "               comments on various lines above it\n",
+    "    \"\"\"\n",
+    "    line = \"\"\"from foo.bar smuggle (    # unrelated first line comment\n",
+    "                  baz,\n",
+    "                  qux as quux,    # unrelated middle line comment\n",
+    "                  spam, \n",
+    "                  # unrelated separate line comment\n",
+    "                  ham as eggs\n",
+    "              )    # pip: foo==0.0.1\"\"\"\n",
+    "    expected_groupdict = {\n",
+    "        'FULL_CMD': line,\n",
+    "        'SEMICOLON_SEP': None,\n",
+    "        'ONION': None,\n",
+    "        'OPEN_PARENS': '(',\n",
+    "        'FROM_ONION_1': None,\n",
+    "        'CLOSE_PARENS_FIRSTLINE': None,\n",
+    "        'FROM_SEMICOLON_SEP': None,\n",
+    "        'FROM_ONION': '# pip: foo==0.0.1'\n",
+    "    }\n",
+    "    match = smuggle_statement_regex.match(line)\n",
+    "    assert match is not None, f\"failed to match: '{line}'\"\n",
+    "    \n",
+    "    result_groupdict = match.groupdict()\n",
+    "    assert result_groupdict == expected_groupdict, (\n",
+    "        \"bad values in match.groupdict(). Expected:\\n\"\n",
+    "        f\"{pformat(expected_groupdict)}\\nFound:\\n{pformat(result_groupdict)}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "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",
+    "    \"\"\"\n",
+    "    should handle multiple semicolon-separated 'smuggle' statements on a \n",
+    "    single line. Match should include first statement only, but note \n",
+    "    that there are other statements on the same line via the \n",
+    "    'SEMICOLON_SEP' group matching. Should *not* include onion comment, \n",
+    "    since that applies to last statement on line. Also incorporates \n",
+    "    multiple elements from prior tests.\n",
+    "    \"\"\"\n",
+    "    line = \"\"\"smuggle foo; from bar smuggle baz; \\\n",
+    "              smuggle qux as quux; \\\n",
+    "              from spam smuggle ham as eggs    # pip: spam-pkg==0.0.1\"\"\"\n",
+    "    expected_groupdict = {\n",
+    "        'FULL_CMD': 'smuggle foo',\n",
+    "        'SEMICOLON_SEP': '',\n",
+    "        'ONION': None,\n",
+    "        'OPEN_PARENS': None,\n",
+    "        'FROM_ONION_1': None,\n",
+    "        'CLOSE_PARENS_FIRSTLINE': None,\n",
+    "        'FROM_SEMICOLON_SEP': None,\n",
+    "        'FROM_ONION': None\n",
+    "    }\n",
+    "    match = smuggle_statement_regex.match(line)\n",
+    "    assert match is not None, f\"failed to match: '{line}'\"\n",
+    "    \n",
+    "    result_groupdict = match.groupdict()\n",
+    "    assert result_groupdict == expected_groupdict, (\n",
+    "        \"bad values in match.groupdict(). Expected:\\n\"\n",
+    "        f\"{pformat(expected_groupdict)}\\nFound:\\n{pformat(result_groupdict)}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "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",
+    "    \"\"\"\n",
+    "    Same as above, but the 'FROM_SEMICOLON_SEP' group should match \n",
+    "    instead of 'SEMICOLON_SEP', since the first statement uses 'from'\n",
+    "    \"\"\"\n",
+    "    line = \"\"\"from foo smuggle bar; smuggle baz; \\\n",
+    "              smuggle qux as quux; \\\n",
+    "              from spam smuggle ham as eggs    # pip: spam-pkg==0.0.1\"\"\"\n",
+    "    expected_groupdict = {\n",
+    "        'FULL_CMD': 'from foo smuggle bar',\n",
+    "        'SEMICOLON_SEP': None,\n",
+    "        'ONION': None,\n",
+    "        'OPEN_PARENS': None,\n",
+    "        'FROM_ONION_1': None,\n",
+    "        'CLOSE_PARENS_FIRSTLINE': None,\n",
+    "        'FROM_SEMICOLON_SEP': '',\n",
+    "        'FROM_ONION': None\n",
+    "    }\n",
+    "    match = smuggle_statement_regex.match(line)\n",
+    "    assert match is not None, f\"failed to match: '{line}'\"\n",
+    "    \n",
+    "    result_groupdict = match.groupdict()\n",
+    "    assert result_groupdict == expected_groupdict, (\n",
+    "        \"bad values in match.groupdict(). Expected:\\n\"\n",
+    "        f\"{pformat(expected_groupdict)}\\nFound:\\n{pformat(result_groupdict)}\"\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "ExecuteTime": {
+     "end_time": "2021-07-21T22:54:18.613154Z",
+     "start_time": "2021-07-21T22:54:18.155Z"
+    }
+   },
+   "outputs": [],
+   "source": [
+    "run_tests()"
+   ]
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "davos",
+   "language": "python",
+   "name": "davos"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.9.2"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 2
+}
diff --git a/tests/utils.py b/tests/utils.py
new file mode 100644
index 00000000..8f4704ac
--- /dev/null
+++ b/tests/utils.py
@@ -0,0 +1,470 @@
+"""shared utilities for davos tests"""
+
+import ast
+import functools
+import html
+import inspect
+import os
+import re
+import pkg_resources
+import signal
+import sys
+import types
+from collections.abc import Sequence
+from contextlib import redirect_stdout
+
+# use typing generics for compatibility with Python 3.6
+from typing import (
+    Any,
+    Callable,
+    Dict,
+    Generic,
+    List,
+    NoReturn,
+    Optional,
+    overload,
+    Pattern,
+    Tuple,
+    Type,
+    TypeVar,
+    Union
+)
+
+if sys.version_info.minor < 8:
+    from typing_extensions import Literal, TypedDict
+else:
+    from typing import Literal, TypedDict
+
+import IPython
+from IPython.core.ultratb import FormattedTB
+from IPython.display import display_html
+
+
+_E = TypeVar('_E', bound=BaseException)
+_E1 = TypeVar('_E1', bound=BaseException)
+_F = TypeVar('_F', bound=Callable[..., None])
+_InstallerKwargVals = Union[bool, int, str]
+_IpyVersions = Literal['5.5.0', '7.3.0', '7.15', '7.16', 'latest']
+_StrOrListStr = TypeVar('_StrOrListStr', str, List[str])
+_NbTypes = Literal['colab', 'jupyter']
+
+
+class _DecoratorData(TypedDict):
+    name: str
+    args: tuple
+    kwargs: Dict[str, Any]
+
+
+########################################
+#           EXCEPTION CLASSES          #
+########################################
+class DavosTestingError(Exception):
+    """Base class for Davos testing-related errors"""
+    pass
+
+
+class DavosAssertionError(DavosTestingError, AssertionError):
+    """Subclasses AssertionError for pytest-specific handling"""
+    pass
+
+
+class TestingEnvironmentError(DavosAssertionError, OSError):
+    """Raised due to issues with the testing environment"""
+    pass
+
+
+class TestTimeoutError(DavosAssertionError):
+    """Raised if a test exceeds its timeout limit"""
+    pass
+
+
+########################################
+#           HELPER FUNCTIONS           #
+########################################
+def expected_onion_parser_output(
+        args_str: str,
+        **installer_kwargs: Union[bool, int, str]
+) -> Tuple[str, str, Dict[str, _InstallerKwargVals]]:
+    installer_kwargs_ = {'editable': False, 'spec': args_str}
+    installer_kwargs_.update(installer_kwargs)
+    return '"pip"', f'"""{args_str}"""', installer_kwargs_
+
+
+def expected_parser_output(
+        name: str,
+        as_: Optional[str] = None,
+        args_str: Optional[str] = None,
+        **installer_kwargs: _InstallerKwargVals
+) -> Union[List[str], str]:
+    if as_ is not None:
+        as_ = f'"{as_}"'
+    expected = f'smuggle(name="{name}", as_={as_}'
+    if args_str is not None or any(installer_kwargs):
+        installer, args_str, inst_kwargs = expected_onion_parser_output(
+            args_str, **installer_kwargs
+        )
+        expected += (f', installer={installer}, '
+                     f'args_str={args_str}, '
+                     f'installer_kwargs={inst_kwargs}')
+    expected += ')'
+    if IPython.version_info[0] >= 7:
+        expected = [f'{expected}\n']
+    return expected
+
+
+def format_traceback(err: _E) -> str:
+    tb_formatter = FormattedTB('Context', 'NoColor')
+    structured_tb: List[str] = tb_formatter.structured_traceback(
+        type(err), err, err.__traceback__
+    )
+    tb_string: str = tb_formatter.stb2text(structured_tb)
+    return html.escape(tb_string)
+
+
+@overload
+def install_davos(
+        source: Literal['conda'],
+        ref: Optional[str],
+        fork: None
+) -> NoReturn: ...
+@overload
+def install_davos(
+        source: Literal['github'],
+        ref: Optional[str],
+        fork: Optional[str]
+) -> None: ...
+@overload
+def install_davos(
+        source: Literal['pip', 'pypi', 'testpypi'],
+        ref: Optional[str],
+        fork: None
+) -> None: ...
+def install_davos(
+        source: Literal['conda', 'github', 'pip', 'pypi', 'testpypi'] = 'github',
+        ref: Optional[str] = None,
+        fork: Optional[str] = None
+) -> None:
+    """
+    Install a version or revision of davos from the specified source.
+
+    Parameters
+    ----------
+    source : {'github', 'pip', 'pypi', 'testpypi', 'conda'}, optional
+        The remote source from which to install `davos`. `'github'`
+        (default) is generally used for CI tests, `'pip'`/`'pypi'` for
+        full releases, and `'testpypi'` for test releases.
+    ref : str, optional
+        The version or revision of `davos` to install. If `source` is
+        `'github'`, this can be a branch, commit hash, or tag.
+        Otherwise, this may be a valid version string to install from
+        the given source. Defaults to most recent revision on the
+        default branch of the specified `fork` (GitHub) or the latest
+        release version (other sources).
+    fork : str, optional
+        Optionally, the fork (GitHub username) to install from. Defaults
+        to the base repository (ContextLab). If `source` is not
+        `'github'`, this has no effect.
+    """
+    source = source.lower()
+    if source == 'github':
+        if fork is None:
+            fork = 'ContextLab'
+        name = f'git+https://github.com/{fork}/davos.git'
+        if ref is not None:
+            name += f'@{ref}'
+        name += '#egg=davos'
+    elif source in ('pip', 'pypi', 'testpypi'):
+        name = 'davos'
+        if source == 'testpypi':
+            name = '--index-url https://test.pypi.org/simple/ ' + name
+        if ref is not None:
+            name += f'=={ref}'
+    elif source == 'conda':
+        raise NotImplementedError(
+            "conda installation is not supported in Colaboratory"
+        )
+    else:
+        raise ValueError(f"Invalid source '{source}'")
+
+    # noinspection PyUnresolvedReferences
+    return_code: int = IPython.core.interactiveshell.system(f'pip install {name}')
+    if return_code != 0:
+        raise DavosTestingError(
+            f"Failed to install 'davos'. Ran command:\n\t`pip install {name}`"
+        )
+
+
+def is_imported(pkg_name: str) -> bool:
+    if pkg_name in sys.modules:
+        return True
+    elif any(pkg.startswith(f'{pkg_name}.') for pkg in sys.modules):
+        return True
+    return False
+
+
+def is_installed(pkg_name: str) -> bool:
+    try:
+        pkg_resources.get_distribution(pkg_name)
+    except pkg_resources.DistributionNotFound:
+        return False
+    else:
+        return True
+
+
+class mark:
+    """
+    class that serves as a namespace for test marker decorators.
+    Essentially mocks pytest.mark module to preserve similar syntax
+    (e.g., pytest.mark.skipif -> utils.mark.skipif)
+    """
+
+    _IPYTHON_VERSION: _IpyVersions = get_ipython().user_ns['IPYTHON_VERSION']
+    _NOTEBOOK_TYPE: _NbTypes = get_ipython().user_ns['NOTEBOOK_TYPE']
+
+    @staticmethod
+    def _set_skiptest_reason(func: _F, reason: str) -> None:
+        if hasattr(func, '_skiptest'):
+            func._skiptest += f', {reason}'
+        else:
+            func._skiptest = f'{reason}'
+
+    @classmethod
+    def colab(cls, func: _F) -> _F:
+        """mark a test that should run only on Google Colab"""
+        if cls._NOTEBOOK_TYPE != 'colab':
+            cls._set_skiptest_reason(func, "requires Google Colab")
+        return func
+
+    @classmethod
+    def ipython_post7(cls, func: _F) -> _F:
+        """mark a test that should run only if IPython>=7.0.0"""
+        if (
+                cls._IPYTHON_VERSION != 'latest' and
+                int(cls._IPYTHON_VERSION.split('.')[0]) < 7
+        ):
+            cls._set_skiptest_reason(func, "requires IPython>=7.0.0")
+        return func
+
+    @classmethod
+    def ipython_pre7(cls, func: _F) -> _F:
+        """mark a test that should run only if IPython<7.0.0"""
+        if (
+                cls._IPYTHON_VERSION == 'latest' or
+                int(cls._IPYTHON_VERSION.split('.')[0]) >= 7
+        ):
+            cls._set_skiptest_reason(func, "requires IPython<7.0.0")
+        return func
+
+    @classmethod
+    def jupyter(cls, func: _F) -> _F:
+        """mark a test that should run only in Jupyter notebooks"""
+        if cls._NOTEBOOK_TYPE != 'jupyter':
+            cls._set_skiptest_reason(func, "requires Jupyter notebooks")
+        return func
+
+    @classmethod
+    def skipif(cls, condition: bool, *, reason: str) -> Callable[[_F], _F]:
+        """
+        mark a test that should be skipped if the given condition
+        evaluates to True. Serves as a stand-in for pytest.mark.skipif
+        (note: for security reasons, only boolean conditions are
+        supported. String conditions are not evaluated)
+        """
+        def decorator(func: _F) -> _F:
+            if condition:
+                cls._set_skiptest_reason(func, reason)
+            return func
+
+        return decorator
+
+    @staticmethod
+    def timeout(timeout: int = 120) -> Callable[[_F], _F]:
+        """
+        mark a test that should fail after a certain amount of time (in
+        seconds). Serves as a stand-in for pytest.mark.timeout (from the
+        pytest-timeout plugin)
+        """
+        def decorator(func: _F) -> _F:
+            def handler(signum: int, frame: Optional[types.FrameType]) -> NoReturn:
+                raise TestTimeoutError(
+                    f"'{func.__name__}' timed out after {timeout} seconds"
+                )
+            @functools.wraps(func)    # noqa: E306
+            def wrapper(*args: Any, **kwargs: Any) -> None:
+                signal.signal(signal.SIGALRM, handler)
+                signal.alarm(timeout)
+                try:
+                    return func(*args, **kwargs)
+                finally:
+                    signal.alarm(0)
+            return wrapper
+        return decorator
+
+
+def matches_expected_output(
+        expected: _StrOrListStr,
+        result: _StrOrListStr
+) -> bool:
+
+    all_match = True
+    # when IPython<=7, both expected & result strings will be [<str>\n]
+    if isinstance(expected, list):
+        expected = expected[0][:-1]
+    if isinstance(result, list):
+        result = result[0][:-1]
+    expected_chunks = expected.split(';')
+    result_chunks = result.split(';')
+    assert len(expected_chunks) == len(result_chunks)
+    for expected_chunk, result_chunk in zip(expected_chunks, result_chunks):
+        if 'installer_kwargs=' in expected_chunk and 'installer_kwargs' in result_chunk:
+            exp_kwds: str
+            res_kwds: str
+            exp_main, exp_kwds = expected_chunk.strip().split('installer_kwargs=')
+            res_main, res_kwds = result_chunk.strip().split('installer_kwargs=')
+            # remove ')'
+            exp_kwargs: Dict[str, _InstallerKwargVals] = ast.literal_eval(exp_kwds[:-1])
+            res_kwargs: Dict[str, _InstallerKwargVals] = ast.literal_eval(res_kwds[:-1])
+            all_match = all_match and exp_main == res_main and exp_kwargs == res_kwargs
+        else:
+            all_match = all_match and expected_chunk == result_chunk
+    return all_match
+
+
+class ExceptionInfo(Generic[_E]):
+    """
+    Simplified stand-in for _pytest._code.code.ExceptionInfo. Mocks
+    some basic functionality useful in 'raises' context manager.
+    """
+    _common_err_msg = ".{} can only be used after the context manager exits"
+
+    def __init__(
+            self,
+            excinfo: Optional[Tuple[Type[_E], _E, types.TracebackType]]
+    ) -> None:
+        self._excinfo = excinfo
+
+    @property
+    def type(self) -> Type[_E]:
+        assert self._excinfo is not None, self._common_err_msg.format('type')
+        return self._excinfo[0]
+
+    @property
+    def value(self) -> _E:
+        assert self._excinfo is not None, self._common_err_msg.format('value')
+        return self._excinfo[1]
+
+    @property
+    def tb(self) -> types.TracebackType:
+        assert self._excinfo is not None, self._common_err_msg.format('tb')
+        return self._excinfo[2]
+
+    @property
+    def typename(self) -> str:
+        assert self._excinfo is not None, self._common_err_msg.format('typename')
+        return self.type.__name__
+
+    def match(self, regexp: Union[str, Pattern[str]]) -> Literal[True]:
+        exc_str = str(self.value)
+        msg = "Regex pattern {!r} does not match {!r}."
+        if regexp == exc_str:
+            msg += " Did you mean to `re.escape()` the regex?"
+        assert re.search(regexp, exc_str), msg.format(regexp, exc_str)
+        # return True so method can be asserted from external code
+        return True
+
+
+class raises:
+    """
+    Port of `pytest.raises` context manager for notebook-based tests.
+    """
+    def __init__(
+            self,
+            expected_exception: Union[Type[_E], Tuple[Type[_E]]],
+            *,
+            match: Optional[Union[str, Pattern[str]]] = None
+    ) -> None:
+        if issubclass(expected_exception, BaseException):
+            expected_exception = (expected_exception,)
+        elif isinstance(expected_exception, Sequence):
+            expected_exception = tuple(expected_exception)
+        else:
+            raise TypeError(
+                "expected_exception may be a exception type or a sequence of "
+                "exception types"
+            )
+        self.expected_exceptions: Tuple[Type[_E]] = expected_exception
+        self.match_expr = match
+        self.excinfo: Optional[ExceptionInfo[_E]] = None
+
+    def __enter__(self) -> ExceptionInfo[_E]:
+        self.excinfo = ExceptionInfo(None)
+        return self.excinfo
+
+    @overload
+    def __exit__(
+            self,
+            exc_type: None,
+            exc_value: None,
+            exc_tb: None
+    ) -> NoReturn: ...
+    @overload    # noqa: E301
+    def __exit__(
+            self,
+            exc_type: Type[_E],
+            exc_value: _E,
+            exc_tb: types.TracebackType
+    ) -> Literal[True]: ...
+    @overload    # noqa: E301
+    def __exit__(
+            self,
+            exc_type: Type[_E1],
+            exc_value: _E1,
+            exc_tb: types.TracebackType
+    ) -> Literal[False]: ...
+    def __exit__(    # noqa: E301
+            self,
+            exc_type: Optional[Type[_E]],
+            exc_value: Optional[_E],
+            exc_tb: Optional[types.TracebackType]
+    ) -> bool:
+        if exc_type is None:
+            raise DavosAssertionError(f"DID NOT RAISE {self.expected_exceptions}")
+        elif not issubclass(exc_type, self.expected_exceptions):
+            # causes exc_value to be raised
+            return False
+        self.excinfo._excinfo = (exc_type, exc_value, exc_tb)
+        if self.match_expr is not None:
+            self.excinfo.match(self.match_expr)
+        return True
+
+
+def run_tests() -> None:
+    caller_globals = inspect.currentframe().f_back.f_globals
+    tests: List[Tuple[str, types.FunctionType]] = []
+    for name, obj in caller_globals.items():
+        if name.startswith('test_') and isinstance(obj, types.FunctionType):
+            tests.append((name, obj))
+
+    longest_name_len = len(max((t[0] for t in tests), key=len))
+    print(f"collected {len(tests)} items\n")
+    for test_name, test_func in tests:
+        # need at least 1 space between test name and result for parsing
+        whitespace = ' ' * (longest_name_len - len(test_name) + 1)
+        skip_reason: Optional[str] = getattr(test_func, '_skiptest', None)
+        if skip_reason is not None:
+            reason_esc: str = f"({html.escape(skip_reason)})"
+            status = f'SKIPPED{whitespace}{reason_esc}'
+        else:
+            try:
+                with open(os.devnull, 'w') as devnull, redirect_stdout(devnull):
+                    test_func()
+            except Exception as e:
+                tb = format_traceback(e)
+                status = f'FAILED\n{tb}\n{"-" * 75}'
+            else:
+                status = 'PASSED'
+
+        html_ = (f"<div id='{test_name}_result' style=\"white-space:pre\">"
+                 f"{test_name}{whitespace}{status}</div>")
+        # noinspection PyTypeChecker
+        display_html(html_, raw=True)