ProjectFlask/FlaskBabelExtension/PyBabelProjectExtension.py

changeset 17
f31df56510a1
child 18
d76a0939be6a
equal deleted inserted replaced
16:dd3f6bfb85f7 17:f31df56510a1
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)

eric ide

mercurial