1"""Adapters for Jupyter msg spec versions."""
2
3# Copyright (c) Jupyter Development Team.
4# Distributed under the terms of the Modified BSD License.
5
6import re
7import json
8
9from jupyter_client import protocol_version_info
10
11def code_to_line(code, cursor_pos):
12    """Turn a multiline code block and cursor position into a single line
13    and new cursor position.
14
15    For adapting ``complete_`` and ``object_info_request``.
16    """
17    if not code:
18        return "", 0
19    for line in code.splitlines(True):
20        n = len(line)
21        if cursor_pos > n:
22            cursor_pos -= n
23        else:
24            break
25    return line, cursor_pos
26
27
28_match_bracket = re.compile(r'\([^\(\)]+\)', re.UNICODE)
29_end_bracket = re.compile(r'\([^\(]*$', re.UNICODE)
30_identifier = re.compile(r'[a-z_][0-9a-z._]*', re.I|re.UNICODE)
31
32def extract_oname_v4(code, cursor_pos):
33    """Reimplement token-finding logic from IPython 2.x javascript
34
35    for adapting object_info_request from v5 to v4
36    """
37
38    line, _ = code_to_line(code, cursor_pos)
39
40    oldline = line
41    line = _match_bracket.sub('', line)
42    while oldline != line:
43        oldline = line
44        line = _match_bracket.sub('', line)
45
46    # remove everything after last open bracket
47    line = _end_bracket.sub('', line)
48    matches = _identifier.findall(line)
49    if matches:
50        return matches[-1]
51    else:
52        return ''
53
54
55class Adapter(object):
56    """Base class for adapting messages
57
58    Override message_type(msg) methods to create adapters.
59    """
60
61    msg_type_map = {}
62
63    def update_header(self, msg):
64        return msg
65
66    def update_metadata(self, msg):
67        return msg
68
69    def update_msg_type(self, msg):
70        header = msg['header']
71        msg_type = header['msg_type']
72        if msg_type in self.msg_type_map:
73            msg['msg_type'] = header['msg_type'] = self.msg_type_map[msg_type]
74        return msg
75
76    def handle_reply_status_error(self, msg):
77        """This will be called *instead of* the regular handler
78
79        on any reply with status != ok
80        """
81        return msg
82
83    def __call__(self, msg):
84        msg = self.update_header(msg)
85        msg = self.update_metadata(msg)
86        msg = self.update_msg_type(msg)
87        header = msg['header']
88
89        handler = getattr(self, header['msg_type'], None)
90        if handler is None:
91            return msg
92
93        # handle status=error replies separately (no change, at present)
94        if msg['content'].get('status', None) in {'error', 'aborted'}:
95            return self.handle_reply_status_error(msg)
96        return handler(msg)
97
98def _version_str_to_list(version):
99    """convert a version string to a list of ints
100
101    non-int segments are excluded
102    """
103    v = []
104    for part in version.split('.'):
105        try:
106            v.append(int(part))
107        except ValueError:
108            pass
109    return v
110
111class V5toV4(Adapter):
112    """Adapt msg protocol v5 to v4"""
113
114    version = '4.1'
115
116    msg_type_map = {
117        'execute_result' : 'pyout',
118        'execute_input' : 'pyin',
119        'error' : 'pyerr',
120        'inspect_request' : 'object_info_request',
121        'inspect_reply' : 'object_info_reply',
122    }
123
124    def update_header(self, msg):
125        msg['header'].pop('version', None)
126        msg['parent_header'].pop('version', None)
127        return msg
128
129    # shell channel
130
131    def kernel_info_reply(self, msg):
132        v4c = {}
133        content = msg['content']
134        for key in ('language_version', 'protocol_version'):
135            if key in content:
136                v4c[key] = _version_str_to_list(content[key])
137        if content.get('implementation', '') == 'ipython' \
138            and 'implementation_version' in content:
139            v4c['ipython_version'] = _version_str_to_list(content['implementation_version'])
140        language_info = content.get('language_info', {})
141        language = language_info.get('name', '')
142        v4c.setdefault('language', language)
143        if 'version' in language_info:
144            v4c.setdefault('language_version', _version_str_to_list(language_info['version']))
145        msg['content'] = v4c
146        return msg
147
148    def execute_request(self, msg):
149        content = msg['content']
150        content.setdefault('user_variables', [])
151        return msg
152
153    def execute_reply(self, msg):
154        content = msg['content']
155        content.setdefault('user_variables', {})
156        # TODO: handle payloads
157        return msg
158
159    def complete_request(self, msg):
160        content = msg['content']
161        code = content['code']
162        cursor_pos = content['cursor_pos']
163        line, cursor_pos = code_to_line(code, cursor_pos)
164
165        new_content = msg['content'] = {}
166        new_content['text'] = ''
167        new_content['line'] = line
168        new_content['block'] = None
169        new_content['cursor_pos'] = cursor_pos
170        return msg
171
172    def complete_reply(self, msg):
173        content = msg['content']
174        cursor_start = content.pop('cursor_start')
175        cursor_end = content.pop('cursor_end')
176        match_len = cursor_end - cursor_start
177        content['matched_text'] = content['matches'][0][:match_len]
178        content.pop('metadata', None)
179        return msg
180
181    def object_info_request(self, msg):
182        content = msg['content']
183        code = content['code']
184        cursor_pos = content['cursor_pos']
185        line, _ = code_to_line(code, cursor_pos)
186
187        new_content = msg['content'] = {}
188        new_content['oname'] = extract_oname_v4(code, cursor_pos)
189        new_content['detail_level'] = content['detail_level']
190        return msg
191
192    def object_info_reply(self, msg):
193        """inspect_reply can't be easily backward compatible"""
194        msg['content'] = {'found' : False, 'oname' : 'unknown'}
195        return msg
196
197    # iopub channel
198
199    def stream(self, msg):
200        content = msg['content']
201        content['data'] = content.pop('text')
202        return msg
203
204    def display_data(self, msg):
205        content = msg['content']
206        content.setdefault("source", "display")
207        data = content['data']
208        if 'application/json' in data:
209            try:
210                data['application/json'] = json.dumps(data['application/json'])
211            except Exception:
212                # warn?
213                pass
214        return msg
215
216    # stdin channel
217
218    def input_request(self, msg):
219        msg['content'].pop('password', None)
220        return msg
221
222
223class V4toV5(Adapter):
224    """Convert msg spec V4 to V5"""
225    version = '5.0'
226
227    # invert message renames above
228    msg_type_map = {v:k for k,v in V5toV4.msg_type_map.items()}
229
230    def update_header(self, msg):
231        msg['header']['version'] = self.version
232        if msg['parent_header']:
233            msg['parent_header']['version'] = self.version
234        return msg
235
236    # shell channel
237
238    def kernel_info_reply(self, msg):
239        content = msg['content']
240        for key in ('protocol_version', 'ipython_version'):
241            if key in content:
242                content[key] = '.'.join(map(str, content[key]))
243
244        content.setdefault('protocol_version', '4.1')
245
246        if content['language'].startswith('python') and 'ipython_version' in content:
247            content['implementation'] = 'ipython'
248            content['implementation_version'] = content.pop('ipython_version')
249
250        language = content.pop('language')
251        language_info = content.setdefault('language_info', {})
252        language_info.setdefault('name', language)
253        if 'language_version' in content:
254            language_version = '.'.join(map(str, content.pop('language_version')))
255            language_info.setdefault('version', language_version)
256
257        content['banner'] = ''
258        return msg
259
260    def execute_request(self, msg):
261        content = msg['content']
262        user_variables = content.pop('user_variables', [])
263        user_expressions = content.setdefault('user_expressions', {})
264        for v in user_variables:
265            user_expressions[v] = v
266        return msg
267
268    def execute_reply(self, msg):
269        content = msg['content']
270        user_expressions = content.setdefault('user_expressions', {})
271        user_variables = content.pop('user_variables', {})
272        if user_variables:
273            user_expressions.update(user_variables)
274
275        # Pager payloads became a mime bundle
276        for payload in content.get('payload', []):
277            if payload.get('source', None) == 'page' and ('text' in payload):
278                if 'data' not in payload:
279                    payload['data'] = {}
280                payload['data']['text/plain'] = payload.pop('text')
281
282        return msg
283
284    def complete_request(self, msg):
285        old_content = msg['content']
286
287        new_content = msg['content'] = {}
288        new_content['code'] = old_content['line']
289        new_content['cursor_pos'] = old_content['cursor_pos']
290        return msg
291
292    def complete_reply(self, msg):
293        # complete_reply needs more context than we have to get cursor_start and end.
294        # use special end=null to indicate current cursor position and negative offset
295        # for start relative to the cursor.
296        # start=None indicates that start == end (accounts for no -0).
297        content = msg['content']
298        new_content = msg['content'] = {'status' : 'ok'}
299        new_content['matches'] = content['matches']
300        if content['matched_text']:
301            new_content['cursor_start'] = -len(content['matched_text'])
302        else:
303            # no -0, use None to indicate that start == end
304            new_content['cursor_start'] = None
305        new_content['cursor_end'] = None
306        new_content['metadata'] = {}
307        return msg
308
309    def inspect_request(self, msg):
310        content = msg['content']
311        name = content['oname']
312
313        new_content = msg['content'] = {}
314        new_content['code'] = name
315        new_content['cursor_pos'] = len(name)
316        new_content['detail_level'] = content['detail_level']
317        return msg
318
319    def inspect_reply(self, msg):
320        """inspect_reply can't be easily backward compatible"""
321        content = msg['content']
322        new_content = msg['content'] = {'status' : 'ok'}
323        found = new_content['found'] = content['found']
324        new_content['data'] = data = {}
325        new_content['metadata'] = {}
326        if found:
327            lines = []
328            for key in ('call_def', 'init_definition', 'definition'):
329                if content.get(key, False):
330                    lines.append(content[key])
331                    break
332            for key in ('call_docstring', 'init_docstring', 'docstring'):
333                if content.get(key, False):
334                    lines.append(content[key])
335                    break
336            if not lines:
337                lines.append("<empty docstring>")
338            data['text/plain'] = '\n'.join(lines)
339        return msg
340
341    # iopub channel
342
343    def stream(self, msg):
344        content = msg['content']
345        content['text'] = content.pop('data')
346        return msg
347
348    def display_data(self, msg):
349        content = msg['content']
350        content.pop("source", None)
351        data = content['data']
352        if 'application/json' in data:
353            try:
354                data['application/json'] = json.loads(data['application/json'])
355            except Exception:
356                # warn?
357                pass
358        return msg
359
360    # stdin channel
361
362    def input_request(self, msg):
363        msg['content'].setdefault('password', False)
364        return msg
365
366
367
368def adapt(msg, to_version=protocol_version_info[0]):
369    """Adapt a single message to a target version
370
371    Parameters
372    ----------
373
374    msg : dict
375        A Jupyter message.
376    to_version : int, optional
377        The target major version.
378        If unspecified, adapt to the current version.
379
380    Returns
381    -------
382
383    msg : dict
384        A Jupyter message appropriate in the new version.
385    """
386    from .session import utcnow
387    header = msg['header']
388    if 'date' not in header:
389        header['date'] = utcnow()
390    if 'version' in header:
391        from_version = int(header['version'].split('.')[0])
392    else:
393        # assume last version before adding the key to the header
394        from_version = 4
395    adapter = adapters.get((from_version, to_version), None)
396    if adapter is None:
397        return msg
398    return adapter(msg)
399
400
401# one adapter per major version from,to
402adapters = {
403    (5,4) : V5toV4(),
404    (4,5) : V4toV5(),
405}
406