diff --git a/.mailmap b/.mailmap index d980cf45912..cc0fd0e3c51 100644 --- a/.mailmap +++ b/.mailmap @@ -88,6 +88,7 @@ Laurent Dufréchou laurent.dufrechou <> Laurent Dufréchou Laurent Dufrechou <> Laurent Dufréchou laurent.dufrechou@gmail.com <> Laurent Dufréchou ldufrechou +Lorena Pantano Lorena Luis Pedro Coelho Luis Pedro Coelho Marc Molla marcmolla Martín Gaitán Martín Gaitán @@ -96,6 +97,7 @@ Matthias Bussonnier Bussonnier Matthias Matthias BUSSONNIER Matthias Bussonnier Matthias Bussonnier Michael Droettboom Michael Droettboom +Nicholas Bollweg bollwyvl Nicholas Bollweg Nicholas Bollweg (Nick) Nicolas Rougier Nikolay Koldunov Nikolay Koldunov diff --git a/Dockerfile b/Dockerfile index dd2f19c2590..9b525936a03 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,15 +20,14 @@ RUN dpkg-reconfigure locales # Python binary dependencies, developer tools RUN apt-get update && apt-get install -y -q \ build-essential \ + curl \ make \ gcc \ zlib1g-dev \ git \ python \ python-dev \ - python-pip \ python3-dev \ - python3-pip \ python-sphinx \ python3-sphinx \ libzmq3-dev \ @@ -40,6 +39,12 @@ RUN apt-get update && apt-get install -y -q \ nodejs-legacy \ npm +# Install the recent pip release +RUN curl -O https://bootstrap.pypa.io/get-pip.py \ + && python2 get-pip.py \ + && python3 get-pip.py \ + && rm get-pip.py + # In order to build from source, need less RUN npm install -g 'less@<3.0' diff --git a/IPython/config/application.py b/IPython/config/application.py index ef97162b318..264d3793a1f 100644 --- a/IPython/config/application.py +++ b/IPython/config/application.py @@ -159,7 +159,7 @@ def _log_level_changed(self, name, old, new): help="The date format used by logging formatters for %(asctime)s" ) def _log_datefmt_changed(self, name, old, new): - self._log_format_changed() + self._log_format_changed('log_format', self.log_format, self.log_format) log_format = Unicode("[%(name)s]%(highlevel)s %(message)s", config=True, help="The Logging format template", diff --git a/IPython/config/tests/test_application.py b/IPython/config/tests/test_application.py index a03d548c244..5da6a130655 100644 --- a/IPython/config/tests/test_application.py +++ b/IPython/config/tests/test_application.py @@ -80,6 +80,7 @@ def test_log(self): # trigger reconstruction of the log formatter app.log.handlers = [handler] app.log_format = "%(message)s" + app.log_datefmt = "%Y-%m-%d %H:%M" app.log.info("hello") nt.assert_in("hello", stream.getvalue()) diff --git a/IPython/core/completer.py b/IPython/core/completer.py index d58c2bf0d44..1b9c59488c9 100644 --- a/IPython/core/completer.py +++ b/IPython/core/completer.py @@ -416,7 +416,7 @@ def get__all__entries(obj): return [w for w in words if isinstance(w, string_types)] -def match_dict_keys(keys, prefix): +def match_dict_keys(keys, prefix, delims): """Used by dict_key_matches, matching the prefix to a list of keys""" if not prefix: return None, 0, [repr(k) for k in keys @@ -427,8 +427,9 @@ def match_dict_keys(keys, prefix): prefix_str = eval(prefix + quote, {}) except Exception: return None, 0, [] - - token_match = re.search(r'\w*$', prefix, re.UNICODE) + + pattern = '[^' + ''.join('\\' + c for c in delims) + ']*$' + token_match = re.search(pattern, prefix, re.UNICODE) token_start = token_match.start() token_prefix = token_match.group() @@ -913,7 +914,7 @@ def get_keys(obj): keys = get_keys(obj) if not keys: return keys - closing_quote, token_offset, matches = match_dict_keys(keys, prefix) + closing_quote, token_offset, matches = match_dict_keys(keys, prefix, self.splitter.delims) if not matches: return matches diff --git a/IPython/core/debugger.py b/IPython/core/debugger.py index 3aaa9fd58d1..eb3373e6247 100644 --- a/IPython/core/debugger.py +++ b/IPython/core/debugger.py @@ -303,7 +303,8 @@ def new_do_quit(self, arg): self.shell.Completer.all_completions=self.old_all_completions # Pdb sets readline delimiters, so set them back to our own - self.shell.readline.set_completer_delims(self.shell.readline_delims) + if self.shell.readline is not None: + self.shell.readline.set_completer_delims(self.shell.readline_delims) return OldPdb.do_quit(self, arg) diff --git a/IPython/core/display.py b/IPython/core/display.py index 0d07fdf5eb7..19f0cb01247 100644 --- a/IPython/core/display.py +++ b/IPython/core/display.py @@ -637,7 +637,9 @@ class Image(DisplayObject): _FMT_PNG = u'png' _ACCEPTABLE_EMBEDDINGS = [_FMT_JPEG, _FMT_PNG] - def __init__(self, data=None, url=None, filename=None, format=u'png', embed=None, width=None, height=None, retina=False): + def __init__(self, data=None, url=None, filename=None, format=u'png', + embed=None, width=None, height=None, retina=False, + unconfined=False, metadata=None): """Create a PNG/JPEG image object given raw data. When this object is returned by an input cell or passed to the @@ -678,6 +680,10 @@ def __init__(self, data=None, url=None, filename=None, format=u'png', embed=None from image data. For non-embedded images, you can just set the desired display width and height directly. + unconfined: bool + Set unconfined=True to disable max-width confinement of the image. + metadata: dict + Specify extra metadata to attach to the image. Examples -------- @@ -728,6 +734,8 @@ def __init__(self, data=None, url=None, filename=None, format=u'png', embed=None self.width = width self.height = height self.retina = retina + self.unconfined = unconfined + self.metadata = metadata super(Image, self).__init__(data=data, url=url, filename=filename) if retina: @@ -756,12 +764,19 @@ def reload(self): def _repr_html_(self): if not self.embed: - width = height = '' + width = height = klass = '' if self.width: width = ' width="%d"' % self.width if self.height: height = ' height="%d"' % self.height - return u'' % (self.url, width, height) + if self.unconfined: + klass = ' class="unconfined"' + return u''.format( + url=self.url, + width=width, + height=height, + klass=klass, + ) def _data_and_metadata(self): """shortcut for returning metadata with shape information, if defined""" @@ -770,6 +785,10 @@ def _data_and_metadata(self): md['width'] = self.width if self.height: md['height'] = self.height + if self.unconfined: + md['unconfined'] = self.unconfined + if self.metadata: + md.update(self.metadata) if md: return self.data, md else: diff --git a/IPython/core/pylabtools.py b/IPython/core/pylabtools.py index af0fd65953d..9ba171c28c5 100644 --- a/IPython/core/pylabtools.py +++ b/IPython/core/pylabtools.py @@ -98,6 +98,8 @@ def print_figure(fig, fmt='png', bbox_inches='tight', **kwargs): return dpi = rcParams['savefig.dpi'] + if dpi == 'figure': + dpi = fig.dpi if fmt == 'retina': dpi = dpi * 2 fmt = 'png' diff --git a/IPython/core/release.py b/IPython/core/release.py index 4975c6ccebd..95c374cade9 100644 --- a/IPython/core/release.py +++ b/IPython/core/release.py @@ -20,11 +20,11 @@ # release. 'dev' as a _version_extra string means this is a development # version _version_major = 3 -_version_minor = 1 -_version_patch = 0 -_version_extra = 'dev' +_version_minor = 2 +_version_patch = 3 +# _version_extra = 'dev' # _version_extra = 'rc1' -# _version_extra = '' # Uncomment this for full releases +_version_extra = '' # Uncomment this for full releases # release.codename is deprecated in 2.0, will be removed in 3.0 codename = '' diff --git a/IPython/core/tests/test_completer.py b/IPython/core/tests/test_completer.py index 9d8a037a97d..947fd7a7e9a 100644 --- a/IPython/core/tests/test_completer.py +++ b/IPython/core/tests/test_completer.py @@ -490,6 +490,11 @@ def test_dict_key_completion_string(): _, matches = complete(line_buffer="d[\"a'") nt.assert_in("b", matches) + # need to not split at delims that readline won't split at + if '-' not in ip.Completer.splitter.delims: + ip.user_ns['d'] = {'before-after': None} + _, matches = complete(line_buffer="d['before-af") + nt.assert_in('before-after', matches) def test_dict_key_completion_contexts(): """Test expression contexts in which dict key completion occurs""" diff --git a/IPython/core/tests/test_display.py b/IPython/core/tests/test_display.py index 713b567d0f6..3d63a720d34 100644 --- a/IPython/core/tests/test_display.py +++ b/IPython/core/tests/test_display.py @@ -22,6 +22,8 @@ def test_image_size(): nt.assert_equal(u'' % (thisurl), img._repr_html_()) img = display.Image(url=thisurl) nt.assert_equal(u'' % (thisurl), img._repr_html_()) + img = display.Image(url=thisurl, unconfined=True) + nt.assert_equal(u'' % (thisurl), img._repr_html_()) def test_retina_png(): here = os.path.dirname(__file__) diff --git a/IPython/core/tests/test_oinspect.py b/IPython/core/tests/test_oinspect.py index 1d7f7bc4ec9..d8f1dd32906 100644 --- a/IPython/core/tests/test_oinspect.py +++ b/IPython/core/tests/test_oinspect.py @@ -243,7 +243,7 @@ def test_info(): fname = fname[:-1] # case-insensitive comparison needed on some filesystems # e.g. Windows: - nt.assert_equal(i['file'].lower(), compress_user(fname.lower())) + nt.assert_equal(i['file'].lower(), compress_user(fname).lower()) nt.assert_equal(i['definition'], None) nt.assert_equal(i['docstring'], Call.__doc__) nt.assert_equal(i['source'], None) diff --git a/IPython/html/auth/login.py b/IPython/html/auth/login.py index fa3b6ba9ad9..85ec2045243 100644 --- a/IPython/html/auth/login.py +++ b/IPython/html/auth/login.py @@ -25,7 +25,11 @@ def _render(self, message=None): def get(self): if self.current_user: - self.redirect(self.get_argument('next', default=self.base_url)) + next_url = self.get_argument('next', default=self.base_url) + if not next_url.startswith(self.base_url): + # require that next_url be absolute path within our path + next_url = self.base_url + self.redirect(next_url) else: self._render() @@ -37,12 +41,22 @@ def post(self): typed_password = self.get_argument('password', default=u'') if self.login_available(self.settings): if passwd_check(self.hashed_password, typed_password): - self.set_secure_cookie(self.cookie_name, str(uuid.uuid4())) + # tornado <4.2 have a bug that consider secure==True as soon as + # 'secure' kwarg is passed to set_secure_cookie + if self.settings.get('secure_cookie', self.request.protocol == 'https'): + kwargs = {'secure':True} + else: + kwargs = {} + self.set_secure_cookie(self.cookie_name, str(uuid.uuid4()), **kwargs) else: self._render(message={'error': 'Invalid password'}) return - - self.redirect(self.get_argument('next', default=self.base_url)) + + next_url = self.get_argument('next', default=self.base_url) + if not next_url.startswith(self.base_url): + # require that next_url be absolute path within our path + next_url = self.base_url + self.redirect(next_url) @classmethod def get_user(cls, handler): diff --git a/IPython/html/base/handlers.py b/IPython/html/base/handlers.py index 1b042062bc0..cf65cd8c912 100644 --- a/IPython/html/base/handlers.py +++ b/IPython/html/base/handlers.py @@ -5,7 +5,6 @@ import functools import json -import logging import os import re import sys @@ -15,6 +14,10 @@ from http.client import responses except ImportError: from httplib import responses +try: + from urllib.parse import urlparse # Py 3 +except ImportError: + from urlparse import urlparse # Py 2 from jinja2 import TemplateNotFound from tornado import web @@ -42,16 +45,24 @@ class AuthenticatedHandler(web.RequestHandler): """A RequestHandler with an authenticated user.""" + + @property + def content_security_policy(self): + """The default Content-Security-Policy header + + Can be overridden by defining Content-Security-Policy in settings['headers'] + """ + return '; '.join([ + "frame-ancestors 'self'", + # Make sure the report-uri is relative to the base_url + "report-uri " + url_path_join(self.base_url, csp_report_uri), + ]) def set_default_headers(self): headers = self.settings.get('headers', {}) if "Content-Security-Policy" not in headers: - headers["Content-Security-Policy"] = ( - "frame-ancestors 'self'; " - # Make sure the report-uri is relative to the base_url - "report-uri " + url_path_join(self.base_url, csp_report_uri) + ";" - ) + headers["Content-Security-Policy"] = self.content_security_policy # Allow for overriding headers for header_name,value in headers.items() : @@ -119,6 +130,11 @@ def log(self): return Application.instance().log else: return app_log + + @property + def jinja_template_vars(self): + """User-supplied values to supply to jinja templates.""" + return self.settings.get('jinja_template_vars', {}) #--------------------------------------------------------------- # URLs @@ -250,6 +266,7 @@ def template_namespace(self): sys_info=sys_info, contents_js_source=self.contents_js_source, version_hash=self.version_hash, + **self.jinja_template_vars ) def get_json_body(self): @@ -301,7 +318,66 @@ def write_error(self, status_code, **kwargs): html = self.render_template('error.html', **ns) self.write(html) + + +class APIHandler(IPythonHandler): + """Base class for API handlers""" + + def check_origin(self): + """Check Origin for cross-site API requests. + Copied from WebSocket with changes: + + - allow unspecified host/origin (e.g. scripts) + """ + if self.allow_origin == '*': + return True + + host = self.request.headers.get("Host") + origin = self.request.headers.get("Origin") + + # If no header is provided, assume it comes from a script/curl. + # We are only concerned with cross-site browser stuff here. + if origin is None or host is None: + return True + + origin = origin.lower() + origin_host = urlparse(origin).netloc + + # OK if origin matches host + if origin_host == host: + return True + + # Check CORS headers + if self.allow_origin: + allow = self.allow_origin == origin + elif self.allow_origin_pat: + allow = bool(self.allow_origin_pat.match(origin)) + else: + # No CORS headers deny the request + allow = False + if not allow: + self.log.warn("Blocking Cross Origin API request. Origin: %s, Host: %s", + origin, host, + ) + return allow + + def prepare(self): + if not self.check_origin(): + raise web.HTTPError(404) + return super(APIHandler, self).prepare() + + @property + def content_security_policy(self): + csp = '; '.join([ + super(APIHandler, self).content_security_policy, + "default-src 'none'", + ]) + return csp + + def finish(self, *args, **kwargs): + self.set_header('Content-Type', 'application/json') + return super(APIHandler, self).finish(*args, **kwargs) class Template404(IPythonHandler): @@ -364,6 +440,7 @@ def wrapper(self, *args, **kwargs): try: result = yield gen.maybe_future(method(self, *args, **kwargs)) except web.HTTPError as e: + self.set_header('Content-Type', 'application/json') status = e.status_code message = e.log_message self.log.warn(message) @@ -371,6 +448,7 @@ def wrapper(self, *args, **kwargs): reply = dict(message=message, reason=e.reason) self.finish(json.dumps(reply)) except Exception: + self.set_header('Content-Type', 'application/json') self.log.error("Unhandled error in API request", exc_info=True) status = 500 message = "Unknown server error" @@ -393,7 +471,7 @@ def wrapper(self, *args, **kwargs): # to minimize subclass changes: HTTPError = web.HTTPError -class FileFindHandler(web.StaticFileHandler): +class FileFindHandler(IPythonHandler, web.StaticFileHandler): """subclass of StaticFileHandler for serving files from a search path""" # cache search results, don't search for files more than once @@ -447,7 +525,7 @@ def validate_absolute_path(self, root, absolute_path): return super(FileFindHandler, self).validate_absolute_path(root, absolute_path) -class ApiVersionHandler(IPythonHandler): +class APIVersionHandler(APIHandler): @json_errors def get(self): @@ -518,5 +596,5 @@ def get(self, path=''): default_handlers = [ (r".*/", TrailingSlashHandler), - (r"api", ApiVersionHandler) + (r"api", APIVersionHandler) ] diff --git a/IPython/html/files/handlers.py b/IPython/html/files/handlers.py index 7727d084df5..b358d9459a5 100644 --- a/IPython/html/files/handlers.py +++ b/IPython/html/files/handlers.py @@ -40,6 +40,11 @@ def get(self, path): cur_mime = mimetypes.guess_type(name)[0] if cur_mime is not None: self.set_header('Content-Type', cur_mime) + else: + if model['format'] == 'base64': + self.set_header('Content-Type', 'application/octet-stream') + else: + self.set_header('Content-Type', 'text/plain') if model['format'] == 'base64': b64_bytes = model['content'].encode('ascii') diff --git a/IPython/html/notebookapp.py b/IPython/html/notebookapp.py index 16708c0781e..094812bc914 100644 --- a/IPython/html/notebookapp.py +++ b/IPython/html/notebookapp.py @@ -19,6 +19,7 @@ import select import signal import socket +import ssl import sys import threading import webbrowser @@ -153,11 +154,13 @@ def init_settings(self, ipython_app, kernel_manager, contents_manager, "template_path", ipython_app.template_file_path, ) - if isinstance(_template_path, str): + if isinstance(_template_path, py3compat.string_types): _template_path = (_template_path,) template_path = [os.path.expanduser(path) for path in _template_path] - jenv_opt = jinja_env_options if jinja_env_options else {} + jenv_opt = {"autoescape": True} + jenv_opt.update(jinja_env_options if jinja_env_options else {}) + env = Environment(loader=FileSystemLoader(template_path), **jenv_opt) sys_info = get_sys_info() @@ -199,6 +202,7 @@ def init_settings(self, ipython_app, kernel_manager, contents_manager, config_manager=config_manager, # IPython stuff + jinja_template_vars=ipython_app.jinja_template_vars, nbextensions_path=ipython_app.nbextensions_path, websocket_url=ipython_app.websocket_url, mathjax_url=ipython_app.mathjax_url, @@ -526,6 +530,11 @@ def _webapp_settings_changed(self, name, old, new): jinja_environment_options = Dict(config=True, help="Supply extra arguments that will be passed to Jinja environment.") + + jinja_template_vars = Dict( + config=True, + help="Extra variables to supply to jinja templates when rendering.", + ) enable_mathjax = Bool(True, config=True, help="""Whether to enable MathJax for typesetting math/TeX @@ -855,6 +864,9 @@ def init_webapp(self): if not ssl_options: # None indicates no SSL config ssl_options = None + else: + # Disable SSLv3, since its use is discouraged. + ssl_options['ssl_version']=ssl.PROTOCOL_TLSv1 self.login_handler_class.validate_security(self, ssl_options=ssl_options) self.http_server = httpserver.HTTPServer(self.web_app, ssl_options=ssl_options, xheaders=self.trust_xheaders) diff --git a/IPython/html/services/clusters/clustermanager.py b/IPython/html/services/clusters/clustermanager.py index c0a8776131c..6b82db65817 100644 --- a/IPython/html/services/clusters/clustermanager.py +++ b/IPython/html/services/clusters/clustermanager.py @@ -71,7 +71,7 @@ def update_profiles(self): for profile in stale: # remove profiles that no longer exist self.log.debug("Profile '%s' no longer exists", profile) - self.profiles.pop(stale) + self.profiles.pop(profile) def list_profiles(self): self.update_profiles() diff --git a/IPython/html/services/clusters/handlers.py b/IPython/html/services/clusters/handlers.py index a6d6312ddc2..a16d21f925f 100644 --- a/IPython/html/services/clusters/handlers.py +++ b/IPython/html/services/clusters/handlers.py @@ -7,28 +7,28 @@ from tornado import web -from ...base.handlers import IPythonHandler +from ...base.handlers import APIHandler #----------------------------------------------------------------------------- # Cluster handlers #----------------------------------------------------------------------------- -class MainClusterHandler(IPythonHandler): +class MainClusterHandler(APIHandler): @web.authenticated def get(self): self.finish(json.dumps(self.cluster_manager.list_profiles())) -class ClusterProfileHandler(IPythonHandler): +class ClusterProfileHandler(APIHandler): @web.authenticated def get(self, profile): self.finish(json.dumps(self.cluster_manager.profile_info(profile))) -class ClusterActionHandler(IPythonHandler): +class ClusterActionHandler(APIHandler): @web.authenticated def post(self, profile, action): diff --git a/IPython/html/services/config/handlers.py b/IPython/html/services/config/handlers.py index a7ff896e5bf..ae7f7e41d79 100644 --- a/IPython/html/services/config/handlers.py +++ b/IPython/html/services/config/handlers.py @@ -9,9 +9,9 @@ from tornado import web from IPython.utils.py3compat import PY3 -from ...base.handlers import IPythonHandler, json_errors +from ...base.handlers import APIHandler, json_errors -class ConfigHandler(IPythonHandler): +class ConfigHandler(APIHandler): SUPPORTED_METHODS = ('GET', 'PUT', 'PATCH') @web.authenticated diff --git a/IPython/html/services/contents/checkpoints.py b/IPython/html/services/contents/checkpoints.py index d87b7cc9595..789cf6d6018 100644 --- a/IPython/html/services/contents/checkpoints.py +++ b/IPython/html/services/contents/checkpoints.py @@ -67,7 +67,7 @@ class GenericCheckpointsMixin(object): - get_notebook_checkpoint(self, checkpoint_id, path) To create a generic CheckpointManager, add this mixin to a class that - implement the above three methods plus the remaining Checkpoints API + implement the above four methods plus the remaining Checkpoints API methods: - delete_checkpoint(self, checkpoint_id, path) @@ -118,10 +118,25 @@ def create_notebook_checkpoint(self, nb, path): """ raise NotImplementedError("must be implemented in a subclass") - def get_checkpoint(self, checkpoint_id, path, type): - """Get the content of a checkpoint. + def get_file_checkpoint(self, checkpoint_id, path): + """Get the content of a checkpoint for a non-notebook file. - Returns an unvalidated model with the same structure as - the return value of ContentsManager.get + Returns a dict of the form: + { + 'type': 'file', + 'content': , + 'format': {'text','base64'}, + } + """ + raise NotImplementedError("must be implemented in a subclass") + + def get_notebook_checkpoint(self, checkpoint_id, path): + """Get the content of a checkpoint for a notebook. + + Returns a dict of the form: + { + 'type': 'notebook', + 'content': , + } """ raise NotImplementedError("must be implemented in a subclass") diff --git a/IPython/html/services/contents/filecheckpoints.py b/IPython/html/services/contents/filecheckpoints.py index 425bae35998..9854f159127 100644 --- a/IPython/html/services/contents/filecheckpoints.py +++ b/IPython/html/services/contents/filecheckpoints.py @@ -142,7 +142,7 @@ class GenericFileCheckpoints(GenericCheckpointsMixin, FileCheckpoints): ContentsManager. """ def create_file_checkpoint(self, content, format, path): - """Create a checkpoint from the current content of a notebook.""" + """Create a checkpoint from the current content of a file.""" path = path.strip('/') # only the one checkpoint ID: checkpoint_id = u"checkpoint" @@ -168,7 +168,7 @@ def create_notebook_checkpoint(self, nb, path): return self.checkpoint_model(checkpoint_id, os_checkpoint_path) def get_notebook_checkpoint(self, checkpoint_id, path): - + """Get a checkpoint for a notebook.""" path = path.strip('/') self.log.info("restoring %s from checkpoint %s", path, checkpoint_id) os_checkpoint_path = self.checkpoint_path(checkpoint_id, path) @@ -185,6 +185,7 @@ def get_notebook_checkpoint(self, checkpoint_id, path): } def get_file_checkpoint(self, checkpoint_id, path): + """Get a checkpoint for a file.""" path = path.strip('/') self.log.info("restoring %s from checkpoint %s", path, checkpoint_id) os_checkpoint_path = self.checkpoint_path(checkpoint_id, path) diff --git a/IPython/html/services/contents/filemanager.py b/IPython/html/services/contents/filemanager.py index 01ce07b643a..c869c75b7c3 100644 --- a/IPython/html/services/contents/filemanager.py +++ b/IPython/html/services/contents/filemanager.py @@ -277,18 +277,20 @@ def _file_model(self, path, content=True, format=None): model['type'] = 'file' os_path = self._get_os_path(path) + model['mimetype'] = mimetypes.guess_type(os_path)[0] if content: content, format = self._read_file(os_path, format) - default_mime = { - 'text': 'text/plain', - 'base64': 'application/octet-stream' - }[format] + if model['mimetype'] is None: + default_mime = { + 'text': 'text/plain', + 'base64': 'application/octet-stream' + }[format] + model['mimetype'] = default_mime model.update( content=content, format=format, - mimetype=mimetypes.guess_type(os_path)[0] or default_mime, ) return model diff --git a/IPython/html/services/contents/handlers.py b/IPython/html/services/contents/handlers.py index 1e8bd338f09..d77e70eb3b8 100644 --- a/IPython/html/services/contents/handlers.py +++ b/IPython/html/services/contents/handlers.py @@ -11,7 +11,7 @@ from IPython.utils.jsonutil import date_default from IPython.html.base.handlers import ( - IPythonHandler, json_errors, path_regex, + IPythonHandler, APIHandler, json_errors, path_regex, ) @@ -52,9 +52,6 @@ def validate_model(model, expect_content): ) maybe_none_keys = ['content', 'format'] - if model['type'] == 'file': - # mimetype should be populated only for file models - maybe_none_keys.append('mimetype') if expect_content: errors = [key for key in maybe_none_keys if model[key] is None] if errors: @@ -75,7 +72,7 @@ def validate_model(model, expect_content): ) -class ContentsHandler(IPythonHandler): +class ContentsHandler(APIHandler): SUPPORTED_METHODS = (u'GET', u'PUT', u'PATCH', u'POST', u'DELETE') @@ -257,7 +254,7 @@ def delete(self, path=''): self.finish() -class CheckpointsHandler(IPythonHandler): +class CheckpointsHandler(APIHandler): SUPPORTED_METHODS = ('GET', 'POST') @@ -286,7 +283,7 @@ def post(self, path=''): self.finish(data) -class ModifyCheckpointsHandler(IPythonHandler): +class ModifyCheckpointsHandler(APIHandler): SUPPORTED_METHODS = ('POST', 'DELETE') diff --git a/IPython/html/services/contents/manager.py b/IPython/html/services/contents/manager.py index 446aed69efd..7970d65ad22 100644 --- a/IPython/html/services/contents/manager.py +++ b/IPython/html/services/contents/manager.py @@ -222,6 +222,9 @@ def rename_file(self, old_path, new_path): def delete(self, path): """Delete a file/directory and any associated checkpoints.""" + path = path.strip('/') + if not path: + raise HTTPError(400, "Can't delete root") self.delete_file(path) self.checkpoints.delete_all_checkpoints(path) diff --git a/IPython/html/services/contents/tests/test_contents_api.py b/IPython/html/services/contents/tests/test_contents_api.py index 34e82ccc79b..6d30513c479 100644 --- a/IPython/html/services/contents/tests/test_contents_api.py +++ b/IPython/html/services/contents/tests/test_contents_api.py @@ -508,6 +508,38 @@ def test_rename(self): self.assertIn('z.ipynb', nbnames) self.assertNotIn('a.ipynb', nbnames) + def test_checkpoints_follow_file(self): + + # Read initial file state + orig = self.api.read('foo/a.ipynb') + + # Create a checkpoint of initial state + r = self.api.new_checkpoint('foo/a.ipynb') + cp1 = r.json() + + # Modify file and save + nbcontent = json.loads(orig.text)['content'] + nb = from_dict(nbcontent) + hcell = new_markdown_cell('Created by test') + nb.cells.append(hcell) + nbmodel = {'content': nb, 'type': 'notebook'} + self.api.save('foo/a.ipynb', body=json.dumps(nbmodel)) + + # Rename the file. + self.api.rename('foo/a.ipynb', 'foo/z.ipynb') + + # Looking for checkpoints in the old location should yield no results. + self.assertEqual(self.api.get_checkpoints('foo/a.ipynb').json(), []) + + # Looking for checkpoints in the new location should work. + cps = self.api.get_checkpoints('foo/z.ipynb').json() + self.assertEqual(cps, [cp1]) + + # Delete the file. The checkpoint should be deleted as well. + self.api.delete('foo/z.ipynb') + cps = self.api.get_checkpoints('foo/z.ipynb').json() + self.assertEqual(cps, []) + def test_rename_existing(self): with assert_http_error(409): self.api.rename('foo/a.ipynb', 'foo/b.ipynb') @@ -518,7 +550,7 @@ def test_save(self): nb = from_dict(nbcontent) nb.cells.append(new_markdown_cell(u'Created by test ³')) - nbmodel= {'content': nb, 'type': 'notebook'} + nbmodel = {'content': nb, 'type': 'notebook'} resp = self.api.save('foo/a.ipynb', body=json.dumps(nbmodel)) nbcontent = self.api.read('foo/a.ipynb').json()['content'] diff --git a/IPython/html/services/contents/tests/test_manager.py b/IPython/html/services/contents/tests/test_manager.py index 73bf522532e..8612a552048 100644 --- a/IPython/html/services/contents/tests/test_manager.py +++ b/IPython/html/services/contents/tests/test_manager.py @@ -257,6 +257,33 @@ def test_new_untitled(self): self.assertEqual(model['name'], 'untitled') self.assertEqual(model['path'], '%s/untitled' % sub_dir) + def test_modified_date(self): + + cm = self.contents_manager + + # Create a new notebook. + nb, name, path = self.new_notebook() + model = cm.get(path) + + # Add a cell and save. + self.add_code_cell(model['content']) + cm.save(model, path) + + # Reload notebook and verify that last_modified incremented. + saved = cm.get(path) + self.assertGreaterEqual(saved['last_modified'], model['last_modified']) + + # Move the notebook and verify that last_modified stayed the same. + # (The frontend fires a warning if last_modified increases on the + # renamed file.) + new_path = 'renamed.ipynb' + cm.rename(path, new_path) + renamed = cm.get(new_path) + self.assertGreaterEqual( + renamed['last_modified'], + saved['last_modified'], + ) + def test_get(self): cm = self.contents_manager # Create a notebook @@ -433,6 +460,12 @@ def test_delete(self): # Check that a 'get' on the deleted notebook raises and error self.assertRaises(HTTPError, cm.get, path) + def test_delete_root(self): + cm = self.contents_manager + with self.assertRaises(HTTPError) as err: + cm.delete('') + self.assertEqual(err.exception.status_code, 400) + def test_copy(self): cm = self.contents_manager parent = u'å b' diff --git a/IPython/html/services/kernels/handlers.py b/IPython/html/services/kernels/handlers.py index 14390ac96d0..9be6845e959 100644 --- a/IPython/html/services/kernels/handlers.py +++ b/IPython/html/services/kernels/handlers.py @@ -13,12 +13,12 @@ from IPython.utils.py3compat import cast_unicode from IPython.html.utils import url_path_join, url_escape -from ...base.handlers import IPythonHandler, json_errors +from ...base.handlers import IPythonHandler, APIHandler, json_errors from ...base.zmqhandlers import AuthenticatedZMQStreamHandler, deserialize_binary_message from IPython.core.release import kernel_protocol_version -class MainKernelHandler(IPythonHandler): +class MainKernelHandler(APIHandler): @web.authenticated @json_errors @@ -46,7 +46,7 @@ def post(self): self.finish(json.dumps(model)) -class KernelHandler(IPythonHandler): +class KernelHandler(APIHandler): SUPPORTED_METHODS = ('DELETE', 'GET') @@ -67,7 +67,7 @@ def delete(self, kernel_id): self.finish() -class KernelActionHandler(IPythonHandler): +class KernelActionHandler(APIHandler): @web.authenticated @json_errors diff --git a/IPython/html/services/kernels/tests/test_kernels_api.py b/IPython/html/services/kernels/tests/test_kernels_api.py index b33142c924a..b7797866d1d 100644 --- a/IPython/html/services/kernels/tests/test_kernels_api.py +++ b/IPython/html/services/kernels/tests/test_kernels_api.py @@ -67,7 +67,8 @@ def test_default_kernel(self): self.assertEqual(r.headers['Content-Security-Policy'], ( "frame-ancestors 'self'; " - "report-uri /api/security/csp-report;" + "report-uri /api/security/csp-report; " + "default-src 'none'" )) def test_main_kernel_handler(self): @@ -80,7 +81,8 @@ def test_main_kernel_handler(self): self.assertEqual(r.headers['Content-Security-Policy'], ( "frame-ancestors 'self'; " - "report-uri /api/security/csp-report;" + "report-uri /api/security/csp-report; " + "default-src 'none'" )) # GET request diff --git a/IPython/html/services/kernelspecs/handlers.py b/IPython/html/services/kernelspecs/handlers.py index 72397788f4f..b8269276045 100644 --- a/IPython/html/services/kernelspecs/handlers.py +++ b/IPython/html/services/kernelspecs/handlers.py @@ -10,7 +10,7 @@ from tornado import web -from ...base.handlers import IPythonHandler, json_errors +from ...base.handlers import APIHandler, json_errors from ...utils import url_path_join def kernelspec_model(handler, name): @@ -40,7 +40,7 @@ def kernelspec_model(handler, name): ) return d -class MainKernelSpecHandler(IPythonHandler): +class MainKernelSpecHandler(APIHandler): SUPPORTED_METHODS = ('GET',) @web.authenticated @@ -62,7 +62,7 @@ def get(self): self.finish(json.dumps(model)) -class KernelSpecHandler(IPythonHandler): +class KernelSpecHandler(APIHandler): SUPPORTED_METHODS = ('GET',) @web.authenticated diff --git a/IPython/html/services/nbconvert/handlers.py b/IPython/html/services/nbconvert/handlers.py index 1c74de5d690..d6e9e0d69ff 100644 --- a/IPython/html/services/nbconvert/handlers.py +++ b/IPython/html/services/nbconvert/handlers.py @@ -2,9 +2,9 @@ from tornado import web -from ...base.handlers import IPythonHandler, json_errors +from ...base.handlers import APIHandler, json_errors -class NbconvertRootHandler(IPythonHandler): +class NbconvertRootHandler(APIHandler): SUPPORTED_METHODS = ('GET',) @web.authenticated diff --git a/IPython/html/services/security/handlers.py b/IPython/html/services/security/handlers.py index 18f7874cd88..03a0dcf4963 100644 --- a/IPython/html/services/security/handlers.py +++ b/IPython/html/services/security/handlers.py @@ -5,10 +5,10 @@ from tornado import gen, web -from ...base.handlers import IPythonHandler, json_errors +from ...base.handlers import APIHandler, json_errors from . import csp_report_uri -class CSPReportHandler(IPythonHandler): +class CSPReportHandler(APIHandler): '''Accepts a content security policy violation report''' @web.authenticated @json_errors diff --git a/IPython/html/services/sessions/handlers.py b/IPython/html/services/sessions/handlers.py index 9d0a5e40047..36c5160b78c 100644 --- a/IPython/html/services/sessions/handlers.py +++ b/IPython/html/services/sessions/handlers.py @@ -7,13 +7,13 @@ from tornado import web -from ...base.handlers import IPythonHandler, json_errors +from ...base.handlers import APIHandler, json_errors from IPython.utils.jsonutil import date_default from IPython.html.utils import url_path_join, url_escape from IPython.kernel.kernelspec import NoSuchKernel -class SessionRootHandler(IPythonHandler): +class SessionRootHandler(APIHandler): @web.authenticated @json_errors @@ -65,7 +65,7 @@ def post(self): self.set_status(201) self.finish(json.dumps(model, default=date_default)) -class SessionHandler(IPythonHandler): +class SessionHandler(APIHandler): SUPPORTED_METHODS = ('GET', 'PATCH', 'DELETE') diff --git a/IPython/html/static/base/js/keyboard.js b/IPython/html/static/base/js/keyboard.js index 474b240886b..fe02abbbb33 100644 --- a/IPython/html/static/base/js/keyboard.js +++ b/IPython/html/static/base/js/keyboard.js @@ -244,13 +244,13 @@ define([ } } help.sort(function (a, b) { - if (a.help_index > b.help_index){ - return 1; + if (a.help_index === b.help_index) { + return 0; } - if (a.help_index < b.help_index){ - return -1; + if (a.help_index === undefined || a.help_index > b.help_index){ + return 1; } - return 0; + return -1; }); return help; }; diff --git a/IPython/html/static/base/js/namespace.js b/IPython/html/static/base/js/namespace.js index 57612c3a9f7..098b3cac5b3 100644 --- a/IPython/html/static/base/js/namespace.js +++ b/IPython/html/static/base/js/namespace.js @@ -4,7 +4,7 @@ var IPython = IPython || {}; define([], function(){ "use strict"; - IPython.version = "3.1.0-dev"; + IPython.version = "3.2.3"; IPython._target = '_blank'; return IPython; }); diff --git a/IPython/html/static/base/less/variables.less b/IPython/html/static/base/less/variables.less index d57f1d9b058..e5d1640c635 100644 --- a/IPython/html/static/base/less/variables.less +++ b/IPython/html/static/base/less/variables.less @@ -13,8 +13,9 @@ @logo_height: 28px; @border-radius-small: 1px; @border-radius-base: 2px; -@border-radius-large: 3px;; +@border-radius-large: 3px; @grid-gutter-width: 0px; +@icon-font-path: "../components/bootstrap/fonts/"; // Disable modal slide-in from top animation. .modal { diff --git a/IPython/html/static/edit/js/editor.js b/IPython/html/static/edit/js/editor.js index dd12ea477f2..75d65e03766 100644 --- a/IPython/html/static/edit/js/editor.js +++ b/IPython/html/static/edit/js/editor.js @@ -90,19 +90,10 @@ function($, }).catch( function(error) { that.events.trigger("file_load_failed.Editor", error); - if (((error.xhr||{}).responseJSON||{}).reason === 'bad format') { - window.location = utils.url_path_join( - that.base_url, - 'files', - that.file_path - ); - } else { - console.warn('Error while loading: the error was:') - console.warn(error) - } + console.warn('Error loading: ', error); cm.setValue("Error! " + error.message + "\nSaving disabled.\nSee Console for more details."); - cm.setOption('readOnly','nocursor') + cm.setOption('readOnly','nocursor'); that.save_enabled = false; } ); @@ -186,7 +177,7 @@ function($, Editor.prototype._clean_state = function(){ var clean = this.codemirror.isClean(this.generation); if (clean === this.clean){ - return + return; } else { this.clean = clean; } diff --git a/IPython/html/static/notebook/js/actions.js b/IPython/html/static/notebook/js/actions.js index e956d27b63e..d3f194e9f2f 100644 --- a/IPython/html/static/notebook/js/actions.js +++ b/IPython/html/static/notebook/js/actions.js @@ -107,7 +107,6 @@ define(function(require){ var index = env.notebook.get_selected_index(); env.notebook.cut_cell(); env.notebook.select(index); - env.notebook.focus_cell(); } }, 'copy-selected-cell' : { @@ -115,7 +114,6 @@ define(function(require){ help_index : 'ef', handler : function (env) { env.notebook.copy_cell(); - env.notebook.focus_cell(); } }, 'paste-cell-before' : { @@ -268,7 +266,6 @@ define(function(require){ help_index : 'ha', handler : function (env) { env.notebook.kernel.interrupt(); - env.notebook.focus_cell(); } }, 'restart-kernel':{ @@ -276,7 +273,6 @@ define(function(require){ help_index : 'hb', handler : function (env) { env.notebook.restart_kernel(); - env.notebook.focus_cell(); } }, 'undo-last-cell-deletion' : { @@ -381,7 +377,6 @@ define(function(require){ if(event){ event.preventDefault(); } - env.notebook.ensure_focused(); return false; } }, diff --git a/IPython/html/static/notebook/js/cell.js b/IPython/html/static/notebook/js/cell.js index dda60e3d69f..9912995fffc 100644 --- a/IPython/html/static/notebook/js/cell.js +++ b/IPython/html/static/notebook/js/cell.js @@ -73,14 +73,25 @@ define([ } }); + // backward compat. + Object.defineProperty(this, 'cm_config', { + get: function() { + return that._options.cm_config; + }, + set: function(value) { + that._options.cm_config = value; + } + }); + // load this from metadata later ? this.user_highlight = 'auto'; + var _local_cm_config = {}; if(this.class_config){ _local_cm_config = this.class_config.get_sync('cm_config'); } - this.cm_config = utils.mergeopt({}, config.cm_config, _local_cm_config); + config.cm_config = utils.mergeopt({}, config.cm_config, _local_cm_config); this.cell_id = utils.uuid(); this._options = config; diff --git a/IPython/html/static/notebook/js/codecell.js b/IPython/html/static/notebook/js/codecell.js index 909c5c73c32..dfff90f4d7e 100644 --- a/IPython/html/static/notebook/js/codecell.js +++ b/IPython/html/static/notebook/js/codecell.js @@ -125,7 +125,7 @@ define([ "Cmd-/" : "toggleComment", "Ctrl-/" : "toggleComment" }, - mode: 'ipython', + mode: 'text', theme: 'ipython', matchBrackets: true, autoCloseBrackets: true @@ -163,7 +163,7 @@ define([ notebook: this.notebook}); inner_cell.append(this.celltoolbar.element); var input_area = $('
').addClass('input_area'); - this.code_mirror = new CodeMirror(input_area.get(0), this.cm_config); + this.code_mirror = new CodeMirror(input_area.get(0), this._options.cm_config); // In case of bugs that put the keyboard manager into an inconsistent state, // ensure KM is enabled when CodeMirror is focused: this.code_mirror.on('focus', function () { diff --git a/IPython/html/static/notebook/js/kernelselector.js b/IPython/html/static/notebook/js/kernelselector.js index e25ed2bdfa2..8e99362506c 100644 --- a/IPython/html/static/notebook/js/kernelselector.js +++ b/IPython/html/static/notebook/js/kernelselector.js @@ -94,6 +94,7 @@ define([ KernelSelector.prototype._spec_changed = function (event, ks) { /** event handler for spec_changed */ + var that = this; // update selection this.current_selection = ks.name; @@ -157,6 +158,15 @@ define([ console.warn("Failed to load kernel.js from ", ks.resources['kernel.js'], err); } ); + this.events.on('spec_changed.Kernel', function (evt, new_ks) { + if (ks.name != new_ks.name) { + console.warn("kernelspec %s had custom kernel.js. Forcing page reload for %s.", + ks.name, new_ks.name); + that.notebook.save_notebook().then(function () { + window.location.reload(); + }); + } + }); } }; @@ -273,7 +283,7 @@ define([ KernelSelector.prototype.new_notebook = function (kernel_name) { - var w = window.open(undefined, IPython._target); + var w = window.open('', IPython._target); // Create a new notebook in the same path as the current // notebook's path. var that = this; diff --git a/IPython/html/static/notebook/js/maintoolbar.js b/IPython/html/static/notebook/js/maintoolbar.js index cced88c1f4a..c7e8a8175e3 100644 --- a/IPython/html/static/notebook/js/maintoolbar.js +++ b/IPython/html/static/notebook/js/maintoolbar.js @@ -25,7 +25,6 @@ define([ this.events = options.events; this.notebook = options.notebook; this._make(); - this.notebook.keyboard_manager.register_events(this.element); Object.seal(this); }; @@ -74,6 +73,7 @@ define([ .append($('