##// END OF EJS Templates
always default to json packer in streamsession
MinRK -
Show More
@@ -1,418 +1,402 b''
1 #!/usr/bin/env python
1 #!/usr/bin/env python
2 """edited session.py to work with streams, and move msg_type to the header
2 """edited session.py to work with streams, and move msg_type to the header
3 """
3 """
4 #-----------------------------------------------------------------------------
4 #-----------------------------------------------------------------------------
5 # Copyright (C) 2010-2011 The IPython Development Team
5 # Copyright (C) 2010-2011 The IPython Development Team
6 #
6 #
7 # Distributed under the terms of the BSD License. The full license is in
7 # Distributed under the terms of the BSD License. The full license is in
8 # the file COPYING, distributed as part of this software.
8 # the file COPYING, distributed as part of this software.
9 #-----------------------------------------------------------------------------
9 #-----------------------------------------------------------------------------
10
10
11
11
12 import os
12 import os
13 import pprint
13 import pprint
14 import uuid
14 import uuid
15 from datetime import datetime
15 from datetime import datetime
16
16
17 try:
17 try:
18 import cPickle
18 import cPickle
19 pickle = cPickle
19 pickle = cPickle
20 except:
20 except:
21 cPickle = None
21 cPickle = None
22 import pickle
22 import pickle
23
23
24 import zmq
24 import zmq
25 from zmq.utils import jsonapi
25 from zmq.utils import jsonapi
26 from zmq.eventloop.zmqstream import ZMQStream
26 from zmq.eventloop.zmqstream import ZMQStream
27
27
28 from .util import ISO8601
28 from .util import ISO8601
29
29
30 # packer priority: jsonlib[2], cPickle, simplejson/json, pickle
31 json_name = '' if not jsonapi.jsonmod else jsonapi.jsonmod.__name__
32 if json_name in ('jsonlib', 'jsonlib2'):
33 use_json = True
34 elif json_name:
35 if cPickle is None:
36 use_json = True
37 else:
38 use_json = False
39 else:
40 use_json = False
41
42 def squash_unicode(obj):
30 def squash_unicode(obj):
43 if isinstance(obj,dict):
31 if isinstance(obj,dict):
44 for key in obj.keys():
32 for key in obj.keys():
45 obj[key] = squash_unicode(obj[key])
33 obj[key] = squash_unicode(obj[key])
46 if isinstance(key, unicode):
34 if isinstance(key, unicode):
47 obj[squash_unicode(key)] = obj.pop(key)
35 obj[squash_unicode(key)] = obj.pop(key)
48 elif isinstance(obj, list):
36 elif isinstance(obj, list):
49 for i,v in enumerate(obj):
37 for i,v in enumerate(obj):
50 obj[i] = squash_unicode(v)
38 obj[i] = squash_unicode(v)
51 elif isinstance(obj, unicode):
39 elif isinstance(obj, unicode):
52 obj = obj.encode('utf8')
40 obj = obj.encode('utf8')
53 return obj
41 return obj
54
42
55 json_packer = jsonapi.dumps
43 json_packer = jsonapi.dumps
56 json_unpacker = lambda s: squash_unicode(jsonapi.loads(s))
44 json_unpacker = lambda s: squash_unicode(jsonapi.loads(s))
57
45
58 pickle_packer = lambda o: pickle.dumps(o,-1)
46 pickle_packer = lambda o: pickle.dumps(o,-1)
59 pickle_unpacker = pickle.loads
47 pickle_unpacker = pickle.loads
60
48
61 if use_json:
49 default_packer = json_packer
62 default_packer = json_packer
50 default_unpacker = json_unpacker
63 default_unpacker = json_unpacker
64 else:
65 default_packer = pickle_packer
66 default_unpacker = pickle_unpacker
67
51
68
52
69 DELIM="<IDS|MSG>"
53 DELIM="<IDS|MSG>"
70
54
71 class Message(object):
55 class Message(object):
72 """A simple message object that maps dict keys to attributes.
56 """A simple message object that maps dict keys to attributes.
73
57
74 A Message can be created from a dict and a dict from a Message instance
58 A Message can be created from a dict and a dict from a Message instance
75 simply by calling dict(msg_obj)."""
59 simply by calling dict(msg_obj)."""
76
60
77 def __init__(self, msg_dict):
61 def __init__(self, msg_dict):
78 dct = self.__dict__
62 dct = self.__dict__
79 for k, v in dict(msg_dict).iteritems():
63 for k, v in dict(msg_dict).iteritems():
80 if isinstance(v, dict):
64 if isinstance(v, dict):
81 v = Message(v)
65 v = Message(v)
82 dct[k] = v
66 dct[k] = v
83
67
84 # Having this iterator lets dict(msg_obj) work out of the box.
68 # Having this iterator lets dict(msg_obj) work out of the box.
85 def __iter__(self):
69 def __iter__(self):
86 return iter(self.__dict__.iteritems())
70 return iter(self.__dict__.iteritems())
87
71
88 def __repr__(self):
72 def __repr__(self):
89 return repr(self.__dict__)
73 return repr(self.__dict__)
90
74
91 def __str__(self):
75 def __str__(self):
92 return pprint.pformat(self.__dict__)
76 return pprint.pformat(self.__dict__)
93
77
94 def __contains__(self, k):
78 def __contains__(self, k):
95 return k in self.__dict__
79 return k in self.__dict__
96
80
97 def __getitem__(self, k):
81 def __getitem__(self, k):
98 return self.__dict__[k]
82 return self.__dict__[k]
99
83
100
84
101 def msg_header(msg_id, msg_type, username, session):
85 def msg_header(msg_id, msg_type, username, session):
102 date=datetime.now().strftime(ISO8601)
86 date=datetime.now().strftime(ISO8601)
103 return locals()
87 return locals()
104
88
105 def extract_header(msg_or_header):
89 def extract_header(msg_or_header):
106 """Given a message or header, return the header."""
90 """Given a message or header, return the header."""
107 if not msg_or_header:
91 if not msg_or_header:
108 return {}
92 return {}
109 try:
93 try:
110 # See if msg_or_header is the entire message.
94 # See if msg_or_header is the entire message.
111 h = msg_or_header['header']
95 h = msg_or_header['header']
112 except KeyError:
96 except KeyError:
113 try:
97 try:
114 # See if msg_or_header is just the header
98 # See if msg_or_header is just the header
115 h = msg_or_header['msg_id']
99 h = msg_or_header['msg_id']
116 except KeyError:
100 except KeyError:
117 raise
101 raise
118 else:
102 else:
119 h = msg_or_header
103 h = msg_or_header
120 if not isinstance(h, dict):
104 if not isinstance(h, dict):
121 h = dict(h)
105 h = dict(h)
122 return h
106 return h
123
107
124 class StreamSession(object):
108 class StreamSession(object):
125 """tweaked version of IPython.zmq.session.Session, for development in Parallel"""
109 """tweaked version of IPython.zmq.session.Session, for development in Parallel"""
126 debug=False
110 debug=False
127 key=None
111 key=None
128
112
129 def __init__(self, username=None, session=None, packer=None, unpacker=None, key=None, keyfile=None):
113 def __init__(self, username=None, session=None, packer=None, unpacker=None, key=None, keyfile=None):
130 if username is None:
114 if username is None:
131 username = os.environ.get('USER','username')
115 username = os.environ.get('USER','username')
132 self.username = username
116 self.username = username
133 if session is None:
117 if session is None:
134 self.session = str(uuid.uuid4())
118 self.session = str(uuid.uuid4())
135 else:
119 else:
136 self.session = session
120 self.session = session
137 self.msg_id = str(uuid.uuid4())
121 self.msg_id = str(uuid.uuid4())
138 if packer is None:
122 if packer is None:
139 self.pack = default_packer
123 self.pack = default_packer
140 else:
124 else:
141 if not callable(packer):
125 if not callable(packer):
142 raise TypeError("packer must be callable, not %s"%type(packer))
126 raise TypeError("packer must be callable, not %s"%type(packer))
143 self.pack = packer
127 self.pack = packer
144
128
145 if unpacker is None:
129 if unpacker is None:
146 self.unpack = default_unpacker
130 self.unpack = default_unpacker
147 else:
131 else:
148 if not callable(unpacker):
132 if not callable(unpacker):
149 raise TypeError("unpacker must be callable, not %s"%type(unpacker))
133 raise TypeError("unpacker must be callable, not %s"%type(unpacker))
150 self.unpack = unpacker
134 self.unpack = unpacker
151
135
152 if key is not None and keyfile is not None:
136 if key is not None and keyfile is not None:
153 raise TypeError("Must specify key OR keyfile, not both")
137 raise TypeError("Must specify key OR keyfile, not both")
154 if keyfile is not None:
138 if keyfile is not None:
155 with open(keyfile) as f:
139 with open(keyfile) as f:
156 self.key = f.read().strip()
140 self.key = f.read().strip()
157 else:
141 else:
158 self.key = key
142 self.key = key
159 if isinstance(self.key, unicode):
143 if isinstance(self.key, unicode):
160 self.key = self.key.encode('utf8')
144 self.key = self.key.encode('utf8')
161 # print key, keyfile, self.key
145 # print key, keyfile, self.key
162 self.none = self.pack({})
146 self.none = self.pack({})
163
147
164 def msg_header(self, msg_type):
148 def msg_header(self, msg_type):
165 h = msg_header(self.msg_id, msg_type, self.username, self.session)
149 h = msg_header(self.msg_id, msg_type, self.username, self.session)
166 self.msg_id = str(uuid.uuid4())
150 self.msg_id = str(uuid.uuid4())
167 return h
151 return h
168
152
169 def msg(self, msg_type, content=None, parent=None, subheader=None):
153 def msg(self, msg_type, content=None, parent=None, subheader=None):
170 msg = {}
154 msg = {}
171 msg['header'] = self.msg_header(msg_type)
155 msg['header'] = self.msg_header(msg_type)
172 msg['msg_id'] = msg['header']['msg_id']
156 msg['msg_id'] = msg['header']['msg_id']
173 msg['parent_header'] = {} if parent is None else extract_header(parent)
157 msg['parent_header'] = {} if parent is None else extract_header(parent)
174 msg['msg_type'] = msg_type
158 msg['msg_type'] = msg_type
175 msg['content'] = {} if content is None else content
159 msg['content'] = {} if content is None else content
176 sub = {} if subheader is None else subheader
160 sub = {} if subheader is None else subheader
177 msg['header'].update(sub)
161 msg['header'].update(sub)
178 return msg
162 return msg
179
163
180 def check_key(self, msg_or_header):
164 def check_key(self, msg_or_header):
181 """Check that a message's header has the right key"""
165 """Check that a message's header has the right key"""
182 if self.key is None:
166 if self.key is None:
183 return True
167 return True
184 header = extract_header(msg_or_header)
168 header = extract_header(msg_or_header)
185 return header.get('key', None) == self.key
169 return header.get('key', None) == self.key
186
170
187
171
188 def send(self, stream, msg_or_type, content=None, buffers=None, parent=None, subheader=None, ident=None, track=False):
172 def send(self, stream, msg_or_type, content=None, buffers=None, parent=None, subheader=None, ident=None, track=False):
189 """Build and send a message via stream or socket.
173 """Build and send a message via stream or socket.
190
174
191 Parameters
175 Parameters
192 ----------
176 ----------
193
177
194 stream : zmq.Socket or ZMQStream
178 stream : zmq.Socket or ZMQStream
195 the socket-like object used to send the data
179 the socket-like object used to send the data
196 msg_or_type : str or Message/dict
180 msg_or_type : str or Message/dict
197 Normally, msg_or_type will be a msg_type unless a message is being sent more
181 Normally, msg_or_type will be a msg_type unless a message is being sent more
198 than once.
182 than once.
199
183
200 content : dict or None
184 content : dict or None
201 the content of the message (ignored if msg_or_type is a message)
185 the content of the message (ignored if msg_or_type is a message)
202 buffers : list or None
186 buffers : list or None
203 the already-serialized buffers to be appended to the message
187 the already-serialized buffers to be appended to the message
204 parent : Message or dict or None
188 parent : Message or dict or None
205 the parent or parent header describing the parent of this message
189 the parent or parent header describing the parent of this message
206 subheader : dict or None
190 subheader : dict or None
207 extra header keys for this message's header
191 extra header keys for this message's header
208 ident : bytes or list of bytes
192 ident : bytes or list of bytes
209 the zmq.IDENTITY routing path
193 the zmq.IDENTITY routing path
210 track : bool
194 track : bool
211 whether to track. Only for use with Sockets, because ZMQStream objects cannot track messages.
195 whether to track. Only for use with Sockets, because ZMQStream objects cannot track messages.
212
196
213 Returns
197 Returns
214 -------
198 -------
215 msg : message dict
199 msg : message dict
216 the constructed message
200 the constructed message
217 (msg,tracker) : (message dict, MessageTracker)
201 (msg,tracker) : (message dict, MessageTracker)
218 if track=True, then a 2-tuple will be returned, the first element being the constructed
202 if track=True, then a 2-tuple will be returned, the first element being the constructed
219 message, and the second being the MessageTracker
203 message, and the second being the MessageTracker
220
204
221 """
205 """
222
206
223 if not isinstance(stream, (zmq.Socket, ZMQStream)):
207 if not isinstance(stream, (zmq.Socket, ZMQStream)):
224 raise TypeError("stream must be Socket or ZMQStream, not %r"%type(stream))
208 raise TypeError("stream must be Socket or ZMQStream, not %r"%type(stream))
225 elif track and isinstance(stream, ZMQStream):
209 elif track and isinstance(stream, ZMQStream):
226 raise TypeError("ZMQStream cannot track messages")
210 raise TypeError("ZMQStream cannot track messages")
227
211
228 if isinstance(msg_or_type, (Message, dict)):
212 if isinstance(msg_or_type, (Message, dict)):
229 # we got a Message, not a msg_type
213 # we got a Message, not a msg_type
230 # don't build a new Message
214 # don't build a new Message
231 msg = msg_or_type
215 msg = msg_or_type
232 content = msg['content']
216 content = msg['content']
233 else:
217 else:
234 msg = self.msg(msg_or_type, content, parent, subheader)
218 msg = self.msg(msg_or_type, content, parent, subheader)
235
219
236 buffers = [] if buffers is None else buffers
220 buffers = [] if buffers is None else buffers
237 to_send = []
221 to_send = []
238 if isinstance(ident, list):
222 if isinstance(ident, list):
239 # accept list of idents
223 # accept list of idents
240 to_send.extend(ident)
224 to_send.extend(ident)
241 elif ident is not None:
225 elif ident is not None:
242 to_send.append(ident)
226 to_send.append(ident)
243 to_send.append(DELIM)
227 to_send.append(DELIM)
244 if self.key is not None:
228 if self.key is not None:
245 to_send.append(self.key)
229 to_send.append(self.key)
246 to_send.append(self.pack(msg['header']))
230 to_send.append(self.pack(msg['header']))
247 to_send.append(self.pack(msg['parent_header']))
231 to_send.append(self.pack(msg['parent_header']))
248
232
249 if content is None:
233 if content is None:
250 content = self.none
234 content = self.none
251 elif isinstance(content, dict):
235 elif isinstance(content, dict):
252 content = self.pack(content)
236 content = self.pack(content)
253 elif isinstance(content, bytes):
237 elif isinstance(content, bytes):
254 # content is already packed, as in a relayed message
238 # content is already packed, as in a relayed message
255 pass
239 pass
256 else:
240 else:
257 raise TypeError("Content incorrect type: %s"%type(content))
241 raise TypeError("Content incorrect type: %s"%type(content))
258 to_send.append(content)
242 to_send.append(content)
259 flag = 0
243 flag = 0
260 if buffers:
244 if buffers:
261 flag = zmq.SNDMORE
245 flag = zmq.SNDMORE
262 _track = False
246 _track = False
263 else:
247 else:
264 _track=track
248 _track=track
265 if track:
249 if track:
266 tracker = stream.send_multipart(to_send, flag, copy=False, track=_track)
250 tracker = stream.send_multipart(to_send, flag, copy=False, track=_track)
267 else:
251 else:
268 tracker = stream.send_multipart(to_send, flag, copy=False)
252 tracker = stream.send_multipart(to_send, flag, copy=False)
269 for b in buffers[:-1]:
253 for b in buffers[:-1]:
270 stream.send(b, flag, copy=False)
254 stream.send(b, flag, copy=False)
271 if buffers:
255 if buffers:
272 if track:
256 if track:
273 tracker = stream.send(buffers[-1], copy=False, track=track)
257 tracker = stream.send(buffers[-1], copy=False, track=track)
274 else:
258 else:
275 tracker = stream.send(buffers[-1], copy=False)
259 tracker = stream.send(buffers[-1], copy=False)
276
260
277 # omsg = Message(msg)
261 # omsg = Message(msg)
278 if self.debug:
262 if self.debug:
279 pprint.pprint(msg)
263 pprint.pprint(msg)
280 pprint.pprint(to_send)
264 pprint.pprint(to_send)
281 pprint.pprint(buffers)
265 pprint.pprint(buffers)
282
266
283 msg['tracker'] = tracker
267 msg['tracker'] = tracker
284
268
285 return msg
269 return msg
286
270
287 def send_raw(self, stream, msg, flags=0, copy=True, ident=None):
271 def send_raw(self, stream, msg, flags=0, copy=True, ident=None):
288 """Send a raw message via ident path.
272 """Send a raw message via ident path.
289
273
290 Parameters
274 Parameters
291 ----------
275 ----------
292 msg : list of sendable buffers"""
276 msg : list of sendable buffers"""
293 to_send = []
277 to_send = []
294 if isinstance(ident, bytes):
278 if isinstance(ident, bytes):
295 ident = [ident]
279 ident = [ident]
296 if ident is not None:
280 if ident is not None:
297 to_send.extend(ident)
281 to_send.extend(ident)
298 to_send.append(DELIM)
282 to_send.append(DELIM)
299 if self.key is not None:
283 if self.key is not None:
300 to_send.append(self.key)
284 to_send.append(self.key)
301 to_send.extend(msg)
285 to_send.extend(msg)
302 stream.send_multipart(msg, flags, copy=copy)
286 stream.send_multipart(msg, flags, copy=copy)
303
287
304 def recv(self, socket, mode=zmq.NOBLOCK, content=True, copy=True):
288 def recv(self, socket, mode=zmq.NOBLOCK, content=True, copy=True):
305 """receives and unpacks a message
289 """receives and unpacks a message
306 returns [idents], msg"""
290 returns [idents], msg"""
307 if isinstance(socket, ZMQStream):
291 if isinstance(socket, ZMQStream):
308 socket = socket.socket
292 socket = socket.socket
309 try:
293 try:
310 msg = socket.recv_multipart(mode)
294 msg = socket.recv_multipart(mode)
311 except zmq.ZMQError as e:
295 except zmq.ZMQError as e:
312 if e.errno == zmq.EAGAIN:
296 if e.errno == zmq.EAGAIN:
313 # We can convert EAGAIN to None as we know in this case
297 # We can convert EAGAIN to None as we know in this case
314 # recv_multipart won't return None.
298 # recv_multipart won't return None.
315 return None
299 return None
316 else:
300 else:
317 raise
301 raise
318 # return an actual Message object
302 # return an actual Message object
319 # determine the number of idents by trying to unpack them.
303 # determine the number of idents by trying to unpack them.
320 # this is terrible:
304 # this is terrible:
321 idents, msg = self.feed_identities(msg, copy)
305 idents, msg = self.feed_identities(msg, copy)
322 try:
306 try:
323 return idents, self.unpack_message(msg, content=content, copy=copy)
307 return idents, self.unpack_message(msg, content=content, copy=copy)
324 except Exception as e:
308 except Exception as e:
325 print (idents, msg)
309 print (idents, msg)
326 # TODO: handle it
310 # TODO: handle it
327 raise e
311 raise e
328
312
329 def feed_identities(self, msg, copy=True):
313 def feed_identities(self, msg, copy=True):
330 """feed until DELIM is reached, then return the prefix as idents and remainder as
314 """feed until DELIM is reached, then return the prefix as idents and remainder as
331 msg. This is easily broken by setting an IDENT to DELIM, but that would be silly.
315 msg. This is easily broken by setting an IDENT to DELIM, but that would be silly.
332
316
333 Parameters
317 Parameters
334 ----------
318 ----------
335 msg : a list of Message or bytes objects
319 msg : a list of Message or bytes objects
336 the message to be split
320 the message to be split
337 copy : bool
321 copy : bool
338 flag determining whether the arguments are bytes or Messages
322 flag determining whether the arguments are bytes or Messages
339
323
340 Returns
324 Returns
341 -------
325 -------
342 (idents,msg) : two lists
326 (idents,msg) : two lists
343 idents will always be a list of bytes - the indentity prefix
327 idents will always be a list of bytes - the indentity prefix
344 msg will be a list of bytes or Messages, unchanged from input
328 msg will be a list of bytes or Messages, unchanged from input
345 msg should be unpackable via self.unpack_message at this point.
329 msg should be unpackable via self.unpack_message at this point.
346 """
330 """
347 ikey = int(self.key is not None)
331 ikey = int(self.key is not None)
348 minlen = 3 + ikey
332 minlen = 3 + ikey
349 msg = list(msg)
333 msg = list(msg)
350 idents = []
334 idents = []
351 while len(msg) > minlen:
335 while len(msg) > minlen:
352 if copy:
336 if copy:
353 s = msg[0]
337 s = msg[0]
354 else:
338 else:
355 s = msg[0].bytes
339 s = msg[0].bytes
356 if s == DELIM:
340 if s == DELIM:
357 msg.pop(0)
341 msg.pop(0)
358 break
342 break
359 else:
343 else:
360 idents.append(s)
344 idents.append(s)
361 msg.pop(0)
345 msg.pop(0)
362
346
363 return idents, msg
347 return idents, msg
364
348
365 def unpack_message(self, msg, content=True, copy=True):
349 def unpack_message(self, msg, content=True, copy=True):
366 """Return a message object from the format
350 """Return a message object from the format
367 sent by self.send.
351 sent by self.send.
368
352
369 Parameters:
353 Parameters:
370 -----------
354 -----------
371
355
372 content : bool (True)
356 content : bool (True)
373 whether to unpack the content dict (True),
357 whether to unpack the content dict (True),
374 or leave it serialized (False)
358 or leave it serialized (False)
375
359
376 copy : bool (True)
360 copy : bool (True)
377 whether to return the bytes (True),
361 whether to return the bytes (True),
378 or the non-copying Message object in each place (False)
362 or the non-copying Message object in each place (False)
379
363
380 """
364 """
381 ikey = int(self.key is not None)
365 ikey = int(self.key is not None)
382 minlen = 3 + ikey
366 minlen = 3 + ikey
383 message = {}
367 message = {}
384 if not copy:
368 if not copy:
385 for i in range(minlen):
369 for i in range(minlen):
386 msg[i] = msg[i].bytes
370 msg[i] = msg[i].bytes
387 if ikey:
371 if ikey:
388 if not self.key == msg[0]:
372 if not self.key == msg[0]:
389 raise KeyError("Invalid Session Key: %s"%msg[0])
373 raise KeyError("Invalid Session Key: %s"%msg[0])
390 if not len(msg) >= minlen:
374 if not len(msg) >= minlen:
391 raise TypeError("malformed message, must have at least %i elements"%minlen)
375 raise TypeError("malformed message, must have at least %i elements"%minlen)
392 message['header'] = self.unpack(msg[ikey+0])
376 message['header'] = self.unpack(msg[ikey+0])
393 message['msg_type'] = message['header']['msg_type']
377 message['msg_type'] = message['header']['msg_type']
394 message['parent_header'] = self.unpack(msg[ikey+1])
378 message['parent_header'] = self.unpack(msg[ikey+1])
395 if content:
379 if content:
396 message['content'] = self.unpack(msg[ikey+2])
380 message['content'] = self.unpack(msg[ikey+2])
397 else:
381 else:
398 message['content'] = msg[ikey+2]
382 message['content'] = msg[ikey+2]
399
383
400 message['buffers'] = msg[ikey+3:]# [ m.buffer for m in msg[3:] ]
384 message['buffers'] = msg[ikey+3:]# [ m.buffer for m in msg[3:] ]
401 return message
385 return message
402
386
403
387
404 def test_msg2obj():
388 def test_msg2obj():
405 am = dict(x=1)
389 am = dict(x=1)
406 ao = Message(am)
390 ao = Message(am)
407 assert ao.x == am['x']
391 assert ao.x == am['x']
408
392
409 am['y'] = dict(z=1)
393 am['y'] = dict(z=1)
410 ao = Message(am)
394 ao = Message(am)
411 assert ao.y.z == am['y']['z']
395 assert ao.y.z == am['y']['z']
412
396
413 k1, k2 = 'y', 'z'
397 k1, k2 = 'y', 'z'
414 assert ao[k1][k2] == am[k1][k2]
398 assert ao[k1][k2] == am[k1][k2]
415
399
416 am2 = dict(ao)
400 am2 = dict(ao)
417 assert am['x'] == am2['x']
401 assert am['x'] == am2['x']
418 assert am['y']['z'] == am2['y']['z']
402 assert am['y']['z'] == am2['y']['z']
General Comments 0
You need to be logged in to leave comments. Login now