Minor updates:
[blender.git] / release / scripts / modules / bl_i18n_utils / utils.py
1 # ***** BEGIN GPL LICENSE BLOCK *****
2 #
3 # This program is free software; you can redistribute it and/or
4 # modify it under the terms of the GNU General Public License
5 # as published by the Free Software Foundation; either version 2
6 # of the License, or (at your option) any later version.
7 #
8 # This program is distributed in the hope that it will be useful,
9 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11 # GNU General Public License for more details.
12 #
13 # You should have received a copy of the GNU General Public License
14 # along with this program; if not, write to the Free Software Foundation,
15 # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 #
17 # ***** END GPL LICENSE BLOCK *****
18
19 # <pep8 compliant>
20
21 # Some misc utilities...
22
23 import collections
24 import copy
25 import os
26 import re
27 import sys
28
29 from bl_i18n_utils import settings
30
31
32 PO_COMMENT_PREFIX = settings.PO_COMMENT_PREFIX
33 PO_COMMENT_PREFIX_MSG = settings.PO_COMMENT_PREFIX_MSG
34 PO_COMMENT_PREFIX_SOURCE = settings.PO_COMMENT_PREFIX_SOURCE
35 PO_COMMENT_PREFIX_SOURCE_CUSTOM = settings.PO_COMMENT_PREFIX_SOURCE_CUSTOM
36 PO_COMMENT_FUZZY = settings.PO_COMMENT_FUZZY
37 PO_MSGCTXT = settings.PO_MSGCTXT
38 PO_MSGID = settings.PO_MSGID
39 PO_MSGSTR = settings.PO_MSGSTR
40
41 PO_HEADER_KEY = settings.PO_HEADER_KEY
42 PO_HEADER_COMMENT = settings.PO_HEADER_COMMENT
43 PO_HEADER_COMMENT_COPYRIGHT = settings.PO_HEADER_COMMENT_COPYRIGHT
44 PO_HEADER_MSGSTR = settings.PO_HEADER_MSGSTR
45
46 PARSER_CACHE_HASH = settings.PARSER_CACHE_HASH
47
48 WARN_NC = settings.WARN_MSGID_NOT_CAPITALIZED
49 NC_ALLOWED = settings.WARN_MSGID_NOT_CAPITALIZED_ALLOWED
50 PARSER_CACHE_HASH = settings.PARSER_CACHE_HASH
51
52
53 ##### Misc Utils #####
54
55 def stripeol(s):
56     return s.rstrip("\n\r")
57
58
59 _valid_po_path_re = re.compile(r"^\S+:[0-9]+$")
60 def is_valid_po_path(path):
61     return bool(_valid_po_path_re.match(path))
62
63
64 class I18nMessage:
65     """
66     Internal representation of a message.
67     """
68     __slots__ = ("msgctxt_lines", "msgid_lines", "msgstr_lines", "comment_lines", "is_fuzzy", "is_commented")
69
70     def __init__(self, msgctxt_lines=[], msgid_lines=[], msgstr_lines=[], comment_lines=[],
71                  is_commented=False, is_fuzzy=False):
72         self.msgctxt_lines = msgctxt_lines
73         self.msgid_lines = msgid_lines
74         self.msgstr_lines = msgstr_lines
75         self.comment_lines = comment_lines
76         self.is_fuzzy = is_fuzzy
77         self.is_commented = is_commented
78
79     def _get_msgctxt(self):
80         return ("".join(self.msgctxt_lines)).replace("\\n", "\n")
81     def _set_msgctxt(self, ctxt):
82         self.msgctxt_lines = [ctxt]
83     msgctxt = property(_get_msgctxt, _set_msgctxt)
84
85     def _get_msgid(self):
86         return ("".join(self.msgid_lines)).replace("\\n", "\n")
87     def _set_msgid(self, msgid):
88         self.msgid_lines = [msgid]
89     msgid = property(_get_msgid, _set_msgid)
90
91     def _get_msgstr(self):
92         return ("".join(self.msgstr_lines)).replace("\\n", "\n")
93     def _set_msgstr(self, msgstr):
94         self.msgstr_lines = [msgstr]
95     msgstr = property(_get_msgstr, _set_msgstr)
96
97     def _get_sources(self):
98         lstrip1 = len(PO_COMMENT_PREFIX_SOURCE)
99         lstrip2 = len(PO_COMMENT_PREFIX_SOURCE_CUSTOM)
100         return ([l[lstrip1:] for l in self.comment_lines if l.startswith(PO_COMMENT_PREFIX_SOURCE)] +
101                 [l[lstrip2:] for l in self.comment_lines if l.startswith(PO_COMMENT_PREFIX_SOURCE_CUSTOM)])
102     def _set_sources(self, sources):
103         # list.copy() is not available in py3.2 ...
104         cmmlines = []
105         cmmlines[:] = self.comment_lines
106         for l in cmmlines:
107             if l.startswith(PO_COMMENT_PREFIX_SOURCE) or l.startswith(PO_COMMENT_PREFIX_SOURCE_CUSTOM):
108                 self.comment_lines.remove(l)
109         lines_src = []
110         lines_src_custom = []
111         for src in  sources:
112             if is_valid_po_path(src):
113                 lines_src.append(PO_COMMENT_PREFIX_SOURCE + src)
114             else:
115                 lines_src_custom.append(PO_COMMENT_PREFIX_SOURCE_CUSTOM + src)
116         self.comment_lines += lines_src_custom + lines_src
117     sources = property(_get_sources, _set_sources)
118
119     def _get_is_tooltip(self):
120         # XXX For now, we assume that all messages > 30 chars are tooltips!
121         return len(self.msgid) > 30
122     is_tooltip = property(_get_is_tooltip)
123
124     def normalize(self, max_len=80):
125         """
126         Normalize this message, call this before exporting it...
127         Currently normalize msgctxt, msgid and msgstr lines to given max_len (if below 1, make them single line).
128         """
129         max_len -= 2  # The two quotes!
130         # We do not need the full power of textwrap... We just split first at escaped new lines, then into each line
131         # if needed... No word splitting, nor fancy spaces handling!
132         def _wrap(text, max_len, init_len):
133             if len(text) + init_len < max_len:
134                 return [text]
135             lines = text.splitlines()
136             ret = []
137             for l in lines:
138                 tmp = []
139                 cur_len = 0
140                 words = l.split(' ')
141                 for w in words:
142                     cur_len += len(w) + 1
143                     if cur_len > (max_len - 1) and tmp:
144                         ret.append(" ".join(tmp) + " ")
145                         del tmp[:]
146                         cur_len = len(w) + 1
147                     tmp.append(w)
148                 if tmp:
149                     ret.append(" ".join(tmp))
150             return ret
151         if max_len < 1:
152             self.msgctxt_lines = self.msgctxt.replace("\n", "\\n\n").splitlines()
153             self.msgid_lines = self.msgid.replace("\n", "\\n\n").splitlines()
154             self.msgstr_lines = self.msgstr.replace("\n", "\\n\n").splitlines()
155         else:
156             init_len = len(PO_MSGCTXT) + 1
157             if self.is_commented:
158                 init_len += len(PO_COMMENT_PREFIX_MSG)
159             self.msgctxt_lines = _wrap(self.msgctxt.replace("\n", "\\n\n"), max_len, init_len)
160
161             init_len = len(PO_MSGID) + 1
162             if self.is_commented:
163                 init_len += len(PO_COMMENT_PREFIX_MSG)
164             self.msgid_lines = _wrap(self.msgid.replace("\n", "\\n\n"), max_len, init_len)
165
166             init_len = len(PO_MSGSTR) + 1
167             if self.is_commented:
168                 init_len += len(PO_COMMENT_PREFIX_MSG)
169             self.msgstr_lines = _wrap(self.msgstr.replace("\n", "\\n\n"), max_len, init_len)
170
171
172 class I18nMessages:
173     """
174     Internal representation of messages for one language (iso code), with additional stats info.
175     """
176
177     # Avoid parsing again!
178     # Keys should be (pseudo) file-names, values are tuples (hash, I18nMessages)
179     # Note: only used by po parser currently!
180     _parser_cache = {}
181
182     def __init__(self, iso="__POT__", kind=None, key=None, src=None):
183         self.iso = iso
184         self.msgs = self._new_messages()
185         self.trans_msgs = set()
186         self.fuzzy_msgs = set()
187         self.comm_msgs = set()
188         self.ttip_msgs = set()
189         self.contexts = set()
190         self.nbr_msgs = 0
191         self.nbr_trans_msgs = 0
192         self.nbr_ttips = 0
193         self.nbr_trans_ttips = 0
194         self.nbr_comm_msgs = 0
195         self.nbr_signs = 0
196         self.nbr_trans_signs = 0
197         self.parsing_errors = []
198         if kind and src:
199             self.parse(kind, key, src)
200         self.update_info()
201
202     @staticmethod
203     def _new_messages():
204         return getattr(collections, 'OrderedDict', dict)()
205
206     @classmethod
207     def gen_empty_messages(cls, iso, blender_ver, blender_rev, time, year, default_copyright=True):
208         """Generate an empty I18nMessages object (only header is present!)."""
209         msgstr = PO_HEADER_MSGSTR.format(blender_ver=str(blender_ver), blender_rev=int(blender_rev),
210                                          time=str(time), iso=str(iso))
211         comment = ""
212         if default_copyright:
213             comment = PO_HEADER_COMMENT_COPYRIGHT.format(year=str(year))
214         comment = comment + PO_HEADER_COMMENT
215
216         msgs = cls(iso=iso)
217         msgs.msgs[PO_HEADER_KEY] = I18nMessage([], [""], [msgstr], [comment], False, True)
218         msgs.update_info()
219
220         return msgs
221
222     def normalize(self, max_len=80):
223         for msg in self.msgs.values():
224             msg.normalize(max_len)
225
226     def merge(self, replace=False, *args):
227         pass
228
229     def update(self, ref, use_similar=0.75, keep_old_commented=True):
230         """
231         Update this I18nMessage with the ref one. Translations from ref are never used. Source comments from ref
232         completely replace current ones. If use_similar is not 0.0, it will try to match new messages in ref with an
233         existing one. Messages no more found in ref will be marked as commented if keep_old_commented is True,
234         or removed.
235         """
236         import difflib
237         similar_pool = {}
238         if use_similar > 0.0:
239             for key in self.msgs:
240                 similar_pool.setdefault(key[1], set()).add(key)
241
242         msgs = self._new_messages()
243         for (key, msg) in ref.msgs.items():
244             if key in self.msgs:
245                 msgs[key] = self.msgs[key]
246                 msgs[key].sources = msg.sources
247             else:
248                 skey = None
249                 if use_similar > 0.0:
250                     # try to find some close key in existing messages...
251                     tmp = difflib.get_close_matches(key[1], similar_pool, n=1, cutoff=use_similar)
252                     if tmp:
253                         tmp = tmp[0]
254                         # Try to get the same context, else just get one...
255                         skey = (key[0], tmp)
256                         if skey not in similar_pool[tmp]:
257                             skey = tuple(similar_pool[tmp])[0]
258                 msgs[key] = msg
259                 if skey:
260                     msgs[key].msgstr = self.msgs[skey].msgstr
261                     msgs[key].is_fuzzy = True
262         # Add back all "old" and already commented messages as commented ones, if required.
263         if keep_old_commented:
264             for key, msg in self.msgs.items():
265                 if key not in msgs:
266                     msgs[key] = msg
267                     msgs[key].is_commented = True
268         # And finalize the update!
269         self.msgs = msgs
270
271     def update_info(self):
272         self.trans_msgs.clear()
273         self.fuzzy_msgs.clear()
274         self.comm_msgs.clear()
275         self.ttip_msgs.clear()
276         self.contexts.clear()
277         self.nbr_signs = 0
278         self.nbr_trans_signs = 0
279         for key, msg in self.msgs.items():
280             if key == PO_HEADER_KEY:
281                 continue
282             if msg.is_commented:
283                 self.comm_msgs.add(key)
284             else:
285                 if msg.msgstr:
286                     self.trans_msgs.add(key)
287                 if msg.is_fuzzy:
288                     self.fuzzy_msgs.add(key)
289                 if msg.is_tooltip:
290                     self.ttip_msgs.add(key)
291                 self.contexts.add(key[0])
292                 self.nbr_signs += len(msg.msgid)
293                 self.nbr_trans_signs += len(msg.msgstr)
294         self.nbr_msgs = len(self.msgs)
295         self.nbr_trans_msgs = len(self.trans_msgs)
296         self.nbr_ttips = len(self.ttip_msgs)
297         self.nbr_trans_ttips = len(self.ttip_msgs & self.trans_msgs)
298         self.nbr_comm_msgs = len(self.comm_msgs)
299
300     def print_stats(self, prefix=""):
301         """
302         Print out some stats about an I18nMessages object.
303         """
304         lvl = 0.0
305         lvl_ttips = 0.0
306         lvl_comm = 0.0
307         lvl_trans_ttips = 0.0
308         lvl_ttips_in_trans = 0.0
309         if self.nbr_msgs > 0:
310             lvl = float(self.nbr_trans_msgs) / float(self.nbr_msgs)
311             lvl_ttips = float(self.nbr_ttips) / float(self.nbr_msgs)
312             lvl_comm = float(self.nbr_comm_msgs) / float(self.nbr_msgs + self.nbr_comm_msgs)
313         if self.nbr_ttips > 0:
314             lvl_trans_ttips = float(self.nbr_trans_ttips) / float(self.nbr_ttips)
315         if self.nbr_trans_msgs > 0:
316             lvl_ttips_in_trans = float(self.nbr_trans_ttips) / float(self.nbr_trans_msgs)
317
318         lines = ("",
319                  "{:>6.1%} done! ({} translated messages over {}).\n"
320                  "".format(lvl, self.nbr_trans_msgs, self.nbr_msgs),
321                  "{:>6.1%} of messages are tooltips ({} over {}).\n"
322                  "".format(lvl_ttips, self.nbr_ttips, self.nbr_msgs),
323                  "{:>6.1%} of tooltips are translated ({} over {}).\n"
324                  "".format(lvl_trans_ttips, self.nbr_trans_ttips, self.nbr_ttips),
325                  "{:>6.1%} of translated messages are tooltips ({} over {}).\n"
326                  "".format(lvl_ttips_in_trans, self.nbr_trans_ttips, self.nbr_trans_msgs),
327                  "{:>6.1%} of messages are commented ({} over {}).\n"
328                  "".format(lvl_comm, self.nbr_comm_msgs, self.nbr_comm_msgs + self.nbr_msgs),
329                  "This translation is currently made of {} signs.\n".format(self.nbr_trans_signs))
330         print(prefix.join(lines))
331
332     def parse(self, kind, key, src):
333         del self.parsing_errors[:]
334         self.parsers[kind](self, src, key)
335         if self.parsing_errors:
336             print("WARNING! Errors while parsing {}:".format(key))
337             for line, error in self.parsing_errors:
338                 print("    Around line {}: {}".format(line, error))
339             print("The parser solved them as well as it could...")
340         self.update_info()
341
342     def parse_messages_from_po(self, src, key=None):
343         """
344         Parse a po file.
345         Note: This function will silently "arrange" mis-formated entries, thus using afterward write_messages() should
346               always produce a po-valid file, though not correct!
347         """
348         reading_msgid = False
349         reading_msgstr = False
350         reading_msgctxt = False
351         reading_comment = False
352         is_commented = False
353         is_fuzzy = False
354         msgctxt_lines = []
355         msgid_lines = []
356         msgstr_lines = []
357         comment_lines = []
358
359         # Helper function
360         def finalize_message(self, line_nr):
361             nonlocal reading_msgid, reading_msgstr, reading_msgctxt, reading_comment
362             nonlocal is_commented, is_fuzzy, msgid_lines, msgstr_lines, msgctxt_lines, comment_lines
363
364             msgid = "".join(msgid_lines)
365             msgctxt = "".join(msgctxt_lines)
366             msgkey = (msgctxt, msgid)
367
368             # Never allow overriding existing msgid/msgctxt pairs!
369             if msgkey in self.msgs:
370                 self.parsing_errors.append((line_nr, "{} context/msgid is already in current messages!".format(msgkey)))
371                 return
372
373             self.msgs[msgkey] = I18nMessage(msgctxt_lines, msgid_lines, msgstr_lines, comment_lines,
374                                             is_commented, is_fuzzy)
375
376             # Let's clean up and get ready for next message!
377             reading_msgid = reading_msgstr = reading_msgctxt = reading_comment = False
378             is_commented = is_fuzzy = False
379             msgctxt_lines = []
380             msgid_lines = []
381             msgstr_lines = []
382             comment_lines = []
383
384         # try to use src as file name...
385         if os.path.exists(src):
386             if not key:
387                 key = src
388             with open(src, 'r', encoding="utf-8") as f:
389                 src = f.read()
390
391         # Try to use values from cache!
392         curr_hash = None
393         if key and key in self._parser_cache:
394             old_hash, msgs = self._parser_cache[key]
395             import hashlib
396             curr_hash = hashlib.new(PARSER_CACHE_HASH, src.encode()).digest()
397             if curr_hash == old_hash:
398                 self.msgs = copy.deepcopy(msgs)  # we might edit self.msgs!
399                 return
400
401         _comm_msgctxt = PO_COMMENT_PREFIX_MSG + PO_MSGCTXT
402         _len_msgctxt = len(PO_MSGCTXT + '"')
403         _len_comm_msgctxt = len(_comm_msgctxt + '"')
404         _comm_msgid = PO_COMMENT_PREFIX_MSG + PO_MSGID
405         _len_msgid = len(PO_MSGID + '"')
406         _len_comm_msgid = len(_comm_msgid + '"')
407         _comm_msgstr = PO_COMMENT_PREFIX_MSG + PO_MSGSTR
408         _len_msgstr = len(PO_MSGSTR + '"')
409         _len_comm_msgstr = len(_comm_msgstr + '"')
410         _len_comm_str = len(PO_COMMENT_PREFIX_MSG + '"')
411
412         # Main loop over all lines in src...
413         for line_nr, line in enumerate(src.splitlines()):
414             if line == "":
415                 finalize_message(self, line_nr)
416
417             elif line.startswith(PO_MSGCTXT) or line.startswith(_comm_msgctxt):
418                 reading_comment = False
419                 reading_ctxt = True
420                 if line.startswith(PO_COMMENT_PREFIX_MSG):
421                     is_commented = True
422                     line = line[_len_comm_msgctxt:-1]
423                 else:
424                     line = line[_len_msgctxt:-1]
425                 msgctxt_lines.append(line)
426
427             elif line.startswith(PO_MSGID) or line.startswith(_comm_msgid):
428                 reading_comment = False
429                 reading_msgid = True
430                 if line.startswith(PO_COMMENT_PREFIX_MSG):
431                     if not is_commented and reading_ctxt:
432                         self.parsing_errors.append((line_nr, "commented msgid following regular msgctxt"))
433                     is_commented = True
434                     line = line[_len_comm_msgid:-1]
435                 else:
436                     line = line[_len_msgid:-1]
437                 reading_ctxt = False
438                 msgid_lines.append(line)
439
440             elif line.startswith(PO_MSGSTR) or line.startswith(_comm_msgstr):
441                 if not reading_msgid:
442                     self.parsing_errors.append((line_nr, "msgstr without a prior msgid"))
443                 else:
444                     reading_msgid = False
445                 reading_msgstr = True
446                 if line.startswith(PO_COMMENT_PREFIX_MSG):
447                     line = line[_len_comm_msgstr:-1]
448                     if not is_commented:
449                         self.parsing_errors.append((line_nr, "commented msgstr following regular msgid"))
450                 else:
451                     line = line[_len_msgstr:-1]
452                     if is_commented:
453                         self.parsing_errors.append((line_nr, "regular msgstr following commented msgid"))
454                 msgstr_lines.append(line)
455
456             elif line.startswith(PO_COMMENT_PREFIX[0]):
457                 if line.startswith(PO_COMMENT_PREFIX_MSG):
458                     if reading_msgctxt:
459                         if is_commented:
460                             msgctxt_lines.append(line[_len_comm_str:-1])
461                         else:
462                             msgctxt_lines.append(line)
463                             self.parsing_errors.append((line_nr, "commented string while reading regular msgctxt"))
464                     elif reading_msgid:
465                         if is_commented:
466                             msgid_lines.append(line[_len_comm_str:-1])
467                         else:
468                             msgid_lines.append(line)
469                             self.parsing_errors.append((line_nr, "commented string while reading regular msgid"))
470                     elif reading_msgstr:
471                         if is_commented:
472                             msgstr_lines.append(line[_len_comm_str:-1])
473                         else:
474                             msgstr_lines.append(line)
475                             self.parsing_errors.append((line_nr, "commented string while reading regular msgstr"))
476                 else:
477                     if reading_msgctxt or reading_msgid or reading_msgstr:
478                         self.parsing_errors.append((line_nr,
479                                                     "commented string within msgctxt, msgid or msgstr scope, ignored"))
480                     elif line.startswith(PO_COMMENT_FUZZY):
481                         is_fuzzy = True
482                     else:
483                         comment_lines.append(line)
484                     reading_comment = True
485
486             else:
487                 if reading_msgctxt:
488                     msgctxt_lines.append(line[1:-1])
489                 elif reading_msgid:
490                     msgid_lines.append(line[1:-1])
491                 elif reading_msgstr:
492                     line = line[1:-1]
493                     msgstr_lines.append(line)
494                 else:
495                     self.parsing_errors.append((line_nr, "regular string outside msgctxt, msgid or msgstr scope"))
496
497         # If no final empty line, last message is not finalized!
498         if reading_msgstr:
499             finalize_message(self, line_nr)
500
501         if key:
502             if not curr_hash:
503                 import hashlib
504                 curr_hash = hashlib.new(PARSER_CACHE_HASH, src.encode()).digest()
505             self._parser_cache[key] = (curr_hash, self.msgs)
506
507     def write(self, kind, dest):
508         self.writers[kind](self, dest)
509
510     def write_messages_to_po(self, fname):
511         """
512         Write messages in fname po file.
513         """
514         self.normalize(max_len=0)  # No wrapping for now...
515         with open(fname, 'w', encoding="utf-8") as f:
516             for msg in self.msgs.values():
517                 f.write("\n".join(msg.comment_lines))
518                 # Only mark as fuzzy if msgstr is not empty!
519                 if msg.is_fuzzy and msg.msgstr:
520                     f.write("\n" + PO_COMMENT_FUZZY)
521                 _p = PO_COMMENT_PREFIX_MSG if msg.is_commented else ""
522                 _pmsgctxt = _p + PO_MSGCTXT
523                 _pmsgid = _p + PO_MSGID
524                 _pmsgstr = _p + PO_MSGSTR
525                 chunks = []
526                 if msg.msgctxt:
527                     if len(msg.msgctxt_lines) > 1:
528                         chunks += [
529                             "\n" + _pmsgctxt + "\"\"\n" + _p + "\"",
530                             ("\"\n" + _p + "\"").join(msg.msgctxt_lines),
531                             "\"",
532                         ]
533                     else:
534                         chunks += ["\n" + _pmsgctxt + "\"" + msg.msgctxt + "\""]
535                 if len(msg.msgid_lines) > 1:
536                     chunks += [
537                         "\n" + _pmsgid + "\"\"\n" + _p + "\"",
538                         ("\"\n" + _p + "\"").join(msg.msgid_lines),
539                         "\"",
540                     ]
541                 else:
542                     chunks += ["\n" + _pmsgid + "\"" + msg.msgid + "\""]
543                 if len(msg.msgstr_lines) > 1:
544                     chunks += [
545                         "\n" + _pmsgstr + "\"\"\n" + _p + "\"",
546                         ("\"\n" + _p + "\"").join(msg.msgstr_lines),
547                         "\"",
548                     ]
549                 else:
550                     chunks += ["\n" + _pmsgstr + "\"" + msg.msgstr + "\""]
551                 chunks += ["\n\n"]
552                 f.write("".join(chunks))
553
554     parsers = {
555         "PO": parse_messages_from_po,
556 #        "PYTUPLE": parse_messages_from_pytuple,
557     }
558
559     writers = {
560         "PO": write_messages_to_po,
561         #"PYDICT": write_messages_to_pydict,
562     }
563
564
565 class I18n:
566     """
567     Internal representation of a whole translation set.
568     """
569
570     def __init__(self, src):
571         self.trans = {}
572         self.update_info()
573
574     def update_info(self):
575         self.nbr_trans = 0
576         self.lvl = 0.0
577         self.lvl_ttips = 0.0
578         self.lvl_trans_ttips = 0.0
579         self.lvl_ttips_in_trans = 0.0
580         self.lvl_comm = 0.0
581         self.nbr_signs = 0
582         self.nbr_trans_signs = 0
583         self.contexts = set()
584
585         if TEMPLATE_ISO_ID in self.trans:
586             self.nbr_trans = len(self.trans) - 1
587             self.nbr_signs = self.trans[TEMPLATE_ISO_ID].nbr_signs
588         else:
589             self.nbr_trans = len(self.trans)
590         for iso, msgs in self.trans.items():
591             msgs.update_info()
592             if msgs.nbr_msgs > 0:
593                 self.lvl += float(msgs.nbr_trans_msgs) / float(msgs.nbr_msgs)
594                 self.lvl_ttips += float(msgs.nbr_ttips) / float(msgs.nbr_msgs)
595                 self.lvl_comm += float(msgs.nbr_comm_msgs) / float(msgs.nbr_msgs + msgs.nbr_comm_msgs)
596             if msgs.nbr_ttips > 0:
597                 self.lvl_trans_ttips = float(msgs.nbr_trans_ttips) / float(msgs.nbr_ttips)
598             if msgs.nbr_trans_msgs > 0:
599                 self.lvl_ttips_in_trans = float(msgs.nbr_trans_ttips) / float(msgs.nbr_trans_msgs)
600             if self.nbr_signs == 0:
601                 self.nbr_signs = msgs.nbr_signs
602             self.nbr_trans_signs += msgs.nbr_trans_signs
603             self.contexts |= msgs.contexts
604
605     def print_stats(self, prefix="", print_msgs=True):
606         """
607         Print out some stats about an I18n object.
608         If print_msgs is True, it will also print all its translations' stats.
609         """
610         if print_msgs:
611             msgs_prefix = prefix + "    "
612             for key, msgs in self.trans:
613                 if key == TEMPLATE_ISO_ID:
614                     continue
615                 print(prefix + key + ":")
616                 msgs.print_stats(prefix=msgs_prefix)
617                 print(prefix)
618
619         nbr_contexts = len(self.contexts - {CONTEXT_DEFAULT})
620         if nbr_contexts != 1:
621             if nbr_contexts == 0:
622                 nbr_contexts = "No"
623             _ctx_txt = "s are"
624         else:
625             _ctx_txt = " is"
626         lines = ("",
627                  "Average stats for all {} translations:\n".format(self.nbr_trans),
628                  "    {:>6.1%} done!\n".format(self.lvl / self.nbr_trans),
629                  "    {:>6.1%} of messages are tooltips.\n".format(self.lvl_ttips / self.nbr_trans),
630                  "    {:>6.1%} of tooltips are translated.\n".format(self.lvl_trans_ttips / self.nbr_trans),
631                  "    {:>6.1%} of translated messages are tooltips.\n".format(self.lvl_ttips_in_trans / self.nbr_trans),
632                  "    {:>6.1%} of messages are commented.\n".format(self.lvl_comm / self.nbr_trans),
633                  "    The org msgids are currently made of {} signs.\n".format(self.nbr_signs),
634                  "    All processed translations are currently made of {} signs.\n".format(self.nbr_trans_signs),
635                  "    {} specific context{} present:\n            {}\n"
636                  "".format(self.nbr_contexts, _ctx_txt, "\n            ".join(self.contexts - {CONTEXT_DEFAULT})),
637                  "\n")
638         print(prefix.join(lines))
639
640
641 ##### Parsers #####
642
643 #def parse_messages_from_pytuple(self, src, key=None):
644     #"""
645     #Returns a dict of tuples similar to the one returned by parse_messages_from_po (one per language, plus a 'pot'
646     #one keyed as '__POT__').
647     #"""
648     ## src may be either a string to be interpreted as py code, or a real tuple!
649     #if isinstance(src, str):
650         #src = eval(src)
651 #
652     #curr_hash = None
653     #if key and key in _parser_cache:
654         #old_hash, ret = _parser_cache[key]
655         #import hashlib
656         #curr_hash = hashlib.new(PARSER_CACHE_HASH, str(src).encode()).digest()
657         #if curr_hash == old_hash:
658             #return ret
659 #
660     #pot = new_messages()
661     #states = gen_states()
662     #stats = gen_stats()
663     #ret = {"__POT__": (pot, states, stats)}
664     #for msg in src:
665         #key = msg[0]
666         #messages[msgkey] = gen_message(msgid_lines, msgstr_lines, comment_lines, msgctxt_lines)
667         #pot[key] = gen_message(msgid_lines=[key[1]], msgstr_lines=[
668         #for lang, trans, (is_fuzzy, comments) in msg[2:]:
669             #if trans and not is_fuzzy:
670                 #i18n_dict.setdefault(lang, dict())[key] = trans
671 #
672     #if key:
673         #if not curr_hash:
674             #import hashlib
675             #curr_hash = hashlib.new(PARSER_CACHE_HASH, str(src).encode()).digest()
676         #_parser_cache[key] = (curr_hash, val)
677     #return ret