Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1# -*- coding: utf-8 -*-
2"""
3@file
4@brief define an Email grabbed from a server.
5"""
7import os
8import re
9import json
10import datetime
11import email
12from email.generator import BytesGenerator, Generator
13import email.header
14import email.message
15from io import BytesIO, StringIO
16import mimetypes
17import hashlib
18import warnings
19from collections import OrderedDict
20import dateutil.parser
21from pyquickhelper.loghelper import noLOG
23from .mail_exception import MailException
24from .additional_mime_type import additional_mime_type_ext_type
27class EmailMessage(email.message.Message):
29 """
30 overloads the message to class to add some
31 functionalities such as a display using HTML
32 """
34 expMail1 = re.compile('(\\"([^;,]*?)\\" )?<([^;, ]+?@[^;, ]+)>')
35 expMail2 = re.compile('(([^;,]*?) )?<([^;, ]+?@[^;, ]+)>')
36 expMail3 = re.compile('(\\"([^;,]*?)\\" )?([^;, ]+?@[^;, ]+)')
37 expMail4 = re.compile('((=[?]([^;,]+?)[?]=)? ?<([^;, ]+?@[^;, ]+)>)')
38 expMailA = re.compile(
39 '({0})|({1})|({2})'.format(
40 expMail1.pattern,
41 expMail2.pattern,
42 expMail3.pattern))
44 subset = ["Date", "From", "Subject", "To", "X-bcc"]
45 avoid = ["X-me-spamcause", "X-YMail-OSG"]
47 additionnalMimeType = additional_mime_type_ext_type
48 _date_format = "%Y-%m-%dT%H:%M:%S.%fZ"
50 def as_bytes(self): # pylint: disable=W0221
51 """
52 converts the mail into a binary string
54 @return bytes
56 See `Message.as_bytes <https://docs.python.org/3/library/email.message.html#email.message.Message.as_bytes>`_
57 """
58 fp = BytesIO()
59 g = BytesGenerator(fp, mangle_from_=True, maxheaderlen=60)
60 g.flatten(self)
61 return fp.getvalue()
63 def as_string(self, unixfrom=False, maxheaderlen=None, policy=None):
64 """
65 Converts the mail into a string.
67 @return string
69 See `Message.as_string <https://docs.python.org/3/library/email.message.html#email.message.Message.as_string>`_
70 """
71 fp = StringIO()
72 g = Generator(fp, mangle_from_=True, maxheaderlen=60)
73 g.flatten(self)
74 return fp.getvalue()
76 @staticmethod
77 def create_from_bytes(b):
78 """
79 Creates an instance of @see cl EmailMessage
80 from a binary string (bytes) (see @see me as_bytes).
82 @param b binary string
83 @return instance of @see cl EmailMessage
84 """
85 return email.message_from_bytes(b, _class=EmailMessage)
87 @property
88 def body(self):
89 """
90 return the body of the message
91 """
92 messages = []
93 for part in self.walk():
94 if part.get_content_type() == "text/html":
95 b = part.get_payload(decode=1)
96 if b is not None:
97 encs = [part.get_content_charset(), "utf8"]
98 s = None
99 for enc in encs:
100 try:
101 s = b.decode(enc)
102 except UnicodeDecodeError:
103 continue
104 if s is None:
105 raise UnicodeDecodeError(
106 "unable to decode: {0}".format(b))
107 messages.append(s)
108 return "\n------------------------------------------\n\n".join(
109 messages)
111 def get_all_charsets(self, part=None):
112 """
113 returns all the charsets
114 """
115 if part is None:
116 charsets = set({})
117 for c in self.get_charsets():
118 if c is not None:
119 charsets.update([c])
120 return charsets
121 else:
122 charsets = set({})
123 for c in part.get_charsets():
124 if c is not None:
125 charsets.update([c])
126 return charsets
128 def get_nb_attachements(self):
129 """
130 return the number of attachments
132 @return int
133 """
134 r = 0
135 for part in self.walk():
136 if part.get_content_maintype() == 'multipart':
137 continue
138 if part.get('Content-Disposition') is None:
139 continue
140 r += 1
141 return r
143 @property
144 def body_html(self):
145 """
146 return the body of the messag
147 """
148 messages = []
149 for part in self.walk():
150 if part.get_content_type() == "text/html":
151 b = part.get_payload(decode=1)
152 if b is not None:
153 chs = list(self.get_all_charsets(part))
154 if len(chs) > 0:
155 try:
156 ht = b.decode(chs[0])
157 except UnicodeDecodeError:
158 try:
159 ht = b.decode("utf-8")
160 except UnicodeDecodeError:
161 try:
162 ht = b.decode("latin-1")
163 except UnicodeDecodeError:
164 raise Exception( # pylint: disable=W0707
165 "unable to decode %r: %r" % (chs[0], b))
166 else:
167 try:
168 ht = b.decode("utf-8")
169 except UnicodeDecodeError:
170 ht = b.decode("utf-8", errors='ignore')
171 #raise MailException("unable to decode: " + str(b)) from e
172 htl = ht.lower()
173 pos = htl.find("<body")
174 pos2 = htl.find("</body>")
175 if pos != -1 and pos2 != -1:
176 ht = '<div ' + ht[pos + 5:pos2] + "</div>"
177 elif pos != -1:
178 ht = '<div ' + ht[pos + 5:] + "</div>"
179 elif pos2 != -1:
180 ht = '<div>' + ht[:pos2] + "</div>"
181 else:
182 ht = '<div>' + ht + "</div>"
183 messages.append(ht)
184 text = "<hr />".join(messages)
185 return text
187 def enumerate_attachments(self):
188 """
189 enumerate the attachments as
190 4-uple (filename, content, message_id, content_id)
192 @return iterator on tuple (filename, content, message_id, content_id)
193 """
194 for part in self.walk():
195 if part.get_content_maintype() == 'multipart':
196 continue
197 if part.get('Content-Disposition') is None:
198 continue
200 fileName = part.get_filename()
201 fileName = self.decode_header("file", fileName)
203 if fileName is not None and fileName.startswith(
204 "=?") and fileName.startswith("?="):
205 fileName = fileName.strip("=?").split("=")[-1] # pylint: disable=C0207
207 if fileName is None or "?" in fileName:
208 fileName = "unknown_type"
209 cont = part.get_payload(decode=True)
210 cont_id = part["Message-ID"]
211 cont_id2 = part["Content-ID"]
212 ext = EmailMessage.additionnalMimeType.get(
213 part.get_content_subtype(),
214 None)
215 if ext is None:
216 ext = mimetypes.guess_extension(part.get_content_type())
218 if ext is not None:
219 fileName += ext
220 elif cont is not None:
221 if cont.startswith(b"%PDF"):
222 fileName += ".pdf"
223 elif part.get_content_maintype() == "text":
224 if cont.startswith(b"<html>"):
225 fileName += ".html"
226 else:
227 fileName += ".txt"
228 else:
229 raise MailException("unable to guess type: " +
230 part.get_content_maintype() +
231 "\nsubtype: " +
232 str(part.get_content_subtype()) +
233 " ext: " +
234 str(ext) +
235 " def: " +
236 EmailMessage.additionnalMimeType.get(part.get_content_subtype(), "-") +
237 "\n" +
238 str([cont]))
239 else:
240 cont = part.get_payload(decode=True)
241 cont_id = part[
242 "Message-ID"].strip("<>") if part["Message-ID"] else None
243 cont_id2 = part[
244 "Content-ID"].strip("<>") if part["Content-ID"] else None
246 yield fileName, cont, cont_id, cont_id2
248 def __sortkey__(self):
249 """
250 usual
251 """
252 key = [self.get_date(), self.get_from(), self.get_to(),
253 self.UniqueID, self["subject"]]
254 for i, k in enumerate(key):
255 if isinstance(k, tuple):
256 if None in k:
257 key[i] = tuple("" if _ is None else _ for _ in k)
258 elif k is None:
259 key[i] = ""
260 return tuple(key)
262 def __lt__(self, at):
263 """
264 usual
265 """
266 try:
267 return self.__sortkey__() < at.__sortkey__()
268 except TypeError as e:
269 raise Exception("issue with\n{0}\n{1}".format(
270 self.__sortkey__(), at.__sortkey__())) from e
272 #: use for method @see me call_decode_header
273 _search_encodings = ["iso-8859-1", "windows-1252", "UTF-8", "utf-8"]
275 @staticmethod
276 def call_decode_header(st, is_email=False):
277 """
278 call `email.header.decode_header <https://docs.python.org/3.4/library/email.header.html#email.header.decode_header>`_
280 @param st string or `email.header.Header <https://docs.python.org/3.4/library/email.header.html#email.header.Header>`_
281 @param is_email does something specific for emails
282 @return text, encoding
283 """
284 if isinstance(st, email.header.Header):
285 text, encoding = email.header.decode_header(st)[0]
286 if isinstance(text, bytes):
287 if encoding is None:
288 raise ValueError(
289 "encoding cannot be None if the returned string is bytes")
290 if encoding == "unknown-8bit":
291 try:
292 res = text.decode("utf8")
293 except UnicodeDecodeError:
294 res = text.decode("ascii", errors="ignore")
295 # raise ValueError("encoding {0} is unexpected in\n{1}".format(encoding, st)) from e
296 return res, "ascii"
297 else:
298 return text.decode(encoding), encoding
299 else:
300 return text, encoding
301 elif isinstance(st, str):
302 if is_email:
303 zall = EmailMessage.expMail4.findall(st)
304 if zall:
305 res = []
306 for add in zall:
307 head, enc = EmailMessage.call_decode_header(
308 add[0], is_email=False)
309 fin = "{0} <{1}>".format(head, add[-1])
310 res.append(fin)
311 return "; ".join(res), enc
312 else:
313 return EmailMessage.call_decode_header(st, is_email=False)
314 else:
315 text, encoding = email.header.decode_header(st)[0]
316 if isinstance(text, bytes):
317 position = None
318 if encoding is None:
319 # maybe the string contrains several encoding
320 for enc in EmailMessage._search_encodings:
321 look = "=?%s?" % enc
322 if look in st:
323 position = st.find(look)
325 if position == 0:
326 # otherwise we face an infinite loop
327 position = None
329 if position is not None:
330 first = st[:position]
331 second = st[position:]
332 dec1, enc1 = EmailMessage.call_decode_header(first)
333 dec2, enc2 = EmailMessage.call_decode_header(
334 second)
336 if isinstance(dec1, str) and isinstance(dec2, str):
337 enc = enc2 if enc1 is None else enc1
338 return dec1 + dec2, enc
339 else:
340 mes = ('decoding issue\n File "{0}", line {1},\nunable to decode ' +
341 'string:\n{2}\neven split into:\n1: {3}\n2: {4}')
342 warnings.warn(mes.format(__file__, 250, st.replace("\r", " ").replace("\n", " "),
343 first.replace("\r", " ").replace(
344 "\n", " "),
345 second.replace("\r", " ").replace("\n", " ")))
346 return st, None
347 else:
348 warnings.warn(
349 'decoding issue\n File "{0}", line {1},\nunable to decode string:\n{2}'.format(
350 __file__,
351 260,
352 st.replace(
353 "\r",
354 " ").replace(
355 "\n",
356 " ")))
357 return st, None
358 else:
359 return text.decode(encoding), encoding
360 else:
361 return text, encoding
362 else:
363 raise TypeError("cannot decode type: {0}".format(type(st)))
365 def get_from_str(self):
366 """
367 return a string for the receivers
369 @return string
370 """
371 l, a = self.get_from()
372 res = []
373 if l:
374 res.append(l)
375 else:
376 res.append(a)
377 return ";".join(res)
379 def get_from(self):
380 """
381 returns a tuple (label, email address) or a list of groups
382 from the regular expression
384 @return tuple ( label, email address)
385 """
386 st = self["from"]
387 if isinstance(st, email.header.Header):
388 text, _ = EmailMessage.call_decode_header(st, is_email=True)
389 if text is None:
390 raise MailException(
391 "unable to parse: " +
392 str(text) +
393 "\n" +
394 str(st))
395 else:
396 text = st
398 cp = EmailMessage.expMail1.search(text)
399 if not cp:
400 cp = EmailMessage.expMail2.search(text)
401 if not cp:
402 cp = EmailMessage.expMail3.search(text)
403 if not cp:
404 if text.startswith('"=?utf-8?'):
405 text = text.strip('"')
406 text, _ = EmailMessage.call_decode_header(
407 text, is_email=True)
408 gr = cp.groups()
409 name, mail = gr[1], gr[2]
410 if name is None:
411 name = self.get_name(_fallback_get_from=False)
412 elif name.startswith("=?"):
413 name = EmailMessage.call_decode_header(name)[0]
414 if name is None:
415 name = gr[1]
416 return name, mail
418 def get_name(self, _fallback_get_from=True):
419 """
420 return the sender name of an email (if available)
422 @param _fallback_get_from internal parameter, avoir recursion
423 @return name (or None if not found)
424 """
425 st = self["from"]
426 if isinstance(st, email.header.Header):
427 text, _ = EmailMessage.call_decode_header(st, is_email=True)
428 if text is None:
429 raise MailException(
430 "unable to parse: " +
431 str(text) +
432 "\n" +
433 str(st))
434 elif st.startswith("=?"):
435 text, _ = EmailMessage.call_decode_header(st)
436 else:
437 text = st
439 if "<" in text:
440 r = text.split("<")[0].strip()
441 return r if r else None
442 elif text is None and _fallback_get_from:
443 return self.get_from()[0]
444 else:
445 return text
447 def get_to_str(self, cc=False, field="to"):
448 """
449 return a string for the receivers
451 @param cc get receivers or second receivers
452 @param field field to use, ``to`` or ``Delivered-To``
453 (the second one is used as a backup anyway)
454 @return string
455 """
456 to = self.get_to(cc=cc, field=field)
457 if to is None:
458 return ""
459 res = []
460 for li, a in to: # pylint: disable=E1133
461 if li:
462 res.append(li)
463 else:
464 res.append(a)
465 return ";".join(res)
467 def get_to(self, cc=False, field="to"):
468 """
469 return the receivers
471 @param cc get receivers or second receivers
472 @param field field to use, ``to`` or ``Delivered-To``
473 (the second one is used as a backup anyway)
474 @return list of tuple [ ( label, email address) ]
475 """
476 st = self[field if not cc else "cc"]
477 if st is None and not cc:
478 st = self["Delivered-To"]
479 if st is None:
480 return None
481 text, _ = EmailMessage.call_decode_header(st, is_email=True)
482 if text is None:
483 raise MailException("unable to parse: " + str(st))
485 def find_unnone(ens):
486 "local function"
487 for c in ens:
488 if c is not None:
489 return c
490 return None
492 text = text.replace("\r", " ").replace("\n", " ").replace("\t", " ")
493 cp = []
494 for st in EmailMessage.expMailA.finditer(text):
495 gr = st.groups()
496 if len(gr) != 12:
497 raise MailException(
498 "unexpected error due to a change in regular expressions")
499 values = gr[2], gr[3], gr[6], gr[7], gr[10], gr[11]
500 label = find_unnone(values[::2])
501 add = find_unnone(values[1::2])
502 if label is not None:
503 label = label.strip(" \r\n\t")
504 text, _ = EmailMessage.call_decode_header(
505 label, is_email=True)
506 if text.startswith('"=?utf-8?'):
507 text = text.strip('"')
508 text = EmailMessage.call_decode_header(
509 text, is_email=True)[0]
510 cp.append((text, add))
511 else:
512 cp.append((None, add))
514 return cp if cp else None
516 def get_date(self):
517 """
518 return a datetime object for the field Date
519 """
520 st = self["Date"]
521 res, _ = EmailMessage.call_decode_header(st)
522 if res is None:
523 raise MailException("unable to parse: " + str(st))
525 try:
526 p = dateutil.parser.parse(res)
527 except Exception as e:
528 # it can fail because of dates such as: Wed, 7 Oct 2009 11:43:56
529 # +0200 (Paris, Madrid (heure d'\ufffdt\ufffd))
530 if "(" in res:
531 res = res[:res.find("(")]
532 p = dateutil.parser.parse(res)
533 return p
534 else:
535 if "," in res:
536 b = res.split(",")[1]
537 try:
538 p = dateutil.parser.parse(b)
539 except Exception as e:
540 raise MailException(
541 "unable to parse: " +
542 str(res) +
543 "\n" +
544 str(st)) from e
545 else:
546 raise MailException(
547 "unable to parse: " +
548 str(res) +
549 "\n" +
550 str(st)) from e
551 if p is None:
552 raise MailException(
553 "unable to parse: " +
554 str(res) +
555 "\n" +
556 str(st))
557 return p
559 def get_date_str(self):
560 """
561 return the date into a string
563 @return date as a string (iso format)
564 """
565 return self.get_date().strftime(EmailMessage._date_format)
567 def default_filename(self):
568 """
569 define a default filename (no extension)
571 @return str
572 """
573 b = self.get_from()[1]
574 if len(b) == 0:
575 raise MailException("from is unknown: " + self["from"])
576 b = b.replace("@", "-at-").replace(".", "-")
577 date = self.get_date()
578 d = "%04d-%02d-%02d" % (date.year, date.month, date.day)
579 f = "d_{0}_p_{1}_ii_{2}".format(d, b, self.UniqueID)
580 return f.replace(
581 "\\", "-").replace("\r", "").replace("\n", "-").replace("%", "-").replace("/", "-")
583 @staticmethod
584 def interpret_default_filename(name):
585 """
586 reverse engineer method @see me default_filename
588 @param name filename
589 @return dictionary
591 The function creates a dictionary with keys date, from, uid, name.
592 """
593 pieces = name.split("_")
594 res = {}
595 for i, p in enumerate(pieces):
596 if p == "d" and "date" not in res:
597 res["date"] = pieces[i + 1]
598 elif p == "p" and "from" not in res:
599 res["from"] = pieces[i + 1]
600 elif p == "ii" and "uid" not in res:
601 res["uid"] = pieces[i + 1].split(".")[0]
602 res["name"] = name
603 return res
605 @property
606 def UniqueID(self):
607 """
608 builds a unique ID
609 """
610 md5 = hashlib.md5()
611 t = self["Message-ID"]
612 if t is not None:
613 md5.update(t.encode('utf-8'))
614 else:
615 for f in ["Subject", "To", "From", "Date"]:
616 if self[f] is not None:
617 md5.update(self[f].encode('utf-8'))
618 return md5.hexdigest()
620 def decode_header(self, field, st):
621 """
622 decode a string encoded in the header
624 @param field field
625 @param st string
626 @return string (it never return None)
627 """
628 if st is None:
629 return ""
630 elif isinstance(st, str):
631 if st.startswith("Tr:") and field.lower() == "subject":
632 pos = st.find("=?")
633 return st[:pos] + self.decode_header(field, st[pos:])
634 else:
635 text, _ = EmailMessage.call_decode_header(st)
636 return text if text is not None else ""
637 elif isinstance(st, bytes):
638 text, _ = EmailMessage.call_decode_header(st)
639 return self.decode_header(field, text) if text is not None else ""
640 elif isinstance(st, email.header.Header):
641 text = EmailMessage.call_decode_header(st)[0]
642 return self.decode_header(field, text) if text is not None else ""
643 else:
644 raise MailException(
645 "unable to process type " + str(type(st)) + "\n" + str(st))
647 def get_field(self, field):
648 """
649 get a field and cleans it
651 @param field subject or ...
652 @return text
653 """
654 subj = self[field]
655 if subj is None:
656 subj = self[field]
657 if subj is not None:
658 subj = self.decode_header(field, subj)
659 return subj
661 @property
662 def Fields(self):
663 """
664 @return list of available fields
665 """
666 return list(sorted(self.keys()))
668 def to_dict(self):
669 """
670 Returns all fields for an emails as a dictionary
671 @return dictionary { key : value }
672 """
673 res = OrderedDict((k, self.get_field(k)) for k in self.Fields)
674 res["attached"] = self.get_nb_attachements()
675 return res
677 def dump_attachments(self, attach_folder=".", buffer_write=None, metadata=True, fLOG=noLOG):
678 """
679 Dumps the mail into a folder using HTML format.
680 If the destination files already exists, it skips it.
681 If an attachments already has the same name, it chooses another one if
682 the attachment is different (otherwise it keeps it as it is).
684 @param attach_folder destination folder
685 @param buffer_write None or instance of @see cl BufferFilesWriting
686 @param metadata if True, also dump metadata about attachments
687 @param fLOG logging function
688 @return list of attachments
690 The results is a list of 3-uple:
692 * full name of the attachments
693 * message id
694 * content id
696 The metadata contains information about the mail it comes from.
697 The data is stored in a json format (except for date).
698 It is stored in a file with extension ``.metadata``.
699 """
700 def local_exists(name):
701 "local function"
702 if buffer_write:
703 return buffer_write.exists(name)
704 else:
705 return os.path.exists(name)
707 def local_different(to, content):
708 "local function"
709 if buffer_write:
710 c2 = buffer_write.read_binary_content(to)
711 else:
712 with open(to, "rb") as f:
713 c2 = f.read()
714 return c2 != content
716 atts = []
717 for ai, att in enumerate(self.enumerate_attachments()):
718 if att[1] is None:
719 continue
720 att_id = att[2]
721 cont_id = att[3]
722 to = os.path.split(att[0].replace(":", "_"))[-1]
723 if to == '':
724 to = 'empty_name'
725 to = os.path.join(attach_folder, to)
727 to = to.replace("\n", "_").replace("\r", "")
728 to = os.path.abspath(to)
729 spl = os.path.splitext(to)
731 if "?" in to:
732 raise MailException(
733 "issue with attachments (mail to {0})\n{1}".format(to, att))
735 if local_exists(to):
736 already = True
737 # must be different otherwise we don't do anything
738 different = local_different(to, att[1])
739 if different:
740 i = 1
741 while local_exists(to):
742 to = spl[0] + (".(%d)" % i) + spl[1]
743 i += 1
744 else:
745 already = False
746 different = True
748 fLOG("[dump_attachments] attachment:", to,
749 "different={0} notnew={1}".format(different, already))
751 if different:
752 if metadata:
753 d2 = dict(index=ai, filename=os.path.split(to)[-1],
754 mail=self.default_filename() + ".html",
755 from_=self.get_from(), to=self.get_to(),
756 date=self.get_date_str(), uid=self.UniqueID)
757 d2 = OrderedDict(sorted(d2.items()))
758 st = StringIO()
759 json.dump(d2, st)
760 meta_text = st.getvalue()
761 meta_name = to + ".metadata"
763 if buffer_write is None:
764 with open(to, "wb") as f:
765 f.write(att[1])
766 if metadata:
767 with open(meta_name, "r", encoding="utf8") as f:
768 f.write(meta_text)
769 else:
770 f = buffer_write.open(to, text=False)
771 f.write(att[1])
772 if metadata:
773 f = buffer_write.open(meta_name, text=True)
774 f.write(meta_text)
776 atts.append((to, att_id, cont_id))
777 return atts
779 def dump(self, render, location, attach_folder="attachments", fLOG=noLOG, **params):
780 """
781 Dumps a message using a call such as @see cl EmailMessageRenderer.
783 @param render instance of class @see cl EmailMessageRenderer
784 @param location location of the file to store
785 @param attach_folder folder for the attachments, it will be created if it does not exist
786 @param buffer_write None or instance of @see cl BufferFilesWriting
787 @param fLOG logging function
788 @param params others parameters, see
789 :meth:`EmailMessageRenderer.write <pymmails.grabber.email_message_renderer.EmailMessageRenderer.write>`
790 @return list of stored files
791 """
792 full_fold = os.path.join(location, attach_folder)
793 atts = self.dump_attachments(full_fold,
794 buffer_write=render.BufferWrite,
795 fLOG=fLOG)
796 return render.write(location=location, mail=self,
797 filename=params.get(
798 "filename", self.default_filename() + ".html"),
799 attachments=atts,
800 overwrite=params.get("overwrite", False),
801 file_css=params.get("file_css", "mail_style.css"),
802 encoding=params.get("encoding", "utf8"),
803 prev_mail=params.get("prev_mail", None),
804 next_mail=params.get("next_mail", None))
806 @staticmethod
807 def read_metadata(metafile):
808 """
809 read metadata assuming metafile contaings a json string
811 @param metafile json string
812 @return dictionary
813 """
814 if isinstance(metafile, str):
815 if len(metafile) < 5000 and os.path.exists(metafile):
816 with open(metafile, "r", encoding="utf8") as f:
817 d2 = json.load(f)
818 else:
819 f = StringIO(metafile)
820 d2 = json.load(f)
821 else:
822 d2 = json.load(metafile)
823 d2["date"] = datetime.datetime.strptime(
824 d2["date"], EmailMessage._date_format)
825 d2 = OrderedDict(sorted(d2.items()))
826 return d2