[devtools] make i18ncube customizable in a cube
authorAdrien Di Mascio <Adrien.DiMascio@logilab.fr>
Fri, 21 Oct 2016 18:10:15 +0200
changeset 11735 7a170207acbf
parent 11734 7e2c2354dc99
child 11736 b77c82355325
[devtools] make i18ncube customizable in a cube closes #15613724
cubicweb/devtools/devctl.py
cubicweb/devtools/test/data/libpython/cubicweb_i18ntestcube/excludeme/somefile.py
cubicweb/devtools/test/data/libpython/cubicweb_i18ntestcube/i18n/en.po.ref
cubicweb/devtools/test/data/libpython/cubicweb_i18ntestcube/node_modules/cubes.somefile.js
cubicweb/devtools/test/unittest_i18n.py
doc/book/devweb/internationalization.rst
--- a/cubicweb/devtools/devctl.py	Tue Sep 27 12:28:39 2016 +0200
+++ b/cubicweb/devtools/devctl.py	Fri Oct 21 18:10:15 2016 +0200
@@ -29,9 +29,11 @@
 import sys
 from datetime import datetime, date
 from os import mkdir, chdir, path as osp
+import pkg_resources
 from warnings import warn
 
 from pytz import UTC
+
 from six.moves import input
 
 from logilab.common import STD_BLACKLIST
@@ -566,14 +568,21 @@
     cubedir = osp.abspath(osp.normpath(cubedir))
     workdir = tempfile.mkdtemp()
     try:
-        cube = osp.basename(cubedir)
+        distname = osp.basename(cubedir)
+        cubename = distname.split('_')[-1]
         print('cubedir', cubedir)
-        print(underline_title('Updating i18n catalogs for cube %s' % cube))
+        extract_cls = I18nCubeMessageExtractor
+        try:
+            extract_cls = pkg_resources.load_entry_point(
+                distname, 'cubicweb.i18ncube', cubename)
+        except (pkg_resources.DistributionNotFound, ImportError):
+            pass  # no customization found
+        print(underline_title('Updating i18n catalogs for cube %s' % cubename))
         chdir(cubedir)
-        extractor = I18nCubeMessageExtractor(workdir, cubedir)
+        extractor = extract_cls(workdir, cubedir)
         potfile = extractor.generate_pot_file()
         if potfile is None:
-            print('no message catalog for cube', cube, 'nothing to translate')
+            print('no message catalog for cube', cubename, 'nothing to translate')
             return ()
         print('-> merging main pot file with existing translations:', end=' ')
         chdir('i18n')
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cubicweb/devtools/test/data/libpython/cubicweb_i18ntestcube/excludeme/somefile.py	Fri Oct 21 18:10:15 2016 +0200
@@ -0,0 +1,4 @@
+from cubicweb import _
+
+_('ignore-me')
+
--- a/cubicweb/devtools/test/data/libpython/cubicweb_i18ntestcube/i18n/en.po.ref	Tue Sep 27 12:28:39 2016 +0200
+++ b/cubicweb/devtools/test/data/libpython/cubicweb_i18ntestcube/i18n/en.po.ref	Fri Oct 21 18:10:15 2016 +0200
@@ -87,6 +87,9 @@
 msgid "description_format"
 msgstr ""
 
+msgid "ignore-me"
+msgstr ""
+
 msgid "in_forum"
 msgstr ""
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cubicweb/devtools/test/data/libpython/cubicweb_i18ntestcube/node_modules/cubes.somefile.js	Fri Oct 21 18:10:15 2016 +0200
@@ -0,0 +1,2 @@
+_("hello");
+
--- a/cubicweb/devtools/test/unittest_i18n.py	Tue Sep 27 12:28:39 2016 +0200
+++ b/cubicweb/devtools/test/unittest_i18n.py	Fri Oct 21 18:10:15 2016 +0200
@@ -18,13 +18,19 @@
 # with CubicWeb.  If not, see <http://www.gnu.org/licenses/>.
 """unit tests for i18n messages generator"""
 
+from contextlib import contextmanager
+from io import StringIO, BytesIO
 import os
 import os.path as osp
 import sys
 from subprocess import PIPE, Popen, STDOUT
-
 from unittest import TestCase, main
 
+from six import PY2
+from mock import patch
+
+from cubicweb.devtools import devctl
+from cubicweb.devtools.testlib import BaseTestCase
 
 DATADIR = osp.join(osp.abspath(osp.dirname(__file__)), 'data')
 
@@ -52,6 +58,9 @@
     return msgs
 
 
+TESTCUBE_DIR = osp.join(DATADIR, 'cubes', 'i18ntestcube')
+
+
 class cubePotGeneratorTC(TestCase):
     """test case for i18n pot file generator"""
 
@@ -87,5 +96,61 @@
         self.assertEqual(msgs, newmsgs)
 
 
+class CustomMessageExtractor(devctl.I18nCubeMessageExtractor):
+    blacklist = devctl.I18nCubeMessageExtractor.blacklist | set(['excludeme'])
+
+
+@contextmanager
+def capture_stdout():
+    stream = BytesIO() if PY2 else StringIO()
+    sys.stdout = stream
+    yield stream
+    stream.seek(0)
+    sys.stdout = sys.__stdout__
+
+
+class I18nCollectorTest(BaseTestCase):
+
+    def test_i18ncube_py_collection(self):
+        extractor = CustomMessageExtractor(DATADIR, TESTCUBE_DIR)
+        collected = extractor.collect_py()
+        expected = [osp.join(TESTCUBE_DIR, path)
+                    for path in ('__init__.py', '__pkginfo__.py',
+                                 'views.py', 'schema.py')]
+        self.assertCountEqual(expected, collected)
+
+    def test_i18ncube_js_collection(self):
+        extractor = CustomMessageExtractor(DATADIR, TESTCUBE_DIR)
+        collected = extractor.collect_js()
+        self.assertCountEqual([], collected, [])
+        extractor.blacklist = ()  # don't ignore anything
+        collected = extractor.collect_js()
+        expected = [osp.join(TESTCUBE_DIR, 'node_modules/cubes.somefile.js')]
+        self.assertCountEqual(expected, collected)
+
+    class FakeMessageExtractor(devctl.I18nCubeMessageExtractor):
+        """Fake message extractor that generates no pot file."""
+
+        def generate_pot_file(self):
+            return None
+
+    @patch('pkg_resources.load_entry_point', return_value=FakeMessageExtractor)
+    def test_cube_custom_extractor(self, mock_load_entry_point):
+        for distname, cubedir in [
+            ('cubicweb_i18ntestcube',
+             osp.join(DATADIR, 'libpython', 'cubicweb_i18ntestcube')),
+            # Legacy cubes.
+            ('i18ntestcube', osp.join(DATADIR, 'cubes', 'i18ntestcube')),
+        ]:
+            with self.subTest(cubedir=cubedir):
+                with capture_stdout() as stream:
+                    devctl.update_cube_catalogs(cubedir)
+                self.assertIn(u'no message catalog for cube i18ntestcube',
+                              stream.read())
+                mock_load_entry_point.assert_called_once_with(
+                    distname, 'cubicweb.i18ncube', 'i18ntestcube')
+                mock_load_entry_point.reset_mock()
+
+
 if __name__ == '__main__':
     main()
--- a/doc/book/devweb/internationalization.rst	Tue Sep 27 12:28:39 2016 +0200
+++ b/doc/book/devweb/internationalization.rst	Fri Oct 21 18:10:15 2016 +0200
@@ -153,6 +153,61 @@
 3. `hg ci -m "updated i18n catalogs"`
 4. `cubicweb-ctl i18ninstance <myinstance>`
 
+
+Customizing the messages extraction process
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+The messages extraction performed by the ``i18ncommand`` collects messages
+from a few different sources:
+
+- the schema and application definition (entity names, docstrings,
+  help messages, uicfg),
+
+- the source files:
+
+  - ``i18n:content`` or ``i18n:replace`` directives from TAL files (with ``.pt`` extension),
+  - strings prefixed by an underscore (``_``) in python files,
+  - strings with double quotes prefixed by an underscore in javascript files.
+
+The source files are collected by walking through the cube directory,
+but ignoring a few directories like ``.hg``, ``.tox``, ``test`` or
+``node_modules``.
+
+If you need to customize this behaviour in your cube, you have to
+extend the ``cubicweb.devtools.devctl.I18nCubeMessageExtractor``. The
+example below will collect strings from ``jinja2`` files and ignore
+the ``static`` directory during the messages collection phase::
+
+  # mymodule.py
+  from cubicweb.devtools import devctl
+
+  class MyMessageExtractor(devctl.I18nCubeMessageExtractor):
+
+      blacklist = devctl.I18nCubeMessageExtractor | {'static'}
+      formats = devctl.I18nCubeMessageExtractor.formats + ['jinja2']
+
+      def collect_jinja2(self):
+          return self.find('.jinja2')
+
+      def extract_jinja2(self, files):
+          return self._xgettext(files, output='jinja.pot',
+                                extraopts='-L python --from-code=utf-8')
+
+Then, you'll have to register it with a ``cubicweb.i18ncube`` entry point
+in your cube's setup.py::
+
+  setup(
+      # ...
+      entry_points={
+          # ...
+          'cubicweb.i18ncube': [
+              'mycube=cubicweb_mycube.mymodule:MyMessageExtractor',
+          ],
+      },
+      # ...
+  )
+
+
 Editing po files
 ~~~~~~~~~~~~~~~~