Repository: opsdroid/opsdroid

Issue:
Add support for IPython/Jupyter Notebooks
I've found myself using [Jupyter Notebooks](http://jupyter.org/) more and more and I think it would be a great way to write opsdroid skills. 

The notebooks are a mixture of code, output from code (print statements, matploptlib plots, etc) and markdown. They are stored as a json blob of the cells with the file extension `.ipynb`.

There is a tool called [nbconvert](https://github.com/jupyter/nbconvert) which can convert these files into python scripts. We could wrap this in the opsdroid loader so a user could set a skill path to an `.ipynb` file and when it is loaded it will get converted into a python script and loaded like a regular single file skill.

We could also add some debugging tools which would allow us to test the skill from within the notebook. 


Discussion:

Resolving patch:
diff --git a/opsdroid/helper.py b/opsdroid/helper.py
--- a/opsdroid/helper.py
+++ b/opsdroid/helper.py
@@ -6,6 +6,9 @@
 import logging
 import filecmp
 
+import nbformat
+from nbconvert import PythonExporter
+
 _LOGGER = logging.getLogger(__name__)
 
 
@@ -75,3 +78,36 @@ def move_config_to_appdir(src, dst):
                      src, dst)
         if filecmp.cmp(original_file, copied_file):
             os.remove(original_file)
+
+
+def file_is_ipython_notebook(path):
+    """Check whether a file is an iPython Notebook.
+
+    Args:
+        path (str): path to the file.
+
+    Examples:
+        path : source path with .ipynb file '/path/src/my_file.ipynb.
+
+    """
+    return path.lower().endswith('.ipynb')
+
+
+def convert_ipynb_to_script(notebook_path, output_path):
+    """Convert an iPython Notebook to a python script.
+
+    Args:
+        notebook_path (str): path to the notebook file.
+        output_path (str): path to the script file destination.
+
+    Examples:
+        notebook_path : source path with .ipynb file '/path/src/my_file.ipynb.
+        output_path : destination path with .py file '/path/src/my_file.py.
+
+    """
+    with open(notebook_path, 'r') as notebook_path_handle:
+        raw_notebook = notebook_path_handle.read()
+        notebook = nbformat.reads(raw_notebook, as_version=4)
+        script, _ = PythonExporter().from_notebook_node(notebook)
+        with open(output_path, 'w') as output_path_handle:
+            output_path_handle.write(script)
diff --git a/opsdroid/loader.py b/opsdroid/loader.py
--- a/opsdroid/loader.py
+++ b/opsdroid/loader.py
@@ -10,7 +10,9 @@
 import re
 from collections import Mapping
 import yaml
-from opsdroid.helper import move_config_to_appdir
+from opsdroid.helper import (
+    move_config_to_appdir, file_is_ipython_notebook,
+    convert_ipynb_to_script)
 from opsdroid.const import (
     DEFAULT_GIT_URL, MODULES_DIRECTORY, DEFAULT_MODULES_PATH,
     DEFAULT_MODULE_BRANCH, DEFAULT_CONFIG_PATH, EXAMPLE_CONFIG_FILE,
@@ -428,9 +430,11 @@ def _install_local_module(config):
 
         if os.path.isfile(config["path"]):
             os.makedirs(config["install_path"], exist_ok=True)
-            shutil.copyfile(config["path"],
-                            os.path.join(config["install_path"],
-                                         "__init__.py"))
+            init_path = os.path.join(config["install_path"], "__init__.py")
+            if file_is_ipython_notebook(config["path"]):
+                convert_ipynb_to_script(config["path"], init_path)
+            else:
+                shutil.copyfile(config["path"], init_path)
             installed = True
 
         if not installed:


Test patch:
diff --git a/tests/configs/broken.yaml b/tests/configs/broken.yaml
--- a/tests/configs/broken.yaml
+++ b/tests/configs/broken.yaml
@@ -1 +1 @@
-unbalanced blackets: ][
+unbalanced brackets: ][
diff --git a/tests/mockmodules/skills/test_notebook.ipynb b/tests/mockmodules/skills/test_notebook.ipynb
new file mode 100644
--- /dev/null
+++ b/tests/mockmodules/skills/test_notebook.ipynb
@@ -0,0 +1,45 @@
+{
+ "cells": [
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from opsdroid.matchers import match_regex"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "@match_regex(r'ping')\n",
+    "async def ping(opsdroid, config, message):\n",
+    "    await message.respond('pong')"
+   ]
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "Python 3",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.6.5"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 2
+}
diff --git a/tests/test_helper.py b/tests/test_helper.py
--- a/tests/test_helper.py
+++ b/tests/test_helper.py
@@ -4,7 +4,9 @@
 import unittest
 import unittest.mock as mock
 
-from opsdroid.helper import del_rw, move_config_to_appdir
+from opsdroid.helper import (
+    del_rw, move_config_to_appdir, file_is_ipython_notebook,
+    convert_ipynb_to_script)
 
 
 class TestHelper(unittest.TestCase):
@@ -32,3 +34,16 @@ def test_move_config(self):
             self.assertTrue(mock_mkdir.called)
             self.assertTrue(logmock.called)
             self.assertTrue(mock_remove.called)
+
+    def test_file_is_ipython_notebook(self):
+        self.assertTrue(file_is_ipython_notebook('test.ipynb'))
+        self.assertFalse(file_is_ipython_notebook('test.py'))
+
+    def test_convert_ipynb_to_script(self):
+        notebook_path = \
+            os.path.abspath("tests/mockmodules/skills/test_notebook.ipynb")
+
+        with tempfile.NamedTemporaryFile(
+                mode='w', delete=False) as output_file:
+            convert_ipynb_to_script(notebook_path, output_file.name)
+            self.assertTrue(os.path.getsize(output_file.name) > 0)
diff --git a/tests/test_loader.py b/tests/test_loader.py
--- a/tests/test_loader.py
+++ b/tests/test_loader.py
@@ -467,6 +467,21 @@ def test_install_local_module_file(self):
             config["install_path"], "__init__.py")))
         shutil.rmtree(config["install_path"], onerror=del_rw)
 
+    def test_install_local_module_notebook(self):
+        opsdroid, loader = self.setup()
+        config = {"name": "slack",
+                  "type": "connector",
+                  "install_path": os.path.join(
+                      self._tmp_dir, "test_local_module_file"),
+                  "path": os.path.abspath(
+                      "tests/mockmodules/skills/test_notebook.ipynb")}
+        directory, _ = os.path.split(config["path"])
+        os.makedirs(directory, exist_ok=True, mode=0o777)
+        loader._install_local_module(config)
+        self.assertTrue(os.path.isfile(os.path.join(
+            config["install_path"], "__init__.py")))
+        shutil.rmtree(config["install_path"], onerror=del_rw)
+
     def test_install_local_module_failure(self):
         opsdroid, loader = self.setup()
         config = {"name": "slack",