Fix more UI i18n issues (reported by Leon Cheung).
[blender.git] / release / scripts / modules / bl_i18n_utils / bl_extract_messages.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 # Populate a template file (POT format currently) from Blender RNA/py/C data.
22 # XXX: This script is meant to be used from inside Blender!
23 #      You should not directly use this script, rather use update_msg.py!
24
25 import collections
26 import copy
27 import datetime
28 import os
29 import re
30 import sys
31
32 # XXX Relative import does not work here when used from Blender...
33 from bl_i18n_utils import settings as i18n_settings, utils
34
35 import bpy
36
37 ##### Utils #####
38
39 # check for strings like "+%f°"
40 ignore_reg = re.compile(r"^(?:[-*.()/\\+%°0-9]|%d|%f|%s|%r|\s)*$")
41 filter_message = ignore_reg.match
42
43
44 def init_spell_check(settings, lang="en_US"):
45     try:
46         from bl_i18n_utils import spell_check_utils
47         return spell_check_utils.SpellChecker(settings, lang)
48     except Exception as e:
49         print("Failed to import spell_check_utils ({})".format(str(e)))
50         return None
51
52
53 def _gen_check_ctxt(settings):
54     return {
55         "multi_rnatip": set(),
56         "multi_lines": set(),
57         "py_in_rna": set(),
58         "not_capitalized": set(),
59         "end_point": set(),
60         "undoc_ops": set(),
61         "spell_checker": init_spell_check(settings),
62         "spell_errors": {},
63     }
64
65
66 def _gen_reports(check_ctxt):
67     return {
68         "check_ctxt": check_ctxt,
69         "rna_structs": [],
70         "rna_structs_skipped": [],
71         "rna_props": [],
72         "rna_props_skipped": [],
73         "py_messages": [],
74         "py_messages_skipped": [],
75         "src_messages": [],
76         "src_messages_skipped": [],
77         "messages_skipped": set(),
78     }
79
80
81 def check(check_ctxt, msgs, key, msgsrc, settings):
82     """
83     Performs a set of checks over the given key (context, message)...
84     """
85     if check_ctxt is None:
86         return
87     multi_rnatip = check_ctxt.get("multi_rnatip")
88     multi_lines = check_ctxt.get("multi_lines")
89     py_in_rna = check_ctxt.get("py_in_rna")
90     not_capitalized = check_ctxt.get("not_capitalized")
91     end_point = check_ctxt.get("end_point")
92     undoc_ops = check_ctxt.get("undoc_ops")
93     spell_checker = check_ctxt.get("spell_checker")
94     spell_errors = check_ctxt.get("spell_errors")
95
96     if multi_rnatip is not None:
97         if key in msgs and key not in multi_rnatip:
98             multi_rnatip.add(key)
99     if multi_lines is not None:
100         if '\n' in key[1]:
101             multi_lines.add(key)
102     if py_in_rna is not None:
103         if key in py_in_rna[1]:
104             py_in_rna[0].add(key)
105     if not_capitalized is not None:
106         if(key[1] not in settings.WARN_MSGID_NOT_CAPITALIZED_ALLOWED and
107            key[1][0].isalpha() and not key[1][0].isupper()):
108             not_capitalized.add(key)
109     if end_point is not None:
110         if (key[1].strip().endswith('.') and not key[1].strip().endswith('...') and
111             key[1] not in settings.WARN_MSGID_END_POINT_ALLOWED):
112             end_point.add(key)
113     if undoc_ops is not None:
114         if key[1] == settings.UNDOC_OPS_STR:
115             undoc_ops.add(key)
116     if spell_checker is not None and spell_errors is not None:
117         err = spell_checker.check(key[1])
118         if err:
119             spell_errors[key] = err
120
121
122 def print_info(reports, pot):
123     def _print(*args, **kwargs):
124         kwargs["file"] = sys.stderr
125         print(*args, **kwargs)
126
127     pot.update_info()
128
129     _print("{} RNA structs were processed (among which {} were skipped), containing {} RNA properties "
130            "(among which {} were skipped).".format(len(reports["rna_structs"]), len(reports["rna_structs_skipped"]),
131                                                    len(reports["rna_props"]), len(reports["rna_props_skipped"])))
132     _print("{} messages were extracted from Python UI code (among which {} were skipped), and {} from C source code "
133            "(among which {} were skipped).".format(len(reports["py_messages"]), len(reports["py_messages_skipped"]),
134                                                    len(reports["src_messages"]), len(reports["src_messages_skipped"])))
135     _print("{} messages were rejected.".format(len(reports["messages_skipped"])))
136     _print("\n")
137     _print("Current POT stats:")
138     pot.print_stats(prefix="\t", output=_print)
139     _print("\n")
140
141     check_ctxt = reports["check_ctxt"]
142     if check_ctxt is None:
143         return
144     multi_rnatip = check_ctxt.get("multi_rnatip")
145     multi_lines = check_ctxt.get("multi_lines")
146     py_in_rna = check_ctxt.get("py_in_rna")
147     not_capitalized = check_ctxt.get("not_capitalized")
148     end_point = check_ctxt.get("end_point")
149     undoc_ops = check_ctxt.get("undoc_ops")
150     spell_errors = check_ctxt.get("spell_errors")
151
152     # XXX Temp, no multi_rnatip nor py_in_rna, see below.
153     keys = multi_lines | not_capitalized | end_point | undoc_ops | spell_errors.keys()
154     if keys:
155         _print("WARNINGS:")
156         for key in keys:
157             if undoc_ops and key in undoc_ops:
158                 _print("\tThe following operators are undocumented!")
159             else:
160                 _print("\t“{}”|“{}”:".format(*key))
161                 if multi_lines and key in multi_lines:
162                     _print("\t\t-> newline in this message!")
163                 if not_capitalized and key in not_capitalized:
164                     _print("\t\t-> message not capitalized!")
165                 if end_point and key in end_point:
166                     _print("\t\t-> message with endpoint!")
167                 # XXX Hide this one for now, too much false positives.
168 #                if multi_rnatip and key in multi_rnatip:
169 #                    _print("\t\t-> tip used in several RNA items")
170 #                if py_in_rna and key in py_in_rna:
171 #                    _print("\t\t-> RNA message also used in py UI code!")
172                 if spell_errors and spell_errors.get(key):
173                     lines = ["\t\t-> {}: misspelled, suggestions are ({})".format(w, "'" + "', '".join(errs) + "'")
174                              for w, errs in  spell_errors[key]]
175                     _print("\n".join(lines))
176             _print("\t\t{}".format("\n\t\t".join(pot.msgs[key].sources)))
177
178
179 def enable_addons(addons={}, support={}, disable=False):
180     """
181     Enable (or disable) addons based either on a set of names, or a set of 'support' types.
182     Returns the list of all affected addons (as fake modules)!
183     """
184     import addon_utils
185
186     userpref = bpy.context.user_preferences
187     used_ext = {ext.module for ext in userpref.addons}
188
189     ret = [mod for mod in addon_utils.modules(addon_utils.addons_fake_modules)
190                if ((addons and mod.__name__ in addons) or
191                    (not addons and addon_utils.module_bl_info(mod)["support"] in support))]
192
193     for mod in ret:
194         module_name = mod.__name__
195         if disable:
196             if module_name not in used_ext:
197                 continue
198             print("    Disabling module ", module_name)
199             bpy.ops.wm.addon_disable(module=module_name)
200         else:
201             if module_name in used_ext:
202                 continue
203             print("    Enabling module ", module_name)
204             bpy.ops.wm.addon_enable(module=module_name)
205
206     # XXX There are currently some problems with bpy/rna...
207     #     *Very* tricky to solve!
208     #     So this is a hack to make all newly added operator visible by
209     #     bpy.types.OperatorProperties.__subclasses__()
210     for cat in dir(bpy.ops):
211         cat = getattr(bpy.ops, cat)
212         for op in dir(cat):
213             getattr(cat, op).get_rna()
214
215     return ret
216
217
218 def process_msg(msgs, msgctxt, msgid, msgsrc, reports, check_ctxt, settings):
219     if filter_message(msgid):
220         reports["messages_skipped"].add((msgid, msgsrc))
221         return
222     if not msgctxt:
223         # We do *not* want any "" context!
224         msgctxt = settings.DEFAULT_CONTEXT
225     # Always unescape keys!
226     msgctxt = utils.I18nMessage.do_unescape(msgctxt)
227     msgid = utils.I18nMessage.do_unescape(msgid)
228     key = (msgctxt, msgid)
229     check(check_ctxt, msgs, key, msgsrc, settings)
230     msgsrc = settings.PO_COMMENT_PREFIX_SOURCE_CUSTOM + msgsrc
231     if key not in msgs:
232         msgs[key] = utils.I18nMessage([msgctxt], [msgid], [], [msgsrc], settings=settings)
233     else:
234         msgs[key].comment_lines.append(msgsrc)
235
236
237 ##### RNA #####
238 def dump_messages_rna(msgs, reports, settings):
239     """
240     Dump into messages dict all RNA-defined UI messages (labels en tooltips).
241     """
242     def class_blacklist():
243         blacklist_rna_class = [
244             # core classes
245             "Context", "Event", "Function", "UILayout", "UnknownType",
246             # registerable classes
247             "Panel", "Menu", "Header", "RenderEngine", "Operator", "OperatorMacro", "Macro", "KeyingSetInfo",
248             # window classes
249             "Window",
250         ]
251
252         # Collect internal operators
253         # extend with all internal operators
254         # note that this uses internal api introspection functions
255         # all possible operator names
256         op_ids = set(cls.bl_rna.identifier for cls in bpy.types.OperatorProperties.__subclasses__()) | \
257                  set(cls.bl_rna.identifier for cls in bpy.types.Operator.__subclasses__()) | \
258                  set(cls.bl_rna.identifier for cls in bpy.types.OperatorMacro.__subclasses__())
259
260         get_instance = __import__("_bpy").ops.get_instance
261 #        path_resolve = type(bpy.context).__base__.path_resolve
262         for idname in op_ids:
263             op = get_instance(idname)
264             # XXX Do not skip INTERNAL's anymore, some of those ops show up in UI now!
265 #            if 'INTERNAL' in path_resolve(op, "bl_options"):
266 #                blacklist_rna_class.append(idname)
267
268         # Collect builtin classes we don't need to doc
269         blacklist_rna_class.append("Property")
270         blacklist_rna_class.extend([cls.__name__ for cls in bpy.types.Property.__subclasses__()])
271
272         # Collect classes which are attached to collections, these are api access only.
273         collection_props = set()
274         for cls_id in dir(bpy.types):
275             cls = getattr(bpy.types, cls_id)
276             for prop in cls.bl_rna.properties:
277                 if prop.type == 'COLLECTION':
278                     prop_cls = prop.srna
279                     if prop_cls is not None:
280                         collection_props.add(prop_cls.identifier)
281         blacklist_rna_class.extend(sorted(collection_props))
282
283         return blacklist_rna_class
284
285     check_ctxt_rna = check_ctxt_rna_tip = None
286     check_ctxt = reports["check_ctxt"]
287     if check_ctxt:
288         check_ctxt_rna = {
289             "multi_lines": check_ctxt.get("multi_lines"),
290             "not_capitalized": check_ctxt.get("not_capitalized"),
291             "end_point": check_ctxt.get("end_point"),
292             "undoc_ops": check_ctxt.get("undoc_ops"),
293             "spell_checker": check_ctxt.get("spell_checker"),
294             "spell_errors": check_ctxt.get("spell_errors"),
295         }
296         check_ctxt_rna_tip = check_ctxt_rna
297         check_ctxt_rna_tip["multi_rnatip"] = check_ctxt.get("multi_rnatip")
298
299     default_context = settings.DEFAULT_CONTEXT
300
301     # Function definitions
302     def walk_properties(cls):
303         bl_rna = cls.bl_rna
304         # Get our parents' properties, to not export them multiple times.
305         bl_rna_base = bl_rna.base
306         if bl_rna_base:
307             bl_rna_base_props = set(bl_rna_base.properties.values())
308         else:
309             bl_rna_base_props = set()
310
311         for prop in bl_rna.properties:
312             # Only write this property if our parent hasn't got it.
313             if prop in bl_rna_base_props:
314                 continue
315             if prop.identifier == "rna_type":
316                 continue
317             reports["rna_props"].append((cls, prop))
318
319             msgsrc = "bpy.types.{}.{}".format(bl_rna.identifier, prop.identifier)
320             msgctxt = prop.translation_context or default_context
321
322             if prop.name and (prop.name != prop.identifier or msgctxt != default_context):
323                 process_msg(msgs, msgctxt, prop.name, msgsrc, reports, check_ctxt_rna, settings)
324             if prop.description:
325                 process_msg(msgs, default_context, prop.description, msgsrc, reports, check_ctxt_rna_tip, settings)
326
327             if isinstance(prop, bpy.types.EnumProperty):
328                 for item in prop.enum_items:
329                     msgsrc = "bpy.types.{}.{}:'{}'".format(bl_rna.identifier, prop.identifier, item.identifier)
330                     if item.name and item.name != item.identifier:
331                         process_msg(msgs, msgctxt, item.name, msgsrc, reports, check_ctxt_rna, settings)
332                     if item.description:
333                         process_msg(msgs, default_context, item.description, msgsrc, reports, check_ctxt_rna_tip,
334                                     settings)
335
336     blacklist_rna_class = class_blacklist()
337
338     def walk_class(cls):
339         bl_rna = cls.bl_rna
340         reports["rna_structs"].append(cls)
341         if bl_rna.identifier in blacklist_rna_class:
342             reports["rna_structs_skipped"].append(cls)
343             return
344
345         # XXX translation_context of Operator sub-classes are not "good"!
346         #     So ignore those Operator sub-classes (anyway, will get the same from OperatorProperties sub-classes!)...
347         if issubclass(cls, bpy.types.Operator):
348             reports["rna_structs_skipped"].append(cls)
349             return
350
351         msgsrc = "bpy.types." + bl_rna.identifier
352         msgctxt = bl_rna.translation_context or default_context
353
354         if bl_rna.name and (bl_rna.name != bl_rna.identifier or msgctxt != default_context):
355             process_msg(msgs, msgctxt, bl_rna.name, msgsrc, reports, check_ctxt_rna, settings)
356
357         if bl_rna.description:
358             process_msg(msgs, default_context, bl_rna.description, msgsrc, reports, check_ctxt_rna_tip, settings)
359         elif cls.__doc__:  # XXX Some classes (like KeyingSetInfo subclasses) have void description... :(
360             process_msg(msgs, default_context, cls.__doc__, msgsrc, reports, check_ctxt_rna_tip, settings)
361
362         if hasattr(bl_rna, 'bl_label') and  bl_rna.bl_label:
363             process_msg(msgs, msgctxt, bl_rna.bl_label, msgsrc, reports, check_ctxt_rna, settings)
364
365         walk_properties(cls)
366
367     def walk_keymap_hierarchy(hier, msgsrc_prev):
368         for lvl in hier:
369             msgsrc = msgsrc_prev + "." + lvl[1]
370             process_msg(msgs, default_context, lvl[0], msgsrc, reports, None, settings)
371             if lvl[3]:
372                 walk_keymap_hierarchy(lvl[3], msgsrc)
373
374     # Dump Messages
375     def process_cls_list(cls_list):
376         if not cls_list:
377             return
378
379         def full_class_id(cls):
380             """ gives us 'ID.Lamp.AreaLamp' which is best for sorting."""
381             cls_id = ""
382             bl_rna = cls.bl_rna
383             while bl_rna:
384                 cls_id = bl_rna.identifier + "." + cls_id
385                 bl_rna = bl_rna.base
386             return cls_id
387
388         cls_list.sort(key=full_class_id)
389         for cls in cls_list:
390             walk_class(cls)
391             # Recursively process subclasses.
392             process_cls_list(cls.__subclasses__())
393
394     # Parse everything (recursively parsing from bpy_struct "class"...).
395     process_cls_list(bpy.types.ID.__base__.__subclasses__())
396
397     # And parse keymaps!
398     from bpy_extras.keyconfig_utils import KM_HIERARCHY
399
400     walk_keymap_hierarchy(KM_HIERARCHY, "KM_HIERARCHY")
401
402
403 ##### Python source code #####
404 def dump_py_messages_from_files(msgs, reports, files, settings):
405     """
406     Dump text inlined in the python files given, e.g. 'My Name' in:
407         layout.prop("someprop", text="My Name")
408     """
409     import ast
410
411     bpy_struct = bpy.types.ID.__base__
412
413     root_paths = tuple(bpy.utils.resource_path(t) for t in ('USER', 'LOCAL', 'SYSTEM'))
414     def make_rel(path):
415         for rp in root_paths:
416             if path.startswith(rp):
417                 return os.path.relpath(path, rp)
418         # Use binary's dir as fallback...
419         return os.path.relpath(path, os.path.dirname(bpy.app.binary_path))
420
421     # Helper function
422     def extract_strings_ex(node, is_split=False):
423         """
424         Recursively get strings, needed in case we have "Blah" + "Blah", passed as an argument in that case it won't
425         evaluate to a string. However, break on some kind of stopper nodes, like e.g. Subscript.
426         """
427         if type(node) == ast.Str:
428             eval_str = ast.literal_eval(node)
429             if eval_str:
430                 yield (is_split, eval_str, (node,))
431         else:
432             is_split = (type(node) in separate_nodes)
433             for nd in ast.iter_child_nodes(node):
434                 if type(nd) not in stopper_nodes:
435                     yield from extract_strings_ex(nd, is_split=is_split)
436
437     def _extract_string_merge(estr_ls, nds_ls):
438         return "".join(s for s in estr_ls if s is not None), tuple(n for n in nds_ls if n is not None)
439
440     def extract_strings(node):
441         estr_ls = []
442         nds_ls = []
443         for is_split, estr, nds in extract_strings_ex(node):
444             estr_ls.append(estr)
445             nds_ls.extend(nds)
446         ret = _extract_string_merge(estr_ls, nds_ls)
447         return ret
448     
449     def extract_strings_split(node):
450         """
451         Returns a list args as returned by 'extract_strings()', But split into groups based on separate_nodes, this way
452         expressions like ("A" if test else "B") wont be merged but "A" + "B" will.
453         """
454         estr_ls = []
455         nds_ls = []
456         bag = []
457         for is_split, estr, nds in extract_strings_ex(node):
458             if is_split:
459                 bag.append((estr_ls, nds_ls))
460                 estr_ls = []
461                 nds_ls = []
462
463             estr_ls.append(estr)
464             nds_ls.extend(nds)
465
466         bag.append((estr_ls, nds_ls))
467
468         return [_extract_string_merge(estr_ls, nds_ls) for estr_ls, nds_ls in bag]
469
470
471     def _ctxt_to_ctxt(node):
472         return extract_strings(node)[0]
473
474     def _op_to_ctxt(node):
475         opname, _ = extract_strings(node)
476         if not opname:
477             return settings.DEFAULT_CONTEXT
478         op = bpy.ops
479         for n in opname.split('.'):
480             op = getattr(op, n)
481         try:
482             return op.get_rna().bl_rna.translation_context
483         except Exception as e:
484             default_op_context = bpy.app.translations.contexts.operator_default
485             print("ERROR: ", str(e))
486             print("       Assuming default operator context '{}'".format(default_op_context))
487             return default_op_context
488
489     # Gather function names.
490     # In addition of UI func, also parse pgettext ones...
491     # Tuples of (module name, (short names, ...)).
492     pgettext_variants = (
493         ("pgettext", ("_",)),
494         ("pgettext_iface", ("iface_",)),
495         ("pgettext_tip", ("tip_",))
496     )
497     pgettext_variants_args = {"msgid": (0, {"msgctxt": 1})}
498
499     # key: msgid keywords.
500     # val: tuples of ((keywords,), context_getter_func) to get a context for that msgid.
501     #      Note: order is important, first one wins!
502     translate_kw = {
503         "text": ((("text_ctxt",), _ctxt_to_ctxt),
504                  (("operator",), _op_to_ctxt),
505                 ),
506         "msgid": ((("msgctxt",), _ctxt_to_ctxt),
507                  ),
508         "message": (),
509     }
510
511     context_kw_set = {}
512     for k, ctxts in translate_kw.items():
513         s = set()
514         for c, _ in ctxts:
515             s |= set(c)
516         context_kw_set[k] = s
517
518     # {func_id: {msgid: (arg_pos,
519     #                    {msgctxt: arg_pos,
520     #                     ...
521     #                    }
522     #                   ),
523     #            ...
524     #           },
525     #  ...
526     # }
527     func_translate_args = {}
528
529     # First, functions from UILayout
530     # First loop is for msgid args, second one is for msgctxt args.
531     for func_id, func in bpy.types.UILayout.bl_rna.functions.items():
532         # check it has one or more arguments as defined in translate_kw
533         for arg_pos, (arg_kw, arg) in enumerate(func.parameters.items()):
534             if ((arg_kw in translate_kw) and (not arg.is_output) and (arg.type == 'STRING')):
535                 func_translate_args.setdefault(func_id, {})[arg_kw] = (arg_pos, {})
536     for func_id, func in bpy.types.UILayout.bl_rna.functions.items():
537         if func_id not in func_translate_args:
538             continue
539         for arg_pos, (arg_kw, arg) in enumerate(func.parameters.items()):
540             if (not arg.is_output) and (arg.type == 'STRING'):
541                 for msgid, msgctxts in context_kw_set.items():
542                     if arg_kw in msgctxts:
543                         func_translate_args[func_id][msgid][1][arg_kw] = arg_pos
544     # The report() func of operators.
545     for func_id, func in bpy.types.Operator.bl_rna.functions.items():
546         # check it has one or more arguments as defined in translate_kw
547         for arg_pos, (arg_kw, arg) in enumerate(func.parameters.items()):
548             if ((arg_kw in translate_kw) and (not arg.is_output) and (arg.type == 'STRING')):
549                 func_translate_args.setdefault(func_id, {})[arg_kw] = (arg_pos, {})
550     # We manually add funcs from bpy.app.translations
551     for func_id, func_ids in pgettext_variants:
552         func_translate_args[func_id] = pgettext_variants_args
553         for func_id in func_ids:
554             func_translate_args[func_id] = pgettext_variants_args
555     #print(func_translate_args)
556
557     # Break recursive nodes look up on some kind of nodes.
558     # E.g. we don’t want to get strings inside subscripts (blah["foo"])!
559     stopper_nodes = {ast.Subscript}
560     # Consider strings separate: ("a" if test else "b")
561     separate_nodes = {ast.IfExp}
562
563     check_ctxt_py = None
564     if reports["check_ctxt"]:
565         check_ctxt = reports["check_ctxt"]
566         check_ctxt_py = {
567             "py_in_rna": (check_ctxt.get("py_in_rna"), set(msgs.keys())),
568             "multi_lines": check_ctxt.get("multi_lines"),
569             "not_capitalized": check_ctxt.get("not_capitalized"),
570             "end_point": check_ctxt.get("end_point"),
571             "spell_checker": check_ctxt.get("spell_checker"),
572             "spell_errors": check_ctxt.get("spell_errors"),
573         }
574
575     for fp in files:
576         with open(fp, 'r', encoding="utf8") as filedata:
577             root_node = ast.parse(filedata.read(), fp, 'exec')
578
579         fp_rel = make_rel(fp)
580
581         for node in ast.walk(root_node):
582             if type(node) == ast.Call:
583                 # print("found function at")
584                 # print("%s:%d" % (fp, node.lineno))
585
586                 # We can't skip such situations! from blah import foo\nfoo("bar") would also be an ast.Name func!
587                 if type(node.func) == ast.Name:
588                     func_id = node.func.id
589                 elif hasattr(node.func, "attr"):
590                     func_id = node.func.attr
591                 # Ugly things like getattr(self, con.type)(context, box, con)
592                 else:
593                     continue
594
595                 func_args = func_translate_args.get(func_id, {})
596
597                 # First try to get i18n contexts, for every possible msgid id.
598                 msgctxts = dict.fromkeys(func_args.keys(), "")
599                 for msgid, (_, context_args) in func_args.items():
600                     context_elements = {}
601                     for arg_kw, arg_pos in context_args.items():
602                         if arg_pos < len(node.args):
603                             context_elements[arg_kw] = node.args[arg_pos]
604                         else:
605                             for kw in node.keywords:
606                                 if kw.arg == arg_kw:
607                                     context_elements[arg_kw] = kw.value
608                                     break
609                     #print(context_elements)
610                     for kws, proc in translate_kw[msgid]:
611                         if set(kws) <= context_elements.keys():
612                             args = tuple(context_elements[k] for k in kws)
613                             #print("running ", proc, " with ", args)
614                             ctxt = proc(*args)
615                             if ctxt:
616                                 msgctxts[msgid] = ctxt
617                                 break
618
619                 #print(translate_args)
620                 # do nothing if not found
621                 for arg_kw, (arg_pos, _) in func_args.items():
622                     msgctxt = msgctxts[arg_kw]
623                     estr_lst = [(None, ())]
624                     if arg_pos < len(node.args):
625                         estr_lst = extract_strings_split(node.args[arg_pos])
626                         #print(estr, nds)
627                     else:
628                         for kw in node.keywords:
629                             if kw.arg == arg_kw:
630                                 estr_lst = extract_strings_split(kw.value)
631                                 break
632                         #print(estr, nds)
633                     for estr, nds in estr_lst:
634                         if estr:
635                             if nds:
636                                 msgsrc = "{}:{}".format(fp_rel, sorted({nd.lineno for nd in nds})[0])
637                             else:
638                                 msgsrc = "{}:???".format(fp_rel)
639                             process_msg(msgs, msgctxt, estr, msgsrc, reports, check_ctxt_py, settings)
640                             reports["py_messages"].append((msgctxt, estr, msgsrc))
641
642
643 def dump_py_messages(msgs, reports, addons, settings, addons_only=False):
644     def _get_files(path):
645         if not os.path.exists(path):
646             return []
647         if os.path.isdir(path):
648             return [os.path.join(dpath, fn) for dpath, _, fnames in os.walk(path) for fn in fnames
649                                             if not fn.startswith("_") and fn.endswith(".py")]
650         return [path]
651
652     files = []
653     if not addons_only:
654         for path in settings.CUSTOM_PY_UI_FILES:
655             for root in (bpy.utils.resource_path(t) for t in ('USER', 'LOCAL', 'SYSTEM')):
656                 files += _get_files(os.path.join(root, path))
657
658     # Add all given addons.
659     for mod in addons:
660         fn = mod.__file__
661         if os.path.basename(fn) == "__init__.py":
662             files += _get_files(os.path.dirname(fn))
663         else:
664             files.append(fn)
665
666     dump_py_messages_from_files(msgs, reports, sorted(files), settings)
667
668
669 ##### C source code #####
670 def dump_src_messages(msgs, reports, settings):
671     def get_contexts():
672         """Return a mapping {C_CTXT_NAME: ctxt_value}."""
673         return {k: getattr(bpy.app.translations.contexts, n) for k, n in bpy.app.translations.contexts_C_to_py.items()}
674
675     contexts = get_contexts()
676
677     # Build regexes to extract messages (with optional contexts) from C source.
678     pygettexts = tuple(re.compile(r).search for r in settings.PYGETTEXT_KEYWORDS)
679
680     _clean_str = re.compile(settings.str_clean_re).finditer
681     clean_str = lambda s: "".join(m.group("clean") for m in _clean_str(s))
682
683     def dump_src_file(path, rel_path, msgs, reports, settings):
684         def process_entry(_msgctxt, _msgid):
685             # Context.
686             msgctxt = settings.DEFAULT_CONTEXT
687             if _msgctxt:
688                 if _msgctxt in contexts:
689                     msgctxt = contexts[_msgctxt]
690                 elif '"' in _msgctxt or "'" in _msgctxt:
691                     msgctxt = clean_str(_msgctxt)
692                 else:
693                     print("WARNING: raw context “{}” couldn’t be resolved!".format(_msgctxt))
694             # Message.
695             msgid = ""
696             if _msgid:
697                 if '"' in _msgid or "'" in _msgid:
698                     msgid = clean_str(_msgid)
699                 else:
700                     print("WARNING: raw message “{}” couldn’t be resolved!".format(_msgid))
701             return msgctxt, msgid
702
703         check_ctxt_src = None
704         if reports["check_ctxt"]:
705             check_ctxt = reports["check_ctxt"]
706             check_ctxt_src = {
707                 "multi_lines": check_ctxt.get("multi_lines"),
708                 "not_capitalized": check_ctxt.get("not_capitalized"),
709                 "end_point": check_ctxt.get("end_point"),
710                 "spell_checker": check_ctxt.get("spell_checker"),
711                 "spell_errors": check_ctxt.get("spell_errors"),
712             }
713
714         data = ""
715         with open(path) as f:
716             data = f.read()
717         for srch in pygettexts:
718             m = srch(data)
719             line = pos = 0
720             while m:
721                 d = m.groupdict()
722                 # Line.
723                 line += data[pos:m.start()].count('\n')
724                 msgsrc = rel_path + ":" + str(line)
725                 _msgid = d.get("msg_raw")
726                 # First, try the "multi-contexts" stuff!
727                 _msgctxts = tuple(d.get("ctxt_raw{}".format(i)) for i in range(settings.PYGETTEXT_MAX_MULTI_CTXT))
728                 if _msgctxts[0]:
729                     for _msgctxt in _msgctxts:
730                         if not _msgctxt:
731                             break
732                         msgctxt, msgid = process_entry(_msgctxt, _msgid)
733                         process_msg(msgs, msgctxt, msgid, msgsrc, reports, check_ctxt_src, settings)
734                         reports["src_messages"].append((msgctxt, msgid, msgsrc))
735                 else:
736                     _msgctxt = d.get("ctxt_raw")
737                     msgctxt, msgid = process_entry(_msgctxt, _msgid)
738                     process_msg(msgs, msgctxt, msgid, msgsrc, reports, check_ctxt_src, settings)
739                     reports["src_messages"].append((msgctxt, msgid, msgsrc))
740
741                 pos = m.end()
742                 line += data[m.start():pos].count('\n')
743                 m = srch(data, pos)
744
745     forbidden = set()
746     forced = set()
747     if os.path.isfile(settings.SRC_POTFILES):
748         with open(settings.SRC_POTFILES) as src:
749             for l in src:
750                 if l[0] == '-':
751                     forbidden.add(l[1:].rstrip('\n'))
752                 elif l[0] != '#':
753                     forced.add(l.rstrip('\n'))
754     for root, dirs, files in os.walk(settings.POTFILES_SOURCE_DIR):
755         if "/.svn" in root:
756             continue
757         for fname in files:
758             if os.path.splitext(fname)[1] not in settings.PYGETTEXT_ALLOWED_EXTS:
759                 continue
760             path = os.path.join(root, fname)
761             rel_path = os.path.relpath(path, settings.SOURCE_DIR)
762             if rel_path in forbidden:
763                 continue
764             elif rel_path not in forced:
765                 forced.add(rel_path)
766     for rel_path in sorted(forced):
767         path = os.path.join(settings.SOURCE_DIR, rel_path)
768         if os.path.exists(path):
769             dump_src_file(path, rel_path, msgs, reports, settings)
770
771
772 ##### Main functions! #####
773 def dump_messages(do_messages, do_checks, settings):
774     bl_ver = "Blender " + bpy.app.version_string
775     bl_rev = bpy.app.build_revision
776     bl_date = datetime.datetime.strptime(bpy.app.build_date.decode() + "T" + bpy.app.build_time.decode(),
777                                          "%Y-%m-%dT%H:%M:%S")
778     pot = utils.I18nMessages.gen_empty_messages(settings.PARSER_TEMPLATE_ID, bl_ver, bl_rev, bl_date, bl_date.year,
779                                                 settings=settings)
780     msgs = pot.msgs
781
782     # Enable all wanted addons.
783     # For now, enable all official addons, before extracting msgids.
784     addons = enable_addons(support={"OFFICIAL"})
785     # Note this is not needed if we have been started with factory settings, but just in case...
786     enable_addons(support={"COMMUNITY", "TESTING"}, disable=True)
787
788     reports = _gen_reports(_gen_check_ctxt(settings) if do_checks else None)
789
790     # Get strings from RNA.
791     dump_messages_rna(msgs, reports, settings)
792
793     # Get strings from UI layout definitions text="..." args.
794     dump_py_messages(msgs, reports, addons, settings)
795
796     # Get strings from C source code.
797     dump_src_messages(msgs, reports, settings)
798
799     # Get strings from addons' categories.
800     for uid, label, tip in bpy.types.WindowManager.addon_filter[1]['items'](bpy.context.window_manager, bpy.context):
801         process_msg(msgs, settings.DEFAULT_CONTEXT, label, "Addons' categories", reports, None, settings)
802         if tip:
803             process_msg(msgs, settings.DEFAULT_CONTEXT, tip, "Addons' categories", reports, None, settings)
804
805     # Get strings specific to translations' menu.
806     for lng in settings.LANGUAGES:
807         process_msg(msgs, settings.DEFAULT_CONTEXT, lng[1], "Languages’ labels from bl_i18n_utils/settings.py",
808                     reports, None, settings)
809     for cat in settings.LANGUAGES_CATEGORIES:
810         process_msg(msgs, settings.DEFAULT_CONTEXT, cat[1],
811                     "Language categories’ labels from bl_i18n_utils/settings.py", reports, None, settings)
812
813     #pot.check()
814     pot.unescape()  # Strings gathered in py/C source code may contain escaped chars...
815     print_info(reports, pot)
816     #pot.check()
817
818     if do_messages:
819         print("Writing messages…")
820         pot.write('PO', settings.FILE_NAME_POT)
821
822     print("Finished extracting UI messages!")
823
824
825 def dump_addon_messages(module_name, messages_formats, do_checks, settings):
826     # Enable our addon and get strings from RNA.
827     addon = enable_addons(addons={module_name})[0]
828
829     addon_info = addon_utils.module_bl_info(addon)
830     ver = addon_info.name + " " + ".".join(addon_info.version)
831     rev = "???"
832     date = datetime.datetime()
833     pot = utils.I18nMessages.gen_empty_messages(settings.PARSER_TEMPLATE_ID, ver, rev, date, date.year,
834                                                 settings=settings)
835     msgs = pot.msgs
836
837     minus_msgs = copy.deepcopy(msgs)
838
839     check_ctxt = _gen_check_ctxt(settings) if do_checks else None
840     minus_check_ctxt = _gen_check_ctxt(settings) if do_checks else None
841
842     # Get current addon state (loaded or not):
843     was_loaded = addon_utils.check(module_name)[1]
844
845     # Enable our addon and get strings from RNA.
846     addons = enable_addons(addons={module_name})
847     reports = _gen_reports(check_ctxt)
848     dump_messages_rna(msgs, reports, settings)
849
850     # Now disable our addon, and rescan RNA.
851     enable_addons(addons={module_name}, disable=True)
852     reports["check_ctxt"] = minus_check_ctxt
853     dump_messages_rna(minus_msgs, reports, settings)
854
855     # Restore previous state if needed!
856     if was_loaded:
857         enable_addons(addons={module_name})
858
859     # and make the diff!
860     for key in minus_msgs:
861         if key == settings.PO_HEADER_KEY:
862             continue
863         del msgs[key]
864
865     if check_ctxt:
866         for key in check_ctxt:
867             for warning in minus_check_ctxt[key]:
868                 check_ctxt[key].remove(warning)
869
870     # and we are done with those!
871     del minus_msgs
872     del minus_check_ctxt
873
874     # get strings from UI layout definitions text="..." args
875     reports["check_ctxt"] = check_ctxt
876     dump_messages_pytext(msgs, reports, addons, settings, addons_only=True)
877
878     print_info(reports, pot)
879
880     return pot
881
882
883 def main():
884     try:
885         import bpy
886     except ImportError:
887         print("This script must run from inside blender")
888         return
889
890     import sys
891     back_argv = sys.argv
892     # Get rid of Blender args!
893     sys.argv = sys.argv[sys.argv.index("--") + 1:]
894
895     import argparse
896     parser = argparse.ArgumentParser(description="Process UI messages from inside Blender.")
897     parser.add_argument('-c', '--no_checks', default=True, action="store_false", help="No checks over UI messages.")
898     parser.add_argument('-m', '--no_messages', default=True, action="store_false", help="No export of UI messages.")
899     parser.add_argument('-o', '--output', default=None, help="Output POT file path.")
900     parser.add_argument('-s', '--settings', default=None,
901                         help="Override (some) default settings. Either a JSon file name, or a JSon string.")
902     args = parser.parse_args()
903
904     settings = i18n_settings.I18nSettings()
905     settings.from_json(args.settings)
906
907     if args.output:
908         settings.FILE_NAME_POT = args.output
909
910     dump_messages(do_messages=args.no_messages, do_checks=args.no_checks, settings=settings)
911
912     sys.argv = back_argv
913
914
915 if __name__ == "__main__":
916     print("\n\n *** Running {} *** \n".format(__file__))
917     main()