Grmph! module renaming broke the tool!
[blender.git] / release / scripts / modules / bl_i18n_utils / bl_process_msg.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-80 compliant>
20
21 # Write out messages.txt from Blender.
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 os
26
27 # Quite an ugly hack… But the simplest solution for now!
28 #import sys
29 #sys.path.append(os.path.abspath(os.path.dirname(__file__)))
30 import bl_i18n_utils.settings as settings
31
32
33 #classes = set()
34
35
36 SOURCE_DIR = settings.SOURCE_DIR
37
38 CUSTOM_PY_UI_FILES = [os.path.abspath(os.path.join(SOURCE_DIR, p))
39                       for p in settings.CUSTOM_PY_UI_FILES]
40 FILE_NAME_MESSAGES = settings.FILE_NAME_MESSAGES
41 COMMENT_PREFIX = settings.COMMENT_PREFIX
42 CONTEXT_PREFIX = settings.CONTEXT_PREFIX
43 CONTEXT_DEFAULT = settings.CONTEXT_DEFAULT
44 UNDOC_OPS_STR = settings.UNDOC_OPS_STR
45
46 NC_ALLOWED = settings.WARN_MSGID_NOT_CAPITALIZED_ALLOWED
47
48 def check(check_ctxt, messages, key, msgsrc):
49     if check_ctxt is None:
50         return
51     multi_rnatip = check_ctxt.get("multi_rnatip")
52     multi_lines = check_ctxt.get("multi_lines")
53     py_in_rna = check_ctxt.get("py_in_rna")
54     not_capitalized = check_ctxt.get("not_capitalized")
55     end_point = check_ctxt.get("end_point")
56     undoc_ops = check_ctxt.get("undoc_ops")
57
58     if multi_rnatip is not None:
59         if key in messages and key not in multi_rnatip:
60             multi_rnatip.add(key)
61     if multi_lines is not None:
62         if '\n' in key[1]:
63             multi_lines.add(key)
64     if py_in_rna is not None:
65         if key in py_in_rna[1]:
66             py_in_rna[0].add(key)
67     if not_capitalized is not None:
68         if(key[1] not in NC_ALLOWED and key[1][0].isalpha() and
69            not key[1][0].isupper()):
70             not_capitalized.add(key)
71     if end_point is not None:
72         if key[1].strip().endswith('.'):
73             end_point.add(key)
74     if undoc_ops is not None:
75         if key[1] == UNDOC_OPS_STR:
76             undoc_ops.add(key)
77
78
79 def dump_messages_rna(messages, check_ctxt):
80     import bpy
81
82     def classBlackList():
83         blacklist_rna_class = [# core classes
84                                "Context", "Event", "Function", "UILayout",
85                                "BlendData",
86                                # registerable classes
87                                "Panel", "Menu", "Header", "RenderEngine",
88                                "Operator", "OperatorMacro", "Macro",
89                                "KeyingSetInfo", "UnknownType",
90                                # window classes
91                                "Window",
92                                ]
93
94         # ---------------------------------------------------------------------
95         # Collect internal operators
96
97         # extend with all internal operators
98         # note that this uses internal api introspection functions
99         # all possible operator names
100         op_ids = set(cls.bl_rna.identifier for cls in
101                      bpy.types.OperatorProperties.__subclasses__()) | \
102                  set(cls.bl_rna.identifier for cls in
103                      bpy.types.Operator.__subclasses__()) | \
104                  set(cls.bl_rna.identifier for cls in
105                      bpy.types.OperatorMacro.__subclasses__())
106
107         get_instance = __import__("_bpy").ops.get_instance
108         path_resolve = type(bpy.context).__base__.path_resolve
109         for idname in op_ids:
110             op = get_instance(idname)
111             # XXX Do not skip INTERNAL's anymore, some of those ops
112             #     show up in UI now!
113 #            if 'INTERNAL' in path_resolve(op, "bl_options"):
114 #                blacklist_rna_class.append(idname)
115
116         # ---------------------------------------------------------------------
117         # Collect builtin classes we don't need to doc
118         blacklist_rna_class.append("Property")
119         blacklist_rna_class.extend(
120                 [cls.__name__ for cls in
121                  bpy.types.Property.__subclasses__()])
122
123         # ---------------------------------------------------------------------
124         # Collect classes which are attached to collections, these are api
125         # access only.
126         collection_props = set()
127         for cls_id in dir(bpy.types):
128             cls = getattr(bpy.types, cls_id)
129             for prop in cls.bl_rna.properties:
130                 if prop.type == 'COLLECTION':
131                     prop_cls = prop.srna
132                     if prop_cls is not None:
133                         collection_props.add(prop_cls.identifier)
134         blacklist_rna_class.extend(sorted(collection_props))
135
136         return blacklist_rna_class
137
138     blacklist_rna_class = classBlackList()
139
140     def filterRNA(bl_rna):
141         rid = bl_rna.identifier
142         if rid in blacklist_rna_class:
143             print("  skipping", rid)
144             return True
145         return False
146
147     check_ctxt_rna = check_ctxt_rna_tip = None
148     if check_ctxt:
149         check_ctxt_rna = {"multi_lines": check_ctxt.get("multi_lines"),
150                           "not_capitalized": check_ctxt.get("not_capitalized"),
151                           "end_point": check_ctxt.get("end_point"),
152                           "undoc_ops": check_ctxt.get("undoc_ops")}
153         check_ctxt_rna_tip = check_ctxt_rna
154         check_ctxt_rna_tip["multi_rnatip"] = check_ctxt.get("multi_rnatip")
155
156     # -------------------------------------------------------------------------
157     # Function definitions
158
159     def walkProperties(bl_rna):
160         import bpy
161
162         # Get our parents' properties, to not export them multiple times.
163         bl_rna_base = bl_rna.base
164         if bl_rna_base:
165             bl_rna_base_props = bl_rna_base.properties.values()
166         else:
167             bl_rna_base_props = ()
168
169         for prop in bl_rna.properties:
170             # Only write this property if our parent hasn't got it.
171             if prop in bl_rna_base_props:
172                 continue
173             if prop.identifier == "rna_type":
174                 continue
175
176             msgsrc = "bpy.types.{}.{}".format(bl_rna.identifier, prop.identifier)
177             context = getattr(prop, "translation_context", CONTEXT_DEFAULT)
178             if prop.name and (prop.name != prop.identifier or context):
179                 key = (context, prop.name)
180                 check(check_ctxt_rna, messages, key, msgsrc)
181                 messages.setdefault(key, []).append(msgsrc)
182             if prop.description:
183                 key = (CONTEXT_DEFAULT, prop.description)
184                 check(check_ctxt_rna_tip, messages, key, msgsrc)
185                 messages.setdefault(key, []).append(msgsrc)
186             if isinstance(prop, bpy.types.EnumProperty):
187                 for item in prop.enum_items:
188                     msgsrc = "bpy.types.{}.{}:'{}'".format(bl_rna.identifier,
189                                                             prop.identifier,
190                                                             item.identifier)
191                     if item.name and item.name != item.identifier:
192                         key = (CONTEXT_DEFAULT, item.name)
193                         check(check_ctxt_rna, messages, key, msgsrc)
194                         messages.setdefault(key, []).append(msgsrc)
195                     if item.description:
196                         key = (CONTEXT_DEFAULT, item.description)
197                         check(check_ctxt_rna_tip, messages, key, msgsrc)
198                         messages.setdefault(key, []).append(msgsrc)
199
200     def walkRNA(bl_rna):
201         if filterRNA(bl_rna):
202             return
203
204         msgsrc = ".".join(("bpy.types", bl_rna.identifier))
205         context = getattr(bl_rna, "translation_context", CONTEXT_DEFAULT)
206
207         if bl_rna.name and (bl_rna.name != bl_rna.identifier or context):
208             key = (context, bl_rna.name)
209             check(check_ctxt_rna, messages, key, msgsrc)
210             messages.setdefault(key, []).append(msgsrc)
211
212         if bl_rna.description:
213             key = (CONTEXT_DEFAULT, bl_rna.description)
214             check(check_ctxt_rna_tip, messages, key, msgsrc)
215             messages.setdefault(key, []).append(msgsrc)
216
217         if hasattr(bl_rna, 'bl_label') and  bl_rna.bl_label:
218             key = (context, bl_rna.bl_label)
219             check(check_ctxt_rna, messages, key, msgsrc)
220             messages.setdefault(key, []).append(msgsrc)
221
222         walkProperties(bl_rna)
223
224     def walkClass(cls):
225         walkRNA(cls.bl_rna)
226
227     def walk_keymap_hierarchy(hier, msgsrc_prev):
228         for lvl in hier:
229             msgsrc = "{}.{}".format(msgsrc_prev, lvl[1])
230             messages.setdefault((CONTEXT_DEFAULT, lvl[0]), []).append(msgsrc)
231
232             if lvl[3]:
233                 walk_keymap_hierarchy(lvl[3], msgsrc)
234
235     # -------------------------------------------------------------------------
236     # Dump Messages
237
238     def process_cls_list(cls_list):
239         if not cls_list:
240             return 0
241
242         def full_class_id(cls):
243             """ gives us 'ID.Lamp.AreaLamp' which is best for sorting.
244             """
245             cls_id = ""
246             bl_rna = cls.bl_rna
247             while bl_rna:
248                 cls_id = "{}.{}".format(bl_rna.identifier, cls_id)
249                 bl_rna = bl_rna.base
250             return cls_id
251
252         cls_list.sort(key=full_class_id)
253         processed = 0
254         for cls in cls_list:
255             walkClass(cls)
256 #            classes.add(cls)
257             # Recursively process subclasses.
258             processed += process_cls_list(cls.__subclasses__()) + 1
259         return processed
260
261     # Parse everything (recursively parsing from bpy_struct "class"...).
262     processed = process_cls_list(type(bpy.context).__base__.__subclasses__())
263     print("{} classes processed!".format(processed))
264 #    import pickle
265 #    global classes
266 #    classes = {str(c) for c in classes}
267 #    with open("/home/i7deb64/Bureau/tpck_2", "wb") as f:
268 #        pickle.dump(classes, f, protocol=0)
269
270     from bpy_extras.keyconfig_utils import KM_HIERARCHY
271
272     walk_keymap_hierarchy(KM_HIERARCHY, "KM_HIERARCHY")
273
274
275
276 def dump_messages_pytext(messages, check_ctxt):
277     """ dumps text inlined in the python user interface: eg.
278
279         layout.prop("someprop", text="My Name")
280     """
281     import ast
282
283     # -------------------------------------------------------------------------
284     # Gather function names
285
286     import bpy
287     # key: func_id
288     # val: [(arg_kw, arg_pos), (arg_kw, arg_pos), ...]
289     func_translate_args = {}
290
291     # so far only 'text' keywords, but we may want others translated later
292     translate_kw = ("text", )
293
294     # Break recursive nodes look up on some kind of nodes.
295     # E.g. we don’t want to get strings inside subscripts (blah["foo"])!
296     stopper_nodes = {ast.Subscript,}
297
298     for func_id, func in bpy.types.UILayout.bl_rna.functions.items():
299         # check it has a 'text' argument
300         for (arg_pos, (arg_kw, arg)) in enumerate(func.parameters.items()):
301             if ((arg_kw in translate_kw) and
302                 (arg.is_output == False) and
303                 (arg.type == 'STRING')):
304
305                 func_translate_args.setdefault(func_id, []).append((arg_kw,
306                                                                     arg_pos))
307     # print(func_translate_args)
308
309     check_ctxt_py = None
310     if check_ctxt:
311         check_ctxt_py = {"py_in_rna": (check_ctxt["py_in_rna"], messages.copy()),
312                          "multi_lines": check_ctxt["multi_lines"],
313                          "not_capitalized": check_ctxt["not_capitalized"],
314                          "end_point": check_ctxt["end_point"]}
315
316     # -------------------------------------------------------------------------
317     # Function definitions
318
319     def extract_strings(fp_rel, node):
320         """ Recursively get strings, needed in case we have "Blah" + "Blah",
321             passed as an argument in that case it wont evaluate to a string.
322             However, break on some kind of stopper nodes, like e.g. Subscript.
323         """
324
325         if type(node) == ast.Str:
326             eval_str = ast.literal_eval(node)
327             if eval_str:
328                 key = (CONTEXT_DEFAULT, eval_str)
329                 msgsrc = "{}:{}".format(fp_rel, node.lineno)
330                 check(check_ctxt_py, messages, key, msgsrc)
331                 messages.setdefault(key, []).append(msgsrc)
332             return
333
334         for nd in ast.iter_child_nodes(node):
335             if type(nd) not in stopper_nodes:
336                 extract_strings(fp_rel, nd)
337
338     def extract_strings_from_file(fp):
339         filedata = open(fp, 'r', encoding="utf8")
340         root_node = ast.parse(filedata.read(), fp, 'exec')
341         filedata.close()
342
343         fp_rel = os.path.relpath(fp, SOURCE_DIR)
344
345         for node in ast.walk(root_node):
346             if type(node) == ast.Call:
347                 # print("found function at")
348                 # print("%s:%d" % (fp, node.lineno))
349
350                 # lambda's
351                 if type(node.func) == ast.Name:
352                     continue
353
354                 # getattr(self, con.type)(context, box, con)
355                 if not hasattr(node.func, "attr"):
356                     continue
357
358                 translate_args = func_translate_args.get(node.func.attr, ())
359
360                 # do nothing if not found
361                 for arg_kw, arg_pos in translate_args:
362                     if arg_pos < len(node.args):
363                         extract_strings(fp_rel, node.args[arg_pos])
364                     else:
365                         for kw in node.keywords:
366                             if kw.arg == arg_kw:
367                                 extract_strings(fp_rel, kw.value)
368
369     # -------------------------------------------------------------------------
370     # Dump Messages
371
372     mod_dir = os.path.join(SOURCE_DIR,
373                            "release",
374                            "scripts",
375                            "startup",
376                            "bl_ui")
377
378     files = [os.path.join(mod_dir, fn)
379              for fn in sorted(os.listdir(mod_dir))
380              if not fn.startswith("_")
381              if fn.endswith("py")
382              ]
383
384     # Dummy Cycles has its py addon in its own dir!
385     files += CUSTOM_PY_UI_FILES
386
387     for fp in files:
388         extract_strings_from_file(fp)
389
390
391 def dump_messages(do_messages, do_checks):
392     import collections
393
394     def enable_addons():
395         """For now, enable all official addons, before extracting msgids."""
396         import addon_utils
397         import bpy
398
399         userpref = bpy.context.user_preferences
400         used_ext = {ext.module for ext in userpref.addons}
401         support = {"OFFICIAL"}
402         # collect the categories that can be filtered on
403         addons = [(mod, addon_utils.module_bl_info(mod)) for mod in
404                   addon_utils.modules(addon_utils.addons_fake_modules)]
405
406         for mod, info in addons:
407             module_name = mod.__name__
408             if module_name in used_ext or info["support"] not in support:
409                 continue
410             print("    Enabling module ", module_name)
411             bpy.ops.wm.addon_enable(module=module_name)
412
413         # XXX There are currently some problems with bpy/rna...
414         #     *Very* tricky to solve!
415         #     So this is a hack to make all newly added operator visible by
416         #     bpy.types.OperatorProperties.__subclasses__()
417         for cat in dir(bpy.ops):
418             cat = getattr(bpy.ops, cat)
419             for op in dir(cat):
420                 getattr(cat, op).get_rna()
421
422     # check for strings like ": %d"
423     ignore = ("%d", "%f", "%s", "%r",  # string formatting
424               "*", ".", "(", ")", "-", "/", "\\", "+", ":", "#", "%"
425               "0", "1", "2", "3", "4", "5", "6", "7", "8", "9",
426               "x",  # used on its own eg: 100x200
427               "X", "Y", "Z", "W",  # used alone. no need to include
428               )
429
430     def filter_message(msg):
431         msg_tmp = msg
432         for ign in ignore:
433             msg_tmp = msg_tmp.replace(ign, "")
434         if not msg_tmp.strip():
435             return True
436         # we could filter out different strings here
437         return False
438
439     if hasattr(collections, 'OrderedDict'):
440         messages = collections.OrderedDict()
441     else:
442         messages = {}
443
444     messages[(CONTEXT_DEFAULT, "")] = []
445
446     # Enable all wanted addons.
447     enable_addons()
448
449     check_ctxt = None
450     if do_checks:
451         check_ctxt = {"multi_rnatip": set(),
452                       "multi_lines": set(),
453                       "py_in_rna": set(),
454                       "not_capitalized": set(),
455                       "end_point": set(),
456                       "undoc_ops": set()}
457
458     # get strings from RNA
459     dump_messages_rna(messages, check_ctxt)
460
461     # get strings from UI layout definitions text="..." args
462     dump_messages_pytext(messages, check_ctxt)
463
464     del messages[(CONTEXT_DEFAULT, "")]
465
466     if do_checks:
467         print("WARNINGS:")
468         keys = set()
469         for c in check_ctxt.values():
470             keys |= c
471         # XXX Temp, see below
472         keys -= check_ctxt["multi_rnatip"]
473         for key in keys:
474             if key in check_ctxt["undoc_ops"]:
475                 print("\tThe following operators are undocumented:")
476             else:
477                 print("\t“{}”|“{}”:".format(*key))
478                 if key in check_ctxt["multi_lines"]:
479                     print("\t\t-> newline in this message!")
480                 if key in check_ctxt["not_capitalized"]:
481                     print("\t\t-> message not capitalized!")
482                 if key in check_ctxt["end_point"]:
483                     print("\t\t-> message with endpoint!")
484                 # XXX Hide this one for now, too much false positives.
485 #                if key in check_ctxt["multi_rnatip"]:
486 #                    print("\t\t-> tip used in several RNA items")
487                 if key in check_ctxt["py_in_rna"]:
488                     print("\t\t-> RNA message also used in py UI code:")
489             print("\t\t{}".format("\n\t\t".join(messages[key])))
490
491     if do_messages:
492         print("Writing messages…")
493         num_written = 0
494         num_filtered = 0
495         with open(FILE_NAME_MESSAGES, 'w', encoding="utf8") as message_file:
496             for (ctx, key), value in messages.items():
497                 # filter out junk values
498                 if filter_message(key):
499                     num_filtered += 1
500                     continue
501
502                 # Remove newlines in key and values!
503                 message_file.write("\n".join(COMMENT_PREFIX + msgsrc.replace("\n", "") for msgsrc in value))
504                 message_file.write("\n")
505                 if ctx:
506                     message_file.write(CONTEXT_PREFIX + ctx.replace("\n", "") + "\n")
507                 message_file.write(key.replace("\n", "") + "\n")
508                 num_written += 1
509
510         print("Written {} messages to: {} ({} were filtered out)." \
511               "".format(num_written, FILE_NAME_MESSAGES, num_filtered))
512
513
514 def main():
515     try:
516         import bpy
517     except ImportError:
518         print("This script must run from inside blender")
519         return
520
521     import sys
522     back_argv = sys.argv
523     sys.argv = sys.argv[sys.argv.index("--") + 1:]
524
525     import argparse
526     parser = argparse.ArgumentParser(description="Process UI messages " \
527                                                  "from inside Blender.")
528     parser.add_argument('-c', '--no_checks', default=True,
529                         action="store_false",
530                         help="No checks over UI messages.")
531     parser.add_argument('-m', '--no_messages', default=True,
532                         action="store_false",
533                         help="No export of UI messages.")
534     parser.add_argument('-o', '--output', help="Output messages file path.")
535     args = parser.parse_args()
536
537     if args.output:
538         global FILE_NAME_MESSAGES
539         FILE_NAME_MESSAGES = args.output
540
541     dump_messages(do_messages=args.no_messages, do_checks=args.no_checks)
542
543     sys.argv = back_argv
544
545
546 if __name__ == "__main__":
547     print("\n\n *** Running {} *** \n".format(__file__))
548     main()