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