51197b86678b251655c66b8b0f1a044749421ef9
[blender.git] / release / scripts / modules / bl_i18n_utils / update_pot.py
1 #!/usr/bin/python3
2
3 # ***** BEGIN GPL LICENSE BLOCK *****
4 #
5 # This program is free software; you can redistribute it and/or
6 # modify it under the terms of the GNU General Public License
7 # as published by the Free Software Foundation; either version 2
8 # of the License, or (at your option) any later version.
9 #
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 # GNU General Public License for more details.
14 #
15 # You should have received a copy of the GNU General Public License
16 # along with this program; if not, write to the Free Software Foundation,
17 # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 #
19 # ***** END GPL LICENSE BLOCK *****
20
21 # <pep8 compliant>
22
23 # Update blender.pot file from messages.txt
24
25 import subprocess
26 import os
27 import sys
28 import re
29 import tempfile
30 import argparse
31 import time
32 import pickle
33
34 try:
35     import settings
36     import utils
37 except:
38     from . import (settings, utils)
39
40
41 LANGUAGES_CATEGORIES = settings.LANGUAGES_CATEGORIES
42 LANGUAGES = settings.LANGUAGES
43
44 COMMENT_PREFIX = settings.COMMENT_PREFIX
45 COMMENT_PREFIX_SOURCE = settings.COMMENT_PREFIX_SOURCE
46 CONTEXT_PREFIX = settings.CONTEXT_PREFIX
47 FILE_NAME_MESSAGES = settings.FILE_NAME_MESSAGES
48 FILE_NAME_POT = settings.FILE_NAME_POT
49 SOURCE_DIR = settings.SOURCE_DIR
50 POTFILES_DIR = settings.POTFILES_SOURCE_DIR
51 SRC_POTFILES = settings.FILE_NAME_SRC_POTFILES
52
53 CONTEXT_DEFAULT = settings.CONTEXT_DEFAULT
54 PYGETTEXT_ALLOWED_EXTS = settings.PYGETTEXT_ALLOWED_EXTS
55
56 SVN_EXECUTABLE = settings.SVN_EXECUTABLE
57
58 WARN_NC = settings.WARN_MSGID_NOT_CAPITALIZED
59 NC_ALLOWED = settings.WARN_MSGID_NOT_CAPITALIZED_ALLOWED
60
61 SPELL_CACHE = settings.SPELL_CACHE
62
63
64 # Do this only once!
65 # Get contexts defined in blf.
66 CONTEXTS = {}
67 with open(os.path.join(SOURCE_DIR, settings.PYGETTEXT_CONTEXTS_DEFSRC)) as f:
68     reg = re.compile(settings.PYGETTEXT_CONTEXTS)
69     f = f.read()
70     # This regex is supposed to yield tuples
71     # (key=C_macro_name, value=C_string).
72     CONTEXTS = dict(m.groups() for m in reg.finditer(f))
73
74 # Build regexes to extract messages (with optional contexts) from C source.
75 pygettexts = tuple(re.compile(r).search
76                    for r in settings.PYGETTEXT_KEYWORDS)
77 _clean_str = re.compile(settings.str_clean_re).finditer
78 clean_str = lambda s: "".join(m.group("clean") for m in _clean_str(s))
79
80
81 def check_file(path, rel_path, messages):
82     with open(path, encoding="utf-8") as f:
83         f = f.read()
84         for srch in pygettexts:
85             m = srch(f)
86             line = pos = 0
87             while m:
88                 d = m.groupdict()
89                 # Context.
90                 ctxt = d.get("ctxt_raw")
91                 if ctxt:
92                     if ctxt in CONTEXTS:
93                         ctxt = CONTEXTS[ctxt]
94                     elif '"' in ctxt or "'" in ctxt:
95                         ctxt = clean_str(ctxt)
96                     else:
97                         print("WARNING: raw context “{}” couldn’t be resolved!"
98                               "".format(ctxt))
99                         ctxt = CONTEXT_DEFAULT
100                 else:
101                     ctxt = CONTEXT_DEFAULT
102                 # Message.
103                 msg = d.get("msg_raw")
104                 if msg:
105                     if '"' in msg or "'" in msg:
106                         msg = clean_str(msg)
107                     else:
108                         print("WARNING: raw message “{}” couldn’t be resolved!"
109                               "".format(msg))
110                         msg = ""
111                 else:
112                     msg = ""
113                 # Line.
114                 line += f[pos:m.start()].count('\n')
115                 # And we are done for this item!
116                 messages.setdefault((ctxt, msg), []).append(":".join((rel_path, str(line))))
117                 pos = m.end()
118                 line += f[m.start():pos].count('\n')
119                 m = srch(f, pos)
120
121
122 def py_xgettext(messages):
123     forbidden = set()
124     forced = set()
125     with open(SRC_POTFILES) as src:
126         for l in src:
127             if l[0] == '-':
128                 forbidden.add(l[1:].rstrip('\n'))
129             elif l[0] != '#':
130                 forced.add(l.rstrip('\n'))
131     for root, dirs, files in os.walk(POTFILES_DIR):
132         if "/.svn" in root:
133             continue
134         for fname in files:
135             if os.path.splitext(fname)[1] not in PYGETTEXT_ALLOWED_EXTS:
136                 continue
137             path = os.path.join(root, fname)
138             rel_path = os.path.relpath(path, SOURCE_DIR)
139             if rel_path in forbidden:
140                 continue
141             elif rel_path in forced:
142                 forced.remove(rel_path)
143             check_file(path, rel_path, messages)
144     for path in forced:
145         if os.path.exists(path):
146             check_file(os.path.join(SOURCE_DIR, path), path, messages)
147
148
149 # Spell checking!
150 import enchant
151 dict_spelling = enchant.Dict("en_US")
152
153 from spell_check_utils import (dict_uimsgs,
154                                split_words,
155                               )
156
157 _spell_checked = set()
158
159
160 def spell_check(txt, cache):
161     ret = []
162
163     if cache is not None and txt in cache:
164         return ret
165
166     for w in split_words(txt):
167         w_lower = w.lower()
168         if w_lower in dict_uimsgs | _spell_checked:
169             continue
170         if not dict_spelling.check(w):
171             ret.append("{}: suggestions are ({})"
172                        .format(w, "'" + "', '".join(dict_spelling.suggest(w))
173                                   + "'"))
174         else:
175             _spell_checked.add(w_lower)
176
177     if not ret:
178         if cache is not None:
179             cache.add(txt)
180
181     return ret
182
183
184 def get_svnrev():
185     cmd = [SVN_EXECUTABLE,
186            "info",
187            "--xml",
188            SOURCE_DIR,
189            ]
190     xml = subprocess.check_output(cmd)
191     return re.search(b'revision="(\d+)"', xml).group(1)
192
193
194 def gen_empty_pot():
195     blender_rev = get_svnrev().decode()
196     utctime = time.gmtime()
197     time_str = time.strftime("%Y-%m-%d %H:%M+0000", utctime)
198     year_str = time.strftime("%Y", utctime)
199
200     return utils.gen_empty_messages(blender_rev, time_str, year_str)
201
202
203 escape_re = tuple(re.compile(r[0]) for r in settings.ESCAPE_RE)
204 escape = lambda s, n: escape_re[n].sub(settings.ESCAPE_RE[n][1], s)
205
206
207 def merge_messages(msgs, states, messages, do_checks, spell_cache):
208     num_added = num_present = 0
209     for (context, msgid), srcs in messages.items():
210         if do_checks:
211             err = spell_check(msgid, spell_cache)
212             if err:
213                 print("WARNING: spell check failed on “" + msgid + "”:")
214                 print("\t\t" + "\n\t\t".join(err))
215                 print("\tFrom:\n\t\t" + "\n\t\t".join(srcs))
216
217         # Escape some chars in msgid!
218         for n in range(len(escape_re)):
219             msgid = escape(msgid, n)
220
221         srcs = [COMMENT_PREFIX_SOURCE + s for s in srcs]
222
223         key = (context, msgid)
224         if key not in msgs:
225             msgs[key] = {"msgid_lines": [msgid],
226                          "msgstr_lines": [""],
227                          "comment_lines": srcs,
228                          "msgctxt_lines": [context]}
229             num_added += 1
230         else:
231             # We need to merge comments!
232             msgs[key]["comment_lines"].extend(srcs)
233             num_present += 1
234
235     return num_added, num_present
236
237
238 def main():
239     parser = argparse.ArgumentParser(description="Update blender.pot file from messages.txt and source code parsing, "
240                                                  "and performs some checks over msgids.")
241     parser.add_argument('-w', '--warning', action="store_true",
242                         help="Show warnings.")
243     parser.add_argument('-i', '--input', metavar="File",
244                         help="Input messages file path.")
245     parser.add_argument('-o', '--output', metavar="File",
246                         help="Output pot file path.")
247
248     args = parser.parse_args()
249     if args.input:
250         global FILE_NAME_MESSAGES
251         FILE_NAME_MESSAGES = args.input
252     if args.output:
253         global FILE_NAME_POT
254         FILE_NAME_POT = args.output
255
256     print("Running fake py gettext…")
257     # Not using any more xgettext, simpler to do it ourself!
258     messages = utils.new_messages()
259     py_xgettext(messages)
260     print("Finished, found {} messages.".format(len(messages)))
261
262     if SPELL_CACHE and os.path.exists(SPELL_CACHE):
263         with open(SPELL_CACHE, 'rb') as f:
264             spell_cache = pickle.load(f)
265     else:
266         spell_cache = set()
267
268     print("Generating POT file {}…".format(FILE_NAME_POT))
269     msgs, states = gen_empty_pot()
270     tot_messages, _a = merge_messages(msgs, states, messages,
271                                       True, spell_cache)
272
273     # add messages collected automatically from RNA
274     print("\tMerging RNA messages from {}…".format(FILE_NAME_MESSAGES))
275     messages = utils.new_messages()
276     with open(FILE_NAME_MESSAGES, encoding="utf-8") as f:
277         srcs = []
278         context = ""
279         for line in f:
280             line = utils.stripeol(line)
281
282             if line.startswith(COMMENT_PREFIX):
283                 srcs.append(line[len(COMMENT_PREFIX):].strip())
284             elif line.startswith(CONTEXT_PREFIX):
285                 context = line[len(CONTEXT_PREFIX):].strip()
286             else:
287                 key = (context, line)
288                 messages[key] = srcs
289                 srcs = []
290                 context = ""
291     num_added, num_present = merge_messages(msgs, states, messages,
292                                             True, spell_cache)
293     tot_messages += num_added
294     print("\tMerged {} messages ({} were already present)."
295           "".format(num_added, num_present))
296
297     print("\tAdding languages labels...")
298     messages = {(CONTEXT_DEFAULT, lng[1]):
299                 ("Languages’ labels from bl_i18n_utils/settings.py",)
300                 for lng in LANGUAGES}
301     messages.update({(CONTEXT_DEFAULT, cat[1]):
302                      ("Language categories’ labels from bl_i18n_utils/settings.py",)
303                      for cat in LANGUAGES_CATEGORIES})
304     num_added, num_present = merge_messages(msgs, states, messages,
305                                             True, spell_cache)
306     tot_messages += num_added
307     print("\tAdded {} language messages.".format(num_added))
308
309     # Write back all messages into blender.pot.
310     utils.write_messages(FILE_NAME_POT, msgs, states["comm_msg"],
311                          states["fuzzy_msg"])
312
313     if SPELL_CACHE and spell_cache:
314         with open(SPELL_CACHE, 'wb') as f:
315             pickle.dump(spell_cache, f)
316
317     print("Finished, total: {} messages!".format(tot_messages - 1))
318
319     return 0
320
321
322 if __name__ == "__main__":
323     print("\n\n *** Running {} *** \n".format(__file__))
324     sys.exit(main())