pyramid-notebook 0.3.0
Embed IPython Notebook shell on your Pyramid website
License: MIT
Requires Python: >=3.5.2
Keywords: ipython setuptools development shell uwsgi

================
Pyramid Notebook
================

*pyramid_notebook* embeds IPython Notebook shell on your Pyramid web site. Start a powerful through-the-browser Python shell with a single click.

`IPython Notebook <http://ipython.org/notebook.html>`_ is the de facto tool for researches, data analysts and software developers to perform visual and batch oriented tasks. *pyramid_notebook* puts the power of IPython Notebook inside of a `Pyramid website <http://trypyramid.com>`_.

.. |ci| image:: https://travis-ci.org/websauna/pyramid_notebook.svg
    :target: https://travis-ci.org/websauna/pyramid_notebook

.. |cov| image:: https://codecov.io/bitbucket/miohtama/pyramid_notebook/coverage.svg?branch=master
    :target: https://codecov.io/bitbucket/miohtama/pyramid_notebook?branch=master

.. |downloads| image:: https://img.shields.io/pypi/dm/pyramid_notebook.svg
    :target: https://pypi.python.org/pypi/pyramid_notebook/
    :alt: Downloads

.. |latest| image:: https://img.shields.io/pypi/v/pyramid_notebook.svg
    :target: https://pypi.python.org/pypi/pyramid_notebook/
    :alt: Latest Version

.. |license| image:: https://img.shields.io/pypi/l/pyramid_notebook.svg
    :target: https://pypi.python.org/pypi/pyramid_notebook/
    :alt: License

.. |versions| image:: https://img.shields.io/pypi/pyversions/pyramid_notebook.svg
    :target: https://pypi.python.org/pypi/pyramid_notebook/
    :alt: Supported Python versions

+-----------+-----------+
| |ci|      | |license| |
+-----------+-----------+
| |versions|| |latest|  |
+-----------+-----------+

.. contents:: :local:

Benefits
========

* Easy access: Get powerful shell through a web browser, no additional software installations needed.

* Automatic default variables: Populate Notebook with variables and data depending on the page where you have a shell button.

* Authentication integration: use the same credentials as you use for the site administration. Each Pyramid user gets his/her own IPython Notebook process.

* Rich user interface: Place the cursor with a mouse, use desktop shortcuts for line editing.

How it works
------------

.. image :: https://raw.githubusercontent.com/websauna/pyramid_notebook/master/docs/animation.gif

Use cases
---------

* Visualize and analyze your website data.

* Easier and powerful alternative for normal Python prompt - web browser user interface has more candy over old fashioned terminal.

* Share code sessions and recipes with your team mates.

Prerequisites
-------------

* `Pyramid web site <http://www.pylonsproject.org/projects/pyramid/about>`_ (can be easily extended to other web frameworks)

* Python 3.5+

* OSX, Linux

* uWSGI (on production server only)

Demo
====

* Checkout source code repository::

    git clone https://github.com/websauna/pyramid_notebook.git

* Create virtualenv for Python 3.5. Install dependencies::

    cd pyramid_notebook
    python3 -m venv env  # Create virtual environment
    source env/bin/activate  # Activate virtual environment
    pip install requirements.txt
    python setup.py develop

* Run demo::

    pserve pyramid_notebook/demo/development.ini

Then point your browser at `http://localhost:9999 <http://localhost:9999>`_.

Credentials for this demo are:

    * User 1:

        - username: *user*
        - password: *password*

    * User 2:

        - username: *user2*
        - password: *password*

Installation
============

It is recommend to install using ``pip`` and ``virtualenv``. `Python guide for package installation <https://packaging.python.org/en/latest/installing.html>`_. ::

    pip install pyramid_notebook

On production server where you use uWSGI websocket support::

    pip install pyramid_notebook[uwsgi]

Usage
=====

Your application needs to configure three custom views.

* One or multiple ``launch_ipython()`` notebook launch points. This does user authentication and authorization and then calls ``pyramid_notebook.views.launch_notebook()`` to open a new Notebook for a user. ``launch_ipython()`` takes in Notebook context parameters (see below), starts a new Notebook kernel if needed and then redirects user to Notebook itself.

* ``shutdown_ipython()`` which does authentication and authorization and calls ``pyramid_notebook.views.shutdown_notebook()`` to force close a notebook for a user.

* ``notebook_proxy()`` which does authentication and authorization and calls ``pyramid_notebook.views.notebook_proxy()`` to proxy HTTP request to upstream IPython Notebook server bind to a localhost port. `notebook_proxy` is mapped to `/notebook/` path in your site URL. Both your site and Notebook upstream server should agree on this location.

Example code
------------

The following is an example how to construct ``admin_shell`` view which launches a Notebook for the currently logged in Pyramid user when the view is visited for the first time. For extra security the permission for the notebook view cannot be assigned through normal groups, but the username must be on the whitelist in the INI settings file. This guarantees the shell is initially accessible only by persons who have shell access to the server itself.

For another approach on these views, please see the demo source code.

``views.py``:

.. code-block:: python

    from pyramid.httpexceptions import HTTPFound
    from pyramid.view import view_config
    from pyramid_notebook import startup
    from pyramid_notebook.views import launch_notebook
    from pyramid_notebook.views import shutdown_notebook as _shutdown_notebook
    from pyramid_notebook.views import notebook_proxy as _notebook_proxy
    from pyramid_web20.models import Base


    #: Include our database session in notebook so it is easy to query stuff right away from the prompt
    SCRIPT = """
    from pyramid_web20.models import DBSession as session
    """


    GREETING="""
    * **session** - SQLAlchemy database session
    """


    @view_config(route_name="notebook_proxy", permission="shell")
    def notebook_proxy(request):
        """Proxy IPython Notebook requests to the upstream server."""
        return _notebook_proxy(request, request.user.username)


    @view_config(route_name="admin_shell", permission="shell")
    def admin_shell(request):
        """Open admin shell with default parameters for the user."""
        # Make sure we have a logged in user
        nb = {}

        # Pass around the Pyramid configuration we used to start this application
        global_config = request.registry.settings["pyramid_web20.global_config"]

        # Get the reference to our Pyramid app config file and generate Notebook
        # bootstrap startup.py script for this application
        config_file = global_config["__file__"]
        startup.make_startup(nb, config_file)
        startup.add_script(nb, SCRIPT)
        startup.add_greeting(nb, GREETING)

        #: Include all our SQLAlchemy models in the notebook variables
        startup.include_sqlalchemy_models(nb, Base)

        return launch_notebook(request, request.user.username, notebook_context=nb)


    @view_config(route_name="shutdown_notebook", permission="shell")
    def shutdown_notebook(request):
        """Shutdown the notebook of the current user."""
        _shutdown_notebook(request, request.user.username)
        return HTTPFound(request.route_url("home"))

We also need to capture the INI settings file on the server start up, so that we can pass it forward to IPython Notebook process. In ``__init__.py``:

.. code-block:: python

    def main(global_config, **settings):
        settings["pyramid_web20.global_config"] = global_config

Then we have a custom principals handler granting the ``shell`` permission for users read from the user whitelist in the configuration file:

.. code-block:: python

    def find_groups(userid, request):
        """Get applied groups and other for the user"""

        from horus.interfaces import IUserModel
        user_class = request.registry.queryUtility(IUserModel)

        # Read superuser names from the config
        superusers = aslist(request.registry.settings.get("pyramid_web20.superusers"))

        user = models.DBSession.query(user_class).get(userid)
        if user:
            if user.can_login():
                principals = ['group:{}'.format(g.name) for g in user.groups]

            # Allow superuser permission
            if user.username in superusers or user.email in superusers:
                principals.append("superuser:superuser")

            return principals

        # User not found, user disabled
        return None

We refer to ``superuser:super`` in Pyramid site root object::

    class Root:

        __acl__ = [
            ...
            (Allow, "superuser:superuser", 'shell'),
        ]

And here is the configuration file bit::

    pyramid_web20.superusers =
        mikko@example.com

Pyramid settings
----------------

*python_notebook* reads following parameters from your Pyramid INI configuration file::

    # Where we store IPython Notebook runtime and persistent files
    # (pid, saved notebooks, etc.).
    # Each user will get a personal subfolder in this folder
    pyramid_notebook.notebook_folder = /tmp/pyramid_notebook

    # Automatically shutdown IPython Notebook kernel
    # after his many seconds have elapsed since startup
    pyramid_notebook.kill_timeout = 3600

    # Websocket proxy launch function.
    # This is a view function that upgrades the current HTTP request to Websocket (101 upgrade protocol)
    # and starts the web server websocket proxy loop. Currently only uWSGI supported
    # (see below).
    pyramid_notebook.websocket_proxy =

    # For uWSGI in production
    # pyramid_notebook.websocket_proxy = pyramid_notebook.uwsgi.serve_websocket

    # If you need to server websockets from alternative domain (See below).
    # Example value: https://ws.example.com
    pyramid_notebook.alternative_domain =

Notebook context parameters
---------------------------

Notebooks can be opened with context sensitive parameters. Some are filled in by the framework, some of those you can set yourself.

* You pass in your Notebook context parameters when you call ``launch_notebook()``.

* To have custom context variables change *startup* script.

* To have different info screen change *greeting* text

Example of what context information you can pass below::

    {

        # Extra Python script executed on notebook startup - this is saved as startup.py
        "startup": ""

        # Markdown text displayed at the beginning of the notebook
        "greeting": ""

        # List of paths where to load IPython Notebook Jinja templates
        # http://ipython.org/ipython-doc/3/config/options/notebook.html
        "extra_template_paths": []

        # Notebook daemon process id - filled it in by the daemon itself
        "pid": 1234,

        # Notebook daemon kill timeout in seconds - filled in by the the daemon itself after parsing command line arguments
        "kill_timeout": 5,

        # Bound localhost port for this notebook - filled in by the daemon itself after parsing command line arguments
        "http_port": 9999,

        # Set Notebook HTTP Allow Origin header to tell where websockets are allowed to connect
        "allow_origin": 'localhost:9999',

        # Override websocket URL
        "websocket_url": 'ws://localhost:9998',

        # Path in URL where Notebook is proxyed, must match notebook_proxy() view
        "notebook_path": '/notebook/',

        # Hash of this context. This is generated automatically from supplied context dictionary if not given. If the hash changes the notebook is restarted with new context data.
        "context_hash": 'foo',
    }


Dead man switch
---------------

Launched Notebook processes have maximum life time after which they terminate themselves. Currently the termation is unconditional seconds since the start up, but in the future versions this is expected to change to a dead man switchs where the process only terminates itself if there has not been recent activity.

Websocket proxying
------------------

IPython Notebook needs two different kind of connections to function properly

* HTTP connection for loading the pages, assets

* Websocket for real-time communication with Notebook kernel

When you run Pyramid's ``pserve`` development server on your local machine and enter the Notebook shell, the websocket connection is made directly to IPython Notebook port bound localhost. This is because ``pserve`` does not have any kind of support for websockets. This behavior is controlled by ``pyramid_notebook.websocket_proxy`` setting.

On the production server, you usually run a web server which spawns processes to execute WSGI requests, the Python standard for hosting web applications. Unfortunately, like WSGI for HTTP, there doesn't exist a standard for doing websocket requests in a Python application. Thus, one has to add support for websockets for each web server separately. Currently *pyramid_notebook* supports the following web servers

 * `uWSGI <https://uwsgi-docs.readthedocs.org/en/latest/>`_

It is ok to have another web server at the front of uWSGI, like Nginx, as these web servers can usually do proxy pass for websocket connections. You might need to add following to your Nginx config::

    # include a variable for the upgrade header
    map $http_upgrade $connection_upgrade {
        default   upgrade;
        ''        close;
    }

    server {
        location / {
            include uwsgi_params;

            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection $connection_upgrade;
        }
    }

uWSGI
~~~~~

To turn on websocket support on your uWSGI production server add following to your production INI settings::

    pyramid_notebook.websocket_proxy = pyramid_notebook.uwsgi.serve_websocket

Also you need to enable websockets in your uWSGI settings::

    http-websockets = true


Websocket and reverse proxy services
------------------------------------

Reverse proxy services, like CloudFlare <https://support.cloudflare.com/hc/en-us/articles/200169466-Can-I-use-CloudFlare-with-WebSockets->`_, might give only limited or no support for websockets. This may manifest itself in the form of *400 Bad Request* responses from the server because the reverse proxy service strips out ``Connection: Upgrade`` HTTP Request header. In this case it is recommended that you serve IPython Notebook from a separate domain where the websocket connection gets unhindered access to your server.

You need to

* Configure your naked web server to respond to an alternative domain name (``ws.example.com``).

* Configure ``pyramid_notebook`` to rewrite notebook URLs to come from the alternative domain::

    pyramid_notebook.alternative_domain = https://ws.example.com

* Pyramid ``AuthTktAuthenticationPolicy``, by default, supports wildcard authentication cookies.

* You can limit the naked domain to expose ``/notebook/`` URLs only.

Architecture
============

Each Pyramid user has a named Notebook process. Each Notebook process gets their own working folder, dynamically created upon the first lanch. Notebooks are managed by ``NotebookManager`` class which detects changes in notebook context and restarts the Notebook process for the user with a new context if needed.

Notebook bind itself to localhost ports. Pyramid view proxyes ``/notebook/`` HTTP requests to Notebook and first checks the HTTP request has necessary permissions by performing authentication and authorization checks. The proxy view is also responsible for starting a web server specific websocket proxy loop.

Launched Notebook processes are daemonized and separated from the web server process. The communication between the web server and the daemon process happens through command line, PID file and context file (JSON dump of notebook context parameters, as described above).

Local deployment
----------------

.. image :: https://raw.githubusercontent.com/websauna/pyramid_notebook/master/docs/localhost_deployment.png


Production deployment
---------------------

.. image :: https://raw.githubusercontent.com/websauna/pyramid_notebook/master/docs/production_deployment.png


Scalability
===========

The tool is intended for team internal use only. The default settings limit the number of users who can create and access notebooks to 10 people.

Currently a new daemon process is launched for each user in non-scalable manner. If 100+ users scalability is required there exist several ways to make the tool more lightweight. For example, `you can offload Websockets away from main uWSGI server to a dedicated gevent server <http://uwsgi-docs.readthedocs.org/en/latest/articles/OffloadingWebsocketsAndSSE.html>`_.

Security
========

With great power comes great responsibility.

.. note::

    Giving a user *pyramid_notebook* access is equal to giving him/her SSH access to a website UNIX user.

*pyramid_notebook* relies on user authorization and authentication by Pyramid web framework. It is your site, so the authentication and authorization system is as good as you made it to be. If you do not feel comfortable exposing this much of power over website authentication, you can still have notebook sessions e.g. over SSH tunneling.

Below are some security matters you should consider.

HTTPS only
----------

*pyramid_notebook* accepts HTTPS connections only. HTTP connections are unencrypted and leaking information over HTTP could lead severe compromises.

VPN restrictions
----------------

You can configure your web server to allow access to */notebook/* URLs from whitelisted IP networks only.

Access restricted servers
-------------------------

You do not need to run *pyramid_notebook* sessions on the main web servers. You can configure a server with limited data and code separately for running *pyramid_notebook*.

The access restricted server can have

* Read-only account on the database

* Source code and configuration files containing sensitive secrets removed (HTTPS keys, API tokens, etc.)

Linux containerization
----------------------

Notebook process can be made to start inside Linux container. Thus, it would still run on the same server, but you can limit the access to file system and network by the kernel. `Read more about Linux cgroups <http://en.wikipedia.org/wiki/Cgroups>`_.

Two-factor authentication
-------------------------

Consider requiring your website admins to use `two-factor authentication <http://en.wikipedia.org/wiki/Two_factor_authentication>`_ to protect against admin credential loss due to malware, keylogging and such nasties. Example `two-factor library for Python <http://code.thejeshgn.com/pyg2fa>`_.

Troubleshooting
===============

Taking down loose notebooks
---------------------------

In the case the notebook daemon processes get stuck, e.g. by user starting a infinite loop and do not terminate properly, you can take them down.

* Any time you launch a notebook with different context (different parameters) for the user, the prior notebook process gets terminated forcefully

* You can manually terminate all notebook processes. Ex::

    pkill -f notebook_daemon.py

Crashing Notebooks
------------------

The following are indication of crashed Notebook process.
The following page on Notebook when you try try to start Notebook through web:

    Apparently IPython Notebook daemon process is not running for user

... or the IPython Notebook dialog *Connecting failed* and connecting to kernel does not work.

Notebook has most likely died because of Python exception. There exists a file ``notebook.stderr.log``, one per each user, where you should be able to read traceback what happened.

Debugging Notebook daemon
-------------------------

The notebook daemon can be started from a command line and supports normal UNIX daemon ``start``, ``stop`` and ``fg`` commands. You need to give mandatory pid file, working folder, HTTP port and kill timeout arguments.

Example how to start Notebook daemon manually::

    python $SOMEWHERE/pyramid_notebook/server/notebook_daemon.py fg /tmp/pyramid_notebook/$USER/notebook.pid /tmp/pyramid_notebook/$USER 8899 3600


Seeing startup script exceptions
--------------------------------

If the startup script does not populate your Notebook with default variables as you hope, you can always do

* ``print(locals())`` to see what local variables are set

* ``print(gocals())`` to see what global variables are set

* Manually execute startup script inside IPython Notebook, e.g. ``exec(open("/tmp/pyramid_notebook/user-1/.jupyter/profile_default/startup/startup.py ").read())`` (check the actual path by exploring ``/tmp/pyramid_notebook`` on your local filesystem).

Development
===========

* `Source code <https://github.com/websauna/pyramid_notebook>`_

* `Issue tracker <https://github.com/websauna/pyramid_notebook/issues>`_

* `Documentation <https://github.com/websauna/pyramid_notebook>`_

Tests
-----

.. note ::

    Due to UI complexity of IPython Notebook interaction browser tests must be executed with full Firefox or Chrome driver.

Install test dependencies::

    pip install -e ".[test]"

Running manual instance::

    pserve pyramid_notebook/demo/development.ini --reload

Username is ``username`` and password ``password``.

Running tests::

    py.test tests --splinter-webdriver=chrome --splinter-make-screenshot-on-failure=false --ini=pyramid_notebook/demo/development.ini

Running a single test::

     py.test tests/* --splinter-webdriver=chrome --splinter-make-screenshot-on-failure=false --ini=pyramid_notebook/demo/development.ini -s -k test_notebook_template

Run full test coverage::

    py.test tests/* --cov pyramid_notebook --cov-report xml --splinter-webdriver=chrome --splinter-make-screenshot-on-failure=false --ini=pyramid_notebook/demo/development.ini -s -k test_notebook_template

Running uWSGI server with websockets::

    uwsgi --virtualenv=venv --wsgi-file=pyramid_notebook/demo/wsgi.py --pythonpath=venv/bin/python uwsgi.ini

Running uWSGI under Nginx for manual websocket proxy testing (OSX)::

    pkill nginx ; nginx -c `pwd`/nginx.conf
    uwsgi --virtualenv=venv --wsgi-file=pyramid_notebook/demo/wsgi.py --pythonpath=venv/bin/python uwsgi-under-nginx.ini


.. note ::

    Selenium Firefox has a bug which prevents typing ( on keyboard, preventing running tests on Firefox.

Manual testing
~~~~~~~~~~~~~~

You can manually launch the process to see any errors from IPython Notebook start.

Run ``test_start_stop`` test and capture log output in stdout::

    py.test tests --splinter-webdriver=chrome --splinter-make-screenshot-on-failure=false --ini=pyramid_notebook/demo/development.ini -s -k test_start_stop
    ...
    INFO:pyramid_notebook.notebookmanager:Running notebook command: python ./pyramid_notebook/server/notebook_daemon.py start /tmp/pyramid_notebook_tests/testuser1/notebook.pid /tmp/pyramid_notebook_tests/testuser1 40007 60

You can::

    python ./pyramid_notebook/server/notebook_daemon.py start /tmp/pyramid_notebook_tests/testuser1/notebook.pid /tmp/pyramid_notebook_tests/testuser1 40005 60


Related work
------------

* https://github.com/jupyter/jupyterhub

* https://github.com/Carreau/IPython-notebook-proxy

* https://github.com/UnataInc/ipydra/tree/master/ipydrar

Changelog for pyramid_notebook
==============================

0.3.0 (2018-10-09)
------------------

- Upgraded to IPython 7.0.1 / Jupyter.

- Add support to Python 3.6, 3.7 and 3.8

- Remove support to Python 3.4.

- Enforce Python style checks.

- Update README.


0.2.1 (2017-01-29)
------------------

- Fixed unnecessary "Enter token" prompt with the latest Jupyter Notebook versions


0.2 (2016-12-06)
----------------

- Upgraded to IPython 5.1 / Jupyter

- Better error messages in various situations

- Add custom shutdown command by customizing IPython toolbar menu


0.1.11 (2016-04-18)
-------------------

- Upgrade to Daemonocle 1.0


0.1.10 (2016-01-31)
-------------------

- Allow easily override IPython Notebook current working directory.


0.1.9 (2016-01-31)
------------------

- Make it possible to override the default bootstrap scripts and greeting for ``make_startup()``


0.1.8 (2016-01-31)
------------------

- Adding ws4py as a dependency as it is required for uWSGI which is currently the only supported implementation


0.1.7 (2016-01-16)
------------------

- Fixed README reST syntax


0.1.6 (2016-01-16)
------------------

- Switch to xdaemonocle fork of Daemonocle library


0.1.5 (2016-01-07)
------------------

- Keep IPython Notebook in 3.x series now by setup.py pinnings, as IPython 4.0 is not supported yet


0.1.4 (2015-12-19)
------------------

- Fixed relative image links to absolute in README


0.1.3 (2015-12-19)
------------------

- Fixing MANIFEST.in and release issues due to setuptools_git not present


0.1.2 (2015-12-19)
------------------

- Fixed README markup

- Fixed broken drone.io integration which prevented tests to pass on CI

0.1.1 (2015-12-19)
------------------

- Fixed broken setup.py classifiers

0.1 (2015-12-19)
----------------

- Initial release.