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