d472621029e083233e0f10cb7b6dbd65255d2b0e
[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 concurrent.futures
25 import copy
26 import hashlib
27 import os
28 import re
29 import struct
30 import sys
31 import tempfile
32 #import time
33
34 from bl_i18n_utils import (
35         settings,
36         utils_rtl,
37         )
38
39 import bpy
40
41
42 ##### Misc Utils #####
43 from bpy.app.translations import locale_explode
44
45
46 _valid_po_path_re = re.compile(r"^\S+:[0-9]+$")
47 def is_valid_po_path(path):
48     return bool(_valid_po_path_re.match(path))
49
50
51 def get_best_similar(data):
52     import difflib
53     key, use_similar, similar_pool = data
54
55     # try to find some close key in existing messages...
56     # Optimized code inspired by difflib.get_close_matches (as we only need the best match).
57     # We also consider to never make a match when len differs more than -len_key / 2, +len_key * 2 (which is valid
58     # as long as use_similar is not below ~0.7).
59     # Gives an overall ~20% of improvement!
60     #tmp = difflib.get_close_matches(key[1], similar_pool, n=1, cutoff=use_similar)
61     #if tmp:
62         #tmp = tmp[0]
63     tmp = None
64     s = difflib.SequenceMatcher()
65     s.set_seq2(key[1])
66     len_key = len(key[1])
67     min_len = len_key // 2
68     max_len = len_key * 2
69     for x in similar_pool:
70         if min_len < len(x) < max_len:
71             s.set_seq1(x)
72             if s.real_quick_ratio() >= use_similar and s.quick_ratio() >= use_similar:
73                 sratio = s.ratio()
74                 if sratio >= use_similar:
75                     tmp = x
76                     use_similar = sratio
77     return key, tmp
78
79
80 def locale_match(loc1, loc2):
81     """
82     Return:
83         -n if loc1 is a subtype of loc2 (e.g. 'fr_FR' is a subtype of 'fr').
84         +n if loc2 is a subtype of loc1.
85         n becomes smaller when both locales are more similar (e.g. (sr, sr_SR) are more similar than (sr, sr_SR@latin)).
86         0 if they are exactly the same.
87         ... (Ellipsis) if they cannot match!
88     Note: We consider that 'sr_SR@latin' is a subtype of 'sr@latin', 'sr_SR' and 'sr', but 'sr_SR' and 'sr@latin' won't
89           match (will return ...)!
90     Note: About similarity, diff in variants are more important than diff in countries, currently here are the cases:
91             (sr, sr_SR)             -> 1
92             (sr@latin, sr_SR@latin) -> 1
93             (sr, sr@latin)          -> 2
94             (sr_SR, sr_SR@latin)    -> 2
95             (sr, sr_SR@latin)       -> 3
96     """
97     if loc1 == loc2:
98         return 0
99     l1, c1, v1, *_1 = locale_explode(loc1)
100     l2, c2, v2, *_2 = locale_explode(loc2)
101
102     if l1 == l2:
103         if c1 == c2:
104             if v1 == v2:
105                 return 0
106             elif v2 is None:
107                 return -2
108             elif v1 is None:
109                 return 2
110             return ...
111         elif c2 is None:
112             if v1 == v2:
113                 return -1
114             elif v2 is None:
115                 return -3
116             return ...
117         elif c1 is None:
118             if v1 == v2:
119                 return 1
120             elif v1 is None:
121                 return 3
122             return ...
123     return ...
124
125
126 def find_best_isocode_matches(uid, iso_codes):
127     """
128     Return an ordered tuple of elements in iso_codes that can match the given uid, from most similar to lesser ones.
129     """
130     tmp = ((e, locale_match(e, uid)) for e in iso_codes)
131     return tuple(e[0] for e in sorted((e for e in tmp if e[1] is not ... and e[1] >= 0), key=lambda e: e[1]))
132
133
134 def get_po_files_from_dir(root_dir, langs=set()):
135     """
136     Yield tuples (uid, po_path) of translations for each po file found in the given dir, which should be either
137     a dir containing po files using language uid's as names (e.g. fr.po, es_ES.po, etc.), or
138     a dir containing dirs which names are language uids, and containing po files of the same names.
139     """
140     found_uids = set()
141     for p in os.listdir(root_dir):
142         uid = None
143         po_file = os.path.join(root_dir, p)
144         print(p)
145         if p.endswith(".po") and os.path.isfile(po_file):
146             uid = p[:-3]
147             if langs and uid not in langs:
148                 continue
149         elif os.path.isdir(p):
150             uid = p
151             if langs and uid not in langs:
152                 continue
153             po_file = os.path.join(root_dir, p, p + ".po")
154             if not os.path.isfile(po_file):
155                 continue
156         else:
157             continue
158         if uid in found_uids:
159             printf("WARNING! {} id has been found more than once! only first one has been loaded!".format(uid))
160             continue
161         found_uids.add(uid)
162         yield uid, po_file
163
164
165 def enable_addons(addons=None, support=None, disable=False, check_only=False):
166     """
167     Enable (or disable) addons based either on a set of names, or a set of 'support' types.
168     Returns the list of all affected addons (as fake modules)!
169     If "check_only" is set, no addon will be enabled nor disabled.
170     """
171     import addon_utils
172
173     if addons is None:
174         addons = {}
175     if support is None:
176         support = {}
177
178     userpref = bpy.context.user_preferences
179     used_ext = {ext.module for ext in userpref.addons}
180
181     ret = [mod for mod in addon_utils.modules()
182                if ((addons and mod.__name__ in addons) or
183                    (not addons and addon_utils.module_bl_info(mod)["support"] in support))]
184
185     if not check_only:
186         for mod in ret:
187             module_name = mod.__name__
188             if disable:
189                 if module_name not in used_ext:
190                     continue
191                 print("    Disabling module ", module_name)
192                 bpy.ops.wm.addon_disable(module=module_name)
193             else:
194                 if module_name in used_ext:
195                     continue
196                 print("    Enabling module ", module_name)
197                 bpy.ops.wm.addon_enable(module=module_name)
198
199         # XXX There are currently some problems with bpy/rna...
200         #     *Very* tricky to solve!
201         #     So this is a hack to make all newly added operator visible by
202         #     bpy.types.OperatorProperties.__subclasses__()
203         for cat in dir(bpy.ops):
204             cat = getattr(bpy.ops, cat)
205             for op in dir(cat):
206                 getattr(cat, op).get_rna()
207
208     return ret
209
210
211 ##### Main Classes #####
212
213 class I18nMessage:
214     """
215     Internal representation of a message.
216     """
217     __slots__ = ("msgctxt_lines", "msgid_lines", "msgstr_lines", "comment_lines", "is_fuzzy", "is_commented",
218                  "settings")
219
220     def __init__(self, msgctxt_lines=None, msgid_lines=None, msgstr_lines=None, comment_lines=None,
221                  is_commented=False, is_fuzzy=False, settings=settings):
222         self.settings = settings
223         self.msgctxt_lines = msgctxt_lines or []
224         self.msgid_lines = msgid_lines or []
225         self.msgstr_lines = msgstr_lines or []
226         self.comment_lines = comment_lines or []
227         self.is_fuzzy = is_fuzzy
228         self.is_commented = is_commented
229
230     def _get_msgctxt(self):
231         return "".join(self.msgctxt_lines)
232     def _set_msgctxt(self, ctxt):
233         self.msgctxt_lines = [ctxt]
234     msgctxt = property(_get_msgctxt, _set_msgctxt)
235
236     def _get_msgid(self):
237         return "".join(self.msgid_lines)
238     def _set_msgid(self, msgid):
239         self.msgid_lines = [msgid]
240     msgid = property(_get_msgid, _set_msgid)
241
242     def _get_msgstr(self):
243         return "".join(self.msgstr_lines)
244     def _set_msgstr(self, msgstr):
245         self.msgstr_lines = [msgstr]
246     msgstr = property(_get_msgstr, _set_msgstr)
247
248     def _get_sources(self):
249         lstrip1 = len(self.settings.PO_COMMENT_PREFIX_SOURCE)
250         lstrip2 = len(self.settings.PO_COMMENT_PREFIX_SOURCE_CUSTOM)
251         return ([l[lstrip1:] for l in self.comment_lines if l.startswith(self.settings.PO_COMMENT_PREFIX_SOURCE)] +
252                 [l[lstrip2:] for l in self.comment_lines
253                              if l.startswith(self.settings.PO_COMMENT_PREFIX_SOURCE_CUSTOM)])
254     def _set_sources(self, sources):
255         cmmlines = self.comment_lines.copy()
256         for l in cmmlines:
257             if (l.startswith(self.settings.PO_COMMENT_PREFIX_SOURCE) or
258                 l.startswith(self.settings.PO_COMMENT_PREFIX_SOURCE_CUSTOM)):
259                 self.comment_lines.remove(l)
260         lines_src = []
261         lines_src_custom = []
262         for src in sources:
263             if is_valid_po_path(src):
264                 lines_src.append(self.settings.PO_COMMENT_PREFIX_SOURCE + src)
265             else:
266                 lines_src_custom.append(self.settings.PO_COMMENT_PREFIX_SOURCE_CUSTOM + src)
267         self.comment_lines += lines_src_custom + lines_src
268     sources = property(_get_sources, _set_sources)
269
270     def _get_is_tooltip(self):
271         # XXX For now, we assume that all messages > 30 chars are tooltips!
272         return len(self.msgid) > 30
273     is_tooltip = property(_get_is_tooltip)
274
275     def copy(self):
276         # Deepcopy everything but the settings!
277         return self.__class__(msgctxt_lines=self.msgctxt_lines[:], msgid_lines=self.msgid_lines[:],
278                               msgstr_lines=self.msgstr_lines[:], comment_lines=self.comment_lines[:],
279                               is_commented=self.is_commented, is_fuzzy=self.is_fuzzy, settings=self.settings)
280
281     def normalize(self, max_len=80):
282         """
283         Normalize this message, call this before exporting it...
284         Currently normalize msgctxt, msgid and msgstr lines to given max_len (if below 1, make them single line).
285         """
286         max_len -= 2  # The two quotes!
287
288         def _splitlines(text):
289             lns = text.splitlines()
290             return [l + "\n" for l in lns[:-1]] + lns[-1:]
291
292         # We do not need the full power of textwrap... We just split first at escaped new lines, then into each line
293         # if needed... No word splitting, nor fancy spaces handling!
294         def _wrap(text, max_len, init_len):
295             if len(text) + init_len < max_len:
296                 return [text]
297             lines = _splitlines(text)
298             ret = []
299             for l in lines:
300                 tmp = []
301                 cur_len = 0
302                 words = l.split(' ')
303                 for w in words:
304                     cur_len += len(w) + 1
305                     if cur_len > (max_len - 1) and tmp:
306                         ret.append(" ".join(tmp) + " ")
307                         del tmp[:]
308                         cur_len = len(w) + 1
309                     tmp.append(w)
310                 if tmp:
311                     ret.append(" ".join(tmp))
312             return ret
313
314         if max_len < 1:
315             self.msgctxt_lines = _splitlines(self.msgctxt)
316             self.msgid_lines = _splitlines(self.msgid)
317             self.msgstr_lines = _splitlines(self.msgstr)
318         else:
319             init_len = len(self.settings.PO_MSGCTXT) + 1
320             if self.is_commented:
321                 init_len += len(self.settings.PO_COMMENT_PREFIX_MSG)
322             self.msgctxt_lines = _wrap(self.msgctxt, max_len, init_len)
323
324             init_len = len(self.settings.PO_MSGID) + 1
325             if self.is_commented:
326                 init_len += len(self.settings.PO_COMMENT_PREFIX_MSG)
327             self.msgid_lines = _wrap(self.msgid, max_len, init_len)
328
329             init_len = len(self.settings.PO_MSGSTR) + 1
330             if self.is_commented:
331                 init_len += len(self.settings.PO_COMMENT_PREFIX_MSG)
332             self.msgstr_lines = _wrap(self.msgstr, max_len, init_len)
333
334         # Be sure comment lines are not duplicated (can happen with sources...).
335         tmp = []
336         for l in self.comment_lines:
337             if l not in tmp:
338                 tmp.append(l)
339         self.comment_lines = tmp
340
341     _esc_quotes = re.compile(r'(?!<\\)((?:\\\\)*)"')
342     _unesc_quotes = re.compile(r'(?!<\\)((?:\\\\)*)\\"')
343     _esc_names = ("msgctxt_lines", "msgid_lines", "msgstr_lines")
344     _esc_names_all = _esc_names + ("comment_lines",)
345
346     @classmethod
347     def do_escape(cls, txt):
348         """Replace some chars by their escaped versions!"""
349         if "\n" in txt:
350             txt = txt.replace("\n", r"\n")
351         if "\t" in txt:
352             txt.replace("\t", r"\t")
353         if '"' in txt:
354             txt = cls._esc_quotes.sub(r'\1\"', txt)
355         return txt
356
357     @classmethod
358     def do_unescape(cls, txt):
359         """Replace escaped chars by real ones!"""
360         if r"\n" in txt:
361             txt = txt.replace(r"\n", "\n")
362         if r"\t" in txt:
363             txt = txt.replace(r"\t", "\t")
364         if r'\"' in txt:
365             txt = cls._unesc_quotes.sub(r'\1"', txt)
366         return txt
367
368     def escape(self, do_all=False):
369         names = self._esc_names_all if do_all else self._esc_names
370         for name in names:
371             setattr(self, name, [self.do_escape(l) for l in getattr(self, name)])
372
373     def unescape(self, do_all=True):
374         names = self._esc_names_all if do_all else self._esc_names
375         for name in names:
376             setattr(self, name, [self.do_unescape(l) for l in getattr(self, name)])
377
378
379 class I18nMessages:
380     """
381     Internal representation of messages for one language (iso code), with additional stats info.
382     """
383
384     # Avoid parsing again!
385     # Keys should be (pseudo) file-names, values are tuples (hash, I18nMessages)
386     # Note: only used by po parser currently!
387     #_parser_cache = {}
388
389     def __init__(self, uid=None, kind=None, key=None, src=None, settings=settings):
390         self.settings = settings
391         self.uid = uid if uid is not None else settings.PARSER_TEMPLATE_ID
392         self.msgs = self._new_messages()
393         self.trans_msgs = set()
394         self.fuzzy_msgs = set()
395         self.comm_msgs = set()
396         self.ttip_msgs = set()
397         self.contexts = set()
398         self.nbr_msgs = 0
399         self.nbr_trans_msgs = 0
400         self.nbr_ttips = 0
401         self.nbr_trans_ttips = 0
402         self.nbr_comm_msgs = 0
403         self.nbr_signs = 0
404         self.nbr_trans_signs = 0
405         self.parsing_errors = []
406         if kind and src:
407             self.parse(kind, key, src)
408         self.update_info()
409
410         self._reverse_cache = None
411
412     @staticmethod
413     def _new_messages():
414         return getattr(collections, 'OrderedDict', dict)()
415
416     @classmethod
417     def gen_empty_messages(cls, uid, blender_ver, blender_hash, time, year, default_copyright=True, settings=settings):
418         """Generate an empty I18nMessages object (only header is present!)."""
419         fmt = settings.PO_HEADER_MSGSTR
420         msgstr = fmt.format(blender_ver=str(blender_ver), blender_hash=blender_hash, time=str(time), uid=str(uid))
421         comment = ""
422         if default_copyright:
423             comment = settings.PO_HEADER_COMMENT_COPYRIGHT.format(year=str(year))
424         comment = comment + settings.PO_HEADER_COMMENT
425
426         msgs = cls(uid=uid, settings=settings)
427         key = settings.PO_HEADER_KEY
428         msgs.msgs[key] = I18nMessage([key[0]], [key[1]], msgstr.split("\n"), comment.split("\n"),
429                                      False, False, settings=settings)
430         msgs.update_info()
431
432         return msgs
433
434     def normalize(self, max_len=80):
435         for msg in self.msgs.values():
436             msg.normalize(max_len)
437
438     def escape(self, do_all=False):
439         for msg in self.msgs.values():
440             msg.escape(do_all)
441
442     def unescape(self, do_all=True):
443         for msg in self.msgs.values():
444             msg.unescape(do_all)
445
446     def check(self, fix=False):
447         """
448         Check consistency between messages and their keys!
449         Check messages using format stuff are consistant between msgid and msgstr!
450         If fix is True, tries to fix the issues.
451         Return a list of found errors (empty if everything went OK!).
452         """
453         ret = []
454         default_context = self.settings.DEFAULT_CONTEXT
455         _format = re.compile(self.settings.CHECK_PRINTF_FORMAT).findall
456         done_keys = set()
457         rem = set()
458         tmp = {}
459         for key, msg in self.msgs.items():
460             msgctxt, msgid, msgstr = msg.msgctxt, msg.msgid, msg.msgstr
461             real_key = (msgctxt or default_context, msgid)
462             if key != real_key:
463                 ret.append("Error! msg's context/message do not match its key ({} / {})".format(real_key, key))
464                 if real_key in self.msgs:
465                     ret.append("Error! msg's real_key already used!")
466                     if fix:
467                         rem.add(real_key)
468                 elif fix:
469                     tmp[real_key] = msg
470             done_keys.add(key)
471             if '%' in msgid and msgstr and _format(msgid) != _format(msgstr):
472                 if not msg.is_fuzzy:
473                     ret.append("Error! msg's format entities are not matched in msgid and msgstr ({} / \"{}\")"
474                                "".format(real_key, msgstr))
475                 if fix:
476                     msg.msgstr = ""
477         for k in rem:
478             del self.msgs[k]
479         self.msgs.update(tmp)
480         return ret
481
482     def clean_commented(self):
483         self.update_info()
484         nbr = len(self.comm_msgs)
485         for k in self.comm_msgs:
486             del self.msgs[k]
487         return nbr
488
489     def rtl_process(self):
490         keys = []
491         trans = []
492         for k, m in self.msgs.items():
493             keys.append(k)
494             trans.append(m.msgstr)
495         trans = utils_rtl.log2vis(trans, self.settings)
496         for k, t in zip(keys, trans):
497             self.msgs[k].msgstr = t
498
499     def merge(self, msgs, replace=False):
500         """
501         Merge translations from msgs into self, following those rules:
502             * If a msg is in self and not in msgs, keep self untouched.
503             * If a msg is in msgs and not in self, skip it.
504             * Else (msg both in self and msgs):
505                 * If self is not translated and msgs is translated or fuzzy, replace by msgs.
506                 * If self is fuzzy, and msgs is translated, replace by msgs.
507                 * If self is fuzzy, and msgs is fuzzy, and replace is True, replace by msgs.
508                 * If self is translated, and msgs is translated, and replace is True, replace by msgs.
509                 * Else, skip it!
510         """
511         for k, m in msgs.msgs.items():
512             if k not in self.msgs:
513                 continue
514             sm = self.msgs[k]
515             if (sm.is_commented or m.is_commented or not m.msgstr):
516                 continue
517             if (not sm.msgstr or replace or (sm.is_fuzzy and (not m.is_fuzzy or replace))):
518                 sm.msgstr = m.msgstr
519                 sm.is_fuzzy = m.is_fuzzy
520
521     def update(self, ref, use_similar=None, keep_old_commented=True):
522         """
523         Update this I18nMessage with the ref one. Translations from ref are never used. Source comments from ref
524         completely replace current ones. If use_similar is not 0.0, it will try to match new messages in ref with an
525         existing one. Messages no more found in ref will be marked as commented if keep_old_commented is True,
526         or removed.
527         """
528         if use_similar is None:
529             use_similar = self.settings.SIMILAR_MSGID_THRESHOLD
530
531         similar_pool = {}
532         if use_similar > 0.0:
533             for key, msg in self.msgs.items():
534                 if msg.msgstr:  # No need to waste time with void translations!
535                     similar_pool.setdefault(key[1], set()).add(key)
536
537         msgs = self._new_messages().fromkeys(ref.msgs.keys())
538         ref_keys = set(ref.msgs.keys())
539         org_keys = set(self.msgs.keys())
540         new_keys = ref_keys - org_keys
541         removed_keys = org_keys - ref_keys
542
543         # First process keys present in both org and ref messages.
544         for key in ref_keys - new_keys:
545             msg, refmsg = self.msgs[key], ref.msgs[key]
546             msg.sources = refmsg.sources
547             msg.is_commented = refmsg.is_commented
548             msgs[key] = msg
549
550         # Next process new keys.
551         if use_similar > 0.0:
552             with concurrent.futures.ProcessPoolExecutor() as exctr:
553                 for key, msgid in exctr.map(get_best_similar,
554                                             tuple((nk, use_similar, tuple(similar_pool.keys())) for nk in new_keys)):
555                     if msgid:
556                         # Try to get the same context, else just get one...
557                         skey = (key[0], msgid)
558                         if skey not in similar_pool[msgid]:
559                             skey = tuple(similar_pool[msgid])[0]
560                         # We keep org translation and comments, and mark message as fuzzy.
561                         msg, refmsg = self.msgs[skey].copy(), ref.msgs[key]
562                         msg.msgctxt = refmsg.msgctxt
563                         msg.msgid = refmsg.msgid
564                         msg.sources = refmsg.sources
565                         msg.is_fuzzy = True
566                         msg.is_commented = refmsg.is_commented
567                         msgs[key] = msg
568                     else:
569                         msgs[key] = ref.msgs[key]
570         else:
571             for key in new_keys:
572                 msgs[key] = ref.msgs[key]
573
574         # Add back all "old" and already commented messages as commented ones, if required
575         # (and translation was not void!).
576         if keep_old_commented:
577             for key in removed_keys:
578                 msgs[key] = self.msgs[key]
579                 msgs[key].is_commented = True
580                 msgs[key].sources = []
581
582         # Special 'meta' message, change project ID version and pot creation date...
583         key = self.settings.PO_HEADER_KEY
584         rep = []
585         markers = ("Project-Id-Version:", "POT-Creation-Date:")
586         for mrk in markers:
587             for rl in ref.msgs[key].msgstr_lines:
588                 if rl.startswith(mrk):
589                     for idx, ml in enumerate(msgs[key].msgstr_lines):
590                         if ml.startswith(mrk):
591                             rep.append((idx, rl))
592         for idx, txt in rep:
593             msgs[key].msgstr_lines[idx] = txt
594
595         # And finalize the update!
596         self.msgs = msgs
597
598     def update_info(self):
599         self.trans_msgs.clear()
600         self.fuzzy_msgs.clear()
601         self.comm_msgs.clear()
602         self.ttip_msgs.clear()
603         self.contexts.clear()
604         self.nbr_signs = 0
605         self.nbr_trans_signs = 0
606         for key, msg in self.msgs.items():
607             if key == self.settings.PO_HEADER_KEY:
608                 continue
609             if msg.is_commented:
610                 self.comm_msgs.add(key)
611             else:
612                 if msg.msgstr:
613                     self.trans_msgs.add(key)
614                 if msg.is_fuzzy:
615                     self.fuzzy_msgs.add(key)
616                 if msg.is_tooltip:
617                     self.ttip_msgs.add(key)
618                 self.contexts.add(key[0])
619                 self.nbr_signs += len(msg.msgid)
620                 self.nbr_trans_signs += len(msg.msgstr)
621         self.nbr_msgs = len(self.msgs)
622         self.nbr_trans_msgs = len(self.trans_msgs - self.fuzzy_msgs)
623         self.nbr_ttips = len(self.ttip_msgs)
624         self.nbr_trans_ttips = len(self.ttip_msgs & (self.trans_msgs - self.fuzzy_msgs))
625         self.nbr_comm_msgs = len(self.comm_msgs)
626
627     def print_info(self, prefix="", output=print, print_stats=True, print_errors=True):
628         """
629         Print out some info about an I18nMessages object.
630         """
631         lvl = 0.0
632         lvl_ttips = 0.0
633         lvl_comm = 0.0
634         lvl_trans_ttips = 0.0
635         lvl_ttips_in_trans = 0.0
636         if self.nbr_msgs > 0:
637             lvl = float(self.nbr_trans_msgs) / float(self.nbr_msgs)
638             lvl_ttips = float(self.nbr_ttips) / float(self.nbr_msgs)
639             lvl_comm = float(self.nbr_comm_msgs) / float(self.nbr_msgs + self.nbr_comm_msgs)
640         if self.nbr_ttips > 0:
641             lvl_trans_ttips = float(self.nbr_trans_ttips) / float(self.nbr_ttips)
642         if self.nbr_trans_msgs > 0:
643             lvl_ttips_in_trans = float(self.nbr_trans_ttips) / float(self.nbr_trans_msgs)
644
645         lines = []
646         if print_stats:
647             lines += [
648                 "",
649                 "{:>6.1%} done! ({} translated messages over {}).\n"
650                 "".format(lvl, self.nbr_trans_msgs, self.nbr_msgs),
651                 "{:>6.1%} of messages are tooltips ({} over {}).\n"
652                 "".format(lvl_ttips, self.nbr_ttips, self.nbr_msgs),
653                 "{:>6.1%} of tooltips are translated ({} over {}).\n"
654                 "".format(lvl_trans_ttips, self.nbr_trans_ttips, self.nbr_ttips),
655                 "{:>6.1%} of translated messages are tooltips ({} over {}).\n"
656                 "".format(lvl_ttips_in_trans, self.nbr_trans_ttips, self.nbr_trans_msgs),
657                 "{:>6.1%} of messages are commented ({} over {}).\n"
658                 "".format(lvl_comm, self.nbr_comm_msgs, self.nbr_comm_msgs + self.nbr_msgs),
659                 "This translation is currently made of {} signs.\n".format(self.nbr_trans_signs)
660             ]
661         if print_errors and self.parsing_errors:
662             lines += ["WARNING! Errors during parsing:\n"]
663             lines += ["    Around line {}: {}\n".format(line, error) for line, error in self.parsing_errors]
664         output(prefix.join(lines))
665
666     def invalidate_reverse_cache(self, rebuild_now=False):
667         """
668         Invalidate the reverse cache used by find_best_messages_matches.
669         """
670         self._reverse_cache = None
671         if rebuild_now:
672             src_to_msg, ctxt_to_msg, msgid_to_msg, msgstr_to_msg = {}, {}, {}, {}
673             for key, msg in self.msgs.items():
674                 if msg.is_commented:
675                     continue
676                 ctxt, msgid = key
677                 ctxt_to_msg.setdefault(ctxt, set()).add(key)
678                 msgid_to_msg.setdefault(msgid, set()).add(key)
679                 msgstr_to_msg.setdefault(msg.msgstr, set()).add(key)
680                 for src in msg.sources:
681                     src_to_msg.setdefault(src, set()).add(key)
682             self._reverse_cache = (src_to_msg, ctxt_to_msg, msgid_to_msg, msgstr_to_msg)
683
684     def find_best_messages_matches(self, msgs, msgmap, rna_ctxt, rna_struct_name, rna_prop_name, rna_enum_name):
685         """
686         Try to find the best I18nMessages (i.e. context/msgid pairs) for the given UI messages:
687             msgs: an object containing properties listed in msgmap's values.
688             msgmap: a dict of various messages to use for search:
689                         {"but_label": subdict, "rna_label": subdict, "enum_label": subdict,
690                         "but_tip": subdict, "rna_tip": subdict, "enum_tip": subdict}
691                     each subdict being like that:
692                         {"msgstr": id, "msgid": id, "msg_flags": id, "key": set()}
693                   where msgstr and msgid are identifiers of string props in msgs (resp. translated and org message),
694                         msg_flags is not used here, and key is a set of matching (msgctxt, msgid) keys for the item.
695             The other parameters are about the RNA element from which the strings come from, if it could be determined:
696                 rna_ctxt: the labels' i18n context.
697                 rna_struct_name, rna_prop_name, rna_enum_name: should be self-explanatory!
698         """
699         # Build helper mappings.
700         # Note it's user responsibility to know when to invalidate (and hence force rebuild) this cache!
701         if self._reverse_cache is None:
702             self.invalidate_reverse_cache(True)
703         src_to_msg, ctxt_to_msg, msgid_to_msg, msgstr_to_msg = self._reverse_cache
704
705     #    print(len(src_to_msg), len(ctxt_to_msg), len(msgid_to_msg), len(msgstr_to_msg))
706
707         # Build RNA key.
708         src, src_rna, src_enum = bpy.utils.make_rna_paths(rna_struct_name, rna_prop_name, rna_enum_name)
709         print("src: ", src_rna, src_enum)
710
711         # Labels.
712         elbl = getattr(msgs, msgmap["enum_label"]["msgstr"])
713         if elbl:
714             # Enum items' labels have no i18n context...
715             k = ctxt_to_msg[self.settings.DEFAULT_CONTEXT].copy()
716             if elbl in msgid_to_msg:
717                 k &= msgid_to_msg[elbl]
718             elif elbl in msgstr_to_msg:
719                 k &= msgstr_to_msg[elbl]
720             else:
721                 k = set()
722             # We assume if we already have only one key, it's the good one!
723             if len(k) > 1 and src_enum in src_to_msg:
724                 k &= src_to_msg[src_enum]
725             msgmap["enum_label"]["key"] = k
726         rlbl = getattr(msgs, msgmap["rna_label"]["msgstr"])
727         #print("rna label: " + rlbl, rlbl in msgid_to_msg, rlbl in msgstr_to_msg)
728         if rlbl:
729             k = ctxt_to_msg[rna_ctxt].copy()
730             if k and rlbl in msgid_to_msg:
731                 k &= msgid_to_msg[rlbl]
732             elif k and rlbl in msgstr_to_msg:
733                 k &= msgstr_to_msg[rlbl]
734             else:
735                 k = set()
736             # We assume if we already have only one key, it's the good one!
737             if len(k) > 1 and src_rna in src_to_msg:
738                 k &= src_to_msg[src_rna]
739             msgmap["rna_label"]["key"] = k
740         blbl = getattr(msgs, msgmap["but_label"]["msgstr"])
741         blbls = [blbl]
742         if blbl.endswith(self.settings.NUM_BUTTON_SUFFIX):
743             # Num buttons report their label with a trailing ': '...
744             blbls.append(blbl[:-len(self.settings.NUM_BUTTON_SUFFIX)])
745         print("button label: " + blbl)
746         if blbl and elbl not in blbls and (rlbl not in blbls or rna_ctxt != self.settings.DEFAULT_CONTEXT):
747             # Always Default context for button label :/
748             k = ctxt_to_msg[self.settings.DEFAULT_CONTEXT].copy()
749             found = False
750             for bl in blbls:
751                 if bl in msgid_to_msg:
752                     k &= msgid_to_msg[bl]
753                     found = True
754                     break
755                 elif bl in msgstr_to_msg:
756                     k &= msgstr_to_msg[bl]
757                     found = True
758                     break
759             if not found:
760                 k = set()
761             # XXX No need to check against RNA path here, if blabel is different
762             #     from rlabel, should not match anyway!
763             msgmap["but_label"]["key"] = k
764
765         # Tips (they never have a specific context).
766         etip = getattr(msgs, msgmap["enum_tip"]["msgstr"])
767         #print("enum tip: " + etip)
768         if etip:
769             k = ctxt_to_msg[self.settings.DEFAULT_CONTEXT].copy()
770             if etip in msgid_to_msg:
771                 k &= msgid_to_msg[etip]
772             elif etip in msgstr_to_msg:
773                 k &= msgstr_to_msg[etip]
774             else:
775                 k = set()
776             # We assume if we already have only one key, it's the good one!
777             if len(k) > 1 and src_enum in src_to_msg:
778                 k &= src_to_msg[src_enum]
779             msgmap["enum_tip"]["key"] = k
780         rtip = getattr(msgs, msgmap["rna_tip"]["msgstr"])
781         #print("rna tip: " + rtip)
782         if rtip:
783             k = ctxt_to_msg[self.settings.DEFAULT_CONTEXT].copy()
784             if k and rtip in msgid_to_msg:
785                 k &= msgid_to_msg[rtip]
786             elif k and rtip in msgstr_to_msg:
787                 k &= msgstr_to_msg[rtip]
788             else:
789                 k = set()
790             # We assume if we already have only one key, it's the good one!
791             if len(k) > 1 and src_rna in src_to_msg:
792                 k &= src_to_msg[src_rna]
793             msgmap["rna_tip"]["key"] = k
794             #print(k)
795         btip = getattr(msgs, msgmap["but_tip"]["msgstr"])
796         #print("button tip: " + btip)
797         if btip and btip not in {rtip, etip}:
798             k = ctxt_to_msg[self.settings.DEFAULT_CONTEXT].copy()
799             if btip in msgid_to_msg:
800                 k &= msgid_to_msg[btip]
801             elif btip in msgstr_to_msg:
802                 k &= msgstr_to_msg[btip]
803             else:
804                 k = set()
805             # XXX No need to check against RNA path here, if btip is different from rtip, should not match anyway!
806             msgmap["but_tip"]["key"] = k
807
808     def parse(self, kind, key, src):
809         del self.parsing_errors[:]
810         self.parsers[kind](self, src, key)
811         if self.parsing_errors:
812             print("{} ({}):".format(key, src))
813             self.print_info(print_stats=False)
814             print("The parser solved them as well as it could...")
815         self.update_info()
816
817     def parse_messages_from_po(self, src, key=None):
818         """
819         Parse a po file.
820         Note: This function will silently "arrange" mis-formated entries, thus using afterward write_messages() should
821               always produce a po-valid file, though not correct!
822         """
823         reading_msgid = False
824         reading_msgstr = False
825         reading_msgctxt = False
826         reading_comment = False
827         is_commented = False
828         is_fuzzy = False
829         msgctxt_lines = []
830         msgid_lines = []
831         msgstr_lines = []
832         comment_lines = []
833
834         default_context = self.settings.DEFAULT_CONTEXT
835
836         # Helper function
837         def finalize_message(self, line_nr):
838             nonlocal reading_msgid, reading_msgstr, reading_msgctxt, reading_comment
839             nonlocal is_commented, is_fuzzy, msgid_lines, msgstr_lines, msgctxt_lines, comment_lines
840
841             msgid = I18nMessage.do_unescape("".join(msgid_lines))
842             msgctxt = I18nMessage.do_unescape("".join(msgctxt_lines))
843             msgkey = (msgctxt or default_context, msgid)
844
845             # Never allow overriding existing msgid/msgctxt pairs!
846             if msgkey in self.msgs:
847                 self.parsing_errors.append((line_nr, "{} context/msgid is already in current messages!".format(msgkey)))
848                 return
849
850             self.msgs[msgkey] = I18nMessage(msgctxt_lines, msgid_lines, msgstr_lines, comment_lines,
851                                             is_commented, is_fuzzy, settings=self.settings)
852
853             # Let's clean up and get ready for next message!
854             reading_msgid = reading_msgstr = reading_msgctxt = reading_comment = False
855             is_commented = is_fuzzy = False
856             msgctxt_lines = []
857             msgid_lines = []
858             msgstr_lines = []
859             comment_lines = []
860
861         # try to use src as file name...
862         if os.path.isfile(src):
863             if os.stat(src).st_size > self.settings.PARSER_MAX_FILE_SIZE:
864                 # Security, else we could read arbitrary huge files!
865                 print("WARNING: skipping file {}, too huge!".format(src))
866                 return
867             if not key:
868                 key = src
869             with open(src, 'r', encoding="utf-8") as f:
870                 src = f.read()
871
872         _msgctxt = self.settings.PO_MSGCTXT
873         _comm_msgctxt = self.settings.PO_COMMENT_PREFIX_MSG + _msgctxt
874         _len_msgctxt = len(_msgctxt + '"')
875         _len_comm_msgctxt = len(_comm_msgctxt + '"')
876         _msgid = self.settings.PO_MSGID
877         _comm_msgid = self.settings.PO_COMMENT_PREFIX_MSG + _msgid
878         _len_msgid = len(_msgid + '"')
879         _len_comm_msgid = len(_comm_msgid + '"')
880         _msgstr = self.settings.PO_MSGSTR
881         _comm_msgstr = self.settings.PO_COMMENT_PREFIX_MSG + _msgstr
882         _len_msgstr = len(_msgstr + '"')
883         _len_comm_msgstr = len(_comm_msgstr + '"')
884         _comm_str = self.settings.PO_COMMENT_PREFIX_MSG
885         _comm_fuzzy = self.settings.PO_COMMENT_FUZZY
886         _len_comm_str = len(_comm_str + '"')
887
888         # Main loop over all lines in src...
889         for line_nr, line in enumerate(src.splitlines()):
890             if line == "":
891                 if reading_msgstr:
892                     finalize_message(self, line_nr)
893                 continue
894
895             elif line.startswith(_msgctxt) or line.startswith(_comm_msgctxt):
896                 reading_comment = False
897                 reading_ctxt = True
898                 if line.startswith(_comm_str):
899                     is_commented = True
900                     line = line[_len_comm_msgctxt:-1]
901                 else:
902                     line = line[_len_msgctxt:-1]
903                 msgctxt_lines.append(line)
904
905             elif line.startswith(_msgid) or line.startswith(_comm_msgid):
906                 reading_comment = False
907                 reading_msgid = True
908                 if line.startswith(_comm_str):
909                     if not is_commented and reading_ctxt:
910                         self.parsing_errors.append((line_nr, "commented msgid following regular msgctxt"))
911                     is_commented = True
912                     line = line[_len_comm_msgid:-1]
913                 else:
914                     line = line[_len_msgid:-1]
915                 reading_ctxt = False
916                 msgid_lines.append(line)
917
918             elif line.startswith(_msgstr) or line.startswith(_comm_msgstr):
919                 if not reading_msgid:
920                     self.parsing_errors.append((line_nr, "msgstr without a prior msgid"))
921                 else:
922                     reading_msgid = False
923                 reading_msgstr = True
924                 if line.startswith(_comm_str):
925                     line = line[_len_comm_msgstr:-1]
926                     if not is_commented:
927                         self.parsing_errors.append((line_nr, "commented msgstr following regular msgid"))
928                 else:
929                     line = line[_len_msgstr:-1]
930                     if is_commented:
931                         self.parsing_errors.append((line_nr, "regular msgstr following commented msgid"))
932                 msgstr_lines.append(line)
933
934             elif line.startswith(_comm_str[0]):
935                 if line.startswith(_comm_str):
936                     if reading_msgctxt:
937                         if is_commented:
938                             msgctxt_lines.append(line[_len_comm_str:-1])
939                         else:
940                             msgctxt_lines.append(line)
941                             self.parsing_errors.append((line_nr, "commented string while reading regular msgctxt"))
942                     elif reading_msgid:
943                         if is_commented:
944                             msgid_lines.append(line[_len_comm_str:-1])
945                         else:
946                             msgid_lines.append(line)
947                             self.parsing_errors.append((line_nr, "commented string while reading regular msgid"))
948                     elif reading_msgstr:
949                         if is_commented:
950                             msgstr_lines.append(line[_len_comm_str:-1])
951                         else:
952                             msgstr_lines.append(line)
953                             self.parsing_errors.append((line_nr, "commented string while reading regular msgstr"))
954                 else:
955                     if reading_msgctxt or reading_msgid or reading_msgstr:
956                         self.parsing_errors.append((line_nr,
957                                                     "commented string within msgctxt, msgid or msgstr scope, ignored"))
958                     elif line.startswith(_comm_fuzzy):
959                         is_fuzzy = True
960                     else:
961                         comment_lines.append(line)
962                     reading_comment = True
963
964             else:
965                 if reading_msgctxt:
966                     msgctxt_lines.append(line[1:-1])
967                 elif reading_msgid:
968                     msgid_lines.append(line[1:-1])
969                 elif reading_msgstr:
970                     line = line[1:-1]
971                     msgstr_lines.append(line)
972                 else:
973                     self.parsing_errors.append((line_nr, "regular string outside msgctxt, msgid or msgstr scope"))
974                     #self.parsing_errors += (str(comment_lines), str(msgctxt_lines), str(msgid_lines), str(msgstr_lines))
975
976         # If no final empty line, last message is not finalized!
977         if reading_msgstr:
978             finalize_message(self, line_nr)
979         self.unescape()
980
981     def write(self, kind, dest):
982         self.writers[kind](self, dest)
983
984     def write_messages_to_po(self, fname):
985         """
986         Write messages in fname po file.
987         """
988         default_context = self.settings.DEFAULT_CONTEXT
989
990         def _write(self, f):
991             _msgctxt = self.settings.PO_MSGCTXT
992             _msgid = self.settings.PO_MSGID
993             _msgstr = self.settings.PO_MSGSTR
994             _comm = self.settings.PO_COMMENT_PREFIX_MSG
995
996             self.escape()
997
998             for num, msg in enumerate(self.msgs.values()):
999                 f.write("\n".join(msg.comment_lines))
1000                 # Only mark as fuzzy if msgstr is not empty!
1001                 if msg.is_fuzzy and msg.msgstr:
1002                     f.write("\n" + self.settings.PO_COMMENT_FUZZY)
1003                 _p = _comm if msg.is_commented else ""
1004                 chunks = []
1005                 if msg.msgctxt and msg.msgctxt != default_context:
1006                     if len(msg.msgctxt_lines) > 1:
1007                         chunks += [
1008                             "\n" + _p + _msgctxt + "\"\"\n" + _p + "\"",
1009                             ("\"\n" + _p + "\"").join(msg.msgctxt_lines),
1010                             "\"",
1011                         ]
1012                     else:
1013                         chunks += ["\n" + _p + _msgctxt + "\"" + msg.msgctxt + "\""]
1014                 if len(msg.msgid_lines) > 1:
1015                     chunks += [
1016                         "\n" + _p + _msgid + "\"\"\n" + _p + "\"",
1017                         ("\"\n" + _p + "\"").join(msg.msgid_lines),
1018                         "\"",
1019                     ]
1020                 else:
1021                     chunks += ["\n" + _p + _msgid + "\"" + msg.msgid + "\""]
1022                 if len(msg.msgstr_lines) > 1:
1023                     chunks += [
1024                         "\n" + _p + _msgstr + "\"\"\n" + _p + "\"",
1025                         ("\"\n" + _p + "\"").join(msg.msgstr_lines),
1026                         "\"",
1027                     ]
1028                 else:
1029                     chunks += ["\n" + _p + _msgstr + "\"" + msg.msgstr + "\""]
1030                 chunks += ["\n\n"]
1031                 f.write("".join(chunks))
1032
1033             self.unescape()
1034
1035         self.normalize(max_len=0)  # No wrapping for now...
1036         if isinstance(fname, str):
1037             with open(fname, 'w', encoding="utf-8") as f:
1038                 _write(self, f)
1039         # Else assume fname is already a file(like) object!
1040         else:
1041             _write(self, fname)
1042
1043     def write_messages_to_mo(self, fname):
1044         """
1045         Write messages in fname mo file.
1046         """
1047         # XXX Temp solution, until I can make own mo generator working...
1048         import subprocess
1049         with tempfile.NamedTemporaryFile(mode='w+', encoding="utf-8") as tmp_po_f:
1050             self.write_messages_to_po(tmp_po_f)
1051             cmd = (self.settings.GETTEXT_MSGFMT_EXECUTABLE,
1052                    "--statistics",  # show stats
1053                    tmp_po_f.name,
1054                    "-o",
1055                    fname,
1056                   )
1057             print("Running ", " ".join(cmd))
1058             ret = subprocess.call(cmd)
1059             print("Finished.")
1060             return
1061         # XXX Code below is currently broken (generates corrupted mo files it seems :( )!
1062         # Using http://www.gnu.org/software/gettext/manual/html_node/MO-Files.html notation.
1063         # Not generating hash table!
1064         # Only translated, unfuzzy messages are taken into account!
1065         default_context = self.settings.DEFAULT_CONTEXT
1066         msgs = tuple(v for v in self.msgs.values() if not (v.is_fuzzy or v.is_commented) and v.msgstr and v.msgid)
1067         msgs = sorted(msgs[:2],
1068                       key=lambda e: (e.msgctxt + e.msgid) if (e.msgctxt and e.msgctxt != default_context) else e.msgid)
1069         magic_nbr = 0x950412de
1070         format_rev = 0
1071         N = len(msgs)
1072         O = 32
1073         T = O + N * 8
1074         S = 0
1075         H = T + N * 8
1076         # Prepare our data! we need key (optional context and msgid), translation, and offset and length of both.
1077         # Offset are relative to start of their own list.
1078         EOT = b"0x04"  # Used to concatenate context and msgid
1079         _msgid_offset = 0
1080         _msgstr_offset = 0
1081         def _gen(v):
1082             nonlocal _msgid_offset, _msgstr_offset
1083             msgid = v.msgid.encode("utf-8")
1084             msgstr = v.msgstr.encode("utf-8")
1085             if v.msgctxt and v.msgctxt != default_context:
1086                 msgctxt = v.msgctxt.encode("utf-8")
1087                 msgid = msgctxt + EOT + msgid
1088             # Don't forget the final NULL char!
1089             _msgid_len = len(msgid) + 1
1090             _msgstr_len = len(msgstr) + 1
1091             ret = ((msgid, _msgid_len, _msgid_offset), (msgstr, _msgstr_len, _msgstr_offset))
1092             _msgid_offset += _msgid_len
1093             _msgstr_offset += _msgstr_len
1094             return ret
1095         msgs = tuple(_gen(v) for v in msgs)
1096         msgid_start = H
1097         msgstr_start = msgid_start + _msgid_offset
1098         print(N, msgstr_start + _msgstr_offset)
1099         print(msgs)
1100
1101         with open(fname, 'wb') as f:
1102             # Header...
1103             f.write(struct.pack("=8I", magic_nbr, format_rev, N, O, T, S, H, 0))
1104             # Msgid's length and offset.
1105             f.write(b"".join(struct.pack("=2I", length, msgid_start + offset) for (_1, length, offset), _2 in msgs))
1106             # Msgstr's length and offset.
1107             f.write(b"".join(struct.pack("=2I", length, msgstr_start + offset) for _1, (_2, length, offset) in msgs))
1108             # No hash table!
1109             # Msgid's.
1110             f.write(b"\0".join(msgid for (msgid, _1, _2), _3 in msgs) + b"\0")
1111             # Msgstr's.
1112             f.write(b"\0".join(msgstr for _1, (msgstr, _2, _3) in msgs) + b"\0")
1113
1114     parsers = {
1115         "PO": parse_messages_from_po,
1116     }
1117
1118     writers = {
1119         "PO": write_messages_to_po,
1120         "MO": write_messages_to_mo,
1121     }
1122
1123
1124 class I18n:
1125     """
1126     Internal representation of a whole translation set.
1127     """
1128
1129     @staticmethod
1130     def _parser_check_file(path, maxsize=settings.PARSER_MAX_FILE_SIZE,
1131                            _begin_marker=settings.PARSER_PY_MARKER_BEGIN,
1132                            _end_marker=settings.PARSER_PY_MARKER_END):
1133         if os.stat(path).st_size > maxsize:
1134             # Security, else we could read arbitrary huge files!
1135             print("WARNING: skipping file {}, too huge!".format(path))
1136             return None, None, None, False
1137         txt = ""
1138         with open(path) as f:
1139             txt = f.read()
1140         _in = 0
1141         _out = len(txt)
1142         if _begin_marker:
1143             _in = None
1144             if _begin_marker in txt:
1145                 _in = txt.index(_begin_marker) + len(_begin_marker)
1146         if _end_marker:
1147             _out = None
1148             if _end_marker in txt:
1149                 _out = txt.index(_end_marker)
1150         if _in is not None and _out is not None:
1151             in_txt, txt, out_txt = txt[:_in], txt[_in:_out], txt[_out:]
1152         elif _in is not None:
1153             in_txt, txt, out_txt = txt[:_in], txt[_in:], None
1154         elif _out is not None:
1155             in_txt, txt, out_txt = None, txt[:_out], txt[_out:]
1156         else:
1157             in_txt, txt, out_txt = None, txt, None
1158         return in_txt, txt, out_txt, (True if "translations_tuple" in txt else False)
1159
1160     @staticmethod
1161     def _dst(self, path, uid, kind):
1162         if isinstance(path, str):
1163             if kind == 'PO':
1164                 if uid == self.settings.PARSER_TEMPLATE_ID:
1165                     if not path.endswith(".pot"):
1166                         return os.path.join(os.path.dirname(path), "blender.pot")
1167                 if not path.endswith(".po"):
1168                     return os.path.join(os.path.dirname(path), uid + ".po")
1169             elif kind == 'PY':
1170                 if not path.endswith(".py"):
1171                     if self.src.get(self.settings.PARSER_PY_ID):
1172                         return self.src[self.settings.PARSER_PY_ID]
1173                     return os.path.join(os.path.dirname(path), "translations.py")
1174         return path
1175
1176     def __init__(self, kind=None, src=None, langs=set(), settings=settings):
1177         self.settings = settings
1178         self.trans = {}
1179         self.src = {}  # Should have the same keys as self.trans (plus PARSER_PY_ID for py file)!
1180         self.dst = self._dst  # A callable that transforms src_path into dst_path!
1181         if kind and src:
1182             self.parse(kind, src, langs)
1183         self.update_info()
1184
1185     def _py_file_get(self):
1186         return self.src.get(self.settings.PARSER_PY_ID)
1187     def _py_file_set(self, value):
1188         self.src[self.settings.PARSER_PY_ID] = value
1189     py_file = property(_py_file_get, _py_file_set)
1190
1191     def escape(self, do_all=False):
1192         for trans in self.trans.values():
1193             trans.escape(do_all)
1194
1195     def unescape(self, do_all=True):
1196         for trans in self.trans.values():
1197             trans.unescape(do_all)
1198
1199     def update_info(self):
1200         self.nbr_trans = 0
1201         self.lvl = 0.0
1202         self.lvl_ttips = 0.0
1203         self.lvl_trans_ttips = 0.0
1204         self.lvl_ttips_in_trans = 0.0
1205         self.lvl_comm = 0.0
1206         self.nbr_signs = 0
1207         self.nbr_trans_signs = 0
1208         self.contexts = set()
1209
1210         if self.settings.PARSER_TEMPLATE_ID in self.trans:
1211             self.nbr_trans = len(self.trans) - 1
1212             self.nbr_signs = self.trans[self.settings.PARSER_TEMPLATE_ID].nbr_signs
1213         else:
1214             self.nbr_trans = len(self.trans)
1215         for msgs in self.trans.values():
1216             msgs.update_info()
1217             if msgs.nbr_msgs > 0:
1218                 self.lvl += float(msgs.nbr_trans_msgs) / float(msgs.nbr_msgs)
1219                 self.lvl_ttips += float(msgs.nbr_ttips) / float(msgs.nbr_msgs)
1220                 self.lvl_comm += float(msgs.nbr_comm_msgs) / float(msgs.nbr_msgs + msgs.nbr_comm_msgs)
1221             if msgs.nbr_ttips > 0:
1222                 self.lvl_trans_ttips = float(msgs.nbr_trans_ttips) / float(msgs.nbr_ttips)
1223             if msgs.nbr_trans_msgs > 0:
1224                 self.lvl_ttips_in_trans = float(msgs.nbr_trans_ttips) / float(msgs.nbr_trans_msgs)
1225             if self.nbr_signs == 0:
1226                 self.nbr_signs = msgs.nbr_signs
1227             self.nbr_trans_signs += msgs.nbr_trans_signs
1228             self.contexts |= msgs.contexts
1229
1230     def print_stats(self, prefix="", print_msgs=True):
1231         """
1232         Print out some stats about an I18n object.
1233         If print_msgs is True, it will also print all its translations' stats.
1234         """
1235         if print_msgs:
1236             msgs_prefix = prefix + "    "
1237             for key, msgs in self.trans.items():
1238                 if key == self.settings.PARSER_TEMPLATE_ID:
1239                     continue
1240                 print(prefix + key + ":")
1241                 msgs.print_stats(prefix=msgs_prefix)
1242                 print(prefix)
1243
1244         nbr_contexts = len(self.contexts - {bpy.app.translations.contexts.default})
1245         if nbr_contexts != 1:
1246             if nbr_contexts == 0:
1247                 nbr_contexts = "No"
1248             _ctx_txt = "s are"
1249         else:
1250             _ctx_txt = " is"
1251         lines = (("",
1252             "Average stats for all {} translations:\n".format(self.nbr_trans),
1253             "    {:>6.1%} done!\n".format(self.lvl / self.nbr_trans),
1254             "    {:>6.1%} of messages are tooltips.\n".format(self.lvl_ttips / self.nbr_trans),
1255             "    {:>6.1%} of tooltips are translated.\n".format(self.lvl_trans_ttips / self.nbr_trans),
1256             "    {:>6.1%} of translated messages are tooltips.\n".format(self.lvl_ttips_in_trans / self.nbr_trans),
1257             "    {:>6.1%} of messages are commented.\n".format(self.lvl_comm / self.nbr_trans),
1258             "    The org msgids are currently made of {} signs.\n".format(self.nbr_signs),
1259             "    All processed translations are currently made of {} signs.\n".format(self.nbr_trans_signs),
1260             "    {} specific context{} present:\n".format(self.nbr_contexts, _ctx_txt)) +
1261             tuple("            " + c + "\n" for c in self.contexts - {bpy.app.translations.contexts.default}) +
1262             ("\n",)
1263         )
1264         print(prefix.join(lines))
1265
1266     @classmethod
1267     def check_py_module_has_translations(clss, src, settings=settings):
1268         """
1269         Check whether a given src (a py module, either a directory or a py file) has some i18n translation data,
1270         and returns a tuple (src_file, translations_tuple) if yes, else (None, None).
1271         """
1272         txts = []
1273         if os.path.isdir(src):
1274             for root, dnames, fnames in os.walk(src):
1275                 for fname in fnames:
1276                     if not fname.endswith(".py"):
1277                         continue
1278                     path = os.path.join(root, fname)
1279                     _1, txt, _2, has_trans = clss._parser_check_file(path)
1280                     if has_trans:
1281                         txts.append((path, txt))
1282         elif src.endswith(".py") and os.path.isfile(src):
1283             _1, txt, _2, has_trans = clss._parser_check_file(src)
1284             if has_trans:
1285                 txts.append((src, txt))
1286         for path, txt in txts:
1287             tuple_id = "translations_tuple"
1288             env = globals().copy()
1289             exec(txt, env)
1290             if tuple_id in env:
1291                 return path, env[tuple_id]
1292         return None, None  # No data...
1293
1294     def parse(self, kind, src, langs=set()):
1295         self.parsers[kind](self, src, langs)
1296
1297     def parse_from_po(self, src, langs=set()):
1298         """
1299         src must be a tuple (dir_of_pos, pot_file), where:
1300             * dir_of_pos may either contains iso_CODE.po files, and/or iso_CODE/iso_CODE.po files.
1301             * pot_file may be None (in which case there will be no ref messages).
1302         if langs set is void, all languages found are loaded.
1303         """
1304         root_dir, pot_file = src
1305         if pot_file and os.path.isfile(pot_file):
1306             self.trans[self.settings.PARSER_TEMPLATE_ID] = I18nMessages(self.settings.PARSER_TEMPLATE_ID, 'PO',
1307                                                                         pot_file, pot_file, settings=self.settings)
1308             self.src_po[self.settings.PARSER_TEMPLATE_ID] = pot_file
1309
1310         for uid, po_file in get_po_files_from_dir(root_dir, langs):
1311             self.trans[uid] = I18nMessages(uid, 'PO', po_file, po_file, settings=self.settings)
1312             self.src_po[uid] = po_file
1313
1314     def parse_from_py(self, src, langs=set()):
1315         """
1316         src must be a valid path, either a py file or a module directory (in which case all py files inside it
1317         will be checked, first file macthing will win!).
1318         if langs set is void, all languages found are loaded.
1319         """
1320         default_context = self.settings.DEFAULT_CONTEXT
1321         self.src[self.settings.PARSER_PY_ID], msgs = self.check_py_module_has_translations(src, self.settings)
1322         if msgs is None:
1323             self.src[self.settings.PARSER_PY_ID] = src
1324             msgs = ()
1325         for key, (sources, gen_comments), *translations in msgs:
1326             if self.settings.PARSER_TEMPLATE_ID not in self.trans:
1327                 self.trans[self.settings.PARSER_TEMPLATE_ID] = I18nMessages(self.settings.PARSER_TEMPLATE_ID,
1328                                                                             settings=self.settings)
1329                 self.src[self.settings.PARSER_TEMPLATE_ID] = self.src[self.settings.PARSER_PY_ID]
1330             if key in self.trans[self.settings.PARSER_TEMPLATE_ID].msgs:
1331                 print("ERROR! key {} is defined more than once! Skipping re-definitions!")
1332                 continue
1333             custom_src = [c for c in sources if c.startswith("bpy.")]
1334             src = [c for c in sources if not c.startswith("bpy.")]
1335             common_comment_lines = [self.settings.PO_COMMENT_PREFIX_GENERATED + c for c in gen_comments] + \
1336                                    [self.settings.PO_COMMENT_PREFIX_SOURCE_CUSTOM + c for c in custom_src] + \
1337                                    [self.settings.PO_COMMENT_PREFIX_SOURCE + c for c in src]
1338             ctxt = [key[0]] if key[0] else [default_context]
1339             self.trans[self.settings.PARSER_TEMPLATE_ID].msgs[key] = I18nMessage(ctxt, [key[1]], [""],
1340                                                                                  common_comment_lines, False, False,
1341                                                                                  settings=self.settings)
1342             for uid, msgstr, (is_fuzzy, user_comments) in translations:
1343                 if uid not in self.trans:
1344                     self.trans[uid] = I18nMessages(uid, settings=self.settings)
1345                     self.src[uid] = self.src[self.settings.PARSER_PY_ID]
1346                 comment_lines = [self.settings.PO_COMMENT_PREFIX + c for c in user_comments] + common_comment_lines
1347                 self.trans[uid].msgs[key] = I18nMessage(ctxt, [key[1]], [msgstr], comment_lines, False, is_fuzzy,
1348                                                         settings=self.settings)
1349         #key = self.settings.PO_HEADER_KEY
1350         #for uid, trans in self.trans.items():
1351             #if key not in trans.msgs:
1352                 #trans.msgs[key]
1353         self.unescape()
1354
1355     def write(self, kind, langs=set()):
1356         self.writers[kind](self, langs)
1357
1358     def write_to_po(self, langs=set()):
1359         """
1360         Write all translations into po files. By default, write in the same files (or dir) as the source, specify
1361         a custom self.dst function to write somewhere else!
1362         Note: If langs is set and you want to export the pot template as well, langs must contain PARSER_TEMPLATE_ID
1363               ({} currently).
1364         """.format(self.settings.PARSER_TEMPLATE_ID)
1365         keys = self.trans.keys()
1366         if langs:
1367             keys &= langs
1368         for uid in keys:
1369             dst = self.dst(self, self.src.get(uid, ""), uid, 'PO')
1370             self.trans[uid].write('PO', dst)
1371
1372     def write_to_py(self, langs=set()):
1373         """
1374         Write all translations as python code, either in a "translations.py" file under same dir as source(s), or in
1375         specified file if self.py_file is set (default, as usual can be customized with self.dst callable!).
1376         Note: If langs is set and you want to export the pot template as well, langs must contain PARSER_TEMPLATE_ID
1377               ({} currently).
1378         """.format(self.settings.PARSER_TEMPLATE_ID)
1379         default_context = self.settings.DEFAULT_CONTEXT
1380
1381         def _gen_py(self, langs, tab="    "):
1382             _lencomm = len(self.settings.PO_COMMENT_PREFIX)
1383             _lengen = len(self.settings.PO_COMMENT_PREFIX_GENERATED)
1384             _lensrc = len(self.settings.PO_COMMENT_PREFIX_SOURCE)
1385             _lencsrc = len(self.settings.PO_COMMENT_PREFIX_SOURCE_CUSTOM)
1386             ret = [
1387                 "# NOTE: You can safely move around this auto-generated block (with the begin/end markers!),",
1388                 "#       and edit the translations by hand.",
1389                 "#       Just carefully respect the format of the tuple!",
1390                 "",
1391                 "# Tuple of tuples "
1392                 "((msgctxt, msgid), (sources, gen_comments), (lang, translation, (is_fuzzy, comments)), ...)",
1393                 "translations_tuple = (",
1394             ]
1395             # First gather all keys (msgctxt, msgid) - theoretically, all translations should share the same, but...
1396             # Note: using an ordered dict if possible (stupid sets cannot be ordered :/ ).
1397             keys = I18nMessages._new_messages()
1398             for trans in self.trans.values():
1399                 keys.update(trans.msgs)
1400             # Get the ref translation (ideally, PARSER_TEMPLATE_ID one, else the first one that pops up!
1401             # Ref translation will be used to generate sources "comments"
1402             ref = self.trans.get(self.settings.PARSER_TEMPLATE_ID) or self.trans[list(self.trans.keys())[0]]
1403             # Get all languages (uids) and sort them (PARSER_TEMPLATE_ID and PARSER_PY_ID excluded!)
1404             translations = self.trans.keys() - {self.settings.PARSER_TEMPLATE_ID, self.settings.PARSER_PY_ID}
1405             if langs:
1406                 translations &= langs
1407             translations = [('"' + lng + '"', " " * (len(lng) + 6), self.trans[lng]) for lng in sorted(translations)]
1408             print(k for k in keys.keys())
1409             for key in keys.keys():
1410                 if ref.msgs[key].is_commented:
1411                     continue
1412                 # Key (context + msgid).
1413                 msgctxt, msgid = ref.msgs[key].msgctxt, ref.msgs[key].msgid
1414                 if not msgctxt:
1415                     msgctxt = default_context
1416                 ret.append(tab + "(({}, \"{}\"),".format('"' + msgctxt + '"' if msgctxt else "None", msgid))
1417                 # Common comments (mostly sources!).
1418                 sources = []
1419                 gen_comments = []
1420                 for comment in ref.msgs[key].comment_lines:
1421                     if comment.startswith(self.settings.PO_COMMENT_PREFIX_SOURCE_CUSTOM):
1422                         sources.append(comment[_lencsrc:])
1423                     elif comment.startswith(self.settings.PO_COMMENT_PREFIX_SOURCE):
1424                         sources.append(comment[_lensrc:])
1425                     elif comment.startswith(self.settings.PO_COMMENT_PREFIX_GENERATED):
1426                         gen_comments.append(comment[_lengen:])
1427                 if not (sources or gen_comments):
1428                     ret.append(tab + " ((), ()),")
1429                 else:
1430                     if len(sources) > 1:
1431                         ret.append(tab + ' (("' + sources[0] + '",')
1432                         ret += [tab + '   "' + s + '",' for s in sources[1:-1]]
1433                         ret.append(tab + '   "' + sources[-1] + '"),')
1434                     else:
1435                         ret.append(tab + " ((" + ('"' + sources[0] + '",' if sources else "") + "),")
1436                     if len(gen_comments) > 1:
1437                         ret.append(tab + '  ("' + gen_comments[0] + '",')
1438                         ret += [tab + '   "' + s + '",' for s in gen_comments[1:-1]]
1439                         ret.append(tab + '   "' + gen_comments[-1] + '")),')
1440                     else:
1441                         ret.append(tab + "  (" + ('"' + gen_comments[0] + '",' if gen_comments else "") + ")),")
1442                 # All languages
1443                 for lngstr, lngsp, trans in translations:
1444                     if trans.msgs[key].is_commented:
1445                         continue
1446                     # Language code and translation.
1447                     ret.append(tab + " (" + lngstr + ', "' + trans.msgs[key].msgstr + '",')
1448                     # User comments and fuzzy.
1449                     comments = []
1450                     for comment in trans.msgs[key].comment_lines:
1451                         if comment.startswith(self.settings.PO_COMMENT_PREFIX):
1452                             comments.append(comment[_lencomm:])
1453                     ret.append(tab + lngsp + "(" + ("True" if trans.msgs[key].is_fuzzy else "False") + ",")
1454                     if len(comments) > 1:
1455                         ret.append(tab + lngsp + ' ("' + comments[0] + '",')
1456                         ret += [tab + lngsp + '  "' + s + '",' for s in comments[1:-1]]
1457                         ret.append(tab + lngsp + '  "' + comments[-1] + '"))),')
1458                     else:
1459                         ret[-1] = ret[-1] + " (" + (('"' + comments[0] + '",') if comments else "") + "))),"
1460
1461                 ret.append(tab + "),")
1462             ret += [
1463                 ")",
1464                 "",
1465                 "translations_dict = {}",
1466                 "for msg in translations_tuple:",
1467                 tab + "key = msg[0]",
1468                 tab + "for lang, trans, (is_fuzzy, comments) in msg[2:]:",
1469                 tab * 2 + "if trans and not is_fuzzy:",
1470                 tab * 3 + "translations_dict.setdefault(lang, {})[key] = trans",
1471                 "",
1472             ]
1473             return ret
1474
1475         self.escape(True)
1476         dst = self.dst(self, self.src.get(self.settings.PARSER_PY_ID, ""), self.settings.PARSER_PY_ID, 'PY')
1477         print(dst)
1478         prev = txt = nxt = ""
1479         if os.path.exists(dst):
1480             if not os.path.isfile(dst):
1481                 print("WARNING: trying to write as python code into {}, which is not a file! Aborting.".format(dst))
1482                 return
1483             prev, txt, nxt, has_trans = self._parser_check_file(dst)
1484             if prev is None and nxt is None:
1485                 print("WARNING: Looks like given python file {} has no auto-generated translations yet, will be added "
1486                       "at the end of the file, you can move that section later if needed...".format(dst))
1487                 txt = ([txt, "", self.settings.PARSER_PY_MARKER_BEGIN] +
1488                        _gen_py(self, langs) +
1489                        ["", self.settings.PARSER_PY_MARKER_END])
1490             else:
1491                 # We completely replace the text found between start and end markers...
1492                 txt = _gen_py(self, langs)
1493         else:
1494             printf("Creating python file {} containing translations.".format(dst))
1495             txt = [
1496                 "# ***** BEGIN GPL LICENSE BLOCK *****",
1497                 "#",
1498                 "# This program is free software; you can redistribute it and/or",
1499                 "# modify it under the terms of the GNU General Public License",
1500                 "# as published by the Free Software Foundation; either version 2",
1501                 "# of the License, or (at your option) any later version.",
1502                 "#",
1503                 "# This program is distributed in the hope that it will be useful,",
1504                 "# but WITHOUT ANY WARRANTY; without even the implied warranty of",
1505                 "# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the",
1506                 "# GNU General Public License for more details.",
1507                 "#",
1508                 "# You should have received a copy of the GNU General Public License",
1509                 "# along with this program; if not, write to the Free Software Foundation,",
1510                 "# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.",
1511                 "#",
1512                 "# ***** END GPL LICENSE BLOCK *****",
1513                 "",
1514                 self.settings.PARSER_PY_MARKER_BEGIN,
1515                 "",
1516             ]
1517             txt += _gen_py(self, langs)
1518             txt += [
1519                 "",
1520                 self.settings.PARSER_PY_MARKER_END,
1521             ]
1522         with open(dst, 'w') as f:
1523             f.write((prev or "") + "\n".join(txt) + (nxt or ""))
1524         self.unescape()
1525
1526     parsers = {
1527         "PO": parse_from_po,
1528         "PY": parse_from_py,
1529     }
1530
1531     writers = {
1532         "PO": write_to_po,
1533         "PY": write_to_py,
1534     }