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