##// END OF EJS Templates
add IPython version to session header...
MinRK -
Show More
@@ -1,767 +1,769 b''
1 """Session object for building, serializing, sending, and receiving messages in
1 """Session object for building, serializing, sending, and receiving messages in
2 IPython. The Session object supports serialization, HMAC signatures, and
2 IPython. The Session object supports serialization, HMAC signatures, and
3 metadata on messages.
3 metadata on messages.
4
4
5 Also defined here are utilities for working with Sessions:
5 Also defined here are utilities for working with Sessions:
6 * A SessionFactory to be used as a base class for configurables that work with
6 * A SessionFactory to be used as a base class for configurables that work with
7 Sessions.
7 Sessions.
8 * A Message object for convenience that allows attribute-access to the msg dict.
8 * A Message object for convenience that allows attribute-access to the msg dict.
9
9
10 Authors:
10 Authors:
11
11
12 * Min RK
12 * Min RK
13 * Brian Granger
13 * Brian Granger
14 * Fernando Perez
14 * Fernando Perez
15 """
15 """
16 #-----------------------------------------------------------------------------
16 #-----------------------------------------------------------------------------
17 # Copyright (C) 2010-2011 The IPython Development Team
17 # Copyright (C) 2010-2011 The IPython Development Team
18 #
18 #
19 # Distributed under the terms of the BSD License. The full license is in
19 # Distributed under the terms of the BSD License. The full license is in
20 # the file COPYING, distributed as part of this software.
20 # the file COPYING, distributed as part of this software.
21 #-----------------------------------------------------------------------------
21 #-----------------------------------------------------------------------------
22
22
23 #-----------------------------------------------------------------------------
23 #-----------------------------------------------------------------------------
24 # Imports
24 # Imports
25 #-----------------------------------------------------------------------------
25 #-----------------------------------------------------------------------------
26
26
27 import hmac
27 import hmac
28 import logging
28 import logging
29 import os
29 import os
30 import pprint
30 import pprint
31 import uuid
31 import uuid
32 from datetime import datetime
32 from datetime import datetime
33
33
34 try:
34 try:
35 import cPickle
35 import cPickle
36 pickle = cPickle
36 pickle = cPickle
37 except:
37 except:
38 cPickle = None
38 cPickle = None
39 import pickle
39 import pickle
40
40
41 import zmq
41 import zmq
42 from zmq.utils import jsonapi
42 from zmq.utils import jsonapi
43 from zmq.eventloop.ioloop import IOLoop
43 from zmq.eventloop.ioloop import IOLoop
44 from zmq.eventloop.zmqstream import ZMQStream
44 from zmq.eventloop.zmqstream import ZMQStream
45
45
46 import IPython
46 from IPython.config.application import Application, boolean_flag
47 from IPython.config.application import Application, boolean_flag
47 from IPython.config.configurable import Configurable, LoggingConfigurable
48 from IPython.config.configurable import Configurable, LoggingConfigurable
48 from IPython.utils.importstring import import_item
49 from IPython.utils.importstring import import_item
49 from IPython.utils.jsonutil import extract_dates, squash_dates, date_default
50 from IPython.utils.jsonutil import extract_dates, squash_dates, date_default
50 from IPython.utils.py3compat import str_to_bytes
51 from IPython.utils.py3compat import str_to_bytes
51 from IPython.utils.traitlets import (CBytes, Unicode, Bool, Any, Instance, Set,
52 from IPython.utils.traitlets import (CBytes, Unicode, Bool, Any, Instance, Set,
52 DottedObjectName, CUnicode, Dict, Integer)
53 DottedObjectName, CUnicode, Dict, Integer)
53 from IPython.zmq.serialize import MAX_ITEMS, MAX_BYTES
54 from IPython.zmq.serialize import MAX_ITEMS, MAX_BYTES
54
55
55 #-----------------------------------------------------------------------------
56 #-----------------------------------------------------------------------------
56 # utility functions
57 # utility functions
57 #-----------------------------------------------------------------------------
58 #-----------------------------------------------------------------------------
58
59
59 def squash_unicode(obj):
60 def squash_unicode(obj):
60 """coerce unicode back to bytestrings."""
61 """coerce unicode back to bytestrings."""
61 if isinstance(obj,dict):
62 if isinstance(obj,dict):
62 for key in obj.keys():
63 for key in obj.keys():
63 obj[key] = squash_unicode(obj[key])
64 obj[key] = squash_unicode(obj[key])
64 if isinstance(key, unicode):
65 if isinstance(key, unicode):
65 obj[squash_unicode(key)] = obj.pop(key)
66 obj[squash_unicode(key)] = obj.pop(key)
66 elif isinstance(obj, list):
67 elif isinstance(obj, list):
67 for i,v in enumerate(obj):
68 for i,v in enumerate(obj):
68 obj[i] = squash_unicode(v)
69 obj[i] = squash_unicode(v)
69 elif isinstance(obj, unicode):
70 elif isinstance(obj, unicode):
70 obj = obj.encode('utf8')
71 obj = obj.encode('utf8')
71 return obj
72 return obj
72
73
73 #-----------------------------------------------------------------------------
74 #-----------------------------------------------------------------------------
74 # globals and defaults
75 # globals and defaults
75 #-----------------------------------------------------------------------------
76 #-----------------------------------------------------------------------------
76
77
77
78 _version_info_list = list(IPython.version_info)
78 # ISO8601-ify datetime objects
79 # ISO8601-ify datetime objects
79 json_packer = lambda obj: jsonapi.dumps(obj, default=date_default)
80 json_packer = lambda obj: jsonapi.dumps(obj, default=date_default)
80 json_unpacker = lambda s: extract_dates(jsonapi.loads(s))
81 json_unpacker = lambda s: extract_dates(jsonapi.loads(s))
81
82
82 pickle_packer = lambda o: pickle.dumps(o,-1)
83 pickle_packer = lambda o: pickle.dumps(o,-1)
83 pickle_unpacker = pickle.loads
84 pickle_unpacker = pickle.loads
84
85
85 default_packer = json_packer
86 default_packer = json_packer
86 default_unpacker = json_unpacker
87 default_unpacker = json_unpacker
87
88
88 DELIM = b"<IDS|MSG>"
89 DELIM = b"<IDS|MSG>"
89 # singleton dummy tracker, which will always report as done
90 # singleton dummy tracker, which will always report as done
90 DONE = zmq.MessageTracker()
91 DONE = zmq.MessageTracker()
91
92
92 #-----------------------------------------------------------------------------
93 #-----------------------------------------------------------------------------
93 # Mixin tools for apps that use Sessions
94 # Mixin tools for apps that use Sessions
94 #-----------------------------------------------------------------------------
95 #-----------------------------------------------------------------------------
95
96
96 session_aliases = dict(
97 session_aliases = dict(
97 ident = 'Session.session',
98 ident = 'Session.session',
98 user = 'Session.username',
99 user = 'Session.username',
99 keyfile = 'Session.keyfile',
100 keyfile = 'Session.keyfile',
100 )
101 )
101
102
102 session_flags = {
103 session_flags = {
103 'secure' : ({'Session' : { 'key' : str_to_bytes(str(uuid.uuid4())),
104 'secure' : ({'Session' : { 'key' : str_to_bytes(str(uuid.uuid4())),
104 'keyfile' : '' }},
105 'keyfile' : '' }},
105 """Use HMAC digests for authentication of messages.
106 """Use HMAC digests for authentication of messages.
106 Setting this flag will generate a new UUID to use as the HMAC key.
107 Setting this flag will generate a new UUID to use as the HMAC key.
107 """),
108 """),
108 'no-secure' : ({'Session' : { 'key' : b'', 'keyfile' : '' }},
109 'no-secure' : ({'Session' : { 'key' : b'', 'keyfile' : '' }},
109 """Don't authenticate messages."""),
110 """Don't authenticate messages."""),
110 }
111 }
111
112
112 def default_secure(cfg):
113 def default_secure(cfg):
113 """Set the default behavior for a config environment to be secure.
114 """Set the default behavior for a config environment to be secure.
114
115
115 If Session.key/keyfile have not been set, set Session.key to
116 If Session.key/keyfile have not been set, set Session.key to
116 a new random UUID.
117 a new random UUID.
117 """
118 """
118
119
119 if 'Session' in cfg:
120 if 'Session' in cfg:
120 if 'key' in cfg.Session or 'keyfile' in cfg.Session:
121 if 'key' in cfg.Session or 'keyfile' in cfg.Session:
121 return
122 return
122 # key/keyfile not specified, generate new UUID:
123 # key/keyfile not specified, generate new UUID:
123 cfg.Session.key = str_to_bytes(str(uuid.uuid4()))
124 cfg.Session.key = str_to_bytes(str(uuid.uuid4()))
124
125
125
126
126 #-----------------------------------------------------------------------------
127 #-----------------------------------------------------------------------------
127 # Classes
128 # Classes
128 #-----------------------------------------------------------------------------
129 #-----------------------------------------------------------------------------
129
130
130 class SessionFactory(LoggingConfigurable):
131 class SessionFactory(LoggingConfigurable):
131 """The Base class for configurables that have a Session, Context, logger,
132 """The Base class for configurables that have a Session, Context, logger,
132 and IOLoop.
133 and IOLoop.
133 """
134 """
134
135
135 logname = Unicode('')
136 logname = Unicode('')
136 def _logname_changed(self, name, old, new):
137 def _logname_changed(self, name, old, new):
137 self.log = logging.getLogger(new)
138 self.log = logging.getLogger(new)
138
139
139 # not configurable:
140 # not configurable:
140 context = Instance('zmq.Context')
141 context = Instance('zmq.Context')
141 def _context_default(self):
142 def _context_default(self):
142 return zmq.Context.instance()
143 return zmq.Context.instance()
143
144
144 session = Instance('IPython.zmq.session.Session')
145 session = Instance('IPython.zmq.session.Session')
145
146
146 loop = Instance('zmq.eventloop.ioloop.IOLoop', allow_none=False)
147 loop = Instance('zmq.eventloop.ioloop.IOLoop', allow_none=False)
147 def _loop_default(self):
148 def _loop_default(self):
148 return IOLoop.instance()
149 return IOLoop.instance()
149
150
150 def __init__(self, **kwargs):
151 def __init__(self, **kwargs):
151 super(SessionFactory, self).__init__(**kwargs)
152 super(SessionFactory, self).__init__(**kwargs)
152
153
153 if self.session is None:
154 if self.session is None:
154 # construct the session
155 # construct the session
155 self.session = Session(**kwargs)
156 self.session = Session(**kwargs)
156
157
157
158
158 class Message(object):
159 class Message(object):
159 """A simple message object that maps dict keys to attributes.
160 """A simple message object that maps dict keys to attributes.
160
161
161 A Message can be created from a dict and a dict from a Message instance
162 A Message can be created from a dict and a dict from a Message instance
162 simply by calling dict(msg_obj)."""
163 simply by calling dict(msg_obj)."""
163
164
164 def __init__(self, msg_dict):
165 def __init__(self, msg_dict):
165 dct = self.__dict__
166 dct = self.__dict__
166 for k, v in dict(msg_dict).iteritems():
167 for k, v in dict(msg_dict).iteritems():
167 if isinstance(v, dict):
168 if isinstance(v, dict):
168 v = Message(v)
169 v = Message(v)
169 dct[k] = v
170 dct[k] = v
170
171
171 # Having this iterator lets dict(msg_obj) work out of the box.
172 # Having this iterator lets dict(msg_obj) work out of the box.
172 def __iter__(self):
173 def __iter__(self):
173 return iter(self.__dict__.iteritems())
174 return iter(self.__dict__.iteritems())
174
175
175 def __repr__(self):
176 def __repr__(self):
176 return repr(self.__dict__)
177 return repr(self.__dict__)
177
178
178 def __str__(self):
179 def __str__(self):
179 return pprint.pformat(self.__dict__)
180 return pprint.pformat(self.__dict__)
180
181
181 def __contains__(self, k):
182 def __contains__(self, k):
182 return k in self.__dict__
183 return k in self.__dict__
183
184
184 def __getitem__(self, k):
185 def __getitem__(self, k):
185 return self.__dict__[k]
186 return self.__dict__[k]
186
187
187
188
188 def msg_header(msg_id, msg_type, username, session):
189 def msg_header(msg_id, msg_type, username, session):
189 date = datetime.now()
190 date = datetime.now()
191 version = _version_info_list
190 return locals()
192 return locals()
191
193
192 def extract_header(msg_or_header):
194 def extract_header(msg_or_header):
193 """Given a message or header, return the header."""
195 """Given a message or header, return the header."""
194 if not msg_or_header:
196 if not msg_or_header:
195 return {}
197 return {}
196 try:
198 try:
197 # See if msg_or_header is the entire message.
199 # See if msg_or_header is the entire message.
198 h = msg_or_header['header']
200 h = msg_or_header['header']
199 except KeyError:
201 except KeyError:
200 try:
202 try:
201 # See if msg_or_header is just the header
203 # See if msg_or_header is just the header
202 h = msg_or_header['msg_id']
204 h = msg_or_header['msg_id']
203 except KeyError:
205 except KeyError:
204 raise
206 raise
205 else:
207 else:
206 h = msg_or_header
208 h = msg_or_header
207 if not isinstance(h, dict):
209 if not isinstance(h, dict):
208 h = dict(h)
210 h = dict(h)
209 return h
211 return h
210
212
211 class Session(Configurable):
213 class Session(Configurable):
212 """Object for handling serialization and sending of messages.
214 """Object for handling serialization and sending of messages.
213
215
214 The Session object handles building messages and sending them
216 The Session object handles building messages and sending them
215 with ZMQ sockets or ZMQStream objects. Objects can communicate with each
217 with ZMQ sockets or ZMQStream objects. Objects can communicate with each
216 other over the network via Session objects, and only need to work with the
218 other over the network via Session objects, and only need to work with the
217 dict-based IPython message spec. The Session will handle
219 dict-based IPython message spec. The Session will handle
218 serialization/deserialization, security, and metadata.
220 serialization/deserialization, security, and metadata.
219
221
220 Sessions support configurable serialiization via packer/unpacker traits,
222 Sessions support configurable serialiization via packer/unpacker traits,
221 and signing with HMAC digests via the key/keyfile traits.
223 and signing with HMAC digests via the key/keyfile traits.
222
224
223 Parameters
225 Parameters
224 ----------
226 ----------
225
227
226 debug : bool
228 debug : bool
227 whether to trigger extra debugging statements
229 whether to trigger extra debugging statements
228 packer/unpacker : str : 'json', 'pickle' or import_string
230 packer/unpacker : str : 'json', 'pickle' or import_string
229 importstrings for methods to serialize message parts. If just
231 importstrings for methods to serialize message parts. If just
230 'json' or 'pickle', predefined JSON and pickle packers will be used.
232 'json' or 'pickle', predefined JSON and pickle packers will be used.
231 Otherwise, the entire importstring must be used.
233 Otherwise, the entire importstring must be used.
232
234
233 The functions must accept at least valid JSON input, and output *bytes*.
235 The functions must accept at least valid JSON input, and output *bytes*.
234
236
235 For example, to use msgpack:
237 For example, to use msgpack:
236 packer = 'msgpack.packb', unpacker='msgpack.unpackb'
238 packer = 'msgpack.packb', unpacker='msgpack.unpackb'
237 pack/unpack : callables
239 pack/unpack : callables
238 You can also set the pack/unpack callables for serialization directly.
240 You can also set the pack/unpack callables for serialization directly.
239 session : bytes
241 session : bytes
240 the ID of this Session object. The default is to generate a new UUID.
242 the ID of this Session object. The default is to generate a new UUID.
241 username : unicode
243 username : unicode
242 username added to message headers. The default is to ask the OS.
244 username added to message headers. The default is to ask the OS.
243 key : bytes
245 key : bytes
244 The key used to initialize an HMAC signature. If unset, messages
246 The key used to initialize an HMAC signature. If unset, messages
245 will not be signed or checked.
247 will not be signed or checked.
246 keyfile : filepath
248 keyfile : filepath
247 The file containing a key. If this is set, `key` will be initialized
249 The file containing a key. If this is set, `key` will be initialized
248 to the contents of the file.
250 to the contents of the file.
249
251
250 """
252 """
251
253
252 debug=Bool(False, config=True, help="""Debug output in the Session""")
254 debug=Bool(False, config=True, help="""Debug output in the Session""")
253
255
254 packer = DottedObjectName('json',config=True,
256 packer = DottedObjectName('json',config=True,
255 help="""The name of the packer for serializing messages.
257 help="""The name of the packer for serializing messages.
256 Should be one of 'json', 'pickle', or an import name
258 Should be one of 'json', 'pickle', or an import name
257 for a custom callable serializer.""")
259 for a custom callable serializer.""")
258 def _packer_changed(self, name, old, new):
260 def _packer_changed(self, name, old, new):
259 if new.lower() == 'json':
261 if new.lower() == 'json':
260 self.pack = json_packer
262 self.pack = json_packer
261 self.unpack = json_unpacker
263 self.unpack = json_unpacker
262 self.unpacker = new
264 self.unpacker = new
263 elif new.lower() == 'pickle':
265 elif new.lower() == 'pickle':
264 self.pack = pickle_packer
266 self.pack = pickle_packer
265 self.unpack = pickle_unpacker
267 self.unpack = pickle_unpacker
266 self.unpacker = new
268 self.unpacker = new
267 else:
269 else:
268 self.pack = import_item(str(new))
270 self.pack = import_item(str(new))
269
271
270 unpacker = DottedObjectName('json', config=True,
272 unpacker = DottedObjectName('json', config=True,
271 help="""The name of the unpacker for unserializing messages.
273 help="""The name of the unpacker for unserializing messages.
272 Only used with custom functions for `packer`.""")
274 Only used with custom functions for `packer`.""")
273 def _unpacker_changed(self, name, old, new):
275 def _unpacker_changed(self, name, old, new):
274 if new.lower() == 'json':
276 if new.lower() == 'json':
275 self.pack = json_packer
277 self.pack = json_packer
276 self.unpack = json_unpacker
278 self.unpack = json_unpacker
277 self.packer = new
279 self.packer = new
278 elif new.lower() == 'pickle':
280 elif new.lower() == 'pickle':
279 self.pack = pickle_packer
281 self.pack = pickle_packer
280 self.unpack = pickle_unpacker
282 self.unpack = pickle_unpacker
281 self.packer = new
283 self.packer = new
282 else:
284 else:
283 self.unpack = import_item(str(new))
285 self.unpack = import_item(str(new))
284
286
285 session = CUnicode(u'', config=True,
287 session = CUnicode(u'', config=True,
286 help="""The UUID identifying this session.""")
288 help="""The UUID identifying this session.""")
287 def _session_default(self):
289 def _session_default(self):
288 u = unicode(uuid.uuid4())
290 u = unicode(uuid.uuid4())
289 self.bsession = u.encode('ascii')
291 self.bsession = u.encode('ascii')
290 return u
292 return u
291
293
292 def _session_changed(self, name, old, new):
294 def _session_changed(self, name, old, new):
293 self.bsession = self.session.encode('ascii')
295 self.bsession = self.session.encode('ascii')
294
296
295 # bsession is the session as bytes
297 # bsession is the session as bytes
296 bsession = CBytes(b'')
298 bsession = CBytes(b'')
297
299
298 username = Unicode(os.environ.get('USER',u'username'), config=True,
300 username = Unicode(os.environ.get('USER',u'username'), config=True,
299 help="""Username for the Session. Default is your system username.""")
301 help="""Username for the Session. Default is your system username.""")
300
302
301 metadata = Dict({}, config=True,
303 metadata = Dict({}, config=True,
302 help="""Metadata dictionary, which serves as the default top-level metadata dict for each message.""")
304 help="""Metadata dictionary, which serves as the default top-level metadata dict for each message.""")
303
305
304 # message signature related traits:
306 # message signature related traits:
305
307
306 key = CBytes(b'', config=True,
308 key = CBytes(b'', config=True,
307 help="""execution key, for extra authentication.""")
309 help="""execution key, for extra authentication.""")
308 def _key_changed(self, name, old, new):
310 def _key_changed(self, name, old, new):
309 if new:
311 if new:
310 self.auth = hmac.HMAC(new)
312 self.auth = hmac.HMAC(new)
311 else:
313 else:
312 self.auth = None
314 self.auth = None
313 auth = Instance(hmac.HMAC)
315 auth = Instance(hmac.HMAC)
314 digest_history = Set()
316 digest_history = Set()
315
317
316 keyfile = Unicode('', config=True,
318 keyfile = Unicode('', config=True,
317 help="""path to file containing execution key.""")
319 help="""path to file containing execution key.""")
318 def _keyfile_changed(self, name, old, new):
320 def _keyfile_changed(self, name, old, new):
319 with open(new, 'rb') as f:
321 with open(new, 'rb') as f:
320 self.key = f.read().strip()
322 self.key = f.read().strip()
321
323
322 # serialization traits:
324 # serialization traits:
323
325
324 pack = Any(default_packer) # the actual packer function
326 pack = Any(default_packer) # the actual packer function
325 def _pack_changed(self, name, old, new):
327 def _pack_changed(self, name, old, new):
326 if not callable(new):
328 if not callable(new):
327 raise TypeError("packer must be callable, not %s"%type(new))
329 raise TypeError("packer must be callable, not %s"%type(new))
328
330
329 unpack = Any(default_unpacker) # the actual packer function
331 unpack = Any(default_unpacker) # the actual packer function
330 def _unpack_changed(self, name, old, new):
332 def _unpack_changed(self, name, old, new):
331 # unpacker is not checked - it is assumed to be
333 # unpacker is not checked - it is assumed to be
332 if not callable(new):
334 if not callable(new):
333 raise TypeError("unpacker must be callable, not %s"%type(new))
335 raise TypeError("unpacker must be callable, not %s"%type(new))
334
336
335 # thresholds:
337 # thresholds:
336 copy_threshold = Integer(2**16, config=True,
338 copy_threshold = Integer(2**16, config=True,
337 help="Threshold (in bytes) beyond which a buffer should be sent without copying.")
339 help="Threshold (in bytes) beyond which a buffer should be sent without copying.")
338 buffer_threshold = Integer(MAX_BYTES, config=True,
340 buffer_threshold = Integer(MAX_BYTES, config=True,
339 help="Threshold (in bytes) beyond which an object's buffer should be extracted to avoid pickling.")
341 help="Threshold (in bytes) beyond which an object's buffer should be extracted to avoid pickling.")
340 item_threshold = Integer(MAX_ITEMS, config=True,
342 item_threshold = Integer(MAX_ITEMS, config=True,
341 help="""The maximum number of items for a container to be introspected for custom serialization.
343 help="""The maximum number of items for a container to be introspected for custom serialization.
342 Containers larger than this are pickled outright.
344 Containers larger than this are pickled outright.
343 """
345 """
344 )
346 )
345
347
346 def __init__(self, **kwargs):
348 def __init__(self, **kwargs):
347 """create a Session object
349 """create a Session object
348
350
349 Parameters
351 Parameters
350 ----------
352 ----------
351
353
352 debug : bool
354 debug : bool
353 whether to trigger extra debugging statements
355 whether to trigger extra debugging statements
354 packer/unpacker : str : 'json', 'pickle' or import_string
356 packer/unpacker : str : 'json', 'pickle' or import_string
355 importstrings for methods to serialize message parts. If just
357 importstrings for methods to serialize message parts. If just
356 'json' or 'pickle', predefined JSON and pickle packers will be used.
358 'json' or 'pickle', predefined JSON and pickle packers will be used.
357 Otherwise, the entire importstring must be used.
359 Otherwise, the entire importstring must be used.
358
360
359 The functions must accept at least valid JSON input, and output
361 The functions must accept at least valid JSON input, and output
360 *bytes*.
362 *bytes*.
361
363
362 For example, to use msgpack:
364 For example, to use msgpack:
363 packer = 'msgpack.packb', unpacker='msgpack.unpackb'
365 packer = 'msgpack.packb', unpacker='msgpack.unpackb'
364 pack/unpack : callables
366 pack/unpack : callables
365 You can also set the pack/unpack callables for serialization
367 You can also set the pack/unpack callables for serialization
366 directly.
368 directly.
367 session : unicode (must be ascii)
369 session : unicode (must be ascii)
368 the ID of this Session object. The default is to generate a new
370 the ID of this Session object. The default is to generate a new
369 UUID.
371 UUID.
370 bsession : bytes
372 bsession : bytes
371 The session as bytes
373 The session as bytes
372 username : unicode
374 username : unicode
373 username added to message headers. The default is to ask the OS.
375 username added to message headers. The default is to ask the OS.
374 key : bytes
376 key : bytes
375 The key used to initialize an HMAC signature. If unset, messages
377 The key used to initialize an HMAC signature. If unset, messages
376 will not be signed or checked.
378 will not be signed or checked.
377 keyfile : filepath
379 keyfile : filepath
378 The file containing a key. If this is set, `key` will be
380 The file containing a key. If this is set, `key` will be
379 initialized to the contents of the file.
381 initialized to the contents of the file.
380 """
382 """
381 super(Session, self).__init__(**kwargs)
383 super(Session, self).__init__(**kwargs)
382 self._check_packers()
384 self._check_packers()
383 self.none = self.pack({})
385 self.none = self.pack({})
384 # ensure self._session_default() if necessary, so bsession is defined:
386 # ensure self._session_default() if necessary, so bsession is defined:
385 self.session
387 self.session
386
388
387 @property
389 @property
388 def msg_id(self):
390 def msg_id(self):
389 """always return new uuid"""
391 """always return new uuid"""
390 return str(uuid.uuid4())
392 return str(uuid.uuid4())
391
393
392 def _check_packers(self):
394 def _check_packers(self):
393 """check packers for binary data and datetime support."""
395 """check packers for binary data and datetime support."""
394 pack = self.pack
396 pack = self.pack
395 unpack = self.unpack
397 unpack = self.unpack
396
398
397 # check simple serialization
399 # check simple serialization
398 msg = dict(a=[1,'hi'])
400 msg = dict(a=[1,'hi'])
399 try:
401 try:
400 packed = pack(msg)
402 packed = pack(msg)
401 except Exception:
403 except Exception:
402 raise ValueError("packer could not serialize a simple message")
404 raise ValueError("packer could not serialize a simple message")
403
405
404 # ensure packed message is bytes
406 # ensure packed message is bytes
405 if not isinstance(packed, bytes):
407 if not isinstance(packed, bytes):
406 raise ValueError("message packed to %r, but bytes are required"%type(packed))
408 raise ValueError("message packed to %r, but bytes are required"%type(packed))
407
409
408 # check that unpack is pack's inverse
410 # check that unpack is pack's inverse
409 try:
411 try:
410 unpacked = unpack(packed)
412 unpacked = unpack(packed)
411 except Exception:
413 except Exception:
412 raise ValueError("unpacker could not handle the packer's output")
414 raise ValueError("unpacker could not handle the packer's output")
413
415
414 # check datetime support
416 # check datetime support
415 msg = dict(t=datetime.now())
417 msg = dict(t=datetime.now())
416 try:
418 try:
417 unpacked = unpack(pack(msg))
419 unpacked = unpack(pack(msg))
418 except Exception:
420 except Exception:
419 self.pack = lambda o: pack(squash_dates(o))
421 self.pack = lambda o: pack(squash_dates(o))
420 self.unpack = lambda s: extract_dates(unpack(s))
422 self.unpack = lambda s: extract_dates(unpack(s))
421
423
422 def msg_header(self, msg_type):
424 def msg_header(self, msg_type):
423 return msg_header(self.msg_id, msg_type, self.username, self.session)
425 return msg_header(self.msg_id, msg_type, self.username, self.session)
424
426
425 def msg(self, msg_type, content=None, parent=None, header=None, metadata=None):
427 def msg(self, msg_type, content=None, parent=None, header=None, metadata=None):
426 """Return the nested message dict.
428 """Return the nested message dict.
427
429
428 This format is different from what is sent over the wire. The
430 This format is different from what is sent over the wire. The
429 serialize/unserialize methods converts this nested message dict to the wire
431 serialize/unserialize methods converts this nested message dict to the wire
430 format, which is a list of message parts.
432 format, which is a list of message parts.
431 """
433 """
432 msg = {}
434 msg = {}
433 header = self.msg_header(msg_type) if header is None else header
435 header = self.msg_header(msg_type) if header is None else header
434 msg['header'] = header
436 msg['header'] = header
435 msg['msg_id'] = header['msg_id']
437 msg['msg_id'] = header['msg_id']
436 msg['msg_type'] = header['msg_type']
438 msg['msg_type'] = header['msg_type']
437 msg['parent_header'] = {} if parent is None else extract_header(parent)
439 msg['parent_header'] = {} if parent is None else extract_header(parent)
438 msg['content'] = {} if content is None else content
440 msg['content'] = {} if content is None else content
439 msg['metadata'] = self.metadata.copy()
441 msg['metadata'] = self.metadata.copy()
440 if metadata is not None:
442 if metadata is not None:
441 msg['metadata'].update(metadata)
443 msg['metadata'].update(metadata)
442 return msg
444 return msg
443
445
444 def sign(self, msg_list):
446 def sign(self, msg_list):
445 """Sign a message with HMAC digest. If no auth, return b''.
447 """Sign a message with HMAC digest. If no auth, return b''.
446
448
447 Parameters
449 Parameters
448 ----------
450 ----------
449 msg_list : list
451 msg_list : list
450 The [p_header,p_parent,p_content] part of the message list.
452 The [p_header,p_parent,p_content] part of the message list.
451 """
453 """
452 if self.auth is None:
454 if self.auth is None:
453 return b''
455 return b''
454 h = self.auth.copy()
456 h = self.auth.copy()
455 for m in msg_list:
457 for m in msg_list:
456 h.update(m)
458 h.update(m)
457 return str_to_bytes(h.hexdigest())
459 return str_to_bytes(h.hexdigest())
458
460
459 def serialize(self, msg, ident=None):
461 def serialize(self, msg, ident=None):
460 """Serialize the message components to bytes.
462 """Serialize the message components to bytes.
461
463
462 This is roughly the inverse of unserialize. The serialize/unserialize
464 This is roughly the inverse of unserialize. The serialize/unserialize
463 methods work with full message lists, whereas pack/unpack work with
465 methods work with full message lists, whereas pack/unpack work with
464 the individual message parts in the message list.
466 the individual message parts in the message list.
465
467
466 Parameters
468 Parameters
467 ----------
469 ----------
468 msg : dict or Message
470 msg : dict or Message
469 The nexted message dict as returned by the self.msg method.
471 The nexted message dict as returned by the self.msg method.
470
472
471 Returns
473 Returns
472 -------
474 -------
473 msg_list : list
475 msg_list : list
474 The list of bytes objects to be sent with the format:
476 The list of bytes objects to be sent with the format:
475 [ident1,ident2,...,DELIM,HMAC,p_header,p_parent,p_metadata,p_content,
477 [ident1,ident2,...,DELIM,HMAC,p_header,p_parent,p_metadata,p_content,
476 buffer1,buffer2,...]. In this list, the p_* entities are
478 buffer1,buffer2,...]. In this list, the p_* entities are
477 the packed or serialized versions, so if JSON is used, these
479 the packed or serialized versions, so if JSON is used, these
478 are utf8 encoded JSON strings.
480 are utf8 encoded JSON strings.
479 """
481 """
480 content = msg.get('content', {})
482 content = msg.get('content', {})
481 if content is None:
483 if content is None:
482 content = self.none
484 content = self.none
483 elif isinstance(content, dict):
485 elif isinstance(content, dict):
484 content = self.pack(content)
486 content = self.pack(content)
485 elif isinstance(content, bytes):
487 elif isinstance(content, bytes):
486 # content is already packed, as in a relayed message
488 # content is already packed, as in a relayed message
487 pass
489 pass
488 elif isinstance(content, unicode):
490 elif isinstance(content, unicode):
489 # should be bytes, but JSON often spits out unicode
491 # should be bytes, but JSON often spits out unicode
490 content = content.encode('utf8')
492 content = content.encode('utf8')
491 else:
493 else:
492 raise TypeError("Content incorrect type: %s"%type(content))
494 raise TypeError("Content incorrect type: %s"%type(content))
493
495
494 real_message = [self.pack(msg['header']),
496 real_message = [self.pack(msg['header']),
495 self.pack(msg['parent_header']),
497 self.pack(msg['parent_header']),
496 self.pack(msg['metadata']),
498 self.pack(msg['metadata']),
497 content,
499 content,
498 ]
500 ]
499
501
500 to_send = []
502 to_send = []
501
503
502 if isinstance(ident, list):
504 if isinstance(ident, list):
503 # accept list of idents
505 # accept list of idents
504 to_send.extend(ident)
506 to_send.extend(ident)
505 elif ident is not None:
507 elif ident is not None:
506 to_send.append(ident)
508 to_send.append(ident)
507 to_send.append(DELIM)
509 to_send.append(DELIM)
508
510
509 signature = self.sign(real_message)
511 signature = self.sign(real_message)
510 to_send.append(signature)
512 to_send.append(signature)
511
513
512 to_send.extend(real_message)
514 to_send.extend(real_message)
513
515
514 return to_send
516 return to_send
515
517
516 def send(self, stream, msg_or_type, content=None, parent=None, ident=None,
518 def send(self, stream, msg_or_type, content=None, parent=None, ident=None,
517 buffers=None, track=False, header=None, metadata=None):
519 buffers=None, track=False, header=None, metadata=None):
518 """Build and send a message via stream or socket.
520 """Build and send a message via stream or socket.
519
521
520 The message format used by this function internally is as follows:
522 The message format used by this function internally is as follows:
521
523
522 [ident1,ident2,...,DELIM,HMAC,p_header,p_parent,p_content,
524 [ident1,ident2,...,DELIM,HMAC,p_header,p_parent,p_content,
523 buffer1,buffer2,...]
525 buffer1,buffer2,...]
524
526
525 The serialize/unserialize methods convert the nested message dict into this
527 The serialize/unserialize methods convert the nested message dict into this
526 format.
528 format.
527
529
528 Parameters
530 Parameters
529 ----------
531 ----------
530
532
531 stream : zmq.Socket or ZMQStream
533 stream : zmq.Socket or ZMQStream
532 The socket-like object used to send the data.
534 The socket-like object used to send the data.
533 msg_or_type : str or Message/dict
535 msg_or_type : str or Message/dict
534 Normally, msg_or_type will be a msg_type unless a message is being
536 Normally, msg_or_type will be a msg_type unless a message is being
535 sent more than once. If a header is supplied, this can be set to
537 sent more than once. If a header is supplied, this can be set to
536 None and the msg_type will be pulled from the header.
538 None and the msg_type will be pulled from the header.
537
539
538 content : dict or None
540 content : dict or None
539 The content of the message (ignored if msg_or_type is a message).
541 The content of the message (ignored if msg_or_type is a message).
540 header : dict or None
542 header : dict or None
541 The header dict for the message (ignored if msg_to_type is a message).
543 The header dict for the message (ignored if msg_to_type is a message).
542 parent : Message or dict or None
544 parent : Message or dict or None
543 The parent or parent header describing the parent of this message
545 The parent or parent header describing the parent of this message
544 (ignored if msg_or_type is a message).
546 (ignored if msg_or_type is a message).
545 ident : bytes or list of bytes
547 ident : bytes or list of bytes
546 The zmq.IDENTITY routing path.
548 The zmq.IDENTITY routing path.
547 metadata : dict or None
549 metadata : dict or None
548 The metadata describing the message
550 The metadata describing the message
549 buffers : list or None
551 buffers : list or None
550 The already-serialized buffers to be appended to the message.
552 The already-serialized buffers to be appended to the message.
551 track : bool
553 track : bool
552 Whether to track. Only for use with Sockets, because ZMQStream
554 Whether to track. Only for use with Sockets, because ZMQStream
553 objects cannot track messages.
555 objects cannot track messages.
554
556
555
557
556 Returns
558 Returns
557 -------
559 -------
558 msg : dict
560 msg : dict
559 The constructed message.
561 The constructed message.
560 """
562 """
561
563
562 if not isinstance(stream, (zmq.Socket, ZMQStream)):
564 if not isinstance(stream, (zmq.Socket, ZMQStream)):
563 raise TypeError("stream must be Socket or ZMQStream, not %r"%type(stream))
565 raise TypeError("stream must be Socket or ZMQStream, not %r"%type(stream))
564 elif track and isinstance(stream, ZMQStream):
566 elif track and isinstance(stream, ZMQStream):
565 raise TypeError("ZMQStream cannot track messages")
567 raise TypeError("ZMQStream cannot track messages")
566
568
567 if isinstance(msg_or_type, (Message, dict)):
569 if isinstance(msg_or_type, (Message, dict)):
568 # We got a Message or message dict, not a msg_type so don't
570 # We got a Message or message dict, not a msg_type so don't
569 # build a new Message.
571 # build a new Message.
570 msg = msg_or_type
572 msg = msg_or_type
571 else:
573 else:
572 msg = self.msg(msg_or_type, content=content, parent=parent,
574 msg = self.msg(msg_or_type, content=content, parent=parent,
573 header=header, metadata=metadata)
575 header=header, metadata=metadata)
574
576
575 buffers = [] if buffers is None else buffers
577 buffers = [] if buffers is None else buffers
576 to_send = self.serialize(msg, ident)
578 to_send = self.serialize(msg, ident)
577 to_send.extend(buffers)
579 to_send.extend(buffers)
578 longest = max([ len(s) for s in to_send ])
580 longest = max([ len(s) for s in to_send ])
579 copy = (longest < self.copy_threshold)
581 copy = (longest < self.copy_threshold)
580
582
581 if buffers and track and not copy:
583 if buffers and track and not copy:
582 # only really track when we are doing zero-copy buffers
584 # only really track when we are doing zero-copy buffers
583 tracker = stream.send_multipart(to_send, copy=False, track=True)
585 tracker = stream.send_multipart(to_send, copy=False, track=True)
584 else:
586 else:
585 # use dummy tracker, which will be done immediately
587 # use dummy tracker, which will be done immediately
586 tracker = DONE
588 tracker = DONE
587 stream.send_multipart(to_send, copy=copy)
589 stream.send_multipart(to_send, copy=copy)
588
590
589 if self.debug:
591 if self.debug:
590 pprint.pprint(msg)
592 pprint.pprint(msg)
591 pprint.pprint(to_send)
593 pprint.pprint(to_send)
592 pprint.pprint(buffers)
594 pprint.pprint(buffers)
593
595
594 msg['tracker'] = tracker
596 msg['tracker'] = tracker
595
597
596 return msg
598 return msg
597
599
598 def send_raw(self, stream, msg_list, flags=0, copy=True, ident=None):
600 def send_raw(self, stream, msg_list, flags=0, copy=True, ident=None):
599 """Send a raw message via ident path.
601 """Send a raw message via ident path.
600
602
601 This method is used to send a already serialized message.
603 This method is used to send a already serialized message.
602
604
603 Parameters
605 Parameters
604 ----------
606 ----------
605 stream : ZMQStream or Socket
607 stream : ZMQStream or Socket
606 The ZMQ stream or socket to use for sending the message.
608 The ZMQ stream or socket to use for sending the message.
607 msg_list : list
609 msg_list : list
608 The serialized list of messages to send. This only includes the
610 The serialized list of messages to send. This only includes the
609 [p_header,p_parent,p_metadata,p_content,buffer1,buffer2,...] portion of
611 [p_header,p_parent,p_metadata,p_content,buffer1,buffer2,...] portion of
610 the message.
612 the message.
611 ident : ident or list
613 ident : ident or list
612 A single ident or a list of idents to use in sending.
614 A single ident or a list of idents to use in sending.
613 """
615 """
614 to_send = []
616 to_send = []
615 if isinstance(ident, bytes):
617 if isinstance(ident, bytes):
616 ident = [ident]
618 ident = [ident]
617 if ident is not None:
619 if ident is not None:
618 to_send.extend(ident)
620 to_send.extend(ident)
619
621
620 to_send.append(DELIM)
622 to_send.append(DELIM)
621 to_send.append(self.sign(msg_list))
623 to_send.append(self.sign(msg_list))
622 to_send.extend(msg_list)
624 to_send.extend(msg_list)
623 stream.send_multipart(msg_list, flags, copy=copy)
625 stream.send_multipart(msg_list, flags, copy=copy)
624
626
625 def recv(self, socket, mode=zmq.NOBLOCK, content=True, copy=True):
627 def recv(self, socket, mode=zmq.NOBLOCK, content=True, copy=True):
626 """Receive and unpack a message.
628 """Receive and unpack a message.
627
629
628 Parameters
630 Parameters
629 ----------
631 ----------
630 socket : ZMQStream or Socket
632 socket : ZMQStream or Socket
631 The socket or stream to use in receiving.
633 The socket or stream to use in receiving.
632
634
633 Returns
635 Returns
634 -------
636 -------
635 [idents], msg
637 [idents], msg
636 [idents] is a list of idents and msg is a nested message dict of
638 [idents] is a list of idents and msg is a nested message dict of
637 same format as self.msg returns.
639 same format as self.msg returns.
638 """
640 """
639 if isinstance(socket, ZMQStream):
641 if isinstance(socket, ZMQStream):
640 socket = socket.socket
642 socket = socket.socket
641 try:
643 try:
642 msg_list = socket.recv_multipart(mode, copy=copy)
644 msg_list = socket.recv_multipart(mode, copy=copy)
643 except zmq.ZMQError as e:
645 except zmq.ZMQError as e:
644 if e.errno == zmq.EAGAIN:
646 if e.errno == zmq.EAGAIN:
645 # We can convert EAGAIN to None as we know in this case
647 # We can convert EAGAIN to None as we know in this case
646 # recv_multipart won't return None.
648 # recv_multipart won't return None.
647 return None,None
649 return None,None
648 else:
650 else:
649 raise
651 raise
650 # split multipart message into identity list and message dict
652 # split multipart message into identity list and message dict
651 # invalid large messages can cause very expensive string comparisons
653 # invalid large messages can cause very expensive string comparisons
652 idents, msg_list = self.feed_identities(msg_list, copy)
654 idents, msg_list = self.feed_identities(msg_list, copy)
653 try:
655 try:
654 return idents, self.unserialize(msg_list, content=content, copy=copy)
656 return idents, self.unserialize(msg_list, content=content, copy=copy)
655 except Exception as e:
657 except Exception as e:
656 # TODO: handle it
658 # TODO: handle it
657 raise e
659 raise e
658
660
659 def feed_identities(self, msg_list, copy=True):
661 def feed_identities(self, msg_list, copy=True):
660 """Split the identities from the rest of the message.
662 """Split the identities from the rest of the message.
661
663
662 Feed until DELIM is reached, then return the prefix as idents and
664 Feed until DELIM is reached, then return the prefix as idents and
663 remainder as msg_list. This is easily broken by setting an IDENT to DELIM,
665 remainder as msg_list. This is easily broken by setting an IDENT to DELIM,
664 but that would be silly.
666 but that would be silly.
665
667
666 Parameters
668 Parameters
667 ----------
669 ----------
668 msg_list : a list of Message or bytes objects
670 msg_list : a list of Message or bytes objects
669 The message to be split.
671 The message to be split.
670 copy : bool
672 copy : bool
671 flag determining whether the arguments are bytes or Messages
673 flag determining whether the arguments are bytes or Messages
672
674
673 Returns
675 Returns
674 -------
676 -------
675 (idents, msg_list) : two lists
677 (idents, msg_list) : two lists
676 idents will always be a list of bytes, each of which is a ZMQ
678 idents will always be a list of bytes, each of which is a ZMQ
677 identity. msg_list will be a list of bytes or zmq.Messages of the
679 identity. msg_list will be a list of bytes or zmq.Messages of the
678 form [HMAC,p_header,p_parent,p_content,buffer1,buffer2,...] and
680 form [HMAC,p_header,p_parent,p_content,buffer1,buffer2,...] and
679 should be unpackable/unserializable via self.unserialize at this
681 should be unpackable/unserializable via self.unserialize at this
680 point.
682 point.
681 """
683 """
682 if copy:
684 if copy:
683 idx = msg_list.index(DELIM)
685 idx = msg_list.index(DELIM)
684 return msg_list[:idx], msg_list[idx+1:]
686 return msg_list[:idx], msg_list[idx+1:]
685 else:
687 else:
686 failed = True
688 failed = True
687 for idx,m in enumerate(msg_list):
689 for idx,m in enumerate(msg_list):
688 if m.bytes == DELIM:
690 if m.bytes == DELIM:
689 failed = False
691 failed = False
690 break
692 break
691 if failed:
693 if failed:
692 raise ValueError("DELIM not in msg_list")
694 raise ValueError("DELIM not in msg_list")
693 idents, msg_list = msg_list[:idx], msg_list[idx+1:]
695 idents, msg_list = msg_list[:idx], msg_list[idx+1:]
694 return [m.bytes for m in idents], msg_list
696 return [m.bytes for m in idents], msg_list
695
697
696 def unserialize(self, msg_list, content=True, copy=True):
698 def unserialize(self, msg_list, content=True, copy=True):
697 """Unserialize a msg_list to a nested message dict.
699 """Unserialize a msg_list to a nested message dict.
698
700
699 This is roughly the inverse of serialize. The serialize/unserialize
701 This is roughly the inverse of serialize. The serialize/unserialize
700 methods work with full message lists, whereas pack/unpack work with
702 methods work with full message lists, whereas pack/unpack work with
701 the individual message parts in the message list.
703 the individual message parts in the message list.
702
704
703 Parameters:
705 Parameters:
704 -----------
706 -----------
705 msg_list : list of bytes or Message objects
707 msg_list : list of bytes or Message objects
706 The list of message parts of the form [HMAC,p_header,p_parent,
708 The list of message parts of the form [HMAC,p_header,p_parent,
707 p_metadata,p_content,buffer1,buffer2,...].
709 p_metadata,p_content,buffer1,buffer2,...].
708 content : bool (True)
710 content : bool (True)
709 Whether to unpack the content dict (True), or leave it packed
711 Whether to unpack the content dict (True), or leave it packed
710 (False).
712 (False).
711 copy : bool (True)
713 copy : bool (True)
712 Whether to return the bytes (True), or the non-copying Message
714 Whether to return the bytes (True), or the non-copying Message
713 object in each place (False).
715 object in each place (False).
714
716
715 Returns
717 Returns
716 -------
718 -------
717 msg : dict
719 msg : dict
718 The nested message dict with top-level keys [header, parent_header,
720 The nested message dict with top-level keys [header, parent_header,
719 content, buffers].
721 content, buffers].
720 """
722 """
721 minlen = 5
723 minlen = 5
722 message = {}
724 message = {}
723 if not copy:
725 if not copy:
724 for i in range(minlen):
726 for i in range(minlen):
725 msg_list[i] = msg_list[i].bytes
727 msg_list[i] = msg_list[i].bytes
726 if self.auth is not None:
728 if self.auth is not None:
727 signature = msg_list[0]
729 signature = msg_list[0]
728 if not signature:
730 if not signature:
729 raise ValueError("Unsigned Message")
731 raise ValueError("Unsigned Message")
730 if signature in self.digest_history:
732 if signature in self.digest_history:
731 raise ValueError("Duplicate Signature: %r"%signature)
733 raise ValueError("Duplicate Signature: %r"%signature)
732 self.digest_history.add(signature)
734 self.digest_history.add(signature)
733 check = self.sign(msg_list[1:5])
735 check = self.sign(msg_list[1:5])
734 if not signature == check:
736 if not signature == check:
735 raise ValueError("Invalid Signature: %r" % signature)
737 raise ValueError("Invalid Signature: %r" % signature)
736 if not len(msg_list) >= minlen:
738 if not len(msg_list) >= minlen:
737 raise TypeError("malformed message, must have at least %i elements"%minlen)
739 raise TypeError("malformed message, must have at least %i elements"%minlen)
738 header = self.unpack(msg_list[1])
740 header = self.unpack(msg_list[1])
739 message['header'] = header
741 message['header'] = header
740 message['msg_id'] = header['msg_id']
742 message['msg_id'] = header['msg_id']
741 message['msg_type'] = header['msg_type']
743 message['msg_type'] = header['msg_type']
742 message['parent_header'] = self.unpack(msg_list[2])
744 message['parent_header'] = self.unpack(msg_list[2])
743 message['metadata'] = self.unpack(msg_list[3])
745 message['metadata'] = self.unpack(msg_list[3])
744 if content:
746 if content:
745 message['content'] = self.unpack(msg_list[4])
747 message['content'] = self.unpack(msg_list[4])
746 else:
748 else:
747 message['content'] = msg_list[4]
749 message['content'] = msg_list[4]
748
750
749 message['buffers'] = msg_list[5:]
751 message['buffers'] = msg_list[5:]
750 return message
752 return message
751
753
752 def test_msg2obj():
754 def test_msg2obj():
753 am = dict(x=1)
755 am = dict(x=1)
754 ao = Message(am)
756 ao = Message(am)
755 assert ao.x == am['x']
757 assert ao.x == am['x']
756
758
757 am['y'] = dict(z=1)
759 am['y'] = dict(z=1)
758 ao = Message(am)
760 ao = Message(am)
759 assert ao.y.z == am['y']['z']
761 assert ao.y.z == am['y']['z']
760
762
761 k1, k2 = 'y', 'z'
763 k1, k2 = 'y', 'z'
762 assert ao[k1][k2] == am[k1][k2]
764 assert ao[k1][k2] == am[k1][k2]
763
765
764 am2 = dict(ao)
766 am2 = dict(ao)
765 assert am['x'] == am2['x']
767 assert am['x'] == am2['x']
766 assert am['y']['z'] == am2['y']['z']
768 assert am['y']['z'] == am2['y']['z']
767
769
General Comments 0
You need to be logged in to leave comments. Login now