Merge pull request #2175 from minrk/staticfile

add FileFindHandler for Notebook static files

  * The static file handler now uses a search path, instead of a single dir.

    This allows easier customization of available js/css,
    and provides a place for extra files to go for extending the notebook.


  * An empty custom.js / custom.css are added to the templates for trivial 
    custom user styling/scripting.

    The search only happens once, and the result is cached after the first.

  * A few methods are pulled from tornado 2.2-dev verbatim to have tornado 2.1 compatibility.

  * mathjax is now installed by default in profile.
This commit is contained in:
Bussonnier Matthias 2012-07-26 00:36:28 -07:00
commit 9a52bdc18b
6 changed files with 227 additions and 9 deletions

View File

@ -16,8 +16,15 @@ Authors:
# Imports # Imports
#----------------------------------------------------------------------------- #-----------------------------------------------------------------------------
import logging
import Cookie import Cookie
import datetime
import email.utils
import hashlib
import logging
import mimetypes
import os
import stat
import threading
import time import time
import uuid import uuid
@ -31,6 +38,7 @@ from IPython.external.decorator import decorator
from IPython.zmq.session import Session from IPython.zmq.session import Session
from IPython.lib.security import passwd_check from IPython.lib.security import passwd_check
from IPython.utils.jsonutil import date_default from IPython.utils.jsonutil import date_default
from IPython.utils.path import filefind
try: try:
from docutils.core import publish_string from docutils.core import publish_string
@ -735,4 +743,178 @@ class RSTHandler(AuthenticatedHandler):
self.set_header('Content-Type', 'text/html') self.set_header('Content-Type', 'text/html')
self.finish(html) self.finish(html)
# to minimize subclass changes:
HTTPError = web.HTTPError
class FileFindHandler(web.StaticFileHandler):
"""subclass of StaticFileHandler for serving files from a search path"""
_static_paths = {}
# _lock is needed for tornado < 2.2.0 compat
_lock = threading.Lock() # protects _static_hashes
def initialize(self, path, default_filename=None):
if isinstance(path, basestring):
path = [path]
self.roots = tuple(
os.path.abspath(os.path.expanduser(p)) + os.path.sep for p in path
)
self.default_filename = default_filename
@classmethod
def locate_file(cls, path, roots):
"""locate a file to serve on our static file search path"""
with cls._lock:
if path in cls._static_paths:
return cls._static_paths[path]
try:
abspath = os.path.abspath(filefind(path, roots))
except IOError:
# empty string should always give exists=False
return ''
# os.path.abspath strips a trailing /
# it needs to be temporarily added back for requests to root/
if not (abspath + os.path.sep).startswith(roots):
raise HTTPError(403, "%s is not in root static directory", path)
cls._static_paths[path] = abspath
return abspath
def get(self, path, include_body=True):
path = self.parse_url_path(path)
# begin subclass override
abspath = self.locate_file(path, self.roots)
# end subclass override
if os.path.isdir(abspath) and self.default_filename is not None:
# need to look at the request.path here for when path is empty
# but there is some prefix to the path that was already
# trimmed by the routing
if not self.request.path.endswith("/"):
self.redirect(self.request.path + "/")
return
abspath = os.path.join(abspath, self.default_filename)
if not os.path.exists(abspath):
raise HTTPError(404)
if not os.path.isfile(abspath):
raise HTTPError(403, "%s is not a file", path)
stat_result = os.stat(abspath)
modified = datetime.datetime.fromtimestamp(stat_result[stat.ST_MTIME])
self.set_header("Last-Modified", modified)
mime_type, encoding = mimetypes.guess_type(abspath)
if mime_type:
self.set_header("Content-Type", mime_type)
cache_time = self.get_cache_time(path, modified, mime_type)
if cache_time > 0:
self.set_header("Expires", datetime.datetime.utcnow() + \
datetime.timedelta(seconds=cache_time))
self.set_header("Cache-Control", "max-age=" + str(cache_time))
else:
self.set_header("Cache-Control", "public")
self.set_extra_headers(path)
# Check the If-Modified-Since, and don't send the result if the
# content has not been modified
ims_value = self.request.headers.get("If-Modified-Since")
if ims_value is not None:
date_tuple = email.utils.parsedate(ims_value)
if_since = datetime.datetime.fromtimestamp(time.mktime(date_tuple))
if if_since >= modified:
self.set_status(304)
return
with open(abspath, "rb") as file:
data = file.read()
hasher = hashlib.sha1()
hasher.update(data)
self.set_header("Etag", '"%s"' % hasher.hexdigest())
if include_body:
self.write(data)
else:
assert self.request.method == "HEAD"
self.set_header("Content-Length", len(data))
@classmethod
def get_version(cls, settings, path):
"""Generate the version string to be used in static URLs.
This method may be overridden in subclasses (but note that it
is a class method rather than a static method). The default
implementation uses a hash of the file's contents.
``settings`` is the `Application.settings` dictionary and ``path``
is the relative location of the requested asset on the filesystem.
The returned value should be a string, or ``None`` if no version
could be determined.
"""
# begin subclass override:
static_paths = settings['static_path']
if isinstance(static_paths, basestring):
static_paths = [static_paths]
roots = tuple(
os.path.abspath(os.path.expanduser(p)) + os.path.sep for p in static_paths
)
try:
abs_path = filefind(path, roots)
except IOError:
logging.error("Could not find static file %r", path)
return None
# end subclass override
with cls._lock:
hashes = cls._static_hashes
if abs_path not in hashes:
try:
f = open(abs_path, "rb")
hashes[abs_path] = hashlib.md5(f.read()).hexdigest()
f.close()
except Exception:
logging.error("Could not open static file %r", path)
hashes[abs_path] = None
hsh = hashes.get(abs_path)
if hsh:
return hsh[:5]
return None
# make_static_url and parse_url_path totally unchanged from tornado 2.2.0
# but needed for tornado < 2.2.0 compat
@classmethod
def make_static_url(cls, settings, path):
"""Constructs a versioned url for the given path.
This method may be overridden in subclasses (but note that it is
a class method rather than an instance method).
``settings`` is the `Application.settings` dictionary. ``path``
is the static path being requested. The url returned should be
relative to the current host.
"""
static_url_prefix = settings.get('static_url_prefix', '/static/')
version_hash = cls.get_version(settings, path)
if version_hash:
return static_url_prefix + path + "?v=" + version_hash
return static_url_prefix + path
def parse_url_path(self, url_path):
"""Converts a static URL path into a filesystem path.
``url_path`` is the path component of the URL with
``static_url_prefix`` removed. The return value should be
filesystem path relative to ``static_path``.
"""
if os.path.sep != "/":
url_path = url_path.replace("/", os.path.sep)
return url_path

View File

@ -48,7 +48,8 @@ from .handlers import (LoginHandler, LogoutHandler,
MainKernelHandler, KernelHandler, KernelActionHandler, IOPubHandler, MainKernelHandler, KernelHandler, KernelActionHandler, IOPubHandler,
ShellHandler, NotebookRootHandler, NotebookHandler, NotebookCopyHandler, ShellHandler, NotebookRootHandler, NotebookHandler, NotebookCopyHandler,
RSTHandler, AuthenticatedFileHandler, PrintNotebookHandler, RSTHandler, AuthenticatedFileHandler, PrintNotebookHandler,
MainClusterHandler, ClusterProfileHandler, ClusterActionHandler MainClusterHandler, ClusterProfileHandler, ClusterActionHandler,
FileFindHandler,
) )
from .notebookmanager import NotebookManager from .notebookmanager import NotebookManager
from .clustermanager import ClusterManager from .clustermanager import ClusterManager
@ -67,6 +68,7 @@ from IPython.zmq.ipkernel import (
) )
from IPython.utils.traitlets import Dict, Unicode, Integer, List, Enum, Bool from IPython.utils.traitlets import Dict, Unicode, Integer, List, Enum, Bool
from IPython.utils import py3compat from IPython.utils import py3compat
from IPython.utils.path import filefind
#----------------------------------------------------------------------------- #-----------------------------------------------------------------------------
# Module globals # Module globals
@ -153,7 +155,8 @@ class NotebookWebApplication(web.Application):
settings = dict( settings = dict(
template_path=os.path.join(os.path.dirname(__file__), "templates"), template_path=os.path.join(os.path.dirname(__file__), "templates"),
static_path=os.path.join(os.path.dirname(__file__), "static"), static_path=ipython_app.static_file_path,
static_handler_class = FileFindHandler,
cookie_secret=os.urandom(1024), cookie_secret=os.urandom(1024),
login_url="%s/login"%(base_project_url.rstrip('/')), login_url="%s/login"%(base_project_url.rstrip('/')),
) )
@ -356,19 +359,31 @@ class NotebookApp(BaseIPythonApplication):
help="""The hostname for the websocket server.""" help="""The hostname for the websocket server."""
) )
extra_static_paths = List(Unicode, config=True,
help="""Extra paths to search for serving static files.
This allows adding javascript/css to be available from the notebook server machine,
or overriding individual files in the IPython"""
)
def _extra_static_paths_default(self):
return [os.path.join(self.profile_dir.location, 'static')]
@property
def static_file_path(self):
"""return extra paths + the default location"""
return self.extra_static_paths + [os.path.join(os.path.dirname(__file__), "static")]
mathjax_url = Unicode("", config=True, mathjax_url = Unicode("", config=True,
help="""The url for MathJax.js.""" help="""The url for MathJax.js."""
) )
def _mathjax_url_default(self): def _mathjax_url_default(self):
if not self.enable_mathjax: if not self.enable_mathjax:
return u'' return u''
static_path = self.webapp_settings.get("static_path", os.path.join(os.path.dirname(__file__), "static"))
static_url_prefix = self.webapp_settings.get("static_url_prefix", static_url_prefix = self.webapp_settings.get("static_url_prefix",
"/static/") "/static/")
if os.path.exists(os.path.join(static_path, 'mathjax', "MathJax.js")): try:
self.log.info("Using local MathJax") mathjax = filefind(os.path.join('mathjax', 'MathJax.js'), self.static_file_path)
return static_url_prefix+u"mathjax/MathJax.js" except IOError:
else:
if self.certfile: if self.certfile:
# HTTPS: load from Rackspace CDN, because SSL certificate requires it # HTTPS: load from Rackspace CDN, because SSL certificate requires it
base = u"https://c328740.ssl.cf1.rackcdn.com" base = u"https://c328740.ssl.cf1.rackcdn.com"
@ -378,6 +393,9 @@ class NotebookApp(BaseIPythonApplication):
url = base + u"/mathjax/latest/MathJax.js" url = base + u"/mathjax/latest/MathJax.js"
self.log.info("Using MathJax from CDN: %s", url) self.log.info("Using MathJax from CDN: %s", url)
return url return url
else:
self.log.info("Using local MathJax from %s" % mathjax)
return static_url_prefix+u"mathjax/MathJax.js"
def _mathjax_url_changed(self, name, old, new): def _mathjax_url_changed(self, name, old, new):
if new and not self.enable_mathjax: if new and not self.enable_mathjax:

View File

@ -0,0 +1,7 @@
/*
Placeholder for custom user CSS
mainly to be overridden in profile/static/css/custom.css
This will always be an empty file in IPython
*/

View File

@ -0,0 +1,7 @@
/*
Placeholder for custom user javascript
mainly to be overridden in profile/static/js/custom.js
This will always be an empty file in IPython
*/

View File

@ -245,6 +245,6 @@ data-notebook-id={{notebook_id}}
<script src="{{ static_url("js/tooltip.js") }}" type="text/javascript" charset="utf-8"></script> <script src="{{ static_url("js/tooltip.js") }}" type="text/javascript" charset="utf-8"></script>
<script src="{{ static_url("js/notebookmain.js") }}" type="text/javascript" charset="utf-8"></script> <script src="{{ static_url("js/notebookmain.js") }}" type="text/javascript" charset="utf-8"></script>
<script src="{{ static_url("js/contexthint.js") }} charset="utf-8"></script> <script src="{{ static_url("js/contexthint.js") }}" charset="utf-8"></script>
{% end %} {% end %}

View File

@ -12,6 +12,8 @@
<link rel="stylesheet" href="{{static_url("css/page.css") }}" type="text/css"/> <link rel="stylesheet" href="{{static_url("css/page.css") }}" type="text/css"/>
{% block stylesheet %} {% block stylesheet %}
{% end %} {% end %}
<link rel="stylesheet" href="{{ static_url("css/custom.css") }}" type="text/css" />
{% block meta %} {% block meta %}
{% end %} {% end %}
@ -53,6 +55,8 @@
{% block script %} {% block script %}
{% end %} {% end %}
<script src="{{static_url("js/custom.js") }}" type="text/javascript" charset="utf-8"></script>
</body> </body>
</html> </html>