├── .coveragerc ├── .gitignore ├── .isort.cfg ├── .travis.yml ├── CHANGES.rst ├── LICENSE.txt ├── MANIFEST.in ├── README.rst ├── docs ├── animation.gif ├── localhost_deployment.png ├── production_deployment.png ├── pyramid_notebook.vpp └── pyramid_notebook.vpp~1 ├── nginx.conf ├── pyramid_notebook ├── __init__.py ├── demo │ ├── __init__.py │ ├── auth.py │ ├── development.ini │ ├── templates │ │ ├── home.html │ │ ├── login.html │ │ └── site │ │ │ ├── analytics.html │ │ │ ├── base.html │ │ │ ├── base_compact.html │ │ │ ├── css.html │ │ │ ├── description.html │ │ │ ├── footer.html │ │ │ ├── header.html │ │ │ ├── javascript.html │ │ │ ├── logo.html │ │ │ ├── messages.html │ │ │ ├── meta.html │ │ │ └── nav.html │ ├── views.py │ └── wsgi.py ├── notebookmanager.py ├── proxy.py ├── server │ ├── __init__.py │ ├── comm.py │ ├── custom.js │ └── notebook_daemon.py ├── startup.py ├── utils.py ├── uwsgi.py └── views.py ├── screenshots └── README.txt ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── conftest.py ├── test_alt_domain.py ├── test_integration.py └── test_start_stop.py ├── tox.ini ├── uwsgi-development.ini ├── uwsgi-under-nginx.ini └── uwsgi.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = codecov 4 | 5 | [report] 6 | exclude_lines = 7 | pragma: no cover 8 | def __repr__ 9 | if .debug: 10 | raise NotImplementedError 11 | if __name__ == .__main__.: 12 | 13 | 14 | ignore_errors = True 15 | omit = 16 | pyramid_notebook/demo/auth.py 17 | pyramid_notebook/demo/view.py 18 | setup.py -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | venv 2 | build/ 3 | dist/ 4 | *.pyc 5 | __pycache__ 6 | *.egg 7 | *.egg-info 8 | *.log 9 | node_modules 10 | *.sqlite 11 | .vagrant 12 | *.ipynb 13 | .ipynb-checkpoints 14 | 15 | # splinter unnecessary output 16 | tests.* 17 | .coverage 18 | .coverage.* 19 | coverage.xml 20 | screenshots/* 21 | *.vpp~* 22 | *.vpp.working 23 | 24 | .cache -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | sections=FUTURE,STDLIB,IPYTHON,PYRAMID,THIRDPARTY,FIRSTPARTY,LOCALFOLDER 3 | force_single_line=True 4 | known_future_library=future,pies 5 | known_ipython_library=ipython 6 | known_pyramid=colander,deform,jinja2,plaster,pyramid,pyramid_layout,venusian,zope,transaction 7 | known_first_party=pyramid_notebook 8 | import_heading_stdlib=Standard Library 9 | import_heading_pyramid=Pyramid 10 | import_heading_ipython=IPython 11 | import_heading_thirdparty=Third Party 12 | import_heading_firstparty=Pyramid Notebook 13 | lines_after_imports = 2 14 | not_skip=__init__.py 15 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | addons: 4 | firefox: latest 5 | 6 | services: 7 | - postgresql 8 | - redis-server 9 | 10 | env: # Without this, allow_failures will not work 11 | matrix: 12 | fast_finish: true 13 | include: 14 | - python: 3.5 15 | env: 16 | - PYTHON_VERSION=python3.5 17 | - TOXENV=py35 18 | - python: 3.6 19 | env: 20 | - PYTHON_VERSION=python3.6 21 | - TOXENV=py36 22 | - python: 3.7 23 | env: 24 | - PYTHON_VERSION=python3.7 25 | - TOXENV=py37 26 | dist: xenial 27 | sudo: true 28 | - python: 3.8-dev 29 | env: 30 | - PYTHON_VERSION=python3.8 31 | - TOXENV=py38 32 | dist: xenial 33 | sudo: true 34 | - python: 3.6 35 | env: 36 | - PYTHON_VERSION=python3.6 37 | - TOXENV=style 38 | allow_failures: 39 | - python: 3.8-dev 40 | env: 41 | - PYTHON_VERSION=python3.8 42 | - TOXENV=py38 43 | 44 | # http://stackoverflow.com/a/19460794/315168 45 | cache: 46 | directories: 47 | # /home/travis/.cache/pip/wheels is the normal pip cache folder 48 | - $HOME/.cache/pip 49 | - .tox 50 | 51 | before_install: 52 | - wget https://github.com/mozilla/geckodriver/releases/download/v0.23.0/geckodriver-v0.23.0-linux64.tar.gz 53 | - mkdir geckodriver 54 | - tar -xzf geckodriver-v0.23.0-linux64.tar.gz -C geckodriver 55 | - export PATH=$PATH:$PWD/geckodriver 56 | 57 | install: 58 | - travis_retry pip install tox 59 | - pip install -U pip 60 | 61 | before_script: 62 | - "export MOZ_HEADLESS=1" 63 | - "export DISPLAY=:99.0" 64 | - "sh -e /etc/init.d/xvfb start" 65 | - sleep 3 # give xvfb some time to start 66 | - echo "Using firefox version `firefox --version`" 67 | 68 | script: 69 | - tox -- --ini=pyramid_notebook/demo/development.ini --splinter-headless=true --debug 70 | 71 | after_success: 72 | - .tox/$TOXENV/bin/pip freeze 73 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | Changelog for pyramid_notebook 2 | ============================== 3 | 4 | 0.3.1 (unreleased) 5 | ------------------ 6 | 7 | - Nothing changed yet. 8 | 9 | 10 | 0.3.0 (2018-10-09) 11 | ------------------ 12 | 13 | - Upgraded to IPython 7.0.1 / Jupyter. 14 | 15 | - Add support to Python 3.6, 3.7 and 3.8 16 | 17 | - Remove support to Python 3.4. 18 | 19 | - Enforce Python style checks. 20 | 21 | - Update README. 22 | 23 | 24 | 0.2.1 (2017-01-29) 25 | ------------------ 26 | 27 | - Fixed unnecessary "Enter token" prompt with the latest Jupyter Notebook versions 28 | 29 | 30 | 0.2 (2016-12-06) 31 | ---------------- 32 | 33 | - Upgraded to IPython 5.1 / Jupyter 34 | 35 | - Better error messages in various situations 36 | 37 | - Add custom shutdown command by customizing IPython toolbar menu 38 | 39 | 40 | 0.1.11 (2016-04-18) 41 | ------------------- 42 | 43 | - Upgrade to Daemonocle 1.0 44 | 45 | 46 | 0.1.10 (2016-01-31) 47 | ------------------- 48 | 49 | - Allow easily override IPython Notebook current working directory. 50 | 51 | 52 | 0.1.9 (2016-01-31) 53 | ------------------ 54 | 55 | - Make it possible to override the default bootstrap scripts and greeting for ``make_startup()`` 56 | 57 | 58 | 0.1.8 (2016-01-31) 59 | ------------------ 60 | 61 | - Adding ws4py as a dependency as it is required for uWSGI which is currently the only supported implementation 62 | 63 | 64 | 0.1.7 (2016-01-16) 65 | ------------------ 66 | 67 | - Fixed README reST syntax 68 | 69 | 70 | 0.1.6 (2016-01-16) 71 | ------------------ 72 | 73 | - Switch to xdaemonocle fork of Daemonocle library 74 | 75 | 76 | 0.1.5 (2016-01-07) 77 | ------------------ 78 | 79 | - Keep IPython Notebook in 3.x series now by setup.py pinnings, as IPython 4.0 is not supported yet 80 | 81 | 82 | 0.1.4 (2015-12-19) 83 | ------------------ 84 | 85 | - Fixed relative image links to absolute in README 86 | 87 | 88 | 0.1.3 (2015-12-19) 89 | ------------------ 90 | 91 | - Fixing MANIFEST.in and release issues due to setuptools_git not present 92 | 93 | 94 | 0.1.2 (2015-12-19) 95 | ------------------ 96 | 97 | - Fixed README markup 98 | 99 | - Fixed broken drone.io integration which prevented tests to pass on CI 100 | 101 | 0.1.1 (2015-12-19) 102 | ------------------ 103 | 104 | - Fixed broken setup.py classifiers 105 | 106 | 0.1 (2015-12-19) 107 | ---------------- 108 | 109 | - Initial release. 110 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Mikko Ohtamaa 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | exclude .gitignore 2 | exclude tests 3 | exclude tests/* 4 | include *.conf 5 | include *.rst 6 | include *.txt 7 | include .coveragerc 8 | include .isort.cfg 9 | include tox.ini 10 | include uwsgi-development.ini 11 | include uwsgi-under-nginx.ini 12 | include uwsgi.ini 13 | recursive-include docs *.gif 14 | recursive-include docs *.png 15 | recursive-include docs *.vpp 16 | recursive-include pyramid_notebook *.html 17 | recursive-include pyramid_notebook *.ini 18 | recursive-include pyramid_notebook *.js 19 | recursive-include screenshots *.txt 20 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ================ 2 | Pyramid Notebook 3 | ================ 4 | 5 | *pyramid_notebook* embeds IPython Notebook shell on your Pyramid web site. Start a powerful through-the-browser Python shell with a single click. 6 | 7 | `IPython Notebook `_ 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 `_. 8 | 9 | .. |ci| image:: https://travis-ci.org/websauna/pyramid_notebook.svg 10 | :target: https://travis-ci.org/websauna/pyramid_notebook 11 | 12 | .. |cov| image:: https://codecov.io/bitbucket/miohtama/pyramid_notebook/coverage.svg?branch=master 13 | :target: https://codecov.io/bitbucket/miohtama/pyramid_notebook?branch=master 14 | 15 | .. |downloads| image:: https://img.shields.io/pypi/dm/pyramid_notebook.svg 16 | :target: https://pypi.python.org/pypi/pyramid_notebook/ 17 | :alt: Downloads 18 | 19 | .. |latest| image:: https://img.shields.io/pypi/v/pyramid_notebook.svg 20 | :target: https://pypi.python.org/pypi/pyramid_notebook/ 21 | :alt: Latest Version 22 | 23 | .. |license| image:: https://img.shields.io/pypi/l/pyramid_notebook.svg 24 | :target: https://pypi.python.org/pypi/pyramid_notebook/ 25 | :alt: License 26 | 27 | .. |versions| image:: https://img.shields.io/pypi/pyversions/pyramid_notebook.svg 28 | :target: https://pypi.python.org/pypi/pyramid_notebook/ 29 | :alt: Supported Python versions 30 | 31 | +-----------+-----------+ 32 | | |ci| | |license| | 33 | +-----------+-----------+ 34 | | |versions|| |latest| | 35 | +-----------+-----------+ 36 | 37 | .. contents:: :local: 38 | 39 | Benefits 40 | ======== 41 | 42 | * Easy access: Get powerful shell through a web browser, no additional software installations needed. 43 | 44 | * Automatic default variables: Populate Notebook with variables and data depending on the page where you have a shell button. 45 | 46 | * Authentication integration: use the same credentials as you use for the site administration. Each Pyramid user gets his/her own IPython Notebook process. 47 | 48 | * Rich user interface: Place the cursor with a mouse, use desktop shortcuts for line editing. 49 | 50 | How it works 51 | ------------ 52 | 53 | .. image :: https://raw.githubusercontent.com/websauna/pyramid_notebook/master/docs/animation.gif 54 | 55 | Use cases 56 | --------- 57 | 58 | * Visualize and analyze your website data. 59 | 60 | * Easier and powerful alternative for normal Python prompt - web browser user interface has more candy over old fashioned terminal. 61 | 62 | * Share code sessions and recipes with your team mates. 63 | 64 | Prerequisites 65 | ------------- 66 | 67 | * `Pyramid web site `_ (can be easily extended to other web frameworks) 68 | 69 | * Python 3.5+ 70 | 71 | * OSX, Linux 72 | 73 | * uWSGI (on production server only) 74 | 75 | Demo 76 | ==== 77 | 78 | * Checkout source code repository:: 79 | 80 | git clone https://github.com/websauna/pyramid_notebook.git 81 | 82 | * Create virtualenv for Python 3.5. Install dependencies:: 83 | 84 | cd pyramid_notebook 85 | python3 -m venv env # Create virtual environment 86 | source env/bin/activate # Activate virtual environment 87 | pip install requirements.txt 88 | python setup.py develop 89 | 90 | * Run demo:: 91 | 92 | pserve pyramid_notebook/demo/development.ini 93 | 94 | Then point your browser at `http://localhost:9999 `_. 95 | 96 | Credentials for this demo are: 97 | 98 | * User 1: 99 | 100 | - username: *user* 101 | - password: *password* 102 | 103 | * User 2: 104 | 105 | - username: *user2* 106 | - password: *password* 107 | 108 | Installation 109 | ============ 110 | 111 | It is recommend to install using ``pip`` and ``virtualenv``. `Python guide for package installation `_. :: 112 | 113 | pip install pyramid_notebook 114 | 115 | On production server where you use uWSGI websocket support:: 116 | 117 | pip install pyramid_notebook[uwsgi] 118 | 119 | Usage 120 | ===== 121 | 122 | Your application needs to configure three custom views. 123 | 124 | * 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. 125 | 126 | * ``shutdown_ipython()`` which does authentication and authorization and calls ``pyramid_notebook.views.shutdown_notebook()`` to force close a notebook for a user. 127 | 128 | * ``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. 129 | 130 | Example code 131 | ------------ 132 | 133 | 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. 134 | 135 | For another approach on these views, please see the demo source code. 136 | 137 | ``views.py``: 138 | 139 | .. code-block:: python 140 | 141 | from pyramid.httpexceptions import HTTPFound 142 | from pyramid.view import view_config 143 | from pyramid_notebook import startup 144 | from pyramid_notebook.views import launch_notebook 145 | from pyramid_notebook.views import shutdown_notebook as _shutdown_notebook 146 | from pyramid_notebook.views import notebook_proxy as _notebook_proxy 147 | from pyramid_web20.models import Base 148 | 149 | 150 | #: Include our database session in notebook so it is easy to query stuff right away from the prompt 151 | SCRIPT = """ 152 | from pyramid_web20.models import DBSession as session 153 | """ 154 | 155 | 156 | GREETING=""" 157 | * **session** - SQLAlchemy database session 158 | """ 159 | 160 | 161 | @view_config(route_name="notebook_proxy", permission="shell") 162 | def notebook_proxy(request): 163 | """Proxy IPython Notebook requests to the upstream server.""" 164 | return _notebook_proxy(request, request.user.username) 165 | 166 | 167 | @view_config(route_name="admin_shell", permission="shell") 168 | def admin_shell(request): 169 | """Open admin shell with default parameters for the user.""" 170 | # Make sure we have a logged in user 171 | nb = {} 172 | 173 | # Pass around the Pyramid configuration we used to start this application 174 | global_config = request.registry.settings["pyramid_web20.global_config"] 175 | 176 | # Get the reference to our Pyramid app config file and generate Notebook 177 | # bootstrap startup.py script for this application 178 | config_file = global_config["__file__"] 179 | startup.make_startup(nb, config_file) 180 | startup.add_script(nb, SCRIPT) 181 | startup.add_greeting(nb, GREETING) 182 | 183 | #: Include all our SQLAlchemy models in the notebook variables 184 | startup.include_sqlalchemy_models(nb, Base) 185 | 186 | return launch_notebook(request, request.user.username, notebook_context=nb) 187 | 188 | 189 | @view_config(route_name="shutdown_notebook", permission="shell") 190 | def shutdown_notebook(request): 191 | """Shutdown the notebook of the current user.""" 192 | _shutdown_notebook(request, request.user.username) 193 | return HTTPFound(request.route_url("home")) 194 | 195 | 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``: 196 | 197 | .. code-block:: python 198 | 199 | def main(global_config, **settings): 200 | settings["pyramid_web20.global_config"] = global_config 201 | 202 | Then we have a custom principals handler granting the ``shell`` permission for users read from the user whitelist in the configuration file: 203 | 204 | .. code-block:: python 205 | 206 | def find_groups(userid, request): 207 | """Get applied groups and other for the user""" 208 | 209 | from horus.interfaces import IUserModel 210 | user_class = request.registry.queryUtility(IUserModel) 211 | 212 | # Read superuser names from the config 213 | superusers = aslist(request.registry.settings.get("pyramid_web20.superusers")) 214 | 215 | user = models.DBSession.query(user_class).get(userid) 216 | if user: 217 | if user.can_login(): 218 | principals = ['group:{}'.format(g.name) for g in user.groups] 219 | 220 | # Allow superuser permission 221 | if user.username in superusers or user.email in superusers: 222 | principals.append("superuser:superuser") 223 | 224 | return principals 225 | 226 | # User not found, user disabled 227 | return None 228 | 229 | We refer to ``superuser:super`` in Pyramid site root object:: 230 | 231 | class Root: 232 | 233 | __acl__ = [ 234 | ... 235 | (Allow, "superuser:superuser", 'shell'), 236 | ] 237 | 238 | And here is the configuration file bit:: 239 | 240 | pyramid_web20.superusers = 241 | mikko@example.com 242 | 243 | Pyramid settings 244 | ---------------- 245 | 246 | *python_notebook* reads following parameters from your Pyramid INI configuration file:: 247 | 248 | # Where we store IPython Notebook runtime and persistent files 249 | # (pid, saved notebooks, etc.). 250 | # Each user will get a personal subfolder in this folder 251 | pyramid_notebook.notebook_folder = /tmp/pyramid_notebook 252 | 253 | # Automatically shutdown IPython Notebook kernel 254 | # after his many seconds have elapsed since startup 255 | pyramid_notebook.kill_timeout = 3600 256 | 257 | # Websocket proxy launch function. 258 | # This is a view function that upgrades the current HTTP request to Websocket (101 upgrade protocol) 259 | # and starts the web server websocket proxy loop. Currently only uWSGI supported 260 | # (see below). 261 | pyramid_notebook.websocket_proxy = 262 | 263 | # For uWSGI in production 264 | # pyramid_notebook.websocket_proxy = pyramid_notebook.uwsgi.serve_websocket 265 | 266 | # If you need to server websockets from alternative domain (See below). 267 | # Example value: https://ws.example.com 268 | pyramid_notebook.alternative_domain = 269 | 270 | Notebook context parameters 271 | --------------------------- 272 | 273 | Notebooks can be opened with context sensitive parameters. Some are filled in by the framework, some of those you can set yourself. 274 | 275 | * You pass in your Notebook context parameters when you call ``launch_notebook()``. 276 | 277 | * To have custom context variables change *startup* script. 278 | 279 | * To have different info screen change *greeting* text 280 | 281 | Example of what context information you can pass below:: 282 | 283 | { 284 | 285 | # Extra Python script executed on notebook startup - this is saved as startup.py 286 | "startup": "" 287 | 288 | # Markdown text displayed at the beginning of the notebook 289 | "greeting": "" 290 | 291 | # List of paths where to load IPython Notebook Jinja templates 292 | # http://ipython.org/ipython-doc/3/config/options/notebook.html 293 | "extra_template_paths": [] 294 | 295 | # Notebook daemon process id - filled it in by the daemon itself 296 | "pid": 1234, 297 | 298 | # Notebook daemon kill timeout in seconds - filled in by the the daemon itself after parsing command line arguments 299 | "kill_timeout": 5, 300 | 301 | # Bound localhost port for this notebook - filled in by the daemon itself after parsing command line arguments 302 | "http_port": 9999, 303 | 304 | # Set Notebook HTTP Allow Origin header to tell where websockets are allowed to connect 305 | "allow_origin": 'localhost:9999', 306 | 307 | # Override websocket URL 308 | "websocket_url": 'ws://localhost:9998', 309 | 310 | # Path in URL where Notebook is proxyed, must match notebook_proxy() view 311 | "notebook_path": '/notebook/', 312 | 313 | # 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. 314 | "context_hash": 'foo', 315 | } 316 | 317 | 318 | Dead man switch 319 | --------------- 320 | 321 | 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. 322 | 323 | Websocket proxying 324 | ------------------ 325 | 326 | IPython Notebook needs two different kind of connections to function properly 327 | 328 | * HTTP connection for loading the pages, assets 329 | 330 | * Websocket for real-time communication with Notebook kernel 331 | 332 | 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. 333 | 334 | 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 335 | 336 | * `uWSGI `_ 337 | 338 | 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:: 339 | 340 | # include a variable for the upgrade header 341 | map $http_upgrade $connection_upgrade { 342 | default upgrade; 343 | '' close; 344 | } 345 | 346 | server { 347 | location / { 348 | include uwsgi_params; 349 | 350 | proxy_http_version 1.1; 351 | proxy_set_header Upgrade $http_upgrade; 352 | proxy_set_header Connection $connection_upgrade; 353 | } 354 | } 355 | 356 | uWSGI 357 | ~~~~~ 358 | 359 | To turn on websocket support on your uWSGI production server add following to your production INI settings:: 360 | 361 | pyramid_notebook.websocket_proxy = pyramid_notebook.uwsgi.serve_websocket 362 | 363 | Also you need to enable websockets in your uWSGI settings:: 364 | 365 | http-websockets = true 366 | 367 | 368 | Websocket and reverse proxy services 369 | ------------------------------------ 370 | 371 | Reverse proxy services, like CloudFlare `_, 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. 372 | 373 | You need to 374 | 375 | * Configure your naked web server to respond to an alternative domain name (``ws.example.com``). 376 | 377 | * Configure ``pyramid_notebook`` to rewrite notebook URLs to come from the alternative domain:: 378 | 379 | pyramid_notebook.alternative_domain = https://ws.example.com 380 | 381 | * Pyramid ``AuthTktAuthenticationPolicy``, by default, supports wildcard authentication cookies. 382 | 383 | * You can limit the naked domain to expose ``/notebook/`` URLs only. 384 | 385 | Architecture 386 | ============ 387 | 388 | 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. 389 | 390 | 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. 391 | 392 | 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). 393 | 394 | Local deployment 395 | ---------------- 396 | 397 | .. image :: https://raw.githubusercontent.com/websauna/pyramid_notebook/master/docs/localhost_deployment.png 398 | 399 | 400 | Production deployment 401 | --------------------- 402 | 403 | .. image :: https://raw.githubusercontent.com/websauna/pyramid_notebook/master/docs/production_deployment.png 404 | 405 | 406 | Scalability 407 | =========== 408 | 409 | 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. 410 | 411 | 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 `_. 412 | 413 | Security 414 | ======== 415 | 416 | With great power comes great responsibility. 417 | 418 | .. note:: 419 | 420 | Giving a user *pyramid_notebook* access is equal to giving him/her SSH access to a website UNIX user. 421 | 422 | *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. 423 | 424 | Below are some security matters you should consider. 425 | 426 | HTTPS only 427 | ---------- 428 | 429 | *pyramid_notebook* accepts HTTPS connections only. HTTP connections are unencrypted and leaking information over HTTP could lead severe compromises. 430 | 431 | VPN restrictions 432 | ---------------- 433 | 434 | You can configure your web server to allow access to */notebook/* URLs from whitelisted IP networks only. 435 | 436 | Access restricted servers 437 | ------------------------- 438 | 439 | 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*. 440 | 441 | The access restricted server can have 442 | 443 | * Read-only account on the database 444 | 445 | * Source code and configuration files containing sensitive secrets removed (HTTPS keys, API tokens, etc.) 446 | 447 | Linux containerization 448 | ---------------------- 449 | 450 | 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 `_. 451 | 452 | Two-factor authentication 453 | ------------------------- 454 | 455 | Consider requiring your website admins to use `two-factor authentication `_ to protect against admin credential loss due to malware, keylogging and such nasties. Example `two-factor library for Python `_. 456 | 457 | Troubleshooting 458 | =============== 459 | 460 | Taking down loose notebooks 461 | --------------------------- 462 | 463 | 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. 464 | 465 | * Any time you launch a notebook with different context (different parameters) for the user, the prior notebook process gets terminated forcefully 466 | 467 | * You can manually terminate all notebook processes. Ex:: 468 | 469 | pkill -f notebook_daemon.py 470 | 471 | Crashing Notebooks 472 | ------------------ 473 | 474 | The following are indication of crashed Notebook process. 475 | The following page on Notebook when you try try to start Notebook through web: 476 | 477 | Apparently IPython Notebook daemon process is not running for user 478 | 479 | ... or the IPython Notebook dialog *Connecting failed* and connecting to kernel does not work. 480 | 481 | 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. 482 | 483 | Debugging Notebook daemon 484 | ------------------------- 485 | 486 | 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. 487 | 488 | Example how to start Notebook daemon manually:: 489 | 490 | python $SOMEWHERE/pyramid_notebook/server/notebook_daemon.py fg /tmp/pyramid_notebook/$USER/notebook.pid /tmp/pyramid_notebook/$USER 8899 3600 491 | 492 | 493 | Seeing startup script exceptions 494 | -------------------------------- 495 | 496 | If the startup script does not populate your Notebook with default variables as you hope, you can always do 497 | 498 | * ``print(locals())`` to see what local variables are set 499 | 500 | * ``print(gocals())`` to see what global variables are set 501 | 502 | * 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). 503 | 504 | Development 505 | =========== 506 | 507 | * `Source code `_ 508 | 509 | * `Issue tracker `_ 510 | 511 | * `Documentation `_ 512 | 513 | Tests 514 | ----- 515 | 516 | .. note :: 517 | 518 | Due to UI complexity of IPython Notebook interaction browser tests must be executed with full Firefox or Chrome driver. 519 | 520 | Install test dependencies:: 521 | 522 | pip install -e ".[test]" 523 | 524 | Running manual instance:: 525 | 526 | pserve pyramid_notebook/demo/development.ini --reload 527 | 528 | Username is ``username`` and password ``password``. 529 | 530 | Running tests:: 531 | 532 | py.test tests --splinter-webdriver=chrome --splinter-make-screenshot-on-failure=false --ini=pyramid_notebook/demo/development.ini 533 | 534 | Running a single test:: 535 | 536 | py.test tests/* --splinter-webdriver=chrome --splinter-make-screenshot-on-failure=false --ini=pyramid_notebook/demo/development.ini -s -k test_notebook_template 537 | 538 | Run full test coverage:: 539 | 540 | 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 541 | 542 | Running uWSGI server with websockets:: 543 | 544 | uwsgi --virtualenv=venv --wsgi-file=pyramid_notebook/demo/wsgi.py --pythonpath=venv/bin/python uwsgi.ini 545 | 546 | Running uWSGI under Nginx for manual websocket proxy testing (OSX):: 547 | 548 | pkill nginx ; nginx -c `pwd`/nginx.conf 549 | uwsgi --virtualenv=venv --wsgi-file=pyramid_notebook/demo/wsgi.py --pythonpath=venv/bin/python uwsgi-under-nginx.ini 550 | 551 | 552 | .. note :: 553 | 554 | Selenium Firefox has a bug which prevents typing ( on keyboard, preventing running tests on Firefox. 555 | 556 | Manual testing 557 | ~~~~~~~~~~~~~~ 558 | 559 | You can manually launch the process to see any errors from IPython Notebook start. 560 | 561 | Run ``test_start_stop`` test and capture log output in stdout:: 562 | 563 | py.test tests --splinter-webdriver=chrome --splinter-make-screenshot-on-failure=false --ini=pyramid_notebook/demo/development.ini -s -k test_start_stop 564 | ... 565 | 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 566 | 567 | You can:: 568 | 569 | python ./pyramid_notebook/server/notebook_daemon.py start /tmp/pyramid_notebook_tests/testuser1/notebook.pid /tmp/pyramid_notebook_tests/testuser1 40005 60 570 | 571 | 572 | Related work 573 | ------------ 574 | 575 | * https://github.com/jupyter/jupyterhub 576 | 577 | * https://github.com/Carreau/IPython-notebook-proxy 578 | 579 | * https://github.com/UnataInc/ipydra/tree/master/ipydrar 580 | -------------------------------------------------------------------------------- /docs/animation.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/websauna/pyramid_notebook/8a7ecfa0259810de1a818e4b415a62811a7b077a/docs/animation.gif -------------------------------------------------------------------------------- /docs/localhost_deployment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/websauna/pyramid_notebook/8a7ecfa0259810de1a818e4b415a62811a7b077a/docs/localhost_deployment.png -------------------------------------------------------------------------------- /docs/production_deployment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/websauna/pyramid_notebook/8a7ecfa0259810de1a818e4b415a62811a7b077a/docs/production_deployment.png -------------------------------------------------------------------------------- /docs/pyramid_notebook.vpp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/websauna/pyramid_notebook/8a7ecfa0259810de1a818e4b415a62811a7b077a/docs/pyramid_notebook.vpp -------------------------------------------------------------------------------- /docs/pyramid_notebook.vpp~1: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/websauna/pyramid_notebook/8a7ecfa0259810de1a818e4b415a62811a7b077a/docs/pyramid_notebook.vpp~1 -------------------------------------------------------------------------------- /nginx.conf: -------------------------------------------------------------------------------- 1 | worker_processes 4; 2 | 3 | events { 4 | worker_connections 1024; 5 | } 6 | 7 | http { 8 | include /usr/local/etc/nginx/mime.types; 9 | 10 | access_log /usr/local/var/log/nginx/access.log; 11 | error_log /usr/local/var/log/nginx/websocket.log debug; 12 | 13 | upstream uwsgi { server localhost:8008; } 14 | 15 | server { 16 | listen 8080; 17 | server_name localhost; 18 | 19 | location / { 20 | include /usr/local/etc/nginx/uwsgi_params; 21 | 22 | proxy_redirect off; 23 | proxy_set_header Host $host; 24 | proxy_set_header X-Real-IP $remote_addr; 25 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 26 | proxy_set_header X-Forwarded-Host $server_name; 27 | proxy_set_header X-Forwarded-Proto $scheme; 28 | 29 | uwsgi_pass unix:///tmp/uwsgi.sock; 30 | uwsgi_param UWSGI_SCHEME http; 31 | uwsgi_pass_header X_FORWARDED_PROTO; 32 | uwsgi_pass_header X_REAL_IP; 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /pyramid_notebook/__init__.py: -------------------------------------------------------------------------------- 1 | """Pyramid Notebook.""" 2 | 3 | 4 | def includeme(config): 5 | pass 6 | -------------------------------------------------------------------------------- /pyramid_notebook/demo/__init__.py: -------------------------------------------------------------------------------- 1 | # Standard Library 2 | import logging 3 | 4 | # Pyramid 5 | from pyramid.authentication import BasicAuthAuthenticationPolicy 6 | from pyramid.config import Configurator 7 | from pyramid.interfaces import IDebugLogger 8 | 9 | from pyramid_notebook.demo import auth 10 | from pyramid_notebook.demo import views 11 | 12 | 13 | def mycheck(username, password, request): 14 | """Allow in any user with password "password""""" 15 | pwd_ok = password == "password" 16 | if not pwd_ok: 17 | return None 18 | return ['authenticated'] 19 | 20 | 21 | def main(global_config, **settings): 22 | 23 | settings["global_config"] = global_config 24 | config = Configurator(settings=settings) 25 | 26 | # Jinja 2 templates as .html files 27 | config.include('pyramid_jinja2') 28 | config.add_jinja2_renderer('.html') 29 | config.add_jinja2_search_path('pyramid_notebook:demo/templates', name='.html') 30 | 31 | config.add_route('home', '/') 32 | config.add_route('login', '/login') 33 | config.add_route('shell1', '/shell1') 34 | config.add_route('shell2', '/shell2') 35 | config.add_route('shutdown_notebook', '/notebook/shutdown') 36 | config.add_route('notebook_proxy', '/notebook/*remainder') 37 | 38 | config.scan(views) 39 | 40 | authn_policy = auth.AuthTktAuthenticationPolicy('seekrITT', callback=auth.groupfinder) 41 | config.set_authentication_policy(BasicAuthAuthenticationPolicy(mycheck)) 42 | config.set_authorization_policy(authn_policy) 43 | 44 | # Make sure we can target Pyramid router debug messages in logging configuration 45 | pyramid_debug_logger = logging.getLogger("pyramid_debug") 46 | config.registry.registerUtility(pyramid_debug_logger, IDebugLogger) 47 | 48 | return config.make_wsgi_app() 49 | -------------------------------------------------------------------------------- /pyramid_notebook/demo/auth.py: -------------------------------------------------------------------------------- 1 | # Standard Library 2 | import binascii 3 | 4 | # Pyramid 5 | from pyramid.authentication import AuthTktAuthenticationPolicy # noQA 6 | from pyramid.security import Authenticated 7 | from pyramid.security import Everyone 8 | 9 | # Third Party 10 | from paste.httpheaders import AUTHORIZATION 11 | from paste.httpheaders import WWW_AUTHENTICATE 12 | 13 | 14 | def _get_basicauth_credentials(request): 15 | authorization = AUTHORIZATION(request.environ) 16 | try: 17 | authmeth, auth = authorization.split(' ', 1) 18 | except ValueError: # not enough values to unpack 19 | return None 20 | if authmeth.lower() == 'basic': 21 | try: 22 | auth = auth.strip().decode('base64') 23 | except binascii.Error: # can't decode 24 | return None 25 | try: 26 | login, password = auth.split(':', 1) 27 | except ValueError: # not enough values to unpack 28 | return None 29 | return {'login': login, 'password': password} 30 | 31 | return None 32 | 33 | 34 | class BasicAuthenticationPolicy(object): 35 | """ A :app:`Pyramid` :term:`authentication policy` which 36 | obtains data from basic authentication headers. 37 | 38 | Constructor Arguments 39 | 40 | ``check`` 41 | 42 | A callback passed the credentials and the request, 43 | expected to return None if the userid doesn't exist or a sequence 44 | of group identifiers (possibly empty) if the user does exist. 45 | Required. 46 | 47 | ``realm`` 48 | 49 | Default: ``Realm``. The Basic Auth realm string. 50 | 51 | """ 52 | 53 | def __init__(self, check, realm='Realm'): 54 | self.check = check 55 | self.realm = realm 56 | 57 | def authenticated_userid(self, request): 58 | credentials = _get_basicauth_credentials(request) 59 | print("Foobar") 60 | if credentials is None: 61 | return None 62 | userid = credentials['login'] 63 | if self.check(credentials, request) is not None: # is not None! 64 | return userid 65 | 66 | def effective_principals(self, request): 67 | effective_principals = [Everyone] 68 | credentials = _get_basicauth_credentials(request) 69 | if credentials is None: 70 | return effective_principals 71 | userid = credentials['login'] 72 | groups = self.check(credentials, request) 73 | if groups is None: # is None! 74 | return effective_principals 75 | effective_principals.append(Authenticated) 76 | effective_principals.append(userid) 77 | effective_principals.extend(groups) 78 | return effective_principals 79 | 80 | def unauthenticated_userid(self, request): 81 | creds = _get_basicauth_credentials(request) 82 | if creds is not None: 83 | return creds['login'] 84 | return None 85 | 86 | def remember(self, request, principal, **kw): 87 | return [] 88 | 89 | def forget(self, request): 90 | head = WWW_AUTHENTICATE.tuples('Basic realm="%s"' % self.realm) 91 | return head 92 | 93 | 94 | def groupfinder(userid, request): 95 | user = request.user 96 | if user is not None: 97 | return ["authenticated"] 98 | return None 99 | -------------------------------------------------------------------------------- /pyramid_notebook/demo/development.ini: -------------------------------------------------------------------------------- 1 | ### 2 | # app configuration 3 | # http://docs.pylonsproject.org/projects/pyramid/en/1.5-branch/narr/environment.html 4 | ### 5 | 6 | [app:main] 7 | use = egg:pyramid_notebook 8 | 9 | pyramid.reload_templates = true 10 | pyramid.debug_authorization = true 11 | pyramid.debug_notfound = true 12 | pyramid.debug_routematch = true 13 | pyramid.default_locale_name = en 14 | pyramid.includes = 15 | 16 | jinja2.filters = 17 | route_url = pyramid_jinja2.filters:route_url_filter 18 | 19 | jinja2.extensions = 20 | jinja2.ext.with_ 21 | 22 | # Where we store IPython Notebook runtime and persistent files 23 | # (pid, saved notebooks, etc.). 24 | # Each user will get a personal subfolder in this folder 25 | pyramid_notebook.notebook_folder = /tmp/pyramid_notebook 26 | 27 | # Automatically shutdown IPython Notebook kernel 28 | # after his many seconds have elapsed since startup 29 | pyramid_notebook.kill_timeout = 3600 30 | 31 | # Port range where IPython Notebook binds localhost for HTTP and websocket connections. 32 | # By default this is TCP/IP ports localhost:41000 - localhost:41010. 33 | # In production, you need to proxy websocket in these from your front end web server 34 | # using websocket proxying (see example below). 35 | pyramid_notebook.port_base = 40000 36 | 37 | # Serve Notebook from alternative domain and not 38 | # from one where Pyramid main application is running. 39 | # This must include leading https:// prefix e.g. 40 | # https://ws.example.com 41 | pyramid_notebook.alternative_domain = 42 | 43 | ### 44 | # wsgi server configuration 45 | ### 46 | 47 | [server:main] 48 | use = egg:waitress#main 49 | host = 0.0.0.0 50 | port = 9999 51 | 52 | ### 53 | # logging configuration 54 | # http://docs.pylonsproject.org/projects/pyramid/en/1.5-branch/narr/logging.html 55 | ### 56 | 57 | [loggers] 58 | keys = root, proxy, pyramid_debug 59 | 60 | [handlers] 61 | keys = console 62 | 63 | [formatters] 64 | keys = generic 65 | 66 | [logger_root] 67 | level = DEBUG 68 | handlers = console 69 | 70 | [logger_proxy] 71 | level = WARN 72 | handlers = 73 | qualname = pyramid_notebook.proxy 74 | 75 | # Pyramid router debug info 76 | [logger_pyramid_debug] 77 | level = INFO 78 | qualname = pyramid_debug 79 | handlers = 80 | 81 | [handler_console] 82 | class = StreamHandler 83 | args = (sys.stderr,) 84 | level = NOTSET 85 | formatter = generic 86 | 87 | [formatter_generic] 88 | format = %(asctime)s %(levelname)-5.5s [%(name)s][%(threadName)s] %(message)s 89 | -------------------------------------------------------------------------------- /pyramid_notebook/demo/templates/home.html: -------------------------------------------------------------------------------- 1 | {% extends "site/base.html" %} 2 | 3 | {% block content %} 4 |

pyramid_notebook test application

5 | 6 |

7 | Launch test notebook 1 8 |

9 | 10 | 11 |

12 | Launch test notebook 2 13 |

14 | 15 | {% endblock %} -------------------------------------------------------------------------------- /pyramid_notebook/demo/templates/login.html: -------------------------------------------------------------------------------- 1 | {% extends "site/base.html" %} 2 | 3 | {% block content %} 4 |
5 | 6 |

Hint: user/password

7 | 8 | 9 |
10 | {% endblock %} -------------------------------------------------------------------------------- /pyramid_notebook/demo/templates/site/analytics.html: -------------------------------------------------------------------------------- 1 | {# Your Google Analytics JavaScript and similar goes here #} -------------------------------------------------------------------------------- /pyramid_notebook/demo/templates/site/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {% block description %} 7 | {% include "site/description.html" %} 8 | {% endblock description %} 9 | 10 | {% block meta %} 11 | {% include "site/meta.html" %} 12 | {% endblock meta %} 13 | 14 | {% block css %} 15 | {% include "site/css.html" %} 16 | {% endblock %} 17 | 18 | {% block extra_head %} 19 | {# Pages can register their page specific CSS/JS here #} 20 | {% endblock %} 21 | 22 | 23 | 24 | {# body_classes allows to insert extra CSS classes on tag #} 25 | 29 | 30 | {# body block contains the whole page without a navigation frame and the analytics script #} 31 | {% block body %} 32 | 33 | {# Navigation #} 34 | {% block header %} 35 | {% include "site/header.html" %} 36 | {% endblock header %} 37 | 38 | {# main is the content without margins and flash messages #} 39 | {% block main %} 40 |
41 | 42 |
43 | {% block messages %} 44 | {% include "site/messages.html" %} 45 | {% endblock messages %} 46 |
47 | 48 | {# content is the content with margins #} 49 |
50 | {% block content %} 51 | {% endblock content %} 52 |
53 |
54 | {% endblock main %} 55 | 56 | {% block footer %} 57 | {% include "site/footer.html" %} 58 | {% endblock footer %} 59 | 60 | {% block body_end %} 61 | {% endblock body_end %} 62 | 63 | {# script is shared site JavaScript #} 64 | {% block script %} 65 | {% include "site/javascript.html" %} 66 | {% endblock script %} 67 | 68 | {# custom_script is page specific JavaScript #} 69 | {% block custom_script %} 70 | {% endblock custom_script %} 71 | 72 | {% endblock body %} 73 | 74 | {# analytics is tracking JavaScript #} 75 | {% block analytics %} 76 | {% include "site/analytics.html" %} 77 | {% endblock analytics %} 78 | 79 | 80 | 81 | -------------------------------------------------------------------------------- /pyramid_notebook/demo/templates/site/base_compact.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {# Base template which does not use any request context information and is safe to use on error pages #} 5 | 6 | 7 | 8 | {% block meta %} 9 | {% include "site/meta.html" %} 10 | {% endblock meta %} 11 | 12 | {% block css %} 13 | {% include "site/css.html" %} 14 | {% endblock %} 15 | 16 | 17 | 18 | 19 | {# body_classes allows to insert extra CSS classes on tag #} 20 | 21 | {# body block contains the whole page without a navigation frame and the analytics script #} 22 | {% block body %} 23 | {% endblock body %} 24 | 25 |
26 | 27 | {# content is the content with margins #} 28 |
29 | {% block content %} 30 | {% endblock content %} 31 |
32 |
33 | 34 | {# analytics is tracking JavaScript #} 35 | {% block analytics %} 36 | {% include "site/analytics.html" %} 37 | {% endblock analytics %} 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /pyramid_notebook/demo/templates/site/css.html: -------------------------------------------------------------------------------- 1 | {# Speed up automatic testing by skipping the loading of CSS resources #} 2 | 3 | {% if not testing_skip_css %} 4 | 5 | 6 | {% endif %} 7 | 8 | -------------------------------------------------------------------------------- /pyramid_notebook/demo/templates/site/description.html: -------------------------------------------------------------------------------- 1 | {% if site_title %} 2 | {{site_title}} 3 | {% endif %} 4 | 5 | {% if site_description %} 6 | 7 | {% endif %} 8 | -------------------------------------------------------------------------------- /pyramid_notebook/demo/templates/site/footer.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/websauna/pyramid_notebook/8a7ecfa0259810de1a818e4b415a62811a7b077a/pyramid_notebook/demo/templates/site/footer.html -------------------------------------------------------------------------------- /pyramid_notebook/demo/templates/site/header.html: -------------------------------------------------------------------------------- 1 |
2 | {% include "site/nav.html" %} 3 |
-------------------------------------------------------------------------------- /pyramid_notebook/demo/templates/site/javascript.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /pyramid_notebook/demo/templates/site/logo.html: -------------------------------------------------------------------------------- 1 | 2 | pyramid_notebook 3 | -------------------------------------------------------------------------------- /pyramid_notebook/demo/templates/site/messages.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/websauna/pyramid_notebook/8a7ecfa0259810de1a818e4b415a62811a7b077a/pyramid_notebook/demo/templates/site/messages.html -------------------------------------------------------------------------------- /pyramid_notebook/demo/templates/site/meta.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /pyramid_notebook/demo/templates/site/nav.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pyramid_notebook/demo/views.py: -------------------------------------------------------------------------------- 1 | # Pyramid 2 | from pyramid import httpexceptions 3 | from pyramid.httpexceptions import HTTPFound 4 | from pyramid.httpexceptions import HTTPUnauthorized 5 | from pyramid.interfaces import IAuthenticationPolicy 6 | from pyramid.security import forget 7 | from pyramid.view import forbidden_view_config 8 | from pyramid.view import view_config 9 | 10 | # Pyramid Notebook 11 | from pyramid_notebook import startup 12 | from pyramid_notebook.views import launch_notebook 13 | from pyramid_notebook.views import notebook_proxy as _notebook_proxy 14 | from pyramid_notebook.views import shutdown_notebook as _shutdown_notebook 15 | 16 | 17 | SCRIPT = """ 18 | a = "foo" 19 | b = "bar" 20 | 21 | def fn(): 22 | print("buu") 23 | """ 24 | 25 | GREETING = """ 26 | * **a** - varible a 27 | * **b** - variable b 28 | * **fn** - test function 29 | """ 30 | 31 | 32 | @view_config(route_name="home", renderer="templates/home.html") 33 | def home(request): 34 | return {} 35 | 36 | 37 | @view_config(route_name="notebook_proxy", renderer="templates/login.html") 38 | def notebook_proxy(request): 39 | # Make sure we have a logged in user 40 | auth = request.registry.queryUtility(IAuthenticationPolicy) 41 | username = auth.authenticated_userid(request) 42 | 43 | if not username: 44 | # This will trigger HTTP Basic Auth dialog, as per basic_challenge handler below 45 | raise httpexceptions.HTTPForbidden("You need to be logged in. Hint: user / password") 46 | 47 | return _notebook_proxy(request, username) 48 | 49 | 50 | @view_config(route_name="shell1") 51 | def shell1(request): 52 | # Make sure we have a logged in user 53 | auth = request.registry.queryUtility(IAuthenticationPolicy) 54 | username = auth.authenticated_userid(request) 55 | 56 | if not username: 57 | # This will trigger HTTP Basic Auth dialog, as per basic_challenge handler below 58 | raise httpexceptions.HTTPForbidden("You need to be logged in. Hint: user / password") 59 | 60 | notebook_context = {"greeting": "**Executing shell1 context**\n"} 61 | config_file = request.registry.settings["global_config"]["__file__"] 62 | startup.make_startup(notebook_context, config_file) 63 | 64 | return launch_notebook(request, username, notebook_context=notebook_context) 65 | 66 | 67 | @view_config(route_name="shell2") 68 | def shell2(request): 69 | # Make sure we have a logged in user 70 | auth = request.registry.queryUtility(IAuthenticationPolicy) 71 | username = auth.authenticated_userid(request) 72 | 73 | if not username: 74 | # This will trigger HTTP Basic Auth dialog, as per basic_challenge handler below 75 | raise httpexceptions.HTTPForbidden("You need to be logged in. Hint: user / password") 76 | 77 | notebook_context = {"greeting": "**Executing shell2 context**\n"} 78 | config_file = request.registry.settings["global_config"]["__file__"] 79 | startup.make_startup(notebook_context, config_file) 80 | startup.add_script(notebook_context, SCRIPT) 81 | startup.add_greeting(notebook_context, GREETING) 82 | 83 | return launch_notebook(request, username, notebook_context=notebook_context) 84 | 85 | 86 | @view_config(route_name="shutdown_notebook") 87 | def shutdown_notebook(request): 88 | # Make sure we have a logged in user 89 | auth = request.registry.queryUtility(IAuthenticationPolicy) 90 | username = auth.authenticated_userid(request) 91 | 92 | if not username: 93 | # This will trigger HTTP Basic Auth dialog, as per basic_challenge handler below 94 | raise httpexceptions.HTTPForbidden("You need to be logged in. Hint: user / password") 95 | 96 | _shutdown_notebook(request, username) 97 | 98 | return HTTPFound(request.route_url("home")) 99 | 100 | 101 | @forbidden_view_config() 102 | def basic_challenge(request): 103 | response = HTTPUnauthorized() 104 | response.headers.update(forget(request)) 105 | return response 106 | -------------------------------------------------------------------------------- /pyramid_notebook/demo/wsgi.py: -------------------------------------------------------------------------------- 1 | # Standard Library 2 | import os 3 | 4 | # Pyramid 5 | from pyramid.paster import get_app 6 | from pyramid.paster import setup_logging 7 | 8 | # Pyramid Notebook 9 | from pyramid_notebook.demo import main # noQA 10 | 11 | 12 | ini_path = os.path.join(os.path.dirname(__file__), "..", "..", "uwsgi-development.ini") 13 | setup_logging(ini_path) 14 | 15 | application = get_app(ini_path, 'main') 16 | -------------------------------------------------------------------------------- /pyramid_notebook/notebookmanager.py: -------------------------------------------------------------------------------- 1 | # Standard Library 2 | import logging 3 | import os 4 | import subprocess 5 | import sys 6 | import time 7 | 8 | # Third Party 9 | import port_for 10 | 11 | from pyramid_notebook.server import comm 12 | 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | class NotebookManager: 18 | """Manage any number of detached running Notebook instances. 19 | 20 | * Start instance by name, assign a port 21 | * Get instance by name 22 | * Stop instance by name 23 | * Pass extra parameters to IPython Nobetook 24 | """ 25 | 26 | def __init__(self, notebook_folder, min_port=40000, port_range=10, kill_timeout=50, python=None): 27 | """ 28 | :param notebook_folder: A folder containing a subfolder for each named IPython Notebook. The subfolder contains pid file, log file, default.ipynb and profile files. 29 | """ 30 | self.min_port = min_port 31 | self.port_range = port_range 32 | self.notebook_folder = notebook_folder 33 | self.kill_timeout = kill_timeout 34 | 35 | if python: 36 | self.python = python 37 | else: 38 | self.python = self.discover_python() 39 | 40 | os.makedirs(notebook_folder, exist_ok=True) 41 | 42 | self.cmd = self.get_manager_cmd() 43 | 44 | def discover_python(self): 45 | """Get the Python interpreter we need to use to run our Notebook daemon.""" 46 | python = sys.executable 47 | 48 | #: XXX fix this hack, uwsgi sets itself as Python 49 | #: Make better used Python interpreter autodiscovery 50 | if python.endswith("/uwsgi"): 51 | python = python.replace("/uwsgi", "/python") 52 | return python 53 | 54 | def get_work_folder(self, name): 55 | work_folder = os.path.join(self.notebook_folder, name) 56 | os.makedirs(work_folder, exist_ok=True) 57 | return work_folder 58 | 59 | def get_log_file(self, name): 60 | log_file = os.path.join(self.get_work_folder(name), "ipython.log") 61 | return log_file 62 | 63 | def get_pid(self, name): 64 | """Get PID file name for a named notebook.""" 65 | pid_file = os.path.join(self.get_work_folder(name), "notebook.pid") 66 | return pid_file 67 | 68 | def get_context(self, name): 69 | return comm.get_context(self.get_pid(name)) 70 | 71 | def get_manager_cmd(self): 72 | """Get our daemon script path.""" 73 | cmd = os.path.abspath(os.path.join(os.path.dirname(__file__), "server", "notebook_daemon.py")) 74 | assert os.path.exists(cmd) 75 | return cmd 76 | 77 | def pick_port(self): 78 | """Pick open TCP/IP port.""" 79 | ports = set(range(self.min_port, self.min_port + self.port_range)) 80 | return port_for.select_random(ports) 81 | 82 | def get_notebook_daemon_command(self, name, action, port=0, *extra): 83 | """ 84 | Assume we launch Notebook with the same Python which executed us. 85 | """ 86 | 87 | return [self.python, self.cmd, action, self.get_pid(name), self.get_work_folder(name), port, self.kill_timeout] + list(extra) 88 | 89 | def exec_notebook_daemon_command(self, name, cmd, port=0): 90 | """Run a daemon script command.""" 91 | cmd = self.get_notebook_daemon_command(name, cmd, port) 92 | 93 | # Make all arguments explicit strings 94 | cmd = [str(arg) for arg in cmd] 95 | 96 | logger.info("Running notebook command: %s", " ".join(cmd)) 97 | # print("XXX - DEBUG - Running notebook command:", " ".join(cmd)) 98 | 99 | # Add support for traceback dump on stuck 100 | env = os.environ.copy() 101 | env["PYTHONFAULTHANDLER"] = "true" 102 | 103 | p = subprocess.Popen(cmd, stderr=subprocess.PIPE, stdout=subprocess.PIPE, env=env) 104 | time.sleep(0.2) 105 | stdout, stderr = p.communicate() 106 | 107 | if b"already running" in stderr: 108 | raise RuntimeError("Looks like notebook_daemon is already running. Please kill it manually pkill -f notebook_daemon. Was: {}".format(stderr.decode("utf-8"))) 109 | 110 | if p.returncode != 0: 111 | logger.error("STDOUT: %s", stdout) 112 | logger.error("STDERR: %s", stderr) 113 | 114 | raise RuntimeError("Could not execute notebook command. Exit code: {} cmd: {}".format(p.returncode, " ".join(cmd))) 115 | 116 | return stdout 117 | 118 | def get_notebook_status(self, name): 119 | """Get the running named Notebook status. 120 | 121 | :return: None if no notebook is running, otherwise context dictionary 122 | """ 123 | context = comm.get_context(self.get_pid(name)) 124 | if not context: 125 | return None 126 | return context 127 | 128 | def start_notebook(self, name, context: dict, fg=False): 129 | """Start new IPython Notebook daemon. 130 | 131 | :param name: The owner of the Notebook will be *name*. He/she gets a new Notebook content folder created where all files are placed. 132 | 133 | :param context: Extra context information passed to the started Notebook. This must contain {context_hash:int} parameter used to identify the launch parameters for the notebook 134 | """ 135 | assert context 136 | assert type(context) == dict 137 | assert "context_hash" in context 138 | assert type(context["context_hash"]) == int 139 | 140 | http_port = self.pick_port() 141 | assert http_port 142 | context = context.copy() 143 | context["http_port"] = http_port 144 | 145 | # We can't proxy websocket URLs, so let them go directly through localhost or have front end server to do proxying (nginx) 146 | if "websocket_url" not in context: 147 | context["websocket_url"] = "ws://localhost:{port}".format(port=http_port) 148 | 149 | if "{port}" in context["websocket_url"]: 150 | # Do port substitution for the websocket URL 151 | context["websocket_url"] = context["websocket_url"].format(port=http_port) 152 | 153 | pid = self.get_pid(name) 154 | assert "terminated" not in context 155 | 156 | comm.set_context(pid, context) 157 | 158 | if fg: 159 | self.exec_notebook_daemon_command(name, "fg", port=http_port) 160 | else: 161 | self.exec_notebook_daemon_command(name, "start", port=http_port) 162 | 163 | def stop_notebook(self, name): 164 | self.exec_notebook_daemon_command(name, "stop") 165 | 166 | def is_running(self, name): 167 | status = self.get_notebook_status(name) 168 | if status: 169 | return status.get("pid") is not None 170 | return False 171 | 172 | def is_same_context(self, context_a, context_b): 173 | if context_a == context_b: 174 | return True 175 | 176 | context_a = context_a or {} 177 | context_b = context_b or {} 178 | 179 | return context_a.get("context_hash") == context_b.get("context_hash") 180 | 181 | def start_notebook_on_demand(self, name, context): 182 | """Start notebook if not yet running with these settings. 183 | 184 | Return the updated settings with a port info. 185 | 186 | :return: (context dict, created flag) 187 | """ 188 | if self.is_running(name): 189 | 190 | last_context = self.get_context(name) 191 | logger.info("Notebook context change detected for %s", name) 192 | if not self.is_same_context(context, last_context): 193 | self.stop_notebook(name) 194 | # Make sure we don't get race condition over context.json file 195 | time.sleep(2.0) 196 | else: 197 | return last_context, False 198 | 199 | err_log = os.path.join(self.get_work_folder(name), "notebook.stderr.log") 200 | logger.info("Launching new Notebook named %s, context is %s", name, context) 201 | logger.info("Notebook log is %s", err_log) 202 | 203 | self.start_notebook(name, context) 204 | time.sleep(1) 205 | context = self.get_context(name) 206 | if "notebook_name" not in context: 207 | # Failed to launch within timeout 208 | raise RuntimeError("Failed to launch IPython Notebook, see {}".format(err_log)) 209 | 210 | return context, True 211 | -------------------------------------------------------------------------------- /pyramid_notebook/proxy.py: -------------------------------------------------------------------------------- 1 | # Courtesy of https://bitbucket.org/dahlia/wsgi-proxy/raw/02ab0dfa8e0078add268e91426e1cc1a52664cf5/wsgi_proxy/__init__.py 2 | 3 | # Standard Library 4 | import http.client 5 | import logging 6 | from urllib.parse import unquote_plus 7 | from urllib.parse import urlparse 8 | from urllib.parse import urlunparse 9 | 10 | 11 | #: (:class:`frozenset`) The set of hop-by-hop headers. All header names 12 | #: all normalized to lowercase. 13 | HOPPISH_HEADERS = frozenset([ 14 | 'keep-alive', 'proxy-authenticate', 15 | 'proxy-authorization', 'te', 'trailers', 'transfer-encoding', 16 | 'proxy-connection' 17 | # "upgrade", "connection" 18 | ]) 19 | 20 | 21 | def is_hop_by_hop(header): 22 | """Returns :const:`True` if the given ``header`` is hop by hop. 23 | 24 | :param header: the header name 25 | :type header: :class:`basestring` 26 | :returns: whether the given ``header`` is hop by hop or not 27 | :rtype: :class:`bool` 28 | 29 | """ 30 | return header.lower() in HOPPISH_HEADERS 31 | 32 | 33 | def reconstruct_url(environ, port): 34 | """Reconstruct the remote url from the given WSGI ``environ`` dictionary. 35 | 36 | :param environ: the WSGI environment 37 | :type environ: :class:`collections.MutableMapping` 38 | :returns: the remote url to proxy 39 | :rtype: :class:`basestring` 40 | 41 | """ 42 | # From WSGI spec, PEP 333 43 | url = environ.get('PATH_INFO', '') 44 | if not url.startswith(('http://', 'https://')): 45 | url = '%s://%s%s' % ( 46 | environ['wsgi.url_scheme'], 47 | environ['HTTP_HOST'], 48 | url 49 | ) 50 | # Fix ;arg=value in url 51 | if '%3B' in url: 52 | url, arg = url.split('%3B', 1) 53 | url = ';'.join([url, arg.replace('%3D', '=')]) 54 | # Stick query string back in 55 | try: 56 | query_string = environ['QUERY_STRING'] 57 | except KeyError: 58 | pass 59 | else: 60 | url += '?' + query_string 61 | 62 | parsed = urlparse(url) 63 | replaced = parsed._replace(netloc="localhost:{}".format(port)) 64 | url = urlunparse(replaced) 65 | environ['reconstructed_url'] = url 66 | return url 67 | 68 | 69 | class WSGIProxyApplication: 70 | """WSGI application to handle requests that need to be proxied. 71 | You have to instantiate the class before using it as WSGI app:: 72 | 73 | from wsgiref.simple_server import make_server 74 | 75 | app = WSGIProxyApplication() 76 | make_server('', 8080, app).serve_forever() 77 | 78 | """ 79 | 80 | #: (:class:`types.ClassType`) The connection class of :mod:`httplib` module. 81 | #: It should be a subtype of :class:`httplib.HTTPConnection`. 82 | #: Default is :class:`httplib.HTTPConnection`. 83 | connection_class = http.client.HTTPConnection 84 | 85 | def __init__(self, port): 86 | # Target port where we proxy IPython Notebook 87 | self.port = port 88 | 89 | def handler(self, environ, start_response): 90 | """Proxy for requests to the actual http server""" 91 | logger = logging.getLogger(__name__ + '.WSGIProxyApplication.handler') 92 | url = urlparse(reconstruct_url(environ, self.port)) 93 | 94 | # Create connection object 95 | try: 96 | connection = self.connection_class(url.netloc) 97 | # Build path 98 | path = url.geturl().replace('%s://%s' % (url.scheme, url.netloc), 99 | '') 100 | except Exception: 101 | start_response('501 Gateway Error', [('Content-Type', 'text/html')]) 102 | logger.exception('Could not Connect') 103 | yield '

Could not connect

' 104 | return 105 | 106 | # Read in request body if it exists 107 | body = length = None 108 | try: 109 | length = int(environ['CONTENT_LENGTH']) 110 | except (KeyError, ValueError): 111 | 112 | # This is a situation where client HTTP POST is missing content-length. 113 | # This is also situation where (WebOb?) may screw up encoding and isert extranous = in the body. 114 | # https://github.com/ipython/ipython/issues/8416 115 | if environ["REQUEST_METHOD"] == "POST": 116 | if environ.get("CONTENT_TYPE") == 'application/x-www-form-urlencoded; charset=UTF-8': 117 | body = environ['wsgi.input'].read() 118 | try: 119 | body = unquote_plus(body.decode("utf-8")) 120 | 121 | # Fix extra = at end of JSON payload 122 | if body.startswith("{") and body.endswith("}="): 123 | body = body[0:len(body) - 1] 124 | 125 | except Exception as e: 126 | logger.exception(e) 127 | logger.error("Could not decode body: %s", body) 128 | 129 | length = len(body) 130 | else: 131 | body = environ['wsgi.input'].read(length) 132 | 133 | # Build headers 134 | logger.debug('environ = %r', environ) 135 | headers = dict( 136 | (key, value) 137 | for key, value in ( 138 | # This is a hacky way of getting the header names right 139 | (key[5:].lower().replace('_', '-'), value) 140 | for key, value in environ.items() 141 | # Keys that start with HTTP_ are all headers 142 | if key.startswith('HTTP_') 143 | ) 144 | if not is_hop_by_hop(key) 145 | ) 146 | 147 | # Handler headers that aren't HTTP_ in environ 148 | try: 149 | headers['content-type'] = environ['CONTENT_TYPE'] 150 | except KeyError: 151 | pass 152 | 153 | # Add our host if one isn't defined 154 | if 'host' not in headers: 155 | headers['host'] = environ['SERVER_NAME'] 156 | 157 | # Make the remote request 158 | try: 159 | 160 | logger.debug('%s %s %r', 161 | environ['REQUEST_METHOD'], path, headers) 162 | connection.request(environ['REQUEST_METHOD'], path, 163 | body=body, headers=headers) 164 | except Exception as e: 165 | # We need extra exception handling in the case the server fails 166 | # in mid connection, it's an edge case but I've seen it 167 | if isinstance(e, ConnectionRefusedError): 168 | # The notebook was shutdown by the user 169 | pass 170 | else: 171 | # This might be a genuine error 172 | logger.exception(e) 173 | 174 | start_response('501 Gateway Error', [('Content-Type', 'text/html')]) 175 | yield '

Could not proxy IPython Notebook running localhost:{}

'.format(self.port).encode("utf-8") 176 | return 177 | 178 | try: 179 | response = connection.getresponse() 180 | except ConnectionResetError: 181 | # Notebook shutdown 182 | start_response('501 Gateway Error', [('Content-Type', 'text/html')]) 183 | yield '

Could not proxy IPython Notebook running localhost:{}

'.format(self.port).encode("utf-8") 184 | return 185 | 186 | hopped_headers = response.getheaders() 187 | headers = [(key, value) 188 | for key, value in hopped_headers 189 | if not is_hop_by_hop(key)] 190 | 191 | start_response('{0.status} {0.reason}'.format(response), headers) 192 | while True: 193 | chunk = response.read(4096) 194 | if chunk: 195 | yield chunk 196 | else: 197 | break 198 | 199 | def __call__(self, environ, start_response): 200 | return self.handler(environ, start_response) 201 | -------------------------------------------------------------------------------- /pyramid_notebook/server/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/websauna/pyramid_notebook/8a7ecfa0259810de1a818e4b415a62811a7b077a/pyramid_notebook/server/__init__.py -------------------------------------------------------------------------------- /pyramid_notebook/server/comm.py: -------------------------------------------------------------------------------- 1 | """Communicate extra parameters to the notebook through a JSON file. 2 | 3 | When the notebook daemon is started, dump all special settings, including ports, in a named file similar to PID file. 4 | 5 | Note that port is delivered through command line AND context file. This is to allow starting daemon from the command line for testing without cumbersome writing of context file first. 6 | 7 | 8 | """ 9 | # Standard Library 10 | import datetime 11 | import json 12 | import logging 13 | import os 14 | import shutil 15 | 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | 20 | # http://stackoverflow.com/a/568285/315168 21 | def check_pid(pid): 22 | """ Check For the existence of a unix pid. """ 23 | try: 24 | os.kill(pid, 0) 25 | except OSError: 26 | return False 27 | else: 28 | return True 29 | 30 | 31 | def get_context_file_name(pid_file): 32 | """When the daemon is started write out the information which port it was using.""" 33 | root = os.path.dirname(pid_file) 34 | port_file = os.path.join(root, "context.json") 35 | return port_file 36 | 37 | 38 | def set_context(pid_file, context_info): 39 | """Set context of running notebook. 40 | 41 | :param context_info: dict of extra context parameters, see comm.py comments 42 | """ 43 | assert type(context_info) == dict 44 | 45 | port_file = get_context_file_name(pid_file) 46 | with open(port_file, "wt") as f: 47 | f.write(json.dumps(context_info)) 48 | 49 | 50 | def get_context(pid_file, daemon=False): 51 | """Get context of running notebook. 52 | 53 | A context file is created when notebook starts. 54 | 55 | :param daemon: Are we trying to fetch the context inside the daemon. Otherwise do the death check. 56 | 57 | :return: dict or None if the process is dead/not launcherd 58 | """ 59 | port_file = get_context_file_name(pid_file) 60 | 61 | if not os.path.exists(port_file): 62 | return None 63 | 64 | with open(port_file, "rt") as f: 65 | json_data = f.read() 66 | try: 67 | data = json.loads(json_data) 68 | except ValueError as e: 69 | 70 | logger.error("Damaged context json data %s", json_data) 71 | return None 72 | 73 | if not daemon: 74 | pid = data.get("pid") 75 | if pid and not check_pid(int(pid)): 76 | # The Notebook daemon has exited uncleanly, as the PID does not point to any valid process 77 | return None 78 | 79 | return data 80 | 81 | 82 | def clear_context(pid_file): 83 | """Called at exit. Delete the context file to signal there is no active notebook. 84 | 85 | We don't delete the whole file, but leave it around for debugging purposes. Maybe later we want to pass some information back to the web site. 86 | """ 87 | return 88 | raise RuntimeError("Should not happen") 89 | fname = get_context_file_name(pid_file) 90 | shutil.move(fname, fname.replace("context.json", "context.old.json")) 91 | 92 | data = {} 93 | data["terminated"] = str(datetime.datetime.now(datetime.timezone.utc)) 94 | set_context(pid_file, data) 95 | -------------------------------------------------------------------------------- /pyramid_notebook/server/custom.js: -------------------------------------------------------------------------------- 1 | define( 2 | [ 3 | 'base/js/namespace', 4 | 'base/js/promises', 5 | 'base/js/utils' 6 | ], function (Jupyter, promises, utils) { 7 | promises.app_initialized.then(function (appname) { 8 | if (appname === 'NotebookApp') { 9 | var base_url = utils.get_body_data('base-url'); 10 | 11 | // Remove default close item 12 | $("#close_and_halt").remove(); 13 | 14 | // Add customized close item 15 | $("#file_menu").append('
  • Shutdown
  • '); 16 | 17 | $(document).on("click", "#shutdown", function () { 18 | console.log(base_url); 19 | window.location.href = base_url + "shutdown"; 20 | }); 21 | } 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /pyramid_notebook/server/notebook_daemon.py: -------------------------------------------------------------------------------- 1 | """Daemonized Python Notebook process with pre-allocated port, kill timeout and extra argument passing through JSON file.""" 2 | # Standard Library 3 | import atexit 4 | import faulthandler 5 | import io 6 | import logging 7 | import os 8 | import shutil 9 | import signal 10 | import sys 11 | import time 12 | from pathlib import Path 13 | 14 | # Third Party 15 | import daemonocle 16 | import psutil 17 | from daemonocle import DaemonError 18 | from daemonocle import expose_action 19 | from nbformat.v4.nbjson import JSONWriter 20 | 21 | # Pyramid Notebook 22 | from pyramid_notebook.server import comm 23 | 24 | 25 | try: 26 | import coverage 27 | coverage.process_startup() 28 | except ImportError: 29 | # http://nedbatchelder.com/code/coverage/subprocess.html 30 | pass 31 | 32 | port = None 33 | kill_timeout = None 34 | extra_argv = None 35 | pid_file = None 36 | 37 | 38 | class NotebookDaemon(daemonocle.Daemon): 39 | 40 | def __init__(self, **kwargs): 41 | super(NotebookDaemon, self).__init__(**kwargs) 42 | 43 | @expose_action 44 | def stop(self): 45 | """Stop the daemon. 46 | 47 | IPython Notebook tends to hang on exit 1) on certain Linux servers 2) sometimes. 48 | I am not sure why, but here is the traceback back when gdb was attached to the process:: 49 | 50 | #0 0x00007fa7632c912d in poll () at ../sysdeps/unix/syscall-template.S:81 51 | #1 0x00007fa75e6d2f6a in poll (__timeout=, __nfds=2, __fds=0x7fffd2c60940) at /usr/include/x86_64-linux-gnu/bits/poll2.h:46 52 | #2 zmq_poll (items_=items_@entry=0x2576f00, nitems_=nitems_@entry=2, timeout_=timeout_@entry=2997) at bundled/zeromq/src/zmq.cpp:736 53 | #3 0x00007fa75d0d7c0b in __pyx_pf_3zmq_7backend_6cython_5_poll_zmq_poll (__pyx_self=, __pyx_v_timeout=2997, __pyx_v_sockets=0x7fa75b82c848) at zmq/backend/cython/_poll.c:1552 54 | #4 __pyx_pw_3zmq_7backend_6cython_5_poll_1zmq_poll (__pyx_self=, __pyx_args=, __pyx_kwds=) at zmq/backend/cython/_poll.c:1023 55 | #5 0x000000000057bf33 in PyEval_EvalFrameEx () 56 | #6 0x000000000057d3d3 in PyEval_EvalCodeEx () 57 | 58 | Smells like pyzmq bug. In any it would take pretty extensive debugging to find out why it doesn't always quit cleanly, so we just SIGKILL the process after certain timeout. 59 | 60 | Related bug, but this should be apparently fixed: https://github.com/zeromq/pyzmq/pull/618 61 | """ 62 | if self.pidfile is None: 63 | raise DaemonError('Cannot stop daemon without PID file') 64 | 65 | pid = self._read_pidfile() 66 | if pid is None: 67 | # I don't think this should be a fatal error 68 | self._emit_warning('{prog} is not running'.format(prog=self.prog)) 69 | return 70 | 71 | self._emit_message('Stopping {prog} ... '.format(prog=self.prog)) 72 | 73 | try: 74 | # Try to terminate the process 75 | os.kill(pid, signal.SIGTERM) 76 | except OSError as ex: 77 | self._emit_failed() 78 | self._emit_error(str(ex)) 79 | sys.exit(1) 80 | 81 | _, alive = psutil.wait_procs([psutil.Process(pid)], timeout=self.stop_timeout) 82 | if alive: 83 | # The process didn't terminate for some reason 84 | os.kill(pid, signal.SIGKILL) 85 | time.sleep(0.5) 86 | # Hahahaha. Do you feel alive now? 87 | 88 | self._emit_ok() 89 | 90 | 91 | def create_named_notebook(fname, context): 92 | """Create a named notebook if one doesn't exist.""" 93 | 94 | if os.path.exists(fname): 95 | return 96 | 97 | from nbformat import v4 as nbf 98 | 99 | # Courtesy of http://nbviewer.ipython.org/gist/fperez/9716279 100 | text = "Welcome to *pyramid_notebook!* Use *File* *>* *Shutdown* to close this." 101 | cells = [nbf.new_markdown_cell(text)] 102 | 103 | greeting = context.get("greeting") 104 | if greeting: 105 | cells.append(nbf.new_markdown_cell(greeting)) 106 | 107 | cells.append(nbf.new_code_cell('')) 108 | 109 | nb = nbf.new_notebook(cells=cells) 110 | 111 | with open(fname, 'w') as f: 112 | writer = JSONWriter() 113 | writer.write(nb, f) 114 | 115 | 116 | def run_notebook(foreground=False): 117 | 118 | if not foreground: 119 | # Make it possible to get output what daemonized IPython is doing 120 | sys.stdout = io.open("notebook.stdout.log", "wt") 121 | sys.stderr = io.open("notebook.stderr.log", "wt") 122 | 123 | try: 124 | _run_notebook(foreground) 125 | except Exception as e: 126 | import traceback 127 | traceback.print_exc(file=sys.stderr) 128 | raise 129 | 130 | 131 | def _run_notebook(foreground=False): 132 | 133 | print("Starting notebook, daemon {}".format(not foreground), file=sys.stderr) 134 | 135 | # Set dead man's switch 136 | def kill_me(num, stack): 137 | """Oblig. Alien reference.""" 138 | sys.exit(66) 139 | 140 | signal.signal(signal.SIGALRM, kill_me) 141 | signal.alarm(kill_timeout) 142 | 143 | argv = ["notebook", "--debug"] + extra_argv 144 | 145 | assert port 146 | 147 | # http://jupyter.readthedocs.io/en/latest/projects/jupyter-directories.html 148 | config_dir = os.path.join(os.getcwd(), ".jupyter") 149 | os.environ["JUPYTER_CONFIG_DIR"] = config_dir 150 | 151 | # http://ipython.readthedocs.io/en/stable/development/config.html#configuration-file-location 152 | os.environ["IPYTHONDIR"] = config_dir 153 | 154 | # Update context file with command line port settings 155 | context = comm.get_context(pid_file, daemon=True) 156 | 157 | if foreground: 158 | if not context: 159 | context = {} 160 | else: 161 | if not context: 162 | # We cannot let daemons start up with context, because it keeps running, reverses port but would do all proxy setup wrong 163 | sys.exit("Daemonized process needs an explicit context.json file and could not read one from {}".format(os.path.dirname(pid_file))) 164 | 165 | if "terminated" in context: 166 | print("Invalid context file {}: {}".format(os.path.dirname(pid_file), context), file=sys.stderr) 167 | sys.exit("Bad context - was by terminated notebook daemon") 168 | 169 | print("Starting with context {}".format(context), file=sys.stderr) 170 | 171 | hash = context["context_hash"] 172 | if hash < 0: 173 | # Python hasher can create negative integers which look funny in URL. 174 | # Let's sacrifice one bit of hash collision for aesthetics. 175 | hash = -hash 176 | 177 | notebook_name = "default-{}.ipynb".format(hash) 178 | 179 | context["http_port"] = port 180 | context["pid"] = os.getpid() 181 | context["kill_timeout"] = kill_timeout 182 | context["notebook_name"] = notebook_name 183 | 184 | comm.set_context(pid_file, context) 185 | 186 | create_named_notebook(notebook_name, context) 187 | 188 | # Grind through with print as IPython launcher would mess our loggers 189 | print("Launching on localhost:{}, having context {}".format(port, str(context)), file=sys.stderr) 190 | 191 | try: 192 | import IPython 193 | from traitlets.config.loader import Config 194 | 195 | # http://jupyter-notebook.readthedocs.io/en/latest/config.html 196 | config = Config() 197 | config.NotebookApp.port = port 198 | config.NotebookApp.open_browser = foreground 199 | config.NotebookApp.base_url = context.get("notebook_path", "/notebook") 200 | config.Application.log_level = logging.DEBUG 201 | config.NotebookApp.allow_origin = context.get("allow_origin", "http://localhost:{}/".format(port)) 202 | config.NotebookApp.extra_template_paths = context.get("extra_template_paths", []) 203 | 204 | # Password is disabled when through through proxying as it is handled by the proxy Pyramid app 205 | config.NotebookApp.password_required = False 206 | config.NotebookApp.password = "" 207 | config.NotebookApp.token = "" 208 | 209 | if "websocket_url" in context: 210 | websocket_url = context.get("websocket_url", "http://localhost:{}/".format(port)) 211 | config.NotebookApp.websocket_url = websocket_url 212 | 213 | if "startup" in context: 214 | # Drop in custom startup script 215 | startup_folder = os.path.join(config_dir, "profile_default/startup/") 216 | os.makedirs(startup_folder, exist_ok=True) 217 | startup_py = os.path.join(startup_folder, "startup.py") 218 | print("Dropping startup script {}".format(startup_py), file=sys.stderr) 219 | with open(startup_py, "wt") as f: 220 | f.write(context["startup"]) 221 | 222 | # Drop in our custom.css and custom.js 223 | custom_static_folder = os.path.join(config_dir, "custom") 224 | os.makedirs(custom_static_folder, exist_ok=True) 225 | Path(os.path.join(custom_static_folder, "custom.css")).touch() 226 | src_custom_js = os.path.join(os.path.dirname(__file__), "custom.js") 227 | shutil.copy(src_custom_js, os.path.join(custom_static_folder, "custom.js")) 228 | 229 | # It tries to load custom.js from /static/custom.js, not /custom/custom.js where the latter is correct. 230 | # We work around this by having custom folder in static paths 231 | config.NotebookApp.extra_template_paths = [custom_static_folder] 232 | 233 | IPython.start_ipython(argv=argv, config=config) 234 | 235 | except Exception as e: 236 | import traceback 237 | traceback.print_exc() 238 | sys.exit(str(e)) 239 | 240 | 241 | def clear_context(*args): 242 | comm.clear_context(pid_file) 243 | 244 | 245 | if __name__ == '__main__': 246 | if len(sys.argv) == 1: 247 | sys.exit("Usage: {} start|stop|status|fg pid_file [work_folder] [notebook port] [kill timeout in seconds] *extra_args") 248 | 249 | action = sys.argv[1] 250 | pid_file = sys.argv[2] 251 | 252 | if action in ("start", "restart", "fg"): 253 | workdir = sys.argv[3] 254 | port = int(sys.argv[4]) 255 | kill_timeout = int(sys.argv[5]) 256 | extra_argv = sys.argv[6:] 257 | else: 258 | workdir = os.getcwd() 259 | 260 | os.makedirs(workdir, exist_ok=True) 261 | 262 | if action == "fg": 263 | # Test run on foreground 264 | os.chdir(workdir) 265 | atexit.register(clear_context) 266 | run_notebook(foreground=True) 267 | else: 268 | 269 | def shutdown(message, code): 270 | f = io.open("/tmp/notebook.shutdown.dump", "wt") 271 | faulthandler.dump_traceback(f) 272 | print("shutdown {}: {}".format(message, code), file=sys.stderr) 273 | sys.stderr.flush() 274 | clear_context() 275 | 276 | f = io.open("/tmp/notebook.dump", "wt") 277 | faulthandler.enable(f) 278 | 279 | daemon = NotebookDaemon(pidfile=pid_file, workdir=workdir, shutdown_callback=shutdown) 280 | daemon.worker = run_notebook 281 | daemon.do_action(action) 282 | -------------------------------------------------------------------------------- /pyramid_notebook/startup.py: -------------------------------------------------------------------------------- 1 | """Helpers to set up Notebook startup imports and contexts.""" 2 | # Standard Library 3 | import os 4 | 5 | 6 | #: The splash text displayed at the notebook top. TODO: WSGI application description in Pyramid docs is bad, find better one. 7 | PYRAMID_GREETING = """ 8 | * **app** - The [WSGI application](http://docs.pylonsproject.org/docs/pyramid/en/latest/api/paster.html?highlight=wsgi%20application#pyramid.paster.get_app) object generated by bootstrapping. 9 | * **request** - A [pyramid.request.Request](http://docs.pylonsproject.org/projects/pyramid/en/latest/api/request.html#pyramid.request.Request) object implying the current request state for your script. 10 | * **root** - The resource [root](http://docs.pylonsproject.org/docs/pyramid/en/latest/glossary.html#term-root) of your Pyramid application. This is an object generated by the [root factory](http://docs.pylonsproject.org/docs/pyramid/en/latest/glossary.html#term-root-factory) configured in your application. 11 | * **registry** - The application [registry](http://docs.pylonsproject.org/docs/pyramid/en/latest/glossary.html#term-application-registry) of your Pyramid application. 12 | """ 13 | 14 | #: startup.py bootstrap Python snippet to initialize the Pyramid application and get Pyramid specific variables into the shell 15 | PYRAMID_BOOSTRAP = """ 16 | import os 17 | from pyramid.paster import bootstrap 18 | from pyramid_notebook.utils import change_directory 19 | 20 | # Our development.ini, production.ini, etc. 21 | config_file = '{config_uri}' 22 | project_root_path = '{cwd}' or os.path.dirname(config_file) 23 | 24 | # Most of Pyramid projects expect you run pserve et. al. in the project folder itself, thus config is hardwired to have project root relative paths 25 | with change_directory(project_root_path): 26 | env = bootstrap(config_file) 27 | globals().update(env) 28 | 29 | """ 30 | 31 | 32 | def get_dotted_path(klass): 33 | return klass.__module__ + "." + klass.__name__ 34 | 35 | 36 | def get_import_statement(klass): 37 | mod = klass.__module__ 38 | print(mod) 39 | return "from {} import {}".format(klass.__module__, klass.__name__) 40 | 41 | 42 | def make_startup(notebook_context, config_file, bootstrap_py=PYRAMID_BOOSTRAP, bootstrap_greeting=PYRAMID_GREETING, cwd=""): 43 | """Populate notebook context with startup.py initialization file skeleton and greeting. 44 | 45 | This will set up context ``startup`` and ``greeting`` for their default values. 46 | 47 | :param notebook_context: Dictionary of notebook context info to be to passed to NotebookManager 48 | 49 | :param config_file: The current .ini file used to start up the Pyramid. This is used to pass it around to ``pyramid.paster.boostrap()`` to initialize dummy request object and such. 50 | 51 | :param bootstrap_py: startup.py script header which sets up environment creation 52 | 53 | :parma bootstrap_greeting: Markdown snippet which sets up start of greeting text 54 | 55 | :param cwd: Optional forced working directory. If not set use the directory of a config file. 56 | """ 57 | 58 | # Set up some default imports and variables 59 | 60 | nc = notebook_context 61 | 62 | add_greeting(nc, "\nAvailable variables and functions:") 63 | 64 | # http://docs.pylonsproject.org/projects/pyramid/en/1.1-branch/narr/commandline.html#writing-a-script 65 | 66 | if config_file is not None: 67 | assert type(config_file) == str, "Got bad config_file {}".format(config_file) 68 | config_file = os.path.abspath(config_file) 69 | assert os.path.exists(config_file), "Passed in bad config file: {}".format(config_file) 70 | add_script(nc, bootstrap_py.format(config_uri=config_file, cwd=cwd)) 71 | add_greeting(nc, bootstrap_greeting) 72 | 73 | add_script(nc, "import datetime") 74 | add_greeting(nc, "* **datetime** - Python [datetime module](https://docs.python.org/3.5/library/datetime.html)") 75 | 76 | add_script(nc, "import time") 77 | add_greeting(nc, "* **time** - Python [time module](https://docs.python.org/3.5/library/time.html)") 78 | 79 | try: 80 | # Commonly used with Pyramid applications 81 | import transaction # noQA 82 | add_script(nc, "import transaction\n") 83 | add_greeting(nc, "* **transaction** - Zope [transaction manager](http://zodb.readthedocs.org/en/latest/transactions.html), e.g. `transaction.commit()`") 84 | except ImportError: 85 | pass 86 | 87 | 88 | def include_sqlalchemy_models(nc, Base): 89 | """Include all SQLAlchemy models in the script context. 90 | 91 | :param nc: notebook_context dictionary 92 | :param Base: SQLAlchemy model Base class from where the all models inherit. 93 | """ 94 | 95 | from sqlalchemy.ext.declarative.clsregistry import _ModuleMarker 96 | 97 | # Include all SQLAlchemy models in the local namespace 98 | for name, klass in Base._decl_class_registry.items(): 99 | print(name, klass) 100 | if isinstance(klass, _ModuleMarker): 101 | continue 102 | 103 | add_script(nc, get_import_statement(klass)) 104 | add_greeting(nc, "* **{}** - {}".format(klass.__name__, get_dotted_path(klass))) 105 | 106 | 107 | def add_script(nc, line): 108 | """Add one-liner script or several lines (newline separated)""" 109 | 110 | assert type(nc) == dict 111 | 112 | nc["startup"] = nc.get("startup") or "" 113 | 114 | if not nc["startup"].endswith("\n"): 115 | nc["startup"] += "\n" 116 | nc["startup"] += line + "\n" 117 | 118 | 119 | def add_greeting(nc, line): 120 | """Add one-liner script or several lines (newline separated)""" 121 | 122 | assert type(nc) == dict 123 | 124 | nc["greeting"] = nc.get("greeting") or "" 125 | 126 | # Markdown hard line break is two new lines 127 | if not nc["greeting"].endswith("\n"): 128 | nc["greeting"] += "\n" 129 | nc["greeting"] += line + "\n" 130 | -------------------------------------------------------------------------------- /pyramid_notebook/utils.py: -------------------------------------------------------------------------------- 1 | # Standard Library 2 | import copy 3 | import os 4 | import os.path 5 | 6 | 7 | # Courtesy of http://stackoverflow.com/questions/5884066/hashing-a-python-dictionary 8 | def make_dict_hash(o): 9 | """Make a hash from a dictionary, list, tuple or set to any level, containing 10 | only other hashable types (including any lists, tuples, sets, and dictionaries). 11 | """ 12 | if isinstance(o, (set, tuple, list)): 13 | return tuple([make_dict_hash(e) for e in o]) 14 | elif not isinstance(o, dict): 15 | return hash(o) 16 | 17 | new_o = copy.deepcopy(o) 18 | for k, v in new_o.items(): 19 | new_o[k] = make_dict_hash(v) 20 | 21 | return hash(tuple(frozenset(sorted(new_o.items())))) 22 | 23 | 24 | class change_directory: 25 | """ChangeDirectory is a context manager that allows you to temporary change the working directory. 26 | 27 | Courtesy of http://code.activestate.com/recipes/576620-changedirectory-context-manager/ 28 | """ 29 | 30 | def __init__(self, directory): 31 | self._dir = directory 32 | self._cwd = os.getcwd() 33 | self._pwd = self._cwd 34 | 35 | @property 36 | def current(self): 37 | return self._cwd 38 | 39 | @property 40 | def previous(self): 41 | return self._pwd 42 | 43 | @property 44 | def relative(self): 45 | cwd = self._cwd.split(os.path.sep) 46 | pwd = self._pwd.split(os.path.sep) 47 | length = min(len(cwd), len(pwd)) 48 | idx = 0 49 | while idx < length and cwd[idx] == pwd[idx]: 50 | idx += 1 51 | return os.path.normpath(os.path.join(*(['.'] + (['..'] * (len(cwd) - idx)) + pwd[idx:]))) 52 | 53 | def __enter__(self): 54 | self._pwd = self._cwd 55 | os.chdir(self._dir) 56 | self._cwd = os.getcwd() 57 | return self 58 | 59 | def __exit__(self, *args): 60 | os.chdir(self._pwd) 61 | self._cwd = self._pwd 62 | 63 | 64 | def route_to_alt_domain(request, url): 65 | """Route URL to a different subdomain. 66 | 67 | Used to rewrite URLs to point to websocket serving domain. 68 | """ 69 | 70 | # Do we need to route IPython Notebook request from a different location 71 | alternative_domain = request.registry.settings.get("pyramid_notebook.alternative_domain", "").strip() 72 | if alternative_domain: 73 | url = url.replace(request.host_url, alternative_domain) 74 | 75 | return url 76 | -------------------------------------------------------------------------------- /pyramid_notebook/uwsgi.py: -------------------------------------------------------------------------------- 1 | """UWSGI websocket proxy.""" 2 | # Standard Library 3 | import logging 4 | import time 5 | from urllib.parse import urlparse 6 | from urllib.parse import urlunparse 7 | 8 | # Pyramid 9 | from pyramid import httpexceptions 10 | 11 | # Third Party 12 | from ws4py import WS_VERSION 13 | from ws4py.client import WebSocketBaseClient 14 | 15 | # Pyramid Notebook 16 | import uwsgi 17 | 18 | 19 | #: HTTP headers we need to proxy to upstream websocket server when the Connect: upgrade is performed 20 | CAPTURE_CONNECT_HEADERS = ["sec-websocket-extensions", "sec-websocket-key", "origin"] 21 | 22 | 23 | logger = logging.getLogger(__name__) 24 | 25 | 26 | class ProxyClient(WebSocketBaseClient): 27 | """Proxy between upstream WebSocket server and downstream UWSGI.""" 28 | 29 | @property 30 | def handshake_headers(self): 31 | """ 32 | List of headers appropriate for the upgrade 33 | handshake. 34 | """ 35 | headers = [ 36 | ('Host', self.host), 37 | ('Connection', 'Upgrade'), 38 | ('Upgrade', 'WebSocket'), 39 | ('Sec-WebSocket-Key', self.key.decode('utf-8')), 40 | # Origin is proxyed from the downstream server, don't set it twice 41 | # ('Origin', self.url), 42 | ('Sec-WebSocket-Version', str(max(WS_VERSION))) 43 | ] 44 | 45 | if self.protocols: 46 | headers.append(('Sec-WebSocket-Protocol', ','.join(self.protocols))) 47 | 48 | if self.extra_headers: 49 | headers.extend(self.extra_headers) 50 | 51 | logger.info("Handshake headers: %s", headers) 52 | return headers 53 | 54 | def received_message(self, m): 55 | """Push upstream messages to downstream.""" 56 | 57 | # TODO: No support for binary messages 58 | m = str(m) 59 | logger.debug("Incoming upstream WS: %s", m) 60 | uwsgi.websocket_send(m) 61 | logger.debug("Send ok") 62 | 63 | def handshake_ok(self): 64 | """ 65 | Called when the upgrade handshake has completed 66 | successfully. 67 | 68 | Starts the client's thread. 69 | """ 70 | self.run() 71 | 72 | def terminate(self): 73 | super(ProxyClient, self).terminate() 74 | 75 | def run(self): 76 | """Combine async uwsgi message loop with ws4py message loop. 77 | 78 | TODO: This could do some serious optimizations and behave asynchronously correct instead of just sleep(). 79 | """ 80 | 81 | self.sock.setblocking(False) 82 | try: 83 | while not self.terminated: 84 | logger.debug("Doing nothing") 85 | time.sleep(0.050) 86 | 87 | logger.debug("Asking for downstream msg") 88 | msg = uwsgi.websocket_recv_nb() 89 | if msg: 90 | logger.debug("Incoming downstream WS: %s", msg) 91 | self.send(msg) 92 | 93 | s = self.stream 94 | 95 | self.opened() 96 | 97 | logger.debug("Asking for upstream msg {s}".format(s=s)) 98 | try: 99 | bytes = self.sock.recv(self.reading_buffer_size) 100 | if bytes: 101 | self.process(bytes) 102 | except BlockingIOError: 103 | pass 104 | 105 | except Exception as e: 106 | logger.exception(e) 107 | finally: 108 | logger.info("Terminating WS proxy loop") 109 | self.terminate() 110 | 111 | 112 | def serve_websocket(request, port): 113 | """Start UWSGI websocket loop and proxy.""" 114 | env = request.environ 115 | 116 | # Send HTTP response 101 Switch Protocol downstream 117 | uwsgi.websocket_handshake(env['HTTP_SEC_WEBSOCKET_KEY'], env.get('HTTP_ORIGIN', '')) 118 | 119 | # Map the websocket URL to the upstream localhost:4000x Notebook instance 120 | parts = urlparse(request.url) 121 | parts = parts._replace(scheme="ws", netloc="localhost:{}".format(port)) 122 | url = urlunparse(parts) 123 | 124 | # Proxy initial connection headers 125 | headers = [(header, value) for header, value in request.headers.items() if header.lower() in CAPTURE_CONNECT_HEADERS] 126 | 127 | logger.info("Connecting to upstream websockets: %s, headers: %s", url, headers) 128 | 129 | ws = ProxyClient(url, headers=headers) 130 | ws.connect() 131 | 132 | # TODO: Will complain loudly about already send headers - how to abort? 133 | return httpexceptions.HTTPOk() 134 | -------------------------------------------------------------------------------- /pyramid_notebook/views.py: -------------------------------------------------------------------------------- 1 | # Standard Library 2 | import logging 3 | import os 4 | 5 | # Pyramid 6 | from pyramid.httpexceptions import HTTPFound 7 | from pyramid.httpexceptions import HTTPInternalServerError 8 | from pyramid.util import DottedNameResolver 9 | 10 | # Pyramid Notebook 11 | from pyramid_notebook.notebookmanager import NotebookManager 12 | from pyramid_notebook.proxy import WSGIProxyApplication 13 | from pyramid_notebook.utils import make_dict_hash 14 | from pyramid_notebook.utils import route_to_alt_domain 15 | 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | 20 | def get_notebook_manager(request): 21 | settings = request.registry.settings 22 | notebook_folder = settings["pyramid_notebook.notebook_folder"] 23 | manager = NotebookManager(notebook_folder) 24 | return manager 25 | 26 | 27 | def proxy_it(request, port): 28 | """Proxy HTTP request to upstream IPython Notebook Tornado server.""" 29 | 30 | # Check if we have websocket proxy configured 31 | websocket_proxy = request.registry.settings.get("pyramid_notebook.websocket_proxy", "") 32 | if websocket_proxy.strip(): 33 | r = DottedNameResolver() 34 | websocket_proxy = r.maybe_resolve(websocket_proxy) 35 | 36 | if "upgrade" in request.headers.get("connection", "").lower(): 37 | if websocket_proxy: 38 | return websocket_proxy(request, port) 39 | else: 40 | # If we run on localhost on pserve, we should never hit here as requests go directly to IPython Notebook kernel, not us 41 | raise RuntimeError("Websocket proxy support is not configured.") 42 | 43 | proxy_app = WSGIProxyApplication(port) 44 | 45 | return request.get_response(proxy_app) 46 | 47 | 48 | def security_check(request, username): 49 | """Check for basic misconfiguration errors.""" 50 | assert username, "You must give an username whose IPython Notebook session we open" 51 | 52 | 53 | def prepare_notebook_context(request, notebook_context): 54 | """Fill in notebook context with default values.""" 55 | 56 | if not notebook_context: 57 | notebook_context = {} 58 | 59 | # Override notebook Jinja templates 60 | if "extra_template_paths" not in notebook_context: 61 | notebook_context["extra_template_paths"] = [os.path.join(os.path.dirname(__file__), "server", "templates")] 62 | 63 | # Furious invalid state follows if we let this slip through 64 | assert type(notebook_context["extra_template_paths"]) == list, "Got bad extra_template_paths {}".format(notebook_context["extra_template_paths"]) 65 | 66 | # Jinja variables 67 | notebook_context["jinja_environment_options"] = notebook_context.get("jinja_environment_options", {}) 68 | 69 | assert type(notebook_context["jinja_environment_options"]) == dict 70 | 71 | # XXX: Following passing of global variables to Jinja templates requires Jinja 2.8.0dev+ version and is not yet supported 72 | # http://jinja.pocoo.org/docs/dev/api/#jinja2.Environment.globals 73 | 74 | # notebook_context["jinja_environment_options"]["globals"] = notebook_context["jinja_environment_options"].get("globals", {}) 75 | # globals_ = notebook_context["jinja_environment_options"]["globals"] 76 | # 77 | # assert type(globals_) == dict 78 | # 79 | # if not "home_url" in globals_: 80 | # globals_["home_url"] = request.host_url 81 | # 82 | # if not "home_title" in globals_: 83 | # globals_["home_title"] = "Back to site" 84 | 85 | # Tell notebook to correctly address WebSockets allow origin policy 86 | notebook_context["allow_origin"] = route_to_alt_domain(request, request.host_url) 87 | notebook_context["notebook_path"] = request.route_path("notebook_proxy", remainder="") 88 | 89 | # Record the hash of the current parameters, so we know if this user accesses the notebook in this or different context 90 | if "context_hash" not in notebook_context: 91 | notebook_context["context_hash"] = make_dict_hash(notebook_context) 92 | 93 | print(notebook_context) 94 | 95 | 96 | def launch_on_demand(request, username, notebook_context): 97 | """See if we have notebook already running this context and if not then launch new one.""" 98 | security_check(request, username) 99 | 100 | settings = request.registry.settings 101 | 102 | notebook_folder = settings.get("pyramid_notebook.notebook_folder", None) 103 | if not notebook_folder: 104 | raise RuntimeError("Setting missing: pyramid_notebook.notebook_folder") 105 | 106 | kill_timeout = settings.get("pyramid_notebook.kill_timeout", None) 107 | if not kill_timeout: 108 | raise RuntimeError("Setting missing: pyramid_notebook.kill_timeout") 109 | 110 | kill_timeout = int(kill_timeout) 111 | 112 | if not notebook_context: 113 | notebook_context = {} 114 | 115 | # Override notebook Jinja templates 116 | if "extra_template_paths" not in notebook_context: 117 | notebook_context["extra_template_paths"] = [os.path.join(os.path.dirname(__file__), "server", "templates")] 118 | 119 | # Furious invalid state follows if we let this slip through 120 | assert type(notebook_context["extra_template_paths"]) == list, "Got bad extra_template_paths {}".format(notebook_context["extra_template_paths"]) 121 | notebook_folder = settings.get("pyramid_notebook.notebook_folder", None) 122 | if not notebook_folder: 123 | raise RuntimeError("Setting missing: pyramid_notebook.notebook_folder") 124 | 125 | kill_timeout = settings.get("pyramid_notebook.kill_timeout", None) 126 | if not kill_timeout: 127 | raise RuntimeError("Setting missing: pyramid_notebook.kill_timeout") 128 | 129 | kill_timeout = int(kill_timeout) 130 | 131 | prepare_notebook_context(request, notebook_context) 132 | 133 | # Configure websockets 134 | # websocket_url = settings.get("pyramid_notebook.websocket_url") 135 | # assert websocket_url, "pyramid_notebook.websocket_url setting missing" 136 | # assert websocket_url.startswith("ws:/") or websocket_url.startswith("wss:/") 137 | 138 | if request.registry.settings.get("pyramid_notebook.websocket_proxy", ""): 139 | websocket_url = route_to_alt_domain(request, request.host_url) 140 | websocket_url = websocket_url.replace("http://", "ws://").replace("https://", "wss://") 141 | notebook_context["websocket_url"] = websocket_url 142 | else: 143 | # Connect websockets directly to localhost notebook server, do not try to proxy them 144 | websocket_url = "ws://localhost:{port}/notebook/" 145 | 146 | # Record the hash of the current parameters, so we know if this user accesses the notebook in this or different context 147 | if "context_hash" not in notebook_context: 148 | notebook_context["context_hash"] = make_dict_hash(notebook_context) 149 | 150 | manager = NotebookManager(notebook_folder, kill_timeout=kill_timeout) 151 | notebook_info, creates = manager.start_notebook_on_demand(username, notebook_context) 152 | return notebook_info 153 | 154 | 155 | def notebook_proxy(request, username): 156 | """Renders a IPython Notebook frame wrapper. 157 | 158 | Starts or reattachs ot an existing Notebook session. 159 | """ 160 | security_check(request, username) 161 | 162 | manager = get_notebook_manager(request) 163 | notebook_info = manager.get_context(username) 164 | 165 | if not notebook_info: 166 | raise HTTPInternalServerError("Apparently IPython Notebook daemon process is not running for {}".format(username)) 167 | 168 | if 'http_port' not in notebook_info: 169 | raise RuntimeError("Notebook terminated prematurely before managed to tell us its HTTP port") 170 | 171 | return proxy_it(request, notebook_info["http_port"]) 172 | 173 | 174 | def launch_notebook(request, username, notebook_context): 175 | """Renders a IPython Notebook frame wrapper. 176 | 177 | Starts or reattachs ot an existing Notebook session. 178 | """ 179 | # The notebook manage now tries too hard to get the port allocated for the notebook user, making it slow 180 | # TODO: Manage a proper state e.g. using Redis 181 | notebook_info = launch_on_demand(request, username, notebook_context) 182 | 183 | # Jump to the detault notebook 184 | proxy_route = request.route_url("notebook_proxy", remainder="notebooks/{}".format(notebook_info["notebook_name"])) 185 | proxy_route = route_to_alt_domain(request, proxy_route) 186 | 187 | return HTTPFound(proxy_route) 188 | 189 | 190 | def shutdown_notebook(request, username): 191 | """Stop any running notebook for a user.""" 192 | 193 | manager = get_notebook_manager(request) 194 | if manager.is_running(username): 195 | manager.stop_notebook(username) 196 | -------------------------------------------------------------------------------- /screenshots/README.txt: -------------------------------------------------------------------------------- 1 | Placeholder for Splinter failure screenshots produced on Drone.io CI. 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | # We disable some known plugins that would mess up tests 2 | 3 | [tool:pytest] 4 | addopts = 5 | -p no:celery 6 | -p no:ethereum 7 | 8 | pep8ignore = E501 E128 E731 9 | norecursedirs = alembic .tox .cache .eggs venv 10 | markers = 11 | slow 12 | fail 13 | notebook 14 | 15 | [flake8] 16 | ignore = E128 E731 17 | max-line-length = 999 -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # Standard Library 2 | from codecs import open 3 | from os import path 4 | 5 | from setuptools import find_packages 6 | from setuptools import setup 7 | 8 | 9 | here = path.abspath(path.dirname(__file__)) 10 | 11 | # Get the long description from the relevant file 12 | with open(path.join(here, 'README.rst'), encoding='utf-8') as f: 13 | long_description = f.read() 14 | 15 | with open(path.join(here, 'CHANGES.rst'), encoding='utf-8') as f: 16 | long_description += "\n" + f.read() 17 | 18 | setup( 19 | name='pyramid_notebook', 20 | version='0.3.1.dev0', 21 | description='Embed IPython Notebook shell on your Pyramid website', 22 | long_description=long_description, 23 | url='https://github.com/websauna/pyramid_notebook', 24 | author='Mikko Ohtamaa', 25 | author_email='mikko@opensourcehacker.com', 26 | license='MIT', 27 | classifiers=[ 28 | 'Development Status :: 3 - Alpha', 29 | 'Intended Audience :: Developers', 30 | 'Topic :: Software Development :: Build Tools', 31 | 'Topic :: System :: Shells', 32 | 'License :: OSI Approved :: MIT License', 33 | 'Programming Language :: Python :: 3', 34 | 'Programming Language :: Python :: 3.5', 35 | 'Programming Language :: Python :: 3.6', 36 | 'Programming Language :: Python :: 3.7', 37 | 'Framework :: Pyramid', 38 | 'Framework :: IPython', 39 | ], 40 | keywords='ipython setuptools development shell uwsgi', 41 | packages=find_packages(exclude=['contrib', 'docs', 'tests*']), 42 | zip_safe=False, 43 | python_requires='>=3.5.2', 44 | include_package_data=True, 45 | install_requires=[ 46 | "ipython[notebook]>=7.0.1", 47 | 'daemonocle>=1.0.1', 48 | 'PasteDeploy', 49 | 'port-for', 50 | 'pyramid', 51 | 'sqlalchemy', 52 | 'ws4py' 53 | ], 54 | extras_require={ 55 | 'dev': [ 56 | 'flake8', 57 | 'flake8-isort', 58 | 'zest.releaser[recommended]', 59 | ], 60 | 'test': [ 61 | 'codecov', 62 | 'flaky', 63 | 'paste', 64 | 'pyramid_jinja2', 65 | 'pytest', 66 | 'pytest-cov', 67 | 'pytest-splinter', 68 | 'selenium>3', 69 | 'webtest', 70 | ], 71 | 'uwsgi': [ 72 | 'uwsgi', 73 | 'ws4py' 74 | ] 75 | }, 76 | entry_points={ 77 | "paste.app_factory": [ 78 | 'main = pyramid_notebook.demo:main' 79 | ] 80 | }, 81 | ) 82 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Pyramid Notebook tests.""" 2 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Functional testing with WSGI server.""" 2 | 3 | 4 | # Standard Library 5 | import logging 6 | import os 7 | import threading 8 | import time 9 | from urllib.parse import urlparse 10 | from wsgiref.simple_server import make_server 11 | 12 | # Pyramid 13 | from pyramid.paster import get_appsettings 14 | from pyramid.paster import setup_logging 15 | 16 | # Third Party 17 | import pytest 18 | from webtest import TestApp # noQA 19 | 20 | 21 | logger = logging.getLogger(__name__) 22 | logger.setLevel(logging.DEBUG) 23 | 24 | 25 | def pytest_addoption(parser): 26 | parser.addoption("--ini", action="store", metavar="INI_FILE", help="use INI_FILE to configure SQLAlchemy") 27 | 28 | 29 | @pytest.fixture(scope='session') 30 | def ini_settings(request): 31 | """Load INI settings from py.test command line.""" 32 | if not getattr(request.config.option, "ini", None): 33 | raise RuntimeError("You need to give --ini test.ini command line option to py.test to find our test settings") 34 | 35 | config_uri = os.path.abspath(request.config.option.ini) 36 | setup_logging(config_uri) 37 | config = get_appsettings(config_uri) 38 | 39 | return config_uri, config 40 | 41 | 42 | class ServerThread(threading.Thread): 43 | """ Run WSGI server on a background thread. 44 | 45 | Pass in WSGI app object and serve pages from it for Selenium browser. 46 | """ 47 | 48 | def __init__(self, app, hostbase): 49 | threading.Thread.__init__(self) 50 | self.app = app 51 | self.srv = None 52 | self.daemon = True 53 | self.hostbase = hostbase 54 | 55 | def run(self): 56 | """ 57 | Open WSGI server to listen to HOST_BASE address 58 | """ 59 | print("Running") 60 | parts = urlparse(self.hostbase) 61 | domain, port = parts.netloc.split(":") 62 | print(domain, port) 63 | self.srv = make_server(domain, int(port), self.app) 64 | try: 65 | self.srv.serve_forever() 66 | except Exception as e: 67 | # We are a background thread so we have problems to interrupt tests in the case of error 68 | import traceback 69 | traceback.print_exc() 70 | # Failed to start 71 | self.srv = None 72 | 73 | def quit(self): 74 | """Stop test webserver.""" 75 | print("Shutdown") 76 | if self.srv: 77 | self.srv.shutdown() 78 | 79 | 80 | @pytest.fixture(scope='session') 81 | def web_server(request, ini_settings): 82 | """Creates a test web server which does not give any CSS and JS assets to load. 83 | 84 | Because the server life-cycle is one test session and we run with different settings we need to run a in different port. 85 | """ 86 | from pyramid_notebook import demo 87 | 88 | config_uri, settings = ini_settings 89 | app = demo.main({"__file__": config_uri}, **settings) 90 | 91 | port = 8777 92 | 93 | host_base = "http://localhost:{}".format(port) 94 | server = ServerThread(app, host_base) 95 | server.start() 96 | 97 | # Wait randomish time to allows SocketServer to initialize itself. 98 | # TODO: Replace this with proper event telling the server is up. 99 | time.sleep(0.1) 100 | 101 | assert server.srv is not None, "Could not start the test web server" 102 | 103 | def teardown(): 104 | server.quit() 105 | 106 | request.addfinalizer(teardown) 107 | 108 | return {"host_base": host_base, "port": port} 109 | 110 | 111 | @pytest.fixture() 112 | def pyramid_request(request): 113 | 114 | from pyramid import testing 115 | 116 | testing.setUp() 117 | 118 | def teardown(): 119 | testing.tearDown() 120 | 121 | request.addfinalizer(teardown) 122 | 123 | _request = testing.DummyRequest() 124 | return _request 125 | -------------------------------------------------------------------------------- /tests/test_alt_domain.py: -------------------------------------------------------------------------------- 1 | """Alternative domain tests.""" 2 | # Pyramid Notebook 3 | from pyramid_notebook.utils import route_to_alt_domain 4 | 5 | 6 | def test_reroute(pyramid_request): 7 | """We rewrite URLs to a different domain.""" 8 | request = pyramid_request 9 | request.registry.settings["pyramid_notebook.alternative_domain"] = "http://ws.example.com" 10 | alt = route_to_alt_domain(request, "http://example.com/python-notebook/channels") 11 | assert alt == "http://ws.example.com/python-notebook/channels" 12 | 13 | 14 | def test_reroute_noop(pyramid_request): 15 | """When URL rewriting is not active don't do anything.""" 16 | request = pyramid_request 17 | alt = route_to_alt_domain(request, "http://example.com/python-notebook/channels") 18 | assert alt == "http://example.com/python-notebook/channels" 19 | -------------------------------------------------------------------------------- /tests/test_integration.py: -------------------------------------------------------------------------------- 1 | """Make sure scheisse holds on the wall.""" 2 | # Standard Library 3 | import logging 4 | import os 5 | import random 6 | import sys 7 | import time 8 | 9 | # Third Party 10 | from flaky import flaky 11 | from selenium.webdriver.common.action_chains import ActionChains 12 | from selenium.webdriver.common.keys import Keys 13 | 14 | # Pyramid Notebook 15 | from pyramid_notebook.notebookmanager import NotebookManager # noQA 16 | 17 | 18 | NOTEBOOK_FOLDER = os.path.join("/tmp", "pyramid_notebook_tests") 19 | os.makedirs(NOTEBOOK_FOLDER, exist_ok=True) 20 | 21 | logging.basicConfig(level=logging.DEBUG, stream=sys.stderr) 22 | 23 | USER = "test{}".format(random.randint(0, 1000)) 24 | 25 | 26 | def test_notebook_template(web_server, browser): 27 | """See that we get custom templates through IPython Notebook""" 28 | 29 | # Front page loads up 30 | b = browser 31 | browser.visit("http://localhost:{}/".format(web_server["port"])) 32 | assert b.is_text_present("pyramid_notebook test application") 33 | 34 | # Proxied notebook loads up 35 | browser.visit("http://username:password@localhost:{}/shell1".format(web_server["port"])) 36 | 37 | assert b.is_element_present_by_css("#shutdown", wait_time=3) # Our custom shutdown command 38 | 39 | # File menu 40 | b.find_by_css(".dropdown a")[0].click() 41 | 42 | # Shutdown and Back to the home 43 | assert b.is_element_visible_by_css("#shutdown") 44 | b.find_by_css("#shutdown").click() 45 | 46 | 47 | def hacker_typing(browser, spinter_selection, code): 48 | """We need to break Splinter abstraction and fall back to raw Selenium here. 49 | 50 | Note: There is a bug of entering parenthesis due to IPython capturing keyboard input. 51 | 52 | http://stackoverflow.com/questions/22168651/how-to-enter-left-parentheses-into-a-text-box 53 | """ 54 | elem = spinter_selection[0]._element 55 | driver = browser.driver 56 | 57 | # Activate IPython input mode 58 | ActionChains(driver).click(elem).send_keys(Keys.ENTER).perform() 59 | 60 | # Type in the code 61 | a = ActionChains(driver) 62 | a.send_keys(code) 63 | a.perform() 64 | time.sleep(1.0) 65 | 66 | # Execute the text we just typed 67 | a = ActionChains(driver) 68 | a.key_down(Keys.SHIFT).send_keys(Keys.ENTER).key_up(Keys.SHIFT) 69 | a.perform() 70 | 71 | 72 | @flaky(max_runs=3) 73 | def test_add_context_variables(web_server, browser): 74 | """We can perform meaningful calculations on variables set in startup.py""" 75 | 76 | b = browser 77 | b.visit("http://username:password@localhost:{}/shell2".format(web_server["port"])) 78 | 79 | assert b.is_text_present("a - varible a", wait_time=2) 80 | 81 | # Type in a sample equation suing predefined variable 82 | hacker_typing(b, b.find_by_css(".code_cell"), 'print("Output of a + b is", a + b)') 83 | 84 | # spin, spin, spin my little AJAX spinner 85 | assert b.is_text_present("Output of a + b is foobar", wait_time=1) 86 | 87 | # File menu 88 | b.find_by_css(".dropdown a")[0].click() 89 | 90 | # Shutdown and Back to the home 91 | assert b.is_element_visible_by_css("#shutdown") 92 | b.find_by_css("#shutdown").click() 93 | 94 | # For Python 3.5, this test fails id wait_time is low 95 | wait_time = 10 if sys.version_info >= (3, 6) else 60 96 | assert b.is_text_present("pyramid_notebook test application", wait_time=wait_time) 97 | -------------------------------------------------------------------------------- /tests/test_start_stop.py: -------------------------------------------------------------------------------- 1 | # Standard Library 2 | import logging 3 | import os 4 | import sys 5 | import time 6 | 7 | # Pyramid Notebook 8 | from pyramid_notebook.notebookmanager import NotebookManager 9 | 10 | 11 | NOTEBOOK_FOLDER = os.path.join("/tmp", "pyramid_notebook_tests") 12 | os.makedirs(NOTEBOOK_FOLDER, exist_ok=True) 13 | 14 | logging.basicConfig(level=logging.DEBUG, stream=sys.stdout) 15 | 16 | USER = "testuser1" 17 | 18 | 19 | def test_spawn(): 20 | """Create a Python Notebook process.""" 21 | 22 | m = NotebookManager(notebook_folder=NOTEBOOK_FOLDER, kill_timeout=60) 23 | 24 | output = m.start_notebook(USER, {"context_hash": 1}, fg=False) 25 | time.sleep(1) 26 | 27 | # pid is set if the process launched successfully 28 | status = m.get_notebook_status(USER) 29 | if status is None: 30 | # Failure to launch, get error 31 | print(output) 32 | raise AssertionError("Could not start, context file was not created") 33 | 34 | assert type(status) == dict 35 | assert status["pid"] > 0 36 | assert status["http_port"] > 0 37 | 38 | assert m.is_running(USER) 39 | 40 | m.stop_notebook(USER) 41 | time.sleep(1) 42 | assert not m.is_running(USER) 43 | 44 | 45 | def test_context_change(): 46 | """Check that if the context changes we launch the notebook with new parametrers.""" 47 | 48 | m = NotebookManager(notebook_folder=NOTEBOOK_FOLDER, kill_timeout=60) 49 | 50 | notebook_context = {"context_hash": 123} 51 | 52 | context, created = m.start_notebook_on_demand(USER, notebook_context) 53 | assert created 54 | status = m.get_notebook_status(USER) 55 | old_pid = status["pid"] 56 | 57 | # Restart with same hash 58 | context, created = m.start_notebook_on_demand(USER, notebook_context) 59 | assert not created 60 | status = m.get_notebook_status(USER) 61 | assert status["pid"] == old_pid 62 | 63 | # Restart with different hash 64 | notebook_context = {"context_hash": 456} 65 | context, created = m.start_notebook_on_demand(USER, notebook_context) 66 | assert created 67 | status = m.get_notebook_status(USER) 68 | assert old_pid != status["pid"] 69 | 70 | m.stop_notebook(USER) 71 | time.sleep(1) 72 | assert not m.is_running(USER) 73 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py35, py36, py37, py38, style 3 | 4 | [testenv] 5 | sitepackages = false 6 | basepython = 7 | py35: python3.5 8 | py36: python3.6 9 | py37: python3.7 10 | py38: python3.8 11 | setenv = 12 | PYTHONHASHSEED = 100 13 | passenv = RANDOM_VALUE COVERAGE_PROCESS_START CODECOV_TOKEN DISPLAY SPLINTER_WEBDRIVER TRAVIS 14 | whitelist_externals = py.test 15 | usedevelop = true 16 | deps = 17 | .[dev,test] 18 | 19 | commands = 20 | py.test {posargs} 21 | 22 | [testenv:style] 23 | basepython = python3.6 24 | commands = 25 | flake8 setup.py pyramid_notebook/ 26 | -------------------------------------------------------------------------------- /uwsgi-development.ini: -------------------------------------------------------------------------------- 1 | ### 2 | # app configuration 3 | # http://docs.pylonsproject.org/projects/pyramid/en/1.5-branch/narr/environment.html 4 | ### 5 | 6 | [app:main] 7 | use = egg:pyramid_notebook 8 | 9 | pyramid.reload_templates = true 10 | pyramid.debug_authorization = true 11 | pyramid.debug_notfound = true 12 | pyramid.debug_routematch = true 13 | pyramid.default_locale_name = en 14 | pyramid.includes = 15 | 16 | 17 | jinja2.filters = 18 | route_url = pyramid_jinja2.filters:route_url_filter 19 | 20 | jinja2.extensions = 21 | jinja2.ext.with_ 22 | 23 | 24 | # Where we store IPython Notebook runtime and persistent files 25 | # (pid, saved notebooks, etc.). 26 | # Each user will get a personal subfolder in this folder 27 | pyramid_notebook.notebook_folder = /tmp/pyramid_notebook 28 | 29 | # Automatically shutdown IPython Notebook kernel 30 | # after his many seconds have elapsed since startup 31 | pyramid_notebook.kill_timeout = 3600 32 | 33 | # Port range where IPython Notebook binds localhost for HTTP and websocket connections. 34 | # By default this is TCP/IP ports localhost:41000 - localhost:41010. 35 | # In production, you need to proxy websocket in these from your front end web server 36 | # using websocket proxying (see example below). 37 | pyramid_notebook.port_base = 41000 38 | 39 | # Websocket proxy launch function. 40 | # This function upgrades the current HTTP request to Websocket (101 upgrade protocol) 41 | # and starts websocket proxy loop 42 | pyramid_notebook.websocket_proxy = pyramid_notebook.uwsgi.serve_websocket 43 | 44 | # Production example: 45 | # pyramid_notebook.websocket_url = wss://localhost:{port}/notebook-websocket/{port} 46 | 47 | ### 48 | # wsgi server configuration 49 | ### 50 | 51 | [server:main] 52 | use = egg:waitress#main 53 | host = 0.0.0.0 54 | port = 9999 55 | 56 | ### 57 | # logging configuration 58 | # http://docs.pylonsproject.org/projects/pyramid/en/1.5-branch/narr/logging.html 59 | ### 60 | 61 | [loggers] 62 | keys = root, proxy, pyramid_debug, notebook_uwsgi 63 | 64 | [handlers] 65 | keys = console 66 | 67 | [formatters] 68 | keys = generic 69 | 70 | [logger_root] 71 | level = DEBUG 72 | handlers = console 73 | 74 | [logger_proxy] 75 | level = WARN 76 | handlers = 77 | qualname = pyramid_notebook.proxy 78 | 79 | # Pyramid router debug info 80 | [logger_pyramid_debug] 81 | level = INFO 82 | qualname = pyramid_debug 83 | handlers = 84 | 85 | 86 | [logger_notebook_uwsgi] 87 | level = INFO 88 | qualname = pyramid_notebook.uwsgi 89 | handlers = 90 | 91 | 92 | [handler_console] 93 | class = StreamHandler 94 | args = (sys.stderr,) 95 | level = NOTSET 96 | formatter = generic 97 | 98 | [formatter_generic] 99 | format = %(asctime)s %(levelname)-5.5s [%(name)s][%(threadName)s] %(message)s 100 | -------------------------------------------------------------------------------- /uwsgi-under-nginx.ini: -------------------------------------------------------------------------------- 1 | # Sample file running websockets with uWSGI and Nginx 2 | [uwsgi] 3 | processes = 4 4 | threads = 2 5 | socket = /tmp/uwsgi.sock 6 | http-websockets = true -------------------------------------------------------------------------------- /uwsgi.ini: -------------------------------------------------------------------------------- 1 | # UWSGI configuration for testing out websocket proxy 2 | 3 | [uwsgi] 4 | processes = 4 5 | threads = 2 6 | http = 127.0.0.1:8008 7 | http-websockets = true 8 | --------------------------------------------------------------------------------