Repository: ContextLab/davos (language: python)

Issue:
add function(s) to top-level package namespace that disables/re-enables davos parser
### Jupyter/Colab
- simply remove/re-add `StatelessInputTransformer` object from `davos.ipython_shell.input_transformer_manger.python_line_transforms` list in order to allow user to (marginally) speed up execution of cells that don't use any smuggle statements
- should also modify `davos.ipython_shell.input_splitter.python_line_transforms` list for `IPython<7.0.0`

### Non-interactive Python scripts
- on reading source file,
  - seek `import davos`
  - parse all lines until disable function called
  - seek re-enable function call, if any
  - repeat


Discussion / hints:

Fix patch:
diff --git a/davos/__init__.py b/davos/__init__.py
index f3813db0..4928f112 100644
--- a/davos/__init__.py
+++ b/davos/__init__.py
@@ -1,11 +1,16 @@
-from pkg_resources import get_distribution
+import pkg_resources
 
 from davos._davos import Davos
 
 
-__all__ = ['davos', 'smuggle']
-__version__ = get_distribution('davos').version
+__all__ = ['davos', 'smuggle', 'activate', 'deactivate', 'is_active']
+__version__ = pkg_resources.get_distribution('davos').version
+
 
 davos = Davos()
 davos.initialize()
+
 smuggle = davos.smuggler
+activate = davos.activate_parser
+deactivate = davos.deactivate_parser
+is_active = davos.parser_is_active
diff --git a/davos/_davos.py b/davos/_davos.py
index b00f1384..cc539701 100644
--- a/davos/_davos.py
+++ b/davos/_davos.py
@@ -5,7 +5,7 @@
 """
 
 
-__all__ = ['Davos']
+__all__ = ['Davos', 'capture_stdout']
 
 
 import sys
@@ -16,6 +16,7 @@
 
 # noinspection PyAttributeOutsideInit
 class Davos:
+    # ADD DOCSTRING
     __instance = None
 
     def __new__(cls):
@@ -39,6 +40,9 @@ def initialize(self):
             # 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'
@@ -49,16 +53,26 @@ def initialize(self):
                 # 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.smuggler._register()
+        self.activate_parser()
 
     def run_shell_command(self, command, live_stdout=None):
         if live_stdout is None:
@@ -67,50 +81,60 @@ def run_shell_command(self, command, live_stdout=None):
             command_context = capture_stdout
         else:
             command_context = redirect_stdout
-        try:
-            with command_context(StringIO()) as stdout:
+        with command_context(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
-            if e.output is None:
+            except CalledProcessError as e:
+                # if the exception doesn't record the output, add it
+                # manually before raising
                 stdout = stdout.getvalue()
-                if stdout != '':
+                if e.output is None and stdout != '':
                     e.output = stdout
-            raise e
-        else:
-            return stdout.getvalue(), return_code
+                raise e
+            else:
+                stdout = stdout.getvalue()
+        return stdout, return_code
 
 
 class capture_stdout:
-    def __init__(self, stream):
-        self.stream = stream
+    # 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
-        return self.stream
+        if len(self.streams) == 1:
+            return self.streams[0]
+        return self.streams
 
-    def __exit__(self, *args):
+    def __exit__(self, exc_type, exc_value, traceback):
         sys.stdout.write = self.sys_stdout_write
-        self.stream = self.stream.getvalue()
+        if self.closing:
+            for s in self.streams:
+                s.close()
 
     def _write(self, data):
-        self.stream.write(data)
+        for s in self.streams:
+            s.write(data)
         self.sys_stdout_write(data)
         sys.stdout.flush()
-
-
-# class nullcontext:
-#     """
-#     dummy context manager equivalent to contextlib.nullcontext
-#     (which isn't implemented for Python<3.7)
-#     """
-#     def __init__(self, enter_result=None):
-#         self.enter_result = enter_result
-#
-#     def __enter__(self):
-#         return self.enter_result
-#
-#     def __exit__(self, exc_type, exc_value, traceback):
-#         return None
\ No newline at end of file
diff --git a/davos/colab.py b/davos/colab.py
index 691a088a..890219f8 100644
--- a/davos/colab.py
+++ b/davos/colab.py
@@ -10,28 +10,30 @@
 
 
 __all__ = [
-    'register_smuggler_colab',
+    'activate_parser_colab',
+    'check_parser_active_colab',
+    'deactivate_parser_colab',
     'run_shell_command_colab',
-    'smuggle_colab'
+    'smuggle_colab',
+    'smuggle_parser_colab'
 ]
 
 
 import importlib
+import re
 import sys
-import warnings
-from pathlib import Path
 from subprocess import CalledProcessError
 
 from davos import davos
-from davos.core import Onion, prompt_input
-from davos.exceptions import DavosParserError, OnionTypeError, SmugglerError
+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 Colab notebook rather
+    # 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:
@@ -41,31 +43,137 @@
         )
 
 
-def register_smuggler_colab():
+def _showsyntaxerror_davos(colab_shell, filename=None):
     """
-    adds the smuggle_inspector function to IPython's list of
-    InputTransformers that get called on the contents of each code cell.
-
-    NOTE: there are multiple different groups of InputTransformers that
-        IPython runs on cell content at different pre-execution stages,
-        between the various steps of the IPython parser. We're adding
-        the smuggle_inspector as a "python_line_transform" which runs
-        after the last step of the IPython parser, but 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 long smuggle
-        statements broken into multiple lines by either method will be
-        passed to the smuggle_inspector as a single line
+    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:
+            try:
+                stb = value._render_traceback_()
+            except Exception:
+                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)
-    # entire IPython.core.inputsplitter module was deprecated in v7.0.0,
-    # but Colab runs v5.5.0, so we still have to register our
-    # transformer in both places for it to work correctly
     # noinspection PyDeprecation
-    colab_shell.input_splitter.python_line_transforms.append(smuggle_transformer())
-    colab_shell.input_transformer_manager.python_line_transforms.append(smuggle_transformer())
+    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):
@@ -79,27 +187,33 @@ def run_shell_command_colab(command):
     return retcode
 
 
-def smuggle_colab(name, as_=None, **onion_kwargs):
+def smuggle_colab(
+        name,
+        as_=None,
+        installer='pip',
+        args_str='',
+        installer_kwargs=None
+):
     # ADD DOCSTRING
-    # NOTE: 'name' can be a package, subpackage, module or object
-    # TODO: move this name splitting logic somewhere else where it's cleaner
-    if not any(char in name for char in ('+', '/', ':')):
-        # dirty way of excluding names for VCS & local/remote files/modules
-        pkg_name = name.split('.')[0]
-    else:
-        pkg_name = name
-    try:
-        onion = Onion(pkg_name, **onion_kwargs)
-    except TypeError as e:
-        # TODO(?): find a way to *replace* exception. still shows last
-        #  frame of old traceback
-        raise OnionTypeError(*e.args).with_traceback(e.__traceback__) from None
+    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:
-            imported_obj = import_item(name)
+            # 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, also pip?) and bypass if passed
+            # 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?")
@@ -115,251 +229,142 @@ def smuggle_colab(name, as_=None, **onion_kwargs):
 
     if install_pkg:
         installer_stdout, exit_code = onion.install_package()
-        # packages with C extensions (e.g., numpy, pandas) cannot be
-        # reloaded within an interpreter session. If the package was
-        # previously imported in the current (even if not by user), get
-        # versions before & after reload, and if there's no change, warn
-        # about needing to reset runtime for changes to take effect with
-        # "RESTART RUNTIME" button in output. Elif unable to determine
-        # version both before and after reload, issue lower-priority
-        # warning about uncertainty
+        # 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)
-        # highest-level package should be dealt with last
+        # 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:
-            check_smuggled_pkg = False
+            # smuggled package is brand new
+            pass
         else:
-            check_smuggled_pkg = True
-        for pkg_name in prev_imported_pkgs:
-            # imported_pkgs_updated computes intersection with
-            # set(sys.modules), so names are guaranteed to be there
-            importlib.reload(sys.modules[pkg_name])
-        if check_smuggled_pkg:
-            old_pkg = sys.modules[pkg_name]
-            try:
-                old_version = old_pkg.__version__
-            except AttributeError:
-                old_version = None
-            new_pkg = importlib.reload(old_pkg)
+            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:
-                new_version = new_pkg.__version__
-            except AttributeError:
-                new_version = None
-            if old_version == new_version:
-                if old_version is new_version is None:
-                    warnings.warn(
-                        "Failed to programmatically package version of "
-                        f"previously imported package '{pkg_name}' both before "
-                        "and after smuggling. If this module contains C "
-                        "extensions, you may need to restart the runtime to "
-                        "use the newly installed version"
-                    )
-                elif onion.version_spec is not None:
-                    # casting the widest net possible for this edge
-                    # case: if specific version not provided, old & new
-                    # package versions are likely to be the same (e.g.,
-                    # VCS/local/archive/forced/etc. install) and since
-                    # user can see the pip-install command's output,
-                    # probably better to be conservative about issuing
-                    # big, bright red warnings. Also adding
-                    # `davos.confirm_installed_versions()` for more
-                    # aggressive checking,
-                    _display_mimetype(
-                        "application/vnd.colab-display-data+json",
-                        (
-                            {'pip_warning': {'packages': pkg_name}},
-                        ),
-                        raw=True
-                    )
-        imported_obj = import_item(name)
-    # import_item takes care of adding package to sys.modules (& its
-    # parents if it's a subpackage), but doesn't add the module
-    # name/alias to globals() like the normal import statement
+                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:
-        if name == pkg_name:
-            davos.ipython_shell.user_ns[name] = sys.modules[name]
-        else:
-            davos.ipython_shell.user_ns[name] = imported_obj
+        davos.ipython_shell.user_ns[name] = smuggled_obj
     else:
-        davos.ipython_shell.user_ns[as_] = imported_obj
-    davos.smuggled[onion.install_name] = onion.version_spec
+        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.args_str
 
 
 def smuggle_parser_colab(line):
     # ADD DOCSTRING
-    stripped = line.strip()
-    if (
-            'smuggle ' in line and
-            # ignore commented-out lines
-            not stripped.startswith('#') and
-            # if smuggle is not the first word, it must be preceded by
-            # a space. Helps handle weird edge cases, e.g.:
-            #   `self.unrelated_attr_that_endswith_smuggle = 1`
-            (stripped.startswith('smuggle ') or ' smuggle ' in stripped)
-    ):
-        indent_len = len(line) - len(line.lstrip(' '))
-        # need to handle lines with comments separately, before checking
-        # for characters (',', ';', etc.) in line so characters in
-        # comments don't cause false-positive
-        if '#' in stripped:
-            # have to deal with the possibility of:
-            #   ```
-            #   smuggle (b, # b onion comment
-            #            c, # c onion comment # non-onion comment
-            #            d) # d onion comment # non-onion comment # etc
-            #   ```
-            # and also:
-            #  ```
-            #   smuggle (    # could even be an arbitrary comment here...
-            #       b,       # b onion comment
-            #   # ... and even comments between lines...
-            #       c,       # c onion comment
-            #       d        # d onion comment # non-onion comment # etc
-            #   )            # ... or on an "empty" line
-            #   ```
-            # or even:
-            #   ```
-            #   smuggle (b,       # b onion comment
-            #            c,       # c onion comment
-            #            d)       # d onion comment # non-onion comment # etc
-            #   ```
-            # - only way this can happen is with parentheses & newlines,
-            #   so we can use that as a queue
-            # - on each line, all text before first # is real code
-            # - can only have one onion comment per module imported, so
-            # we can discount statements that don't start with 'smuggle'
-            if all(c in stripped for c in '()\n'):
-                if stripped.startswith('smuggle'):
-                    sub_lines = []
-                    for _line in stripped.splitlines():
-                        code, sep, comment = _line.partition('#')
-                        code = code.strip(', ')
-                        if code.endswith('(') or code == '' or code == ')':
-                            # - skip empty first lines, i.e. 'smuggle ('
-                            # - skip lines with no code, just comments
-                            # - skip empty last lines, i.e. ')'
-                            continue
-                        else:
-                            _line = f"smuggle {code.strip('() ')} {sep} {comment}"
-                            sub_lines.append(smuggle_parser_colab(_line))
-                elif stripped.startswith('from '):
-                    # can only have one onion comment per package, for
-                    # multiline, import from a single package, it can go
-                    # either on the first line (better) or the last line
-                    # (also allowed).
-                    # NOTE: if there is ANY comment on the first line,
-                    #  even if it is not an onion notation, the last
-                    #  line will not be looked at
-                    _lines = stripped.splitlines()
-                    code, _, comment = _lines[0].partition('#')
-                    pkg_name = code.split()[1]
-                    comment = comment.strip()
-                    if comment == '':
-                        comment = _lines[-1].partition('#')[2].strip()
-                    if comment == '':
-                        sep = ''
-                    else:
-                        sep = ' # '
-                    sub_lines = []
-                    _lines = stripped.split('smuggle (')[1].splitlines()
-                    for _line in _lines:
-                        _line = _line.split('#')[0].strip('), ')
-                        for name in _line.split(','):
-                            if name != '':
-                                _cmd = f'from {pkg_name} smuggle {name}'
-                                sub_lines.append(_cmd)
-                    # add onion comment to first statement so package
-                    # exists when the rest are run
-                    sub_lines[0] = f'{sub_lines[0]}{sep}{comment}'
-                    sub_lines = list(map(smuggle_parser_colab, sub_lines))
-                else:
-                    raise DavosParserError(
-                        "failed to parse multiline smuggle statement"
-                    )
-                line = '; '.join(sub_lines)
-            else:
-                # otherwise, there's only one onion comment
-                # TODO: a way to catch this, which is normally a SyntaxError:
-                #   ```
-                #   import a, \  # a onion comment
-                #          b, \  # b onion comment
-                #          c,    # c onion comment
-                #   ```
-                stripped, _, raw_onion = stripped.partition('#')
-                stripped = stripped.strip()
-                # currently in form: `smuggle(pack.age, as_=['<alias>'|None])`
-                base_smuggle_call = smuggle_parser_colab(stripped)
-                # drop any trailing non-onion comments
-                raw_onion = raw_onion.partition('#')[0]
-                # {param: value} kwargs dict for smuggle_colab()
-                peeled_onion = Onion.parse_onion_syntax(raw_onion)
-                kwargs_list = []
-                for k, v in peeled_onion.items():
-                    if isinstance(v, str):
-                        v = '"' + v + '"'
-                    kwargs_list.append(f'{k}={v}')
-                kwargs_fmt = ', '.join(kwargs_list)
-                # insert the kwargs before the closing parenthesis
-                line = f"{base_smuggle_call[:-1]}, {kwargs_fmt})"
-        elif ';' in stripped:
-            # handles semicolon-separated smuggle calls, e.g.:
-            #   `smuggle os.path; from pathlib smuggle Path; smuggle re`
-            # by running function on each call individually & re-joining
-            line = '; '.join(map(smuggle_parser_colab, stripped.split(';')))
-        elif ',' in stripped:
-            # handles comma-separated list of packages/modules/functions
-            # to smuggle
-            if stripped.startswith('from '):
-                # smuggling multiple names from same package, e.g.:
-                #   `from os.path smuggle dirname, join as opj, realpath`
-                # is transformed internally into multiple smuggle calls:
-                #   `smuggle os.path.dirname as dirname; smuggle os....`
-                from_cmd, names = stripped.split(' smuggle ')
-                names = names.strip('()\n\t ').split(',')
-                all_cmds = [f'{from_cmd} smuggle {name}' for name in names]
-            else:
-                # smuggling multiple packages at once, e.g.:
-                #   `smuggle collections.abc as abc, json, os`
-                # is transformed into multiple smuggle calls, e.g.:
-                #   `smugle collections.abc as abc; smuggle json, sm...`
-                names = stripped.replace('smuggle ', '').split(',')
-                all_cmds = [f'smuggle {name}' for name in names]
-            line = '; '.join(map(smuggle_parser_colab, all_cmds))
-        elif stripped.startswith('from '):
-            # handles smuggle calls in formats like, e.g.:
-            #   `from os.path smuggle join as opj`
-            # by transforming them into, e.g.:
-            #   `smuggle os.path.join as opj
-            # This grammar can sometimes fail with the builtin import
-            # statement, e.g.:
-            #   `import numpy.array as array` # -> ModuleNotFoundError
-            # but IPython.utils.importstring.import_item always succeeds
-            pkg_name, name = stripped.split(' smuggle ')
-            pkg_name = pkg_name.replace('from ', '').strip()
-            if ' as ' in name:
-                name, as_ = name.split(' as ')
-            else:
-                as_ = name
-            name = name.strip('()\n\t ')
-            as_ = as_.strip()
-            full_name = f'{pkg_name}.{name}'
-            line = f'smuggle("{full_name}", as_="{as_}")'
+    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)
+
+    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 = cmd_prefix.split()[1] + '.'
+        to_smuggle = re.sub(r'[()]|\s*\#.*$\s*', ' ', to_smuggle, flags=re.M)
+        to_smuggle = to_smuggle.rstrip(', ')
+    else:
+        # cmd_prefix == ''
+        is_from_statement = False
+        onion_chars = matched_groups['ONION']
+        if onion_chars is not None:
+            onion_chars = onion_chars.replace('"', "'")
+        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:
+        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.strip()}"'
+            alias = f'"{alias.strip()}"'
         else:
-            # standard smuggle call, e.g.:
-            #   `smuggle pandas as pd`
-            pkg_name = stripped.replace('smuggle ', '')
-            if ' as ' in pkg_name:
-                pkg_name, as_ = pkg_name.split(' as ')
-                # add quotes here so None can be passed without them
-                as_ = f'"{as_.strip()}"'
-            else:
-                as_ = None
-            pkg_name = pkg_name.strip('()\n\t ')
-            line = f'smuggle("{pkg_name}", as_={as_})'
-        # restore original indent
-        line = ' ' * indent_len + line
-    return line
+            na = na.strip()
+            name = f'"{qualname_prefix}{na}"'
+            alias = f'"{na}"' if is_from_statement else None
 
+        smuggle_funcs.append(f'smuggle(name={name}, as_={alias})')
 
-smuggle_colab._register = register_smuggler_colab
+    smuggle_funcs[0] = smuggle_funcs[0][:-1] + kwargs_str + ')'
+    return before_chars + '; '.join(smuggle_funcs) + after_chars
diff --git a/davos/core.py b/davos/core.py
index 06b6ae53..f53bbabb 100644
--- a/davos/core.py
+++ b/davos/core.py
@@ -1,208 +1,220 @@
 """
-This module contains common utilities used by by the `smuggle` statement
+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']
+__all__ = ['Onion', 'prompt_input', 'smuggle_statement_regex']
 
 
+import re
 import sys
 from pathlib import Path
-from pkg_resources import (DistributionNotFound, find_distributions,
-                           get_distribution, RequirementParseError,
-                           VersionConflict)
 from subprocess import CalledProcessError
 
+import pkg_resources
+from packaging.requirements import InvalidRequirement
+
 from davos import davos
-from davos.exceptions import InstallerError, OnionSyntaxError
+from davos.exceptions import (
+    InstallerError,
+    OnionParserError,
+    ParserNotImplementedError
+)
+from davos.parsers import pip_parser
 
 
 class Onion:
     # ADD DOCSTRING
     @staticmethod
-    def _extract_arg_value(arg_name, args_str):
-        # NOTE: does not handle args with multiple space-separated values
-        if arg_name not in args_str:
-            return None, args_str
-        pre, post = args_str.split(arg_name)
-        pre = pre.strip()
-        post = post.strip()
-        arg_val, _, post = post.partition(' ')
-        if arg_val.startswith('-') or arg_val == '':
-            raise OnionSyntaxError(f"'{arg_name}' flag requires an argument")
-        new_args_str = ' '.join((pre, post))
-        return arg_val, new_args_str
-
-    @staticmethod
-    def parse_onion_syntax(comment_text):
-        # TODO: parse comment string for install arguments, use
-        #  https://github.com/ContextLab/CDL-docker-stacks/blob/master/CI/conda_environment.py#L56-L65
-        #  for splitting version string
-        # TODO: handle scenario where it's an unrelated, non-onion
-        #  comment
-        # TODO: replace short arg versions with long versions to account
-        #  for combinations, e.g. `pip install -UIt <target> package`
-        # NOTE: returns a dict of {param: value} for each, CLI args that
-        #  don't take a value should be ... so they don't
-        #  conflict with None values in constructor
-        # minimal fields that should always be in returned dict
-        peeled_onion = {
-            'installer': 'pip',
-            'installer_args': ''
-            # 'install_name': None,
-            # 'version_spec': None,
-            # 'egg': None,
-            # 'subdirectory': None,
-            # 'build': None
-        }
-        comment_text = comment_text.strip()
-        installer, sep, arg_str = comment_text.partition(':')
-        if installer == 'conda':
-            peeled_onion['installer'] = 'conda'
-        elif installer != 'pip' and installer != 'pypi':
-            # unrelated, non-onion inline comment
-            return peeled_onion
-        if sep == '':
-            # no colon, no install arguments other than installer name
-            return peeled_onion
-        version_spec, _, arg_str = arg_str.strip().partition(' ')
-        # split on ',' to account for 'pkg>=1.0,<=2.0' syntax
-        first_subspec = version_spec.split(',')[0]
-        for spec_delim in ('==', '<=', '>=', '!=', '~=', '<', '>', '='):
-            if spec_delim in first_subspec:
-                inst_name = version_spec[:version_spec.index(spec_delim)]
-                ver_spec = version_spec[version_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)
-                    peeled_onion['build'] = build
-                break
-        else:
-            # either no version specified or installing from VCS, local
-            # project, local/remote archive, wheel file, etc.
-            ver_spec = None
-            inst_name = version_spec
-            if '+' in version_spec:
-                # pip-installing from VCS (e.g., git+https://...)
-                # requires special handling of --egg & --subdirectory
-                # arguments that would normally be part of URL but can't
-                # due to Onion format
-                egg_name, arg_str = Onion._extract_arg_value('--egg', arg_str)
-                subdir_path, arg_str = Onion._extract_arg_value('--subdirectory',
-                                                               arg_str)
-                peeled_onion['egg'] = egg_name
-                peeled_onion['subdirectory'] = subdir_path
-        # --editable flag must go immediately before package, so parse
-        # it out so it can be placed manually later
-        if '--editable' in arg_str:
-            peeled_onion['editable'] = True
-            arg_str = arg_str.replace('--editable', '')
+    def parse_onion(onion_text):
+        onion_text = onion_text.lstrip('# ')
+        installer, args_str = onion_text.split(':', maxsplit=1)
+        # remove all space between '<installer>:' and '<args_str>'
+        args_str = args_str.lstrip()
+        # 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:
-            peeled_onion['editable'] = False
-        peeled_onion['install_name'] = inst_name
-        peeled_onion['version_spec'] = ver_spec
-        peeled_onion['installer_args'] = arg_str
-        return peeled_onion
-
-    def __init__(self, import_name, installer='pip', install_name=None,
-                 version_spec=None, build=None, editable=False, egg=None,
-                 subdirectory=None, installer_args=''):
+            # 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
-        # TODO: what happens if force-reinstall, ignore-installed, etc.
-        #  and module is already in sys.modules?
-        if installer == 'pip' or installer == 'pypi':
+        self.import_name = package_name
+        if installer == 'pip':
             self.install_package = self._pip_install_package
         elif installer == 'conda':
-            self.install_package = self._conda_install_package
+            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 'pip' and 'conda'"
+                "installers are:\n\t'pip'"  # and 'conda'"
             )
-        self.import_name = import_name
-        self.installer = installer
-        if install_name is None:
-            self.install_name = import_name
+        self.build = None
+        self.args_str = args_str
+        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:
-            self.install_name = install_name
-        self.version_spec = version_spec
-        self.build = build
-        self.editable = editable
-        self.egg = egg
-        self.subdirectory = subdirectory
-        self.installer_args = installer_args
+            # 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):
-        # args that trigger install regardless of installed version
+        installer_kwargs = self.installer_kwargs
         if (
-                '--force-reinstall' in self.installer_args or
-                '--ignore-installed' in self.installer_args or
-                '--upgrade' in self.installer_args
+                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.install_name in davos.smuggled:
-            if davos.smuggled[self.install_name] == self.version_spec:
-                return True
-            else:
-                return False
-        elif '+' in self.install_name:
-            # unless same package, URL, & tag were already smuggled in
-            # this session, installing from VCS always triggers install
-            # because versions are too specific to compare reliably
-            # (e.g., could install from many different git commit hashes
-            # tagged with the same version)
-            return False
-        elif Path(self.install_name).expanduser().resolve().exists():
-            # installing from local file or directory
-            local_path = str(Path(self.install_name).expanduser().resolve())
-            return any(find_distributions(local_path, only=True))
-        else:
-            dist_spec = self.install_name
-            if self.version_spec is not None:
-                dist_spec += self.version_spec
+        elif self.args_str == davos.smuggled.get(self.import_name):
+            # 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:
-                get_distribution(dist_spec)
-            except (DistributionNotFound, VersionConflict, RequirementParseError):
-                # DistributionNotFound: package is not installed
-                # VersionConflict: package is installed, but installed
-                #   version doesn't fit requested version constraints
-                # RequirementParserError: version_spec is invalid or
-                # pkg_resources couldn't parse it
+                pkg_resources.get_distribution(full_spec)
+            except (
+                # requested package is not installed
+                pkg_resources.DistributionNotFound,
+                # 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 _pip_install_package(self):
-        # TODO: would be more efficient to use nullcontext here as long
-        #  as it works in ipynb
-        # TODO: add support for all kinds of non-index installs (see
-        #  https://pip.pypa.io/en/stable/reference/pip_install/)
-        install_name = self.install_name
-        if '+' in install_name:
-            vcs_field_sep= '#'
-            if self.egg is not None:
-                install_name += vcs_field_sep + self.egg
-                vcs_field_sep = '&'
-            if self.subdirectory is not None:
-                install_name += vcs_field_sep + self.subdirectory
-        elif self.version_spec is not None:
-            install_name += self.version_spec
-        cmd_str = f'pip install {self.installer_args} {install_name}'
+        # 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)
+            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)
-        else:
-            return stdout, exit_code
+        # hanle 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 _conda_install_package(self):
         raise NotImplementedError(
@@ -249,3 +261,121 @@ def prompt_input(prompt, default=None, interrupt=None):
                 raise
         except KeyError:
             pass
+
+
+_smuggle_subexprs = {
+    'name_re': r'[a-zA-Z]\w*',
+    'qualname_re': r'[a-zA-Z][\w.]*\w',
+    'onion_re': r'\#+ *pip:.+?(?= +\#| *\n| *$)',
+    'comment_re': r'(?m:\#+.*$)'
+}
+_smuggle_subexprs['as_re'] = fr' +as +{_smuggle_subexprs["name_re"]}'
+
+# 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 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 
+                                                                          #   (matches empty string with positive lookahead assertion 
+                                                                          #   so group gets defined without adding to full match)
+            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 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 open 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.]*\w(?: +as +[a-zA-Z]\w*)?(?:
+#  *, *[a-zA-Z][\w.]*\w(?: +as +[a-zA-Z]\w*)?)*(?P<SEMICOLON_SEP>(?= *;
+# *(?:smuggle|from)))?(?(SEMICOLON_SEP)|(?: *(?=\#+ *pip:.+?(?= +\#| *\n
+# | *$))(?P<ONION>\#+ *pip:.+?(?= +\#| *\n| *$))?)?))|(?:from *[a-zA-Z][
+# \w.]*\w +smuggle +(?P<OPEN_PARENS>\()?(?(OPEN_PARENS)(?: *(?:[a-zA-Z]\
+# w*(?: +as +[a-zA-Z]\w*)? *(?:, *[a-zA-Z]\w*(?: +as +[a-zA-Z]\w*)? *)*,
+# ? *)?(?:(?P<FROM_ONION_1>\#+ *pip:.+?(?= +\#| *\n| *$)) *(?m:\#+.*$)?|
+# (?m:\#+.*$)|(?m:$)|(?P<CLOSE_PARENS_FIRSTLINE>\)))(?(CLOSE_PARENS_FIRS
+# TLINE)|(?:\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<F
+# ROM_SEMICOLON_SEP>(?= *; *(?:smuggle|from)))?(?(FROM_SEMICOLON_SEP)|(?
+# (FROM_ONION_1)|(?: *(?=\#+ *pip:.+?(?= +\#| *\n| *$))(?P<FROM_ONION>\#
+# + *pip:.+?(?= +\#| *\n| *$)))?))))
diff --git a/davos/core.pyi b/davos/core.pyi
index 6ffe7cb3..fe5b42cc 100644
--- a/davos/core.pyi
+++ b/davos/core.pyi
@@ -1,3 +1,4 @@
+from re import Pattern
 from typing import Literal, Optional
 
 InputOpts = Literal['y', 'yes', 'n', 'no']
@@ -45,4 +46,6 @@ def prompt_input(
 #     _register: Callable[[], None]
 #
 class Onion(object):
-    pass
\ No newline at end of file
+    pass
+
+smuggle_statement_regex: Pattern
\ No newline at end of file
diff --git a/davos/exceptions.py b/davos/exceptions.py
index ce94d3ef..9f7d546a 100644
--- a/davos/exceptions.py
+++ b/davos/exceptions.py
@@ -1,26 +1,144 @@
+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(DavosError):
-    # class for errors raised during the parsing step
+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
+    """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.)
+    """
+    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]
@@ -56,33 +174,3 @@ def __init__(self, msg, *args, output=None, stderr=None,
 
     def __str__(self):
         return self.msg + '\n\t' + super().__str__()
-
-
-class OnionError(SmugglerError):
-    # general class for errors related to the Onion structure/object
-    pass
-
-
-class OnionTypeError(OnionError, TypeError):
-    # class analogous to TypeError, but for invalid params specified in
-    # Onion construct
-    pass
-
-
-class OnionValueError(OnionError, ValueError):
-    # class analogous to ValueError, but for bad values passed to Onion
-    # construct params
-    pass
-
-
-class OnionSyntaxError(OnionError, SyntaxError):
-    # class analogous to SyntaxError, but for invalid Syntax in Onion
-    # construct
-    def __init__(self, msg, *args, filename=None, lineno=None, offset=None):
-        # TODO: format kwargs into tuple to init super() with correct
-        #  format to raise at specific location, see
-        #  https://stackoverflow.com/questions/33717804/python-raise-syntaxerror-with-lineno
-        super().__init__(msg, *args)
-        self.filename = filename
-        self.lineno = lineno
-        self.offset = offset
\ No newline at end of file
diff --git a/davos/jupyter.py b/davos/jupyter.py
index 029208fd..ab8a8adf 100644
--- a/davos/jupyter.py
+++ b/davos/jupyter.py
@@ -1,20 +1,50 @@
+# ADD DOCSTRING
+
+
 __all__ = [
-    'register_smuggler_jupyter',
+    'activate_parser_jupyter',
+    'check_parser_active_jupyter',
+    'deactivate_parser_jupyter',
     'run_shell_command_jupyter',
-    'smuggle_jupyter'
+    'smuggle_jupyter',
+    'smuggle_parser_jupyter'
 ]
 
 
-def register_smuggler_jupyter():
-    raise NotImplementedError("davos does not currently support Jupyter notebooks")
+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 NotImplementedError("davos does not currently support Jupyter notebooks")
+    raise ParserNotImplementedError(
+        "davos does not currently support Jupyter notebooks"
+    )
 
 
 def smuggle_jupyter():
-    raise NotImplementedError("davos does not currently support Jupyter notebooks")
+    raise NotImplementedError(
+        "davos does not currently support Jupyter notebooks"
+    )
 
 
-smuggle_jupyter._register_smuggler = register_smuggler_jupyter
+def smuggle_parser_jupyter(line):
+    raise ParserNotImplementedError(
+        "davos does not currently support Jupyter notebooks"
+    )
diff --git a/davos/parsers.py b/davos/parsers.py
new file mode 100644
index 00000000..576e220a
--- /dev/null
+++ b/davos/parsers.py
@@ -0,0 +1,505 @@
+from argparse import (
+    Action,
+    ArgumentError,
+    ArgumentTypeError,
+    ArgumentParser,
+    SUPPRESS
+)
+
+from davos.exceptions import OnionArgumentError
+
+
+class OnionParser(ArgumentParser):
+    # ADD DOCSTRING
+    def parse_args(self, args, namespace=None):
+        self._args = ' '.join(args)
+        try:
+            try:
+                ns, extras = super().parse_known_args(args=args,
+                                                      namespace=namespace)
+            except ArgumentError as e:
+                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)
+            else:
+                if extras:
+                    msg = f"Unrecognized arguments: {' '.join(extras)}"
+                    raise OnionArgumentError(msg=msg, argument=extras[0],
+                                             onion_txt=self._args)
+                return ns
+        finally:
+            self._args = None
+
+    def error(self, message):
+        # ADD DOCSTRING
+        raise OnionArgumentError(msg=message, onion_txt=self._args)
+
+
+class EditableAction(Action):
+    # ADD DOCSTRING
+    def __init__(
+            self,
+            option_strings,
+            dest,
+            default=None,
+            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
+        super().__init__(option_strings, dest, default=default,
+                         metavar=metavar, help=help)
+
+    def __call__(self, parser, namespace, values, option_string=None):
+        setattr(namespace, 'editable', True)
+        setattr(namespace, 'spec', values)
+
+
+class SubtractAction(Action):
+    # ADD DOCSTRING
+    def __init__(
+            self,
+            option_strings,
+            dest,
+            default=None,
+            required=False,
+            help=None
+    ):
+        # ADD DOCSTRING
+        super().__init__(option_strings=option_strings, dest=dest, nargs=0,
+                         default=default, required=required, help=help)
+
+    def __call__(self, parser, namespace, values, option_string=None):
+        curr_count = getattr(namespace, self.dest, 0)
+        setattr(namespace, self.dest, curr_count - 1)
+
+
+# does not include usage for `pip install [options] -r
+# <requirements file> [package-index-options] ...` since it's not
+# relevant to `davos` usage
+_pip_install_usage = '\n  '.join("""
+pip install [options] <requirement specifier> [package-index-options] ...
+pip install [options] [-e] <vcs project url> ...
+pip install [options] [-e] <local project path> ...
+pip install [options] <archive url/path> ...
+""".splitlines())
+
+pip_parser = OnionParser(usage=_pip_install_usage,
+                         add_help=False,
+                         argument_default=SUPPRESS)
+
+
+# ======== Install Options ========
+pip_install_opts = pip_parser.add_argument_group(title="Install Options")
+# pip_install_opts.add_argument(
+#     '-r',
+#     '--requirement',
+#     metavar='<file>',
+#     help="Install from the given requirements file. This option can be used "
+#          "multiple times."
+# )
+# pip_install_opts.add_argument(
+#     '-c',
+#     '--constraint',
+#     metavar='<file>',
+#     help="Constrain versions using the given constraints file. This option "
+#          "can be used multiple times."
+# )
+pip_install_opts.add_argument(
+    '--no-deps',
+    action='store_true',
+    help="Don't install package dependencies."
+)
+pip_install_opts.add_argument(
+    '--pre',
+    action='store_true',
+    help="Include pre-release and development versions. By default, pip only "
+         "finds stable versions."
+)
+
+spec_or_editable = pip_install_opts.add_mutually_exclusive_group(required=True)
+spec_or_editable.add_argument('spec', nargs='?', help=SUPPRESS)
+spec_or_editable.add_argument(
+    '-e',
+    '--editable',
+    action=EditableAction,
+    default=False,
+    metavar='<path/url>',
+    help='Install a project in editable mode (i.e. setuptools "develop mode") '
+         'from a local project path or a VCS url.'
+)
+
+pip_install_opts.add_argument(
+    '-t',
+    '--target',
+    metavar='<dir>',
+    help="Install packages into <dir>. By default this will not replace "
+         "existing files/folders in <dir>. Use --upgrade to replace existing "
+         "packages in <dir> with new versions."
+)
+pip_install_opts.add_argument(
+    '--platform',
+    metavar='<platform>',
+    help="Only use wheels compatible with <platform>. Defaults to the "
+         "platform of the running system. Use this option multiple times to "
+         "specify multiple platforms supported by the target interpreter."
+)
+pip_install_opts.add_argument(
+    '--python-version',
+    metavar='<python_version>',
+    help='The Python interpreter version to use for wheel and '
+         '"Requires-Python" compatibility checks. Defaults to a version '
+         'derived from the running interpreter. The version can be specified '
+         'using up to three dot-separated integers (e.g. "3" for 3.0.0, "3.7" '
+         'for 3.7.0, or "3.7.3"). A major-minor version can also be given as '
+         'a string without dots (e.g. "37" for 3.7.0).'
+)
+pip_install_opts.add_argument(
+    '--implementation',
+    metavar='<implementation>',
+    help="Only use wheels compatible with Python implementation "
+         "<implementation>, e.g. 'pp', 'jy', 'cp', or 'ip'. If not specified, "
+         "then the current interpreter implementation is used. Use 'py' to "
+         "force implementation-agnostic wheels."
+)
+pip_install_opts.add_argument(
+    '--abi',
+    metavar='<abi>',
+    help="Only use wheels compatible with Python abi <abi>, e.g. 'pypy_41'. "
+         "If not specified, then the current interpreter abi tag is used. Use "
+         "this option multiple times to specify multiple abis supported by "
+         "the target interpreter. Generally you will need to specify "
+         "--implementation, --platform, and --python-version when using this "
+         "option."
+)
+pip_install_opts.add_argument(
+    '--user',
+    action='store_true',
+    help="Install to the Python user install directory for your platform. "
+         "Typically ~/.local/, or %%APPDATA%%Python on Windows. (See the Python "
+         "documentation for site.USER_BASE for full details.)"
+)
+pip_install_opts.add_argument(
+    '--root',
+    metavar='<dir>',
+    help="Install everything relative to this alternate root directory."
+)
+pip_install_opts.add_argument(
+    '--prefix',
+    metavar='<dir>',
+    help="Installation prefix where lib, bin and other top-level folders are "
+         "placed"
+)
+pip_install_opts.add_argument(
+    '--src',
+    metavar='<dir>',
+    help='Directory to check out editable projects into. The default in a '
+         'virtualenv is "<venv path>/src". The default for global installs is '
+         '"<current dir>/src".'
+)
+pip_install_opts.add_argument(
+    '-U',
+    '--upgrade',
+    action='store_true',
+    help="Upgrade all specified packages to the newest available version. The "
+         "handling of dependencies depends on the upgrade-strategy used."
+)
+pip_install_opts.add_argument(
+    '--upgrade-strategy',
+    metavar='<upgrade_strategy>',
+    help='Determines how dependency upgrading should be handled [default: '
+         'only-if-needed]. "eager" - dependencies are upgraded regardless of '
+         'whether the currently installed version satisfies the requirements '
+         'of the upgraded package(s). "only-if-needed" - are upgraded only '
+         'when they do not satisfy the requirements of the upgraded '
+         'package(s).'
+)
+pip_install_opts.add_argument(
+    '--force-reinstall',
+    action='store_true',
+    help="Reinstall all packages even if they are already up-to-date."
+)
+pip_install_opts.add_argument(
+    '-I',
+    '--ignore-installed',
+    action='store_true',
+    help="Ignore the installed packages, overwriting them. This can break "
+         "your system if the existing package is of a different version or "
+         "was installed with a different package manager!"
+)
+pip_install_opts.add_argument(
+    '--ignore-requires-python',
+    action='store_true',
+    help="Ignore the Requires-Python information."
+)
+pip_install_opts.add_argument(
+    '--no-build-isolation',
+    action='store_true',
+    help="Disable isolation when building a modern source distribution. Build "
+         "dependencies specified by PEP 518 must be already installed if this "
+         "option is used."
+)
+
+pep_517_subgroup = pip_install_opts.add_mutually_exclusive_group()
+pep_517_subgroup.add_argument(
+    '--use-pep517',
+    action='store_true',
+    help="Use PEP 517 for building source distributions (use --no-use-pep517 "
+         "to force legacy behaviour)."
+)
+pep_517_subgroup.add_argument(
+    '--no-use-pep517',
+    action='store_true',
+    help=SUPPRESS
+)
+
+pip_install_opts.add_argument(
+    '--install-option',
+    action='append',
+    metavar='<options>',
+    help='Extra arguments to be supplied to the setup.py install command (use '
+         'like --install-option="--install-scripts=/usr/local/bin"). Use '
+         'multiple --install-option options to pass multiple options to '
+         'setup.py install. If you are using an option with a directory path, '
+         'be sure to use absolute path.'
+)
+pip_install_opts.add_argument(
+    '--global-option',
+    action='append',
+    metavar='<options>',
+    help="Extra global options to be supplied to the setup.py call before the "
+         "install command."
+)
+
+compile_subgroup = pip_install_opts.add_mutually_exclusive_group()
+compile_subgroup.add_argument(
+    '--compile',
+    action='store_true',
+    help="Compile Python source files to bytecode"
+)
+compile_subgroup.add_argument(
+    '--no-compile',
+    action='store_true',
+    help="Do not compile Python source files to bytecode"
+)
+
+pip_install_opts.add_argument(
+    '--no-warn-script-location',
+    action='store_true',
+    help="Do not warn when installing scripts outside PATH"
+)
+pip_install_opts.add_argument(
+    '--no-warn-conflicts',
+    action='store_true',
+    help="Do not warn about broken dependencies"
+)
+# NOTE: in the actual pip-install implementation, `--no-binary`,
+#  `--only-binary`, and `--prefer-binary` triggers a fairly complex
+#  callback. But fortunately, we can just store all invocations and
+#  forward them to the real pip-install parser
+pip_install_opts.add_argument(
+    '--no-binary',
+    action='append',
+    metavar='<format_control>',
+    help='Do not use binary packages. Can be supplied multiple times, and '
+         'each time adds to the existing value. Accepts either ":all:" to '
+         'disable all binary packages, ":none:" to empty the set (notice the '
+         'colons), or one or more package names with commas between them (no '
+         'colons). Note that some packages are tricky to compile and may fail '
+         'to install when this option is used on them.'
+)
+pip_install_opts.add_argument(
+    '--only-binary',
+    action='append',
+    metavar='<format_control>',
+    help='Do not use source packages. Can be supplied multiple times, and '
+         'each time adds to the existing value. Accepts either ":all:" to '
+         'disable all source packages, ":none:" to empty the set, or one or '
+         'more package names with commas between them. Packages without '
+         'binary distributions will fail to install when this option is used '
+         'on them.'
+)
+pip_install_opts.add_argument(
+    '--prefer-binary',
+    action='store_true',
+    help="Prefer older binary packages over newer source packages."
+)
+pip_install_opts.add_argument(
+    '--require-hashes',
+    action='store_true',
+    help="Require a hash to check each requirement against, for repeatable "
+         "installs. This option is implied when any package in a requirements "
+         "file has a --hash option."
+)
+pip_install_opts.add_argument(
+    '--progress-bar',
+    choices=('off', 'on', 'ascii', 'pretty', 'emoji'),
+    metavar='<progress_bar>',
+    help="Specify type of progress to be displayed "
+         "[off|on|ascii|pretty|emoji] (default: on)"
+)
+pip_install_opts.add_argument(
+    '--no-clean',
+    action='store_true',
+    help="Don’t clean up build directories."
+)
+
+
+# ======== Package Index Options ========
+pip_index_opts = pip_parser.add_argument_group(title="Package Index Options")
+pip_index_opts.add_argument(
+    '-i',
+    '--index-url',
+    metavar='<url>',
+    help="Base URL of the Python Package Index (default "
+         "https://pypi.org/simple). This should point to a repository "
+         "compliant with PEP 503 (the simple repository API) or a local "
+         "directory laid out in the same format."
+)
+pip_index_opts.add_argument(
+    '--extra-index-url',
+    action='append',
+    metavar='<url>',
+    help="Extra URLs of package indexes to use in addition to --index-url. "
+         "Should follow the same rules as --index-url."
+)
+pip_index_opts.add_argument(
+    '--no-index',
+    action='store_true',
+    help="Ignore package index (only looking at --find-links URLs instead)."
+)
+pip_index_opts.add_argument(
+    '-f',
+    '--find-links',
+    action='append',
+    metavar='<url>',
+    help="If a URL or path to an html file, then parse for links to archives "
+         "such as sdist (.tar.gz) or wheel (.whl) files. If a local path or "
+         "file:// URL that’s a directory, then look for archives in the "
+         "directory listing. Links to VCS project URLs are not supported."
+)
+
+
+# ======== General Options ========
+pip_general_opts = pip_parser.add_argument_group(title='General Options')
+pip_general_opts.add_argument(
+    '--isolated',
+    action='store_true',
+    help="Run pip in an isolated mode, ignoring environment variables and "
+         "user configuration."
+)
+# verbose and quiet should theoretically be mutally exclusive, but pip
+# itself doesn't seem to implement them as such, so not worth doing so
+# here
+pip_general_opts.add_argument(
+    '-v',
+    '--verbose',
+    action='count',
+    dest='verbosity',
+    help="Give more output. Option is additive, and can be used up to 3 times."
+)
+pip_general_opts.add_argument(
+    '-q',
+    '--quiet',
+    action=SubtractAction,
+    dest='verbosity',
+    help="Give less output. Option is additive, and can be used up to 3 times "
+         "(corresponding to WARNING, ERROR, and CRITICAL logging levels)."
+)
+pip_general_opts.add_argument(
+    '--log',
+    metavar='<path>',
+    help="Path to a verbose appending log."
+)
+pip_general_opts.add_argument(
+    '--retries',
+    type=int,
+    metavar='<retries>',
+    help="Maximum number of retries each connection should attempt (default 5 "
+         "times)."
+)
+pip_general_opts.add_argument(
+    '--timeout',
+    type=float,
+    metavar='<sec>',
+    help="Set the socket timeout (default 15 seconds)."
+)
+pip_general_opts.add_argument(
+    '--exists-action',
+    choices=(
+        's',
+        'switch',
+        'i',
+        'ignore',
+        'w',
+        'wipe',
+        'b',
+        'backup',
+        'a',
+        'abort'
+    ),
+    metavar='<action>',
+    help="Default action when a path already exists: (s)witch, (i)gnore, "
+         "(w)ipe, (b)ackup, (a)bort."
+)
+pip_general_opts.add_argument(
+    '--trusted-host',
+    metavar='<hostname>',
+    help="Mark this host or host:port pair as trusted, even though it does "
+         "not have valid or any HTTPS."
+)
+pip_general_opts.add_argument(
+    '--cert',
+    metavar='<path>',
+    help="Path to alternate CA bundle."
+)
+pip_general_opts.add_argument(
+    '--client-cert',
+    metavar='<path>',
+    help="Path to SSL client certificate, a single file containing the "
+         "private key and the certificate in PEM format."
+)
+
+cache_dir_subgroup = pip_general_opts.add_mutually_exclusive_group()
+cache_dir_subgroup.add_argument(
+    '--cache-dir',
+    metavar='<dir>',
+    help="Store the cache data in <dir>."
+)
+cache_dir_subgroup.add_argument(
+    '--no-cache-dir',
+    action='store_true',
+    help="Disable the cache."
+)
+
+pip_general_opts.add_argument(
+    '--disable-pip-version-check',
+    action='store_true',
+    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."
+)
+pip_general_opts.add_argument(
+    '--no-python-version-warning',
+    action='store_true',
+    help="Silence deprecation warnings for upcoming unsupported Pythons."
+)
+pip_general_opts.add_argument(
+    '--use-feature',
+    metavar='<feature>',
+    help="Enable new functionality, that may be backward incompatible."
+)
+pip_general_opts.add_argument(
+    '--use-deprecated',
+    metavar='<feature>',
+    help="Enable deprecated functionality, that will be removed in the future."
+)
diff --git a/davos/python.py b/davos/python.py
index c434b1c8..b4c76073 100644
--- a/davos/python.py
+++ b/davos/python.py
@@ -1,23 +1,43 @@
+# ADD DOCSTRING
+
+
 __all__ = [
-    'register_smuggler_python',
+    'activate_parser_python',
+    'check_parser_active_python',
+    'deactivate_parser_python',
     'run_shell_command_python',
-    'smuggle_python'
+    'smuggle_python',
+    'smuggle_parser_python'
 ]
 
+
 import locale
 import shlex
 import signal
 import sys
 from subprocess import CalledProcessError, PIPE, Popen
 
-from davos import davos
+
+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 register_smuggler_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())
@@ -40,7 +60,12 @@ def run_shell_command_python(command):
 
 
 def smuggle_python():
-    raise NotImplementedError("davos does not currently support pure Python interpreters")
+    raise NotImplementedError(
+        "davos does not currently support pure Python interpreters"
+    )
 
 
-smuggle_python._register_smuggler = register_smuggler_python
+def smuggle_parser_python(script_text):
+    raise NotImplementedError(
+        "davos does not currently support pure Python interpreters"
+    )
diff --git a/setup.cfg b/setup.cfg
index 0392e0de..8aa76e30 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -29,7 +29,6 @@ classifiers =
     Typing :: Typed
 
 [options]
-install_requires = packaging
 python_requires = >=3.6
 tests_require =
     colabtools==0.0.1


Test patch:
diff --git a/tests/test_davos_colab.ipynb b/tests/test_davos_colab.ipynb
new file mode 100644
index 00000000..d35209bc
--- /dev/null
+++ b/tests/test_davos_colab.ipynb
@@ -0,0 +1,1788 @@
+{
+  "nbformat": 4,
+  "nbformat_minor": 0,
+  "metadata": {
+    "colab": {
+      "name": "test-davos-colab.ipynb",
+      "provenance": [],
+      "collapsed_sections": []
+    },
+    "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": "8a7bc4a4-bd32-45f6-9f2a-866c35793a90"
+      },
+      "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-tgjr5kp0/davos\n",
+            "  Running command git clone -q https://github.com/paxtonfitzpatrick/davos.git /tmp/pip-install-tgjr5kp0/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=27256 sha256=386b5cbd872b78a7d1112dca7f850c1e82c7eaf3caf0c4c645a3ec20b7accbe0\n",
+            "  Stored in directory: /tmp/pip-ephem-wheel-cache-5z6d2tok/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,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",
+        "    # 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",
+        "    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": "486b483a-a489-4e1f-b815-e200925e84f9"
+      },
+      "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