|
1 # -*- coding: utf-8 -*- |
|
2 |
|
3 # Copyright (c) 2020 Detlev Offenbach <detlev@die-offenbachs.de> |
|
4 # |
|
5 |
|
6 """ |
|
7 Module implementing the project support for flask-babel. |
|
8 """ |
|
9 |
|
10 import os |
|
11 import re |
|
12 |
|
13 from PyQt5.QtCore import pyqtSlot, QObject, QProcess |
|
14 from PyQt5.QtWidgets import QDialog |
|
15 |
|
16 from E5Gui import E5MessageBox |
|
17 from E5Gui.E5Application import e5App |
|
18 |
|
19 from .PyBabelCommandDialog import PyBabelCommandDialog |
|
20 |
|
21 import Utilities |
|
22 |
|
23 |
|
24 class PyBabelProject(QObject): |
|
25 """ |
|
26 Class implementing the Flask project support. |
|
27 """ |
|
28 def __init__(self, plugin, project, parent=None): |
|
29 """ |
|
30 Constructor |
|
31 |
|
32 @param plugin reference to the plugin object |
|
33 @type ProjectFlaskPlugin |
|
34 @param project reference to the project object |
|
35 @type Project |
|
36 @param parent parent |
|
37 @type QObject |
|
38 """ |
|
39 super(PyBabelProject, self).__init__(parent) |
|
40 |
|
41 self.__plugin = plugin |
|
42 self.__project = project |
|
43 |
|
44 self.__e5project = e5App().getObject("Project") |
|
45 self.__virtualEnvManager = e5App().getObject("VirtualEnvManager") |
|
46 |
|
47 self.__hooksInstalled = False |
|
48 |
|
49 def registerOpenHook(self): |
|
50 """ |
|
51 Public method to register the open hook to open a translations file |
|
52 in a translations editor. |
|
53 """ |
|
54 if self.__hooksInstalled: |
|
55 editor = self.__plugin.getPreferences("TranslationsEditor") |
|
56 if editor: |
|
57 self.__translationsBrowser.addHookMethodAndMenuEntry( |
|
58 "open", self.openPOEditor, |
|
59 self.tr("Open with {0}").format( |
|
60 os.path.basename(editor))) |
|
61 else: |
|
62 self.__translationsBrowser.removeHookMethod("open") |
|
63 |
|
64 def projectOpenedHooks(self): |
|
65 """ |
|
66 Public method to add our hook methods. |
|
67 """ |
|
68 if self.__project.hasCapability("pybabel"): |
|
69 self.__e5project.projectLanguageAddedByCode.connect( |
|
70 self.__projectLanguageAdded) |
|
71 self.__translationsBrowser = ( |
|
72 e5App().getObject("ProjectBrowser") |
|
73 .getProjectBrowser("translations")) |
|
74 self.__translationsBrowser.addHookMethodAndMenuEntry( |
|
75 "extractMessages", self.extractMessages, |
|
76 self.tr("Extract Messages")) |
|
77 self.__translationsBrowser.addHookMethodAndMenuEntry( |
|
78 "releaseAll", self.compileCatalogs, |
|
79 self.tr("Compile All Catalogs")) |
|
80 self.__translationsBrowser.addHookMethodAndMenuEntry( |
|
81 "releaseSelected", self.compileSelectedCatalogs, |
|
82 self.tr("Compile Selected Catalogs")) |
|
83 self.__translationsBrowser.addHookMethodAndMenuEntry( |
|
84 "generateAll", self.updateCatalogs, |
|
85 self.tr("Update All Catalogs")) |
|
86 self.__translationsBrowser.addHookMethodAndMenuEntry( |
|
87 "generateAllWithObsolete", self.updateCatalogsObsolete, |
|
88 self.tr("Update All Catalogs (with obsolete)")) |
|
89 self.__translationsBrowser.addHookMethodAndMenuEntry( |
|
90 "generateSelected", self.updateSelectedCatalogs, |
|
91 self.tr("Update Selected Catalogs")) |
|
92 self.__translationsBrowser.addHookMethodAndMenuEntry( |
|
93 "generateSelectedWithObsolete", |
|
94 self.updateSelectedCatalogsObsolete, |
|
95 self.tr("Update Selected Catalogs (with obsolete)")) |
|
96 |
|
97 self.__hooksInstalled = True |
|
98 |
|
99 self.registerOpenHook() |
|
100 |
|
101 def projectClosedHooks(self): |
|
102 """ |
|
103 Public method to remove our hook methods. |
|
104 """ |
|
105 if self.__hooksInstalled: |
|
106 self.__e5project.projectLanguageAddedByCode.disconnect( |
|
107 self.__projectLanguageAdded) |
|
108 self.__translationsBrowser.removeHookMethod( |
|
109 "extractMessages") |
|
110 self.__translationsBrowser.removeHookMethod( |
|
111 "releaseAll") |
|
112 self.__translationsBrowser.removeHookMethod( |
|
113 "releaseSelected") |
|
114 self.__translationsBrowser.removeHookMethod( |
|
115 "generateAll") |
|
116 self.__translationsBrowser.removeHookMethod( |
|
117 "generateAllWithObsolete") |
|
118 self.__translationsBrowser.removeHookMethod( |
|
119 "generateSelected") |
|
120 self.__translationsBrowser.removeHookMethod( |
|
121 "generateSelectedWithObsolete") |
|
122 self.__translationsBrowser.removeHookMethod( |
|
123 "open") |
|
124 self.__translationsBrowser = None |
|
125 |
|
126 self.__hooksInstalled = False |
|
127 |
|
128 def determineCapability(self): |
|
129 """ |
|
130 Public method to determine the availability of flask-babel. |
|
131 """ |
|
132 self.__project.setCapability("pybabel", self.flaskBabelAvailable()) |
|
133 |
|
134 ################################################################## |
|
135 ## slots and methods below implement general functionality |
|
136 ################################################################## |
|
137 |
|
138 def getBabelCommand(self): |
|
139 """ |
|
140 Public method to build the Babel command. |
|
141 |
|
142 @return full pybabel command |
|
143 @rtype str |
|
144 """ |
|
145 return self.__project.getFullCommand("pybabel") |
|
146 |
|
147 ################################################################## |
|
148 ## slots and methods below implement i18n and l10n support |
|
149 ################################################################## |
|
150 |
|
151 def flaskBabelAvailable(self): |
|
152 """ |
|
153 Public method to check, if the 'flask-babel' package is available. |
|
154 |
|
155 @return flag indicating the availability of 'flask-babel' |
|
156 @rtype bool |
|
157 """ |
|
158 venvName = self.__plugin.getPreferences("VirtualEnvironmentNamePy3") |
|
159 interpreter = self.__virtualEnvManager.getVirtualenvInterpreter( |
|
160 venvName) |
|
161 if interpreter and Utilities.isinpath(interpreter): |
|
162 detector = os.path.join( |
|
163 os.path.dirname(__file__), "FlaskBabelDetector.py") |
|
164 proc = QProcess() |
|
165 proc.setProcessChannelMode(QProcess.MergedChannels) |
|
166 proc.start(interpreter, [detector]) |
|
167 finished = proc.waitForFinished(30000) |
|
168 if finished and proc.exitCode() == 0: |
|
169 return True |
|
170 |
|
171 return False |
|
172 |
|
173 @pyqtSlot() |
|
174 def configurePyBabel(self): |
|
175 """ |
|
176 Public slot to show a dialog to edit the pybabel configuration. |
|
177 """ |
|
178 from .PyBabelConfigDialog import PyBabelConfigDialog |
|
179 |
|
180 config = self.__project.getData("pybabel", "") |
|
181 dlg = PyBabelConfigDialog(config) |
|
182 if dlg.exec() == QDialog.Accepted: |
|
183 config = dlg.getConfiguration() |
|
184 self.__project.setData("pybabel", "", config) |
|
185 |
|
186 self.__e5project.setTranslationPattern(os.path.join( |
|
187 config["translationsDirectory"], "%language%", "LC_MESSAGES", |
|
188 "{0}.po".format(config["domain"]) |
|
189 )) |
|
190 self.__e5project.setDirty(True) |
|
191 |
|
192 cfgFileName = self.__e5project.getAbsoluteUniversalPath( |
|
193 config["configFile"]) |
|
194 if not os.path.exists(cfgFileName): |
|
195 self.__createBabelCfg(cfgFileName) |
|
196 |
|
197 def __ensurePybabelConfigured(self): |
|
198 """ |
|
199 Private method to ensure, that PyBabel has been configured. |
|
200 |
|
201 @return flag indicating successful configuration |
|
202 @rtype bool |
|
203 """ |
|
204 config = self.__project.getData("pybabel", "") |
|
205 if not config: |
|
206 self.__configurePybabel() |
|
207 return True |
|
208 |
|
209 configFileName = self.__project.getData("pybabel", "configFile") |
|
210 if configFileName: |
|
211 cfgFileName = self.__e5project.getAbsoluteUniversalPath( |
|
212 configFileName) |
|
213 if os.path.exists(cfgFileName): |
|
214 return True |
|
215 else: |
|
216 return self.__createBabelCfg(cfgFileName) |
|
217 |
|
218 return False |
|
219 |
|
220 def __createBabelCfg(self, configFile): |
|
221 """ |
|
222 Private method to create a template pybabel configuration file. |
|
223 |
|
224 @param configFile name of the configuration file to be created |
|
225 @type str |
|
226 @return flag indicating successful configuration file creation |
|
227 @rtype bool |
|
228 """ |
|
229 _, app = self.getApplication() |
|
230 if app.endswith(".py"): |
|
231 template = ( |
|
232 "[python: {0}]\n" |
|
233 "[jinja2: templates/**.html]\n" |
|
234 "extensions=jinja2.ext.autoescape,jinja2.ext.with_\n" |
|
235 ) |
|
236 else: |
|
237 template = ( |
|
238 "[python: {0}/**.py]\n" |
|
239 "[jinja2: {0}/templates/**.html]\n" |
|
240 "extensions=jinja2.ext.autoescape,jinja2.ext.with_\n" |
|
241 ) |
|
242 try: |
|
243 with open(configFile, "w") as f: |
|
244 f.write(template.format(app)) |
|
245 self.__e5project.appendFile(configFile) |
|
246 E5MessageBox.information( |
|
247 None, |
|
248 self.tr("Generate PyBabel Configuration File"), |
|
249 self.tr("""The PyBabel configuration file was created.""" |
|
250 """ Please edit it to adjust the entries as""" |
|
251 """ required.""") |
|
252 ) |
|
253 return True |
|
254 except EnvironmentError as err: |
|
255 E5MessageBox.warning( |
|
256 None, |
|
257 self.tr("Generate PyBabel Configuration File"), |
|
258 self.tr("""<p>The PyBabel Configuration File could not be""" |
|
259 """ generated.</p><p>Reason: {0}</p>""") |
|
260 .format(str(err)) |
|
261 ) |
|
262 return False |
|
263 |
|
264 def __getLocale(self, filename): |
|
265 """ |
|
266 Private method to extract the locale out of a file name. |
|
267 |
|
268 @param filename name of the file used for extraction |
|
269 @type str |
|
270 @return extracted locale |
|
271 @rtype str or None |
|
272 """ |
|
273 if self.__e5project.getTranslationPattern(): |
|
274 filename = os.path.splitext(filename)[0] + ".po" |
|
275 |
|
276 # On Windows, path typically contains backslashes. This leads |
|
277 # to an invalid search pattern '...\(' because the opening bracket |
|
278 # will be escaped. |
|
279 pattern = self.__e5project.getTranslationPattern() |
|
280 pattern = os.path.normpath(pattern) |
|
281 pattern = pattern.replace("%language%", "(.*?)") |
|
282 pattern = pattern.replace('\\', '\\\\') |
|
283 match = re.search(pattern, filename) |
|
284 if match is not None: |
|
285 return match.group(1) |
|
286 |
|
287 return None |
|
288 |
|
289 def openPOEditor(self, poFile): |
|
290 """ |
|
291 Public method to edit the given file in an external .po editor. |
|
292 |
|
293 @param poFile name of the .po file |
|
294 @type str |
|
295 """ |
|
296 editor = self.__plugin.getPreferences("TranslationsEditor") |
|
297 if poFile.endswith(".po") and editor: |
|
298 workdir = self.__project.getApplication()[0] |
|
299 started, pid = QProcess.startDetached(editor, [poFile], workdir) |
|
300 if not started: |
|
301 E5MessageBox.critical( |
|
302 None, |
|
303 self.tr('Process Generation Error'), |
|
304 self.tr('The translations editor process ({0}) could' |
|
305 ' not be started.').format( |
|
306 os.path.basename(editor))) |
|
307 |
|
308 def extractMessages(self): |
|
309 """ |
|
310 Public method to extract the messages catalog template file. |
|
311 """ |
|
312 title = self.tr("Extract messages") |
|
313 if self.__ensurePybabelConfigured(): |
|
314 workdir = self.__project.getApplication()[0] |
|
315 potFile = self.__e5project.getAbsoluteUniversalPath( |
|
316 self.__project.getData("pybabel", "catalogFile")) |
|
317 |
|
318 try: |
|
319 potFilePath = os.path.dirname(potFile) |
|
320 os.makedirs(potFilePath) |
|
321 except EnvironmentError: |
|
322 pass |
|
323 |
|
324 args = [ |
|
325 "-F", |
|
326 os.path.relpath( |
|
327 self.__e5project.getAbsoluteUniversalPath( |
|
328 self.__project.getData("pybabel", "configFile")), |
|
329 workdir |
|
330 ) |
|
331 ] |
|
332 if self.__project.getData("pybabel", "markersList"): |
|
333 for marker in self.__project.getData("pybabel", "markersList"): |
|
334 args += ["-k", marker] |
|
335 args += [ |
|
336 "-o", |
|
337 os.path.relpath(potFile, workdir), |
|
338 "." |
|
339 ] |
|
340 |
|
341 dlg = PyBabelCommandDialog( |
|
342 self, title, |
|
343 msgSuccess=self.tr("\nMessages extracted successfully.") |
|
344 ) |
|
345 res = dlg.startCommand("extract", args, workdir) |
|
346 if res: |
|
347 dlg.exec() |
|
348 self.__e5project.appendFile(potFile) |
|
349 |
|
350 def __projectLanguageAdded(self, code): |
|
351 """ |
|
352 Private slot handling the addition of a new language. |
|
353 |
|
354 @param code language code of the new language |
|
355 @type str |
|
356 """ |
|
357 title = self.tr( |
|
358 "Initializing message catalog for '{0}'").format(code) |
|
359 |
|
360 if self.__ensurePybabelConfigured(): |
|
361 workdir = self.__project.getApplication()[0] |
|
362 langFile = self.__e5project.getAbsoluteUniversalPath( |
|
363 self.__e5project.getTranslationPattern().replace( |
|
364 "%language%", code)) |
|
365 potFile = self.__e5project.getAbsoluteUniversalPath( |
|
366 self.__project.getData("pybabel", "catalogFile")) |
|
367 |
|
368 args = [ |
|
369 "--domain={0}".format( |
|
370 self.__project.getData("pybabel", "domain")), |
|
371 "--input-file={0}".format(os.path.relpath(potFile, workdir)), |
|
372 "--output-file={0}".format(os.path.relpath(langFile, workdir)), |
|
373 "--locale={0}".format(code), |
|
374 ] |
|
375 |
|
376 dlg = PyBabelCommandDialog( |
|
377 self, title, |
|
378 msgSuccess=self.tr( |
|
379 "\nMessage catalog initialized successfully.") |
|
380 ) |
|
381 res = dlg.startCommand("init", args, workdir) |
|
382 if res: |
|
383 dlg.exec() |
|
384 |
|
385 self.__e5project.appendFile(langFile) |
|
386 |
|
387 def compileCatalogs(self, filenames): |
|
388 """ |
|
389 Public method to compile the message catalogs. |
|
390 |
|
391 @param filenames list of filenames (not used) |
|
392 @type list of str |
|
393 """ |
|
394 title = self.tr("Compiling message catalogs") |
|
395 |
|
396 if self.__ensurePybabelConfigured(): |
|
397 workdir = self.__project.getApplication()[0] |
|
398 translationsDirectory = self.__e5project.getAbsoluteUniversalPath( |
|
399 self.__project.getData("pybabel", "translationsDirectory")) |
|
400 |
|
401 args = [ |
|
402 "--domain={0}".format( |
|
403 self.__project.getData("pybabel", "domain")), |
|
404 "--directory={0}".format( |
|
405 os.path.relpath(translationsDirectory, workdir)), |
|
406 "--use-fuzzy", |
|
407 "--statistics", |
|
408 ] |
|
409 |
|
410 dlg = PyBabelCommandDialog( |
|
411 self, title, |
|
412 msgSuccess=self.tr("\nMessage catalogs compiled successfully.") |
|
413 ) |
|
414 res = dlg.startCommand("compile", args, workdir) |
|
415 if res: |
|
416 dlg.exec() |
|
417 |
|
418 for entry in os.walk(translationsDirectory): |
|
419 for fileName in entry[2]: |
|
420 fullName = os.path.join(entry[0], fileName) |
|
421 if fullName.endswith('.mo'): |
|
422 self.__e5project.appendFile(fullName) |
|
423 |
|
424 def compileSelectedCatalogs(self, filenames): |
|
425 """ |
|
426 Public method to update the message catalogs. |
|
427 |
|
428 @param filenames list of file names |
|
429 @type list of str |
|
430 """ |
|
431 title = self.tr("Compiling message catalogs") |
|
432 |
|
433 locales = {self.__getLocale(f) for f in filenames} |
|
434 |
|
435 if len(locales) == 0: |
|
436 E5MessageBox.warning( |
|
437 self.__ui, |
|
438 title, |
|
439 self.tr('No locales detected. Aborting...')) |
|
440 return |
|
441 |
|
442 if self.__ensurePybabelConfigured(): |
|
443 workdir = self.__project.getApplication()[0] |
|
444 translationsDirectory = self.__e5project.getAbsoluteUniversalPath( |
|
445 self.__project.getData("pybabel", "translationsDirectory")) |
|
446 |
|
447 argsList = [] |
|
448 for loc in locales: |
|
449 argsList.append([ |
|
450 "compile", |
|
451 "--domain={0}".format( |
|
452 self.__project.getData("pybabel", "domain")), |
|
453 "--directory={0}".format( |
|
454 os.path.relpath(translationsDirectory, workdir)), |
|
455 "--use-fuzzy", |
|
456 "--statistics", |
|
457 "--locale={0}".format(loc), |
|
458 ]) |
|
459 |
|
460 dlg = PyBabelCommandDialog( |
|
461 self, title=title, |
|
462 msgSuccess=self.tr("\nMessage catalogs compiled successfully.") |
|
463 ) |
|
464 res = dlg.startBatchCommand(argsList, workdir) |
|
465 if res: |
|
466 dlg.exec() |
|
467 |
|
468 for entry in os.walk(translationsDirectory): |
|
469 for fileName in entry[2]: |
|
470 fullName = os.path.join(entry[0], fileName) |
|
471 if fullName.endswith('.mo'): |
|
472 self.__e5project.appendFile(fullName) |
|
473 |
|
474 def updateCatalogs(self, filenames, withObsolete=False): |
|
475 """ |
|
476 Public method to update the message catalogs. |
|
477 |
|
478 @param filenames list of filenames (not used) |
|
479 @type list of str |
|
480 @param withObsolete flag indicating to keep obsolete translations |
|
481 @type bool |
|
482 """ |
|
483 title = self.tr("Updating message catalogs") |
|
484 |
|
485 if self.__ensurePybabelConfigured(): |
|
486 workdir = self.__project.getApplication()[0] |
|
487 translationsDirectory = self.__e5project.getAbsoluteUniversalPath( |
|
488 self.__project.getData("pybabel", "translationsDirectory")) |
|
489 potFile = self.__e5project.getAbsoluteUniversalPath( |
|
490 self.__project.getData("pybabel", "catalogFile")) |
|
491 |
|
492 args = [ |
|
493 "--domain={0}".format( |
|
494 self.__project.getData("pybabel", "domain")), |
|
495 "--input-file={0}".format(os.path.relpath(potFile, workdir)), |
|
496 "--output-dir={0}".format( |
|
497 os.path.relpath(translationsDirectory, workdir)), |
|
498 ] |
|
499 if not withObsolete: |
|
500 args.append("--ignore-obsolete") |
|
501 |
|
502 dlg = PyBabelCommandDialog( |
|
503 self, title, |
|
504 msgSuccess=self.tr("\nMessage catalogs updated successfully.") |
|
505 ) |
|
506 res = dlg.startCommand("update", args, workdir) |
|
507 if res: |
|
508 dlg.exec() |
|
509 |
|
510 def updateCatalogsObsolete(self, filenames): |
|
511 """ |
|
512 Public method to update the message catalogs keeping obsolete |
|
513 translations. |
|
514 |
|
515 @param filenames list of filenames (not used) |
|
516 @type list of str |
|
517 """ |
|
518 self.updateCatalogs(filenames, withObsolete=True) |
|
519 |
|
520 def updateSelectedCatalogs(self, filenames, withObsolete=False): |
|
521 """ |
|
522 Public method to update the selected message catalogs. |
|
523 |
|
524 @param filenames list of filenames |
|
525 @type list of str |
|
526 @param withObsolete flag indicating to keep obsolete translations |
|
527 @type bool |
|
528 """ |
|
529 title = self.tr("Updating message catalogs") |
|
530 |
|
531 locales = {self.__getLocale(f) for f in filenames} |
|
532 |
|
533 if len(locales) == 0: |
|
534 E5MessageBox.warning( |
|
535 self.__ui, |
|
536 title, |
|
537 self.tr('No locales detected. Aborting...')) |
|
538 return |
|
539 |
|
540 if self.__ensurePybabelConfigured(): |
|
541 workdir = self.__project.getApplication()[0] |
|
542 translationsDirectory = self.__e5project.getAbsoluteUniversalPath( |
|
543 self.__project.getData("pybabel", "translationsDirectory")) |
|
544 potFile = self.__e5project.getAbsoluteUniversalPath( |
|
545 self.__project.getData("pybabel", "catalogFile")) |
|
546 argsList = [] |
|
547 for loc in locales: |
|
548 args = [ |
|
549 "update", |
|
550 "--domain={0}".format( |
|
551 self.__project.getData("pybabel", "domain")), |
|
552 "--input-file={0}".format( |
|
553 os.path.relpath(potFile, workdir)), |
|
554 "--output-dir={0}".format( |
|
555 os.path.relpath(translationsDirectory, workdir)), |
|
556 "--locale={0}".format(loc), |
|
557 ] |
|
558 if not withObsolete: |
|
559 args.append("--ignore-obsolete") |
|
560 argsList.append(args) |
|
561 |
|
562 dlg = PyBabelCommandDialog( |
|
563 self, title=title, |
|
564 msgSuccess=self.tr("\nMessage catalogs updated successfully.") |
|
565 ) |
|
566 res = dlg.startBatchCommand(argsList, workdir) |
|
567 if res: |
|
568 dlg.exec() |
|
569 |
|
570 def updateSelectedCatalogsObsolete(self, filenames): |
|
571 """ |
|
572 Public method to update the message catalogs keeping obsolete |
|
573 translations. |
|
574 |
|
575 @param filenames list of filenames (not used) |
|
576 @type list of str |
|
577 """ |
|
578 self.updateSelectedCatalogs(filenames, withObsolete=True) |