From 0eec72e08caaced2d5e0770b7765a34701df1f9b 2012-07-26 07:36:28 From: Bussonnier Matthias Date: 2012-07-26 07:36:28 Subject: [PATCH] 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. --- diff --git a/IPython/external/mathjax.py b/IPython/external/mathjax.py index 0cdf132..cc5252e 100644 --- a/IPython/external/mathjax.py +++ b/IPython/external/mathjax.py @@ -23,18 +23,18 @@ import urllib2 import tempfile import tarfile -from IPython.frontend.html import notebook as nbmod +from IPython.utils.path import locate_profile #----------------------------------------------------------------------------- # Imports #----------------------------------------------------------------------------- -def install_mathjax(tag='v1.1', replace=False): +def install_mathjax(tag='v2.0', replace=False, dest=None): """Download and install MathJax for offline use. - This will install mathjax to the 'static' dir in the IPython notebook - package, so it will fail if the caller does not have write access - to that location. + You can use this to install mathjax to a location on your static file + path. This includes the `static` directory within your IPython profile, + which is the default location for this install. MathJax is a ~15MB download, and ~150MB installed. @@ -43,23 +43,34 @@ def install_mathjax(tag='v1.1', replace=False): replace : bool [False] Whether to remove and replace an existing install. - tag : str ['v1.1'] - Which tag to download. Default is 'v1.1', the current stable release, - but alternatives include 'v1.1a' and 'master'. + tag : str ['v2.0'] + Which tag to download. Default is 'v2.0', the current stable release, + but alternatives include 'v1.1' and 'master'. + dest : path + The path to the directory in which mathjax will be installed. + The default is `IPYTHONDIR/profile_default/static`. + dest must be on your notebook static_path when you run the notebook server. + The default location works for this. """ - mathjax_url = "https://github.com/mathjax/MathJax/tarball/%s"%tag - nbdir = os.path.dirname(os.path.abspath(nbmod.__file__)) - static = os.path.join(nbdir, 'static') + mathjax_url = "https://github.com/mathjax/MathJax/tarball/%s" % tag + + if dest is None: + dest = os.path.join(locate_profile('default'), 'static') + + if not os.path.exists(dest): + os.mkdir(dest) + + static = dest dest = os.path.join(static, 'mathjax') # check for existence and permissions if not os.access(static, os.W_OK): - raise IOError("Need have write access to %s"%static) + raise IOError("Need have write access to %s" % static) if os.path.exists(dest): if replace: if not os.access(dest, os.W_OK): - raise IOError("Need have write access to %s"%dest) + raise IOError("Need have write access to %s" % dest) print "removing previous MathJax install" shutil.rmtree(dest) else: @@ -67,13 +78,13 @@ def install_mathjax(tag='v1.1', replace=False): return # download mathjax - print "Downloading mathjax source..." + print "Downloading mathjax source from %s ..." % mathjax_url response = urllib2.urlopen(mathjax_url) print "done" # use 'r|gz' stream mode, because socket file-like objects can't seek: tar = tarfile.open(fileobj=response.fp, mode='r|gz') topdir = tar.firstmember.path - print "Extracting to %s"%dest + print "Extracting to %s" % dest tar.extractall(static) # it will be mathjax-MathJax-, rename to just mathjax os.rename(os.path.join(static, topdir), dest) diff --git a/IPython/frontend/html/notebook/handlers.py b/IPython/frontend/html/notebook/handlers.py index c4a7551..55e4ccc 100644 --- a/IPython/frontend/html/notebook/handlers.py +++ b/IPython/frontend/html/notebook/handlers.py @@ -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 + diff --git a/IPython/frontend/html/notebook/notebookapp.py b/IPython/frontend/html/notebook/notebookapp.py index 38ff3e0..4d63bbb 100644 --- a/IPython/frontend/html/notebook/notebookapp.py +++ b/IPython/frontend/html/notebook/notebookapp.py @@ -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: diff --git a/IPython/frontend/html/notebook/static/css/custom.css b/IPython/frontend/html/notebook/static/css/custom.css new file mode 100644 index 0000000..e3f18e6 --- /dev/null +++ b/IPython/frontend/html/notebook/static/css/custom.css @@ -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 +*/ \ No newline at end of file diff --git a/IPython/frontend/html/notebook/static/js/custom.js b/IPython/frontend/html/notebook/static/js/custom.js new file mode 100644 index 0000000..375ecf3 --- /dev/null +++ b/IPython/frontend/html/notebook/static/js/custom.js @@ -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 +*/ diff --git a/IPython/frontend/html/notebook/templates/notebook.html b/IPython/frontend/html/notebook/templates/notebook.html index 28c6db3..44fd726 100644 --- a/IPython/frontend/html/notebook/templates/notebook.html +++ b/IPython/frontend/html/notebook/templates/notebook.html @@ -245,6 +245,6 @@ data-notebook-id={{notebook_id}} - + {% end %} diff --git a/IPython/frontend/html/notebook/templates/page.html b/IPython/frontend/html/notebook/templates/page.html index 2701985..311d83a 100644 --- a/IPython/frontend/html/notebook/templates/page.html +++ b/IPython/frontend/html/notebook/templates/page.html @@ -12,6 +12,8 @@ {% block stylesheet %} {% end %} + + {% block meta %} {% end %} @@ -53,6 +55,8 @@ {% block script %} {% end %} + +