ERP5kernel.py.jinja 16.2 KB
Newer Older
Ayush Tiwari's avatar
Ayush Tiwari committed
1 2
#!{{ python_executable }}

3 4
from ipykernel.kernelbase import Kernel
from ipykernel.kernelapp import IPKernelApp
Ayush Tiwari's avatar
Ayush Tiwari committed
5 6 7 8 9
from IPython.core.display import HTML
import requests
import json

# erp5_url from buildout
10 11 12 13 14
erp5_url = "{{ erp5_url }}"
if not erp5_url:
    erp5_url = None
else:
    erp5_url = "%s/erp5/Base_executeJupyter" % erp5_url
Ayush Tiwari's avatar
Ayush Tiwari committed
15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46

class MagicInfo:
  """
  Magics definition structure.
  Initializes a new MagicInfo class with specific paramters to identify a magic.
  """
  def __init__(self, magic_name, variable_name, send_request, request_reference, display_message):
    self.magic_name = magic_name
    self.variable_name = variable_name
    self.send_request = send_request
    self.request_reference = request_reference
    self.display_message = display_message

# XXX: New magics to be added here in the dictionary.
# In this dictionary,
# key = magic_name,
# value = MagicInfo Structure corresponding to the magics
# Different parameters of the structures are :-
# magics_name(str) = Name which would be used on jupyter frontend
# variable_name(str) = Name of variable on which magic would be set in kernel
# send_request(boolean) = Magics for which requests to erp5 backend need to be made
# request_reference(boolean) = Request for notebook references(and titles) from erp5
# display_message(boolean) = If the magics need to display message after
#                             making request. Useful for magics which do get some
#                             useful content from erp5 backend and need to display

MAGICS = {
  'erp5_user': MagicInfo('erp5_user', 'user', True, False, True),
  'erp5_password': MagicInfo('erp5_password', 'password', True, False, True),
  'erp5_url': MagicInfo('erp5_url', 'url', True, False, True),
  'notebook_set_reference': MagicInfo('notebook_set_reference', 'reference', True, False, True),
  'notebook_set_title': MagicInfo('notebook_set_title', 'title', False, False, True),
47
  'my_notebooks': MagicInfo('my_notebooks', '', True, True, False)}
Ayush Tiwari's avatar
Ayush Tiwari committed
48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70

class ERP5Kernel(Kernel):
  """
  Jupyter Kernel class to interact with erp5 backend for code from frontend.
  To use this kernel with erp5, user need to install 'erp5_data_notebook' bt5 
  Also, handlers(aka magics) starting with '%' are predefined.

  Each request to erp5 for code execution requires erp5_user, erp5_password
  and reference of the notebook.
  """

  implementation = 'ERP5'
  implementation_version = '1.0'
  language = 'ERP5'
  language_version = '0.1'
  language_info = {'mimetype': 'text/plain', 'name':'python'}
  banner = "ERP5 integration with ipython notebook"

  def __init__(self, user=None, password=None, url=None, status_code=None,
              *args, **kwargs):
    super(ERP5Kernel, self).__init__(*args, **kwargs)
    self.user = user
    self.password = password
71
    # By default use URL provided by buildout during initiation
Ayush Tiwari's avatar
Ayush Tiwari committed
72
    # It can later be overridden
73 74 75 76
    if url is None:
        self.url = erp5_url
    else:
        self.url = url
Ayush Tiwari's avatar
Ayush Tiwari committed
77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108
    self.status_code = status_code
    self.reference = None
    self.title = None
    # Allowed HTTP request code list for making request to erp5 from Kernel
    # This list should be to used check status_code before making requests to erp5
    self.allowed_HTTP_request_code_list = range(500, 511)
    # Append request code 200 in the allowed HTTP status code list
    self.allowed_HTTP_request_code_list.append(200)

  def display_response(self, response=None):
    """
      Dispays the stream message response to jupyter frontend.
    """
    if response:
      stream_content = {'name': 'stdout', 'text': response}
      self.send_response(self.iopub_socket, 'stream', stream_content)

  def set_magic_attribute(self, magic_info=None, code=None):
    """
      Set attribute for magic which are necessary for making requests to erp5.
      Catch errors and display message. Since user is in contact with jupyter
      frontend, so its better to catch exceptions and dispaly messages than to
      let them fail in backend and stuck the kernel.
      For a making a request to erp5, we need -
      erp5_url, erp5_user, erp5_password, notebook_set_reference
    """
    # Set attributes only for magic who do have any varible to set value to
    if magic_info.variable_name:
      try:
        # Get the magic value recived via code from frontend
        magic_value = code.split()[1]
        # Set magic_value to the required attribute
109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131
        
        if magic_info.magic_name == 'notebook_set_reference':
          required_attributes = ['url', 'password', 'user']
          missing_attributes = []
          for attribute in required_attributes:
            if not getattr(self, attribute):
              missing_attributes.append(attribute)
          
          if missing_attributes != []:
            self.response = "You still haven't entered all required magics. \
Please do so before inputting your reference."
          else:
            if self.check_existing_reference(reference=magic_value):
              self.response = 'WARNING: You already have a notebook with \
reference %s. It might be a good idea to use different references for new \
notebooks. \n' % magic_value
            else:
              self.response = ''
            setattr(self, magic_info.variable_name , magic_value)
            self.response = self.response + 'Your %s is %s. '%(magic_info.magic_name, magic_value)
        elif magic_info.magic_name != 'erp5_password':
          setattr(self, magic_info.variable_name , magic_value)
          self.response = 'Your %s is %s. '%(magic_info.magic_name, magic_value)
132
        else:
133 134
          setattr(self, magic_info.variable_name , magic_value)
          self.response = ""
Ayush Tiwari's avatar
Ayush Tiwari committed
135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152

      # Catch exception while setting attribute and set message in response
      except AttributeError:
        self.response = 'Please enter %s magic value'%magic_info.variable_name

      # Catch IndexError while getting magic_value and set message in response object
      except IndexError:
        self.response = 'Empty value for %s magic'%magic_info.variable_name

      # Catch all other exceptions and set error_message in response object
      # XXX: Might not be best way, but its better to display error to the user
      # via notebook frontend than to fail in backend and stuck the Kernel without
      # any failure message to user.
      except Exception as e:
        self.response = str(e)

      # Display the message/response from this fucntion before moving forward so
      # as to keep track of the status
153 154
      if self.response != "":
        self.display_response(response=(self.response + '\n'))
Ayush Tiwari's avatar
Ayush Tiwari committed
155 156 157 158 159 160 161 162 163 164 165

  def check_required_attributes(self):
    """
      Check if the required attributes for making a request are already set or not.
      Display message to frontend to provide with the values in case they aren't.
      This function can be called anytime to check if the attributes are set. The
      output result will be in Boolean form.
      Also, in case any of attribute is not set, call to display_response would be
      made to ask user to enter value.
    """
    result_list = []
166 167
    required_attributes  = ['url', 'user', 'password', 'reference']
    missing_attributes = []
Ayush Tiwari's avatar
Ayush Tiwari committed
168 169 170 171

    # Set response to empty so as to flush the response set by some earlier fucntion call
    self.response = ''

172
    # Loop to check if the required attributes are set
Ayush Tiwari's avatar
Ayush Tiwari committed
173 174 175 176 177
    for attribute in required_attributes:
      if getattr(self, attribute):
        result_list.append(True)
      else:
        # Set response/message for attributes which aren't set
178
        missing_attributes.append(attribute)
Ayush Tiwari's avatar
Ayush Tiwari committed
179
        result_list.append(False)
180
    
Ayush Tiwari's avatar
Ayush Tiwari committed
181 182
    # Compare result_list to get True for all True results and False for any False result 
    check_attributes = all(result_list)
183 184 185 186
    
    if check_attributes:
      self.response = 'You have entered all required magics. You may now use your notebook.'
    else:
187 188
      self.response = '''You have these required magics remaining: %s. \n''' % (
      ', '.join(map(str, missing_attributes)))
Ayush Tiwari's avatar
Ayush Tiwari committed
189 190

    # Display response to frontend before moving forward
191
    self.display_response(response=(self.response + '\n'))
Ayush Tiwari's avatar
Ayush Tiwari committed
192 193 194 195 196 197 198 199 200 201 202

    return check_attributes

  def make_erp5_request(self, request_reference=False, display_message=True,
                        code=None, message=None, title=None, *args, **kwargs):
    """
      Function to make request to erp5 as per the magics.
      Should return the response json object.
    """

    try:
203
      erp5_request = requests.post(
Ayush Tiwari's avatar
Ayush Tiwari committed
204 205 206
        self.url,
        verify=False,
        auth=(self.user, self.password),
207
        data={
Ayush Tiwari's avatar
Ayush Tiwari committed
208 209 210 211
          'python_expression': code,
          'reference': self.reference,
          'title': self.title,
          'request_reference': request_reference,
212
	  'store_history': kwargs.get('store_history')
213
          })
Ayush Tiwari's avatar
Ayush Tiwari committed
214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249

      # Set value for status_code for self object which would later be used to
      # dispaly response after statement check
      self.status_code = erp5_request.status_code

      # Dispaly error response in case the request give any other status
      # except 200 and 5xx(which is for errors on server side)
      if self.status_code not in self.allowed_HTTP_request_code_list:
        self.response = '''Error code %s on request to ERP5,\n
        check credentials or ERP5 family URL'''%self.status_code
      else:
        # Set value of self.response to the given value in case response from function
        # call. In all other case, response should be the content from request
        if display_message and message:
          self.response = message
        else:
          self.response = erp5_request.content

    except requests.exceptions.RequestException as e:
      self.response = str(e)

  def do_execute(self, code, silent, store_history=True, user_expressions=None,
                  allow_stdin=False):
    """
      Validate magic and call functions to make request to erp5 backend where
      the code is being executed and response is sent back which is then sent
      to jupyter frontend.
    """
    # By default, take the status of response as 'ok' so as show the responses
    # for erp5_url and erp5_user on notebook frontend as successful response.
    status = 'ok'

    if not silent:
      # Remove spaces and newlines from both ends of code
      code = code.strip()

250
      extra_data_list = []
251 252
      print_result = {}
      displayhook_result = {}
253

Ayush Tiwari's avatar
Ayush Tiwari committed
254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272
      if code.startswith('%'):
          # No need to try-catch here as its already been taken that the code
          # starts-with '%', so we'll get magic_name, no matter what be after '%'
          magic_name = code.split()[0][1:]
          magics_name_list = [magic.magic_name for magic in MAGICS.values()]

          # Check validation of magic
          if magic_name and magic_name in magics_name_list:

            # Get MagicInfo object related to the magic
            magic_info = MAGICS.get(magic_name)

            # Function call to set the required magics
            self.set_magic_attribute(magic_info=magic_info, code=code)

            # Call to check if the required_attributes are set
            checked_attribute = self.check_required_attributes()
            if checked_attribute and magic_info.send_request:
              # Call the function to send request to erp5 with the arguments given
273
              self.make_erp5_request(message='Please proceed\n',
Ayush Tiwari's avatar
Ayush Tiwari committed
274 275 276 277 278 279 280
              request_reference=magic_info.request_reference,
              display_message=magic_info.display_message)

              # Display response from erp5 request for magic
              # Since this response would be either success message or failure
              # error message, both of which are string type, so, we can simply
              # display the stream response.
281 282
              if self.response != 'Please proceed\n':
                self.display_response(response=self.response)
Ayush Tiwari's avatar
Ayush Tiwari committed
283 284 285 286 287 288 289 290 291 292

          else:
            # Set response if there is no magic or the magic name is not in MAGICS
            self.response = 'Invalid Magics'
            self.display_response(response=self.response)

      else:
        # Check for status_code before making request to erp5 and make request in
        # only if the status_code is in the allowed_HTTP_request_code_list
        if self.status_code in self.allowed_HTTP_request_code_list:
293
          self.make_erp5_request(code=code, store_history=store_history)
Ayush Tiwari's avatar
Ayush Tiwari committed
294 295 296 297 298 299 300

          # For 200 status_code, Kernel will receive predefined format for data
          # from erp5 which is either json of result or simple result string
          if self.status_code == 200:
            mime_type = 'text/plain'
            try:
              content = json.loads(self.response)
301 302 303 304 305 306 307 308

              # Example format for the json result we are expecting is :
              # content = {
              #            "status": "ok",
              #            "ename": null,
              #            "evalue": null,
              #            "traceback": null,
              #            "code_result": "",
309 310 311 312
              #            "print_result": {},
              #            "displayhook_result": {},
              #            "mime_type": "text/plain",
              #            "extra_data_list": []
313 314 315 316
              #            }
              # So, we can easily use any of the key to update values as such.

              # Getting code_result for succesfull execution of code
Ayush Tiwari's avatar
Ayush Tiwari committed
317
              code_result = content['code_result']
318 319
              print_result = content['print_result']
              displayhook_result = content['displayhook_result']
320 321 322 323 324

              # Update mime_type with the mime_type from the http response result
              # Required in case the mime_type is anything other than 'text/plain'
              mime_type = content['mime_type']

325 326
              extra_data_list = content.get('extra_data_list', [])

Ayush Tiwari's avatar
Ayush Tiwari committed
327 328 329 330 331 332 333 334 335 336 337 338 339 340
            # Display to frontend the error message for content status as 'error'
              if content['status']=='error':
                reply_content = {
                  'status': 'error',
                  'execution_count': self.execution_count,
                  'ename': content['ename'],
                  'evalue': content['evalue'],
                  'traceback': content['traceback']}
                self.send_response(self.iopub_socket, u'error', reply_content)
                return reply_content
            # Catch exception for content which isn't json
            except ValueError:
              content = self.response
              code_result = content
341
              print_result = {'data':{'text/plain':content}, 'metadata':{}}
Ayush Tiwari's avatar
Ayush Tiwari committed
342 343 344 345
          # Display basic error message to frontend in case of error on server side
          else:
            self.make_erp5_request(code=code)
            code_result = "Error at Server Side"
346
            print_result = {'data':{'text/plain':'Error at Server Side'}, 'metadata':{}}
Ayush Tiwari's avatar
Ayush Tiwari committed
347 348 349 350 351
            mime_type = 'text/plain'

        # For all status_code except allowed_HTTP_response_code_list show unauthorized message
        else:
          code_result = 'Unauthorized access'
352
          print_result = {'data':{'text/plain':'Unauthorized access'}, 'metadata':{}}
Ayush Tiwari's avatar
Ayush Tiwari committed
353 354
          mime_type = 'text/plain'

355 356 357 358 359 360
        if print_result.get('data'):
          self.send_response(self.iopub_socket, 'display_data', print_result)

        if displayhook_result.get('data'):
          displayhook_result['execution_count'] = self.execution_count
          self.send_response(self.iopub_socket, 'execute_result', displayhook_result)
Ayush Tiwari's avatar
Ayush Tiwari committed
361

362 363 364
        for extra_data in extra_data_list:
          self.send_response(self.iopub_socket, 'display_data', extra_data)

Ayush Tiwari's avatar
Ayush Tiwari committed
365 366 367 368 369
    reply_content = {
      'status': status,
      # The base class increments the execution count
      'execution_count': self.execution_count,
      'payload': [],
370 371
      'user_expressions': {}}

Ayush Tiwari's avatar
Ayush Tiwari committed
372
    return reply_content
373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397
  
  # Checks the ERP5 site if there are existing notebooks with the same reference.
  # Returns True if there are.
  def check_existing_reference(self, reference):
    if reference == None:
      return False
    
    modified_url = self.url[:self.url.rfind('/')] + '/Base_checkExistingReference'
    result = True
    
    try:
      erp5_request = requests.post(
        modified_url,
        verify=False,
        auth=(self.user, self.password),
        data={
          'reference': reference,
          })
      result = erp5_request.content
      
    except requests.exceptions.RequestException as e:
      self.response = str(e)
      
    return result

Ayush Tiwari's avatar
Ayush Tiwari committed
398 399 400

if __name__ == '__main__':
  IPKernelApp.launch_instance(kernel_class=ERP5Kernel)