Commit 5be40cad authored by Douglas's avatar Douglas Committed by Ivan Tyagov

Jupyter Kernel: added automatic error rendering

@kirr, @Tyagov and @tatuya, please review.

Now the ERP5 Jupyter kernel automatically renders errors that happens in the
user-side code. Errors are captured during the AST tree creation (to be able to
detect syntax errors) and at execution time. The current transaction is
automatically aborted on error detection.

/reviewed-on nexedi/erp5!85
parent 5f59265a
...@@ -8,6 +8,9 @@ import sys ...@@ -8,6 +8,9 @@ import sys
import ast import ast
import types import types
import inspect import inspect
import traceback
import transaction
mime_type = 'text/plain' mime_type = 'text/plain'
# IPython expects 2 status message - 'ok', 'error' # IPython expects 2 status message - 'ok', 'error'
...@@ -79,7 +82,13 @@ def Base_compileJupyterCode(self, jupyter_code, old_local_variable_dict): ...@@ -79,7 +82,13 @@ def Base_compileJupyterCode(self, jupyter_code, old_local_variable_dict):
if jupyter_code: if jupyter_code:
# Create ast parse tree # Create ast parse tree
ast_node = ast.parse(jupyter_code) try:
ast_node = ast.parse(jupyter_code)
except Exception as e:
# It's not necessary to abort the current transaction here 'cause the
# user's code wasn't executed at all yet.
return getErrorMessageForException(self, e, local_variable_dict)
# Get the node list from the parsed tree # Get the node list from the parsed tree
nodelist = ast_node.body nodelist = ast_node.body
...@@ -116,13 +125,35 @@ def Base_compileJupyterCode(self, jupyter_code, old_local_variable_dict): ...@@ -116,13 +125,35 @@ def Base_compileJupyterCode(self, jupyter_code, old_local_variable_dict):
for node in to_run_exec: for node in to_run_exec:
mod = ast.Module([node]) mod = ast.Module([node])
code = compile(mod, '<string>', "exec") code = compile(mod, '<string>', "exec")
exec(code, g, g) try:
exec(code, g, g)
except Exception as e:
# Abort the current transaction. As a consequence, the notebook lines
# are not added if an exception occurs.
#
# TODO: store which notebook line generated which exception.
#
transaction.abort()
# Clear the portal cache from previous transaction
self.getPortalObject().portal_caches.clearAllCache()
return getErrorMessageForException(self, e, local_variable_dict)
# Execute the interactive nodes with 'single' mode # Execute the interactive nodes with 'single' mode
for node in to_run_interactive: for node in to_run_interactive:
mod = ast.Interactive([node]) mod = ast.Interactive([node])
code = compile(mod, '<string>', "single") code = compile(mod, '<string>', "single")
exec(code, g, g) try:
exec(code, g, g)
except Exception as e:
# Abort the current transaction. As a consequence, the notebook lines
# are not added if an exception occurs.
#
# TODO: store which notebook line generated which exception.
#
transaction.abort()
# Clear the portal cache from previous transaction
self.getPortalObject().portal_caches.clearAllCache()
return getErrorMessageForException(self, e, local_variable_dict)
# Letting the code fail in case of error while executing the python script/code # Letting the code fail in case of error while executing the python script/code
# XXX: Need to be refactored so to acclimitize transactions failure as well as # XXX: Need to be refactored so to acclimitize transactions failure as well as
...@@ -168,6 +199,24 @@ def Base_compileJupyterCode(self, jupyter_code, old_local_variable_dict): ...@@ -168,6 +199,24 @@ def Base_compileJupyterCode(self, jupyter_code, old_local_variable_dict):
return result return result
def getErrorMessageForException(self, exception, local_variable_dict):
'''
getErrorMessageForException receives an Expcetion object and a context for
code execution (local_variable_dict) and will return a dict as Jupyter
requires for error rendering.
'''
etype, value, tb = sys.exc_info()
traceback_text = traceback.format_exc().split('\n')[:-1]
return {
'status': 'error',
'result_string': None,
'local_variable_dict': local_variable_dict,
'mime_type': 'text/plain',
'evalue': str(value),
'ename': exception.__class__.__name__,
'traceback': traceback_text
}
def AddNewLocalVariableDict(self): def AddNewLocalVariableDict(self):
""" """
Function to add a new Local Variable for a Data Notebook Function to add a new Local Variable for a Data Notebook
...@@ -323,4 +372,4 @@ def getError(self, previous=1): ...@@ -323,4 +372,4 @@ def getError(self, previous=1):
evalue = unicode(error['value']) evalue = unicode(error['value'])
tb_list = [l+'\n' for l in error['tb_text'].split('\n')] tb_list = [l+'\n' for l in error['tb_text'].split('\n')]
return None return None
\ No newline at end of file
...@@ -46,12 +46,14 @@ ...@@ -46,12 +46,14 @@
<key> <string>text_content_warning_message</string> </key> <key> <string>text_content_warning_message</string> </key>
<value> <value>
<tuple> <tuple>
<string>W: 55, 2: Using the global statement (global-statement)</string> <string>W: 58, 2: Using the global statement (global-statement)</string>
<string>W: 95, 8: Use of exec (exec-used)</string> <string>W:104, 8: Use of exec (exec-used)</string>
<string>W:119, 8: Use of exec (exec-used)</string> <string>W:129, 10: Use of exec (exec-used)</string>
<string>W:125, 8: Use of exec (exec-used)</string> <string>W:146, 10: Use of exec (exec-used)</string>
<string>W:220, 4: Using the global statement (global-statement)</string> <string>W:208, 2: Unused variable \'etype\' (unused-variable)</string>
<string>W:320, 2: Using the global statement (global-statement)</string> <string>W:208, 16: Unused variable \'tb\' (unused-variable)</string>
<string>W:269, 4: Using the global statement (global-statement)</string>
<string>W:369, 2: Using the global statement (global-statement)</string>
</tuple> </tuple>
</value> </value>
</item> </item>
......
<?xml version="1.0"?>
<ZopeData>
<record id="1" aka="AAAAAAAAAAE=">
<pickle>
<global name="ExternalMethod" module="Products.ExternalMethod.ExternalMethod"/>
</pickle>
<pickle>
<dictionary>
<item>
<key> <string>_function</string> </key>
<value> <string>renderAsHtml</string> </value>
</item>
<item>
<key> <string>_module</string> </key>
<value> <string>JupyterCompile</string> </value>
</item>
<item>
<key> <string>id</string> </key>
<value> <string>Base_renderAsHtml</string> </value>
</item>
<item>
<key> <string>title</string> </key>
<value> <string></string> </value>
</item>
</dictionary>
</pickle>
</record>
</ZopeData>
...@@ -32,7 +32,7 @@ from Products.ERP5Type.tests.utils import createZODBPythonScript, removeZODBPyth ...@@ -32,7 +32,7 @@ from Products.ERP5Type.tests.utils import createZODBPythonScript, removeZODBPyth
import time import time
import json import json
import base64 import base64
import transaction
class TestExecuteJupyter(ERP5TypeTestCase): class TestExecuteJupyter(ERP5TypeTestCase):
...@@ -100,21 +100,25 @@ print an_undefined_variable ...@@ -100,21 +100,25 @@ print an_undefined_variable
portal = context.getPortalObject() portal = context.getPortalObject()
portal.%s() portal.%s()
"""%script_id """%script_id
# Make call to Base_runJupyter to run the jupyter code which is making # Make call to Base_runJupyter to run the jupyter code which is making
# a call to the newly created ZODB python_script and assert if the call raises # a call to the newly created ZODB python_script and assert if the call
# NameError as we are sending an invalid python_code to it # processes correctly the NameError as we are sending an invalid
self.assertRaises( # python_code to it.
NameError, #
portal.Base_runJupyter, result = portal.Base_runJupyter(
jupyter_code=jupyter_code, jupyter_code=jupyter_code,
old_local_variable_dict=portal.Base_addLocalVariableDict() old_local_variable_dict=portal.Base_addLocalVariableDict()
) )
# Abort the current transaction of test so that we can proceed to new one
transaction.abort() self.assertEquals(result['ename'], 'NameError')
# Clear the portal cache from previous transaction self.assertEquals(result['result_string'], None)
self.portal.portal_caches.clearAllCache()
# Remove the ZODB python script created above # There's no need to abort the current transaction. The error handling code
# should be responsible for this, so we check the script's title
script_title = script_container.JupyterCompile_errorResult.getTitle()
self.assertNotEqual(script_title, new_test_title)
removeZODBPythonScript(script_container, script_id) removeZODBPythonScript(script_container, script_id)
# Test that calling Base_runJupyter shouldn't change the context Title # Test that calling Base_runJupyter shouldn't change the context Title
...@@ -248,13 +252,14 @@ portal.%s() ...@@ -248,13 +252,14 @@ portal.%s()
reference = 'Test.Notebook.ExecuteJupyterErrorHandling %s' % time.time() reference = 'Test.Notebook.ExecuteJupyterErrorHandling %s' % time.time()
title = 'Test NB Title %s' % time.time() title = 'Test NB Title %s' % time.time()
self.assertRaises( result = json.loads(portal.Base_executeJupyter(
NameError, title=title,
portal.Base_executeJupyter, reference=reference,
title=title, python_expression=python_expression
reference=reference, ))
python_expression=python_expression
) self.assertEquals(result['ename'], 'NameError')
self.assertEquals(result['code_result'], None)
def testBaseExecuteJupyterSaveActiveResult(self): def testBaseExecuteJupyterSaveActiveResult(self):
""" """
...@@ -429,4 +434,4 @@ context.Base_displayImage(image_object=image) ...@@ -429,4 +434,4 @@ context.Base_displayImage(image_object=image)
reference=reference, reference=reference,
python_expression=jupyter_code2 python_expression=jupyter_code2
) )
self.assertEquals(json.loads(result)['code_result'].rstrip(), 'sys') self.assertEquals(json.loads(result)['code_result'].rstrip(), 'sys')
\ No newline at end of file
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment