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
#-----------------------------------------------------------------------------
import logging
import Cookie
import datetime
import email.utils
import hashlib
import logging
import mimetypes
import os
import stat
import threading
import time
import uuid
@ -31,6 +38,7 @@ from IPython.external.decorator import decorator
from IPython.zmq.session import Session
from IPython.lib.security import passwd_check
from IPython.utils.jsonutil import date_default
from IPython.utils.path import filefind
try:
from docutils.core import publish_string
@ -735,4 +743,178 @@ class RSTHandler(AuthenticatedHandler):
self.set_header('Content-Type', 'text/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,
ShellHandler, NotebookRootHandler, NotebookHandler, NotebookCopyHandler,
RSTHandler, AuthenticatedFileHandler, PrintNotebookHandler,
MainClusterHandler, ClusterProfileHandler, ClusterActionHandler
MainClusterHandler, ClusterProfileHandler, ClusterActionHandler,
FileFindHandler,
)
from .notebookmanager import NotebookManager
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 import py3compat
from IPython.utils.path import filefind
#-----------------------------------------------------------------------------
# Module globals
@ -153,7 +155,8 @@ class NotebookWebApplication(web.Application):
settings = dict(
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),
login_url="%s/login"%(base_project_url.rstrip('/')),
)
@ -355,6 +358,20 @@ class NotebookApp(BaseIPythonApplication):
websocket_host = Unicode("", config=True,
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,
help="""The url for MathJax.js."""
@ -362,13 +379,11 @@ class NotebookApp(BaseIPythonApplication):
def _mathjax_url_default(self):
if not self.enable_mathjax:
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/")
if os.path.exists(os.path.join(static_path, 'mathjax', "MathJax.js")):
self.log.info("Using local MathJax")
return static_url_prefix+u"mathjax/MathJax.js"
else:
try:
mathjax = filefind(os.path.join('mathjax', 'MathJax.js'), self.static_file_path)
except IOError:
if self.certfile:
# HTTPS: load from Rackspace CDN, because SSL certificate requires it
base = u"https://c328740.ssl.cf1.rackcdn.com"
@ -378,6 +393,9 @@ class NotebookApp(BaseIPythonApplication):
url = base + u"/mathjax/latest/MathJax.js"
self.log.info("Using MathJax from CDN: %s", 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):
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/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 %}

View File

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