# HG changeset patch # User Adrien Di Mascio # Date 1477066215 -7200 # Node ID 7a170207acbfcd920d4d356ef856c6b8d5b1647b # Parent 7e2c2354dc998938848b9e6e51340d66095e51a3 [devtools] make i18ncube customizable in a cube closes #15613724 diff -r 7e2c2354dc99 -r 7a170207acbf cubicweb/devtools/devctl.py --- 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') diff -r 7e2c2354dc99 -r 7a170207acbf cubicweb/devtools/test/data/libpython/cubicweb_i18ntestcube/excludeme/somefile.py --- /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') + diff -r 7e2c2354dc99 -r 7a170207acbf cubicweb/devtools/test/data/libpython/cubicweb_i18ntestcube/i18n/en.po.ref --- 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 "" diff -r 7e2c2354dc99 -r 7a170207acbf cubicweb/devtools/test/data/libpython/cubicweb_i18ntestcube/node_modules/cubes.somefile.js --- /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"); + diff -r 7e2c2354dc99 -r 7a170207acbf cubicweb/devtools/test/unittest_i18n.py --- 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 . """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() diff -r 7e2c2354dc99 -r 7a170207acbf doc/book/devweb/internationalization.rst --- 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 ` + +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 ~~~~~~~~~~~~~~~~