Repository: ipython/ipython

Issue:
Notebook save failure
I can't save my notebook on master branch, if it contains this cell:

``` Python
from IPython.display import Latex
display(Latex([1]))
```

I know Latex() should not be called like this, still this probably should not lead to a failure in saving the notebook.



Discussion:
Here's the error trying to save this:

```
2013-11-20 11:21:23.228 [NotebookApp] ERROR | Unhandled error in API request
Traceback (most recent call last):
  File "/home/takluyver/.local/lib/python3.3/site-packages/IPython/html/base/handlers.py", line 276, in wrapper
    result = method(self, *args, **kwargs)
  File "/home/takluyver/.local/lib/python3.3/site-packages/IPython/html/services/notebooks/handlers.py", line 197, in put
    self._save_notebook(model, path, name)
  File "/home/takluyver/.local/lib/python3.3/site-packages/IPython/html/services/notebooks/handlers.py", line 133, in _save_notebook
    model = self.notebook_manager.save_notebook_model(model, name, path)
  File "/home/takluyver/.local/lib/python3.3/site-packages/IPython/html/services/notebooks/filenbmanager.py", line 238, in save_notebook_model
    nb = current.to_notebook_json(model['content'])
  File "/home/takluyver/.local/lib/python3.3/site-packages/IPython/nbformat/v3/nbjson.py", line 49, in to_notebook
    return rejoin_lines(from_dict(d))
  File "/home/takluyver/.local/lib/python3.3/site-packages/IPython/nbformat/v3/rwbase.py", line 85, in rejoin_lines
    output[key] = _join_lines(item)
  File "/home/takluyver/.local/lib/python3.3/site-packages/IPython/nbformat/v3/rwbase.py", line 58, in _join_lines
    if lines and lines[0].endswith(('\n', '\r')):
AttributeError: 'int' object has no attribute 'endswith'
```

Either the classes, or the display protocol, or both, should probably be rejecting the data being provided as something that's not a string.

@minrk is going to add checks in various places


Resolving patch:
diff --git a/IPython/core/display.py b/IPython/core/display.py
--- a/IPython/core/display.py
+++ b/IPython/core/display.py
@@ -304,6 +304,11 @@ def __init__(self, data=None, url=None, filename=None):
         self.filename = None if filename is None else unicode_type(filename)
 
         self.reload()
+        self._check_data()
+    
+    def _check_data(self):
+        """Override in subclasses if there's something to check."""
+        pass
 
     def reload(self):
         """Reload the raw data from file or URL."""
@@ -331,13 +336,19 @@ def reload(self):
             except:
                 self.data = None
 
-class Pretty(DisplayObject):
+class TextDisplayObject(DisplayObject):
+    """Validate that display data is text"""
+    def _check_data(self):
+        if self.data is not None and not isinstance(self.data, string_types):
+            raise TypeError("%s expects text, not %r" % (self.__class__.__name__, self.data))
+
+class Pretty(TextDisplayObject):
 
     def _repr_pretty_(self):
         return self.data
 
 
-class HTML(DisplayObject):
+class HTML(TextDisplayObject):
 
     def _repr_html_(self):
         return self.data
@@ -351,14 +362,14 @@ def __html__(self):
         return self._repr_html_()
 
 
-class Math(DisplayObject):
+class Math(TextDisplayObject):
 
     def _repr_latex_(self):
         s = self.data.strip('$')
         return "$$%s$$" % s
 
 
-class Latex(DisplayObject):
+class Latex(TextDisplayObject):
 
     def _repr_latex_(self):
         return self.data
@@ -398,7 +409,7 @@ def _repr_svg_(self):
         return self.data
 
 
-class JSON(DisplayObject):
+class JSON(TextDisplayObject):
 
     def _repr_json_(self):
         return self.data
@@ -415,7 +426,7 @@ def _repr_json_(self):
 lib_t2 = """});
 """
 
-class Javascript(DisplayObject):
+class Javascript(TextDisplayObject):
 
     def __init__(self, data=None, url=None, filename=None, lib=None, css=None):
         """Create a Javascript display object given raw data.
diff --git a/IPython/core/formatters.py b/IPython/core/formatters.py
--- a/IPython/core/formatters.py
+++ b/IPython/core/formatters.py
@@ -33,12 +33,13 @@
 # Our own imports
 from IPython.config.configurable import Configurable
 from IPython.lib import pretty
+from IPython.utils import io
 from IPython.utils.traitlets import (
     Bool, Dict, Integer, Unicode, CUnicode, ObjectName, List,
 )
 from IPython.utils.warn import warn
 from IPython.utils.py3compat import (
-    unicode_to_str, with_metaclass, PY3, string_types,
+    unicode_to_str, with_metaclass, PY3, string_types, unicode_type,
 )
 
 if PY3:
@@ -182,15 +183,22 @@ def format_types(self):
 # Formatters for specific format types (text, html, svg, etc.)
 #-----------------------------------------------------------------------------
 
-
 @decorator
 def warn_format_error(method, self, *args, **kwargs):
     """decorator for warning on failed format call"""
     try:
-        return method(self, *args, **kwargs)
+        r = method(self, *args, **kwargs)
     except Exception as e:
         warn("Exception in %s formatter: %s" % (self.format_type, e))
         return None
+    if r is None or isinstance(r, self._return_type) or \
+        (isinstance(r, tuple) and r and isinstance(r[0], self._return_type)):
+        return r
+    else:
+        warn("%s formatter returned invalid type %s (expected %s) for object: %s" % (
+            self.format_type, type(r), self._return_type, pretty._safe_repr(args[0])
+        ))
+    
 
 
 class FormatterABC(with_metaclass(abc.ABCMeta, object)):
@@ -262,6 +270,7 @@ class BaseFormatter(Configurable):
     """
 
     format_type = Unicode('text/plain')
+    _return_type = string_types
 
     enabled = Bool(True, config=True)
 
@@ -278,7 +287,7 @@ class BaseFormatter(Configurable):
     # The deferred-import type-specific printers.
     # Map (modulename, classname) pairs to the format functions.
     deferred_printers = Dict(config=True)
-
+    
     @warn_format_error
     def __call__(self, obj):
         """Compute the format for an object."""
@@ -679,6 +688,8 @@ class PNGFormatter(BaseFormatter):
     format_type = Unicode('image/png')
 
     print_method = ObjectName('_repr_png_')
+    
+    _return_type = (bytes, unicode_type)
 
 
 class JPEGFormatter(BaseFormatter):
@@ -696,6 +707,8 @@ class JPEGFormatter(BaseFormatter):
 
     print_method = ObjectName('_repr_jpeg_')
 
+    _return_type = (bytes, unicode_type)
+
 
 class LatexFormatter(BaseFormatter):
     """A LaTeX formatter.


Test patch:
diff --git a/IPython/html/tests/casperjs/test_cases/nb_roundtrip.js b/IPython/html/tests/casperjs/test_cases/nb_roundtrip.js
--- a/IPython/html/tests/casperjs/test_cases/nb_roundtrip.js
+++ b/IPython/html/tests/casperjs/test_cases/nb_roundtrip.js
@@ -15,8 +15,8 @@ mime =  {
         "javascript" : "application/javascript",
     };
     
-var black_dot_jpeg="\"\"\"/9j/4AAQSkZJRgABAQEASABIAAD/2wBDACodICUgGiolIiUvLSoyP2lEPzo6P4FcYUxpmYagnpaG\nk5GovfLNqLPltZGT0v/V5fr/////o8v///////L/////2wBDAS0vLz83P3xERHz/rpOu////////\n////////////////////////////////////////////////////////////wgARCAABAAEDAREA\nAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAABP/EABQBAQAAAAAAAAAAAAAAAAAAAAD/2gAMAwEA\nAhADEAAAARn/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/9oACAEBAAEFAn//xAAUEQEAAAAAAAAAAAAA\nAAAAAAAA/9oACAEDAQE/AX//xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oACAECAQE/AX//xAAUEAEA\nAAAAAAAAAAAAAAAAAAAA/9oACAEBAAY/An//xAAUEAEAAAAAAAAAAAAAAAAAAAAA/9oACAEBAAE/\nIX//2gAMAwEAAgADAAAAEB//xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oACAEDAQE/EH//xAAUEQEA\nAAAAAAAAAAAAAAAAAAAA/9oACAECAQE/EH//xAAUEAEAAAAAAAAAAAAAAAAAAAAA/9oACAEBAAE/\nEH//2Q==\"\"\"";
-var black_dot_png = '\"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEUAAACnej3aAAAAAWJLR0QA\\niAUdSAAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB94BCRQnOqNu0b4AAAAKSURBVAjXY2AA\\nAAACAAHiIbwzAAAAAElFTkSuQmCC\"';
+var black_dot_jpeg="u\"\"\"/9j/4AAQSkZJRgABAQEASABIAAD/2wBDACodICUgGiolIiUvLSoyP2lEPzo6P4FcYUxpmYagnpaG\nk5GovfLNqLPltZGT0v/V5fr/////o8v///////L/////2wBDAS0vLz83P3xERHz/rpOu////////\n////////////////////////////////////////////////////////////wgARCAABAAEDAREA\nAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAABP/EABQBAQAAAAAAAAAAAAAAAAAAAAD/2gAMAwEA\nAhADEAAAARn/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/9oACAEBAAEFAn//xAAUEQEAAAAAAAAAAAAA\nAAAAAAAA/9oACAEDAQE/AX//xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oACAECAQE/AX//xAAUEAEA\nAAAAAAAAAAAAAAAAAAAA/9oACAEBAAY/An//xAAUEAEAAAAAAAAAAAAAAAAAAAAA/9oACAEBAAE/\nIX//2gAMAwEAAgADAAAAEB//xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oACAEDAQE/EH//xAAUEQEA\nAAAAAAAAAAAAAAAAAAAA/9oACAECAQE/EH//xAAUEAEAAAAAAAAAAAAAAAAAAAAA/9oACAEBAAE/\nEH//2Q==\"\"\"";
+var black_dot_png = 'u\"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEUAAACnej3aAAAAAWJLR0QA\\niAUdSAAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB94BCRQnOqNu0b4AAAAKSURBVAjXY2AA\\nAAACAAHiIbwzAAAAAElFTkSuQmCC\"';
 var svg = "\"<svg width='1cm' height='1cm' viewBox='0 0 1000 500'><defs><style>rect {fill:red;}; </style></defs><rect id='r1' x='200' y='100' width='600' height='300' /></svg>\"";
 
 // helper function to ensure that the short_name is found in the toJSON
diff --git a/IPython/html/tests/casperjs/test_cases/safe_append_output.js b/IPython/html/tests/casperjs/test_cases/safe_append_output.js
new file mode 100644
--- /dev/null
+++ b/IPython/html/tests/casperjs/test_cases/safe_append_output.js
@@ -0,0 +1,32 @@
+//
+// Test validation in append_output
+//
+// Invalid output data is stripped and logged.
+//
+
+casper.notebook_test(function () {
+    // this.printLog();
+    var messages = [];
+    this.on('remote.message', function (msg) {
+        messages.push(msg);
+    });
+    
+    this.evaluate(function () {
+        var cell = IPython.notebook.get_cell(0);
+        cell.set_text( "dp = get_ipython().display_pub\n" +
+                       "dp.publish('test', {'text/plain' : '5', 'image/png' : 5})"
+        );
+        cell.execute();
+    });
+
+    this.wait_for_output(0);
+    this.on('remote.message', function () {});
+
+    this.then(function () {
+        var output = this.get_output_cell(0);
+        this.test.assert(messages.length > 0, "Captured log message");
+        this.test.assertEquals(messages[messages.length-1], "Invalid type for image/png 5", "Logged Invalid type message");
+        this.test.assertEquals(output['image/png'], undefined, "Non-string png data was stripped");
+        this.test.assertEquals(output['text/plain'], '5', "text data is fine");
+    });
+});