diff options
| author | Robot Odoo <xmo+roboto@odoo.com> | 2019-06-06 09:45:37 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2019-06-06 09:45:37 +0200 |
| commit | a7621137cb0d75ec545152d5e1d413c9d3548e45 (patch) | |
| tree | c4e1088edc33659147128b3666d1c98448138c06 | |
| parent | 7bb0c8b6c5c29e0520967b844326cf57682281e5 (diff) | |
| parent | 55dd375c84d4bf8099d7b6848c69cb1805323349 (diff) | |
[MERGE] Use polib to parse .po files
Remove ugly custom po files parser and use polib for reader and writer.
Refactor reader/writer to use a class and have an abstraction for csv or po files.
Task-id: 1986334
closes odoo/odoo#32165
Signed-off-by: Martin Trigaux (mat) <mat@odoo.com>
| -rw-r--r-- | addons/fleet/data/fleet_cars_data.xml | 6 | ||||
| -rw-r--r-- | addons/website_sale/i18n/website_sale.pot | 2 | ||||
| -rw-r--r-- | addons/website_sale/views/templates.xml | 2 | ||||
| -rw-r--r-- | addons/website_sale_coupon/tests/test_shop_sale_coupon.py | 2 | ||||
| -rw-r--r-- | debian/control | 1 | ||||
| -rw-r--r-- | odoo/addons/base/data/res.country.state.csv | 2 | ||||
| -rw-r--r-- | odoo/addons/base/models/ir_translation.py | 31 | ||||
| -rw-r--r-- | odoo/addons/test_translation_import/i18n/fr.po | 17 | ||||
| -rw-r--r-- | odoo/addons/test_translation_import/i18n/fr_BE.po | 4 | ||||
| -rw-r--r-- | odoo/addons/test_translation_import/i18n/test_translation_import.pot | 74 | ||||
| -rw-r--r-- | odoo/addons/test_translation_import/models.py | 4 | ||||
| -rw-r--r-- | odoo/addons/test_translation_import/tests/test_term_count.py | 28 | ||||
| -rw-r--r-- | odoo/tools/translate.py | 581 | ||||
| -rw-r--r-- | requirements.txt | 1 | ||||
| -rw-r--r-- | setup.cfg | 1 | ||||
| -rw-r--r-- | setup/win32/winpy_requirements.txt | 1 |
16 files changed, 378 insertions, 379 deletions
diff --git a/addons/fleet/data/fleet_cars_data.xml b/addons/fleet/data/fleet_cars_data.xml index 34412ec64c2..c2f4c011ddc 100644 --- a/addons/fleet/data/fleet_cars_data.xml +++ b/addons/fleet/data/fleet_cars_data.xml @@ -56,7 +56,7 @@ <field name="name">Citroen</field> <field name="image" type="base64" file="fleet/static/img/brand_citroen-image.png"/> </record> - <record id="brand_corre la licorne" model="fleet.vehicle.model.brand"> + <record id="brand_corre_la_licorne" model="fleet.vehicle.model.brand"> <field name="name">Corre La Licorne</field> <field name="image" type="base64" file="fleet/static/img/brand_corre-la-licorne-image.png"/> </record> @@ -128,7 +128,7 @@ <field name="name">Lancia</field> <field name="image" type="base64" file="fleet/static/img/brand_lancia-image.png"/> </record> - <record id="brand_land rover" model="fleet.vehicle.model.brand"> + <record id="brand_land_rover" model="fleet.vehicle.model.brand"> <field name="name">Land Rover</field> <field name="image" type="base64" file="fleet/static/img/brand_land-rover-image.png"/> </record> @@ -236,7 +236,7 @@ <field name="name">Subaru</field> <field name="image" type="base64" file="fleet/static/img/brand_subaru-image.png"/> </record> - <record id="brand_tesla motors" model="fleet.vehicle.model.brand"> + <record id="brand_tesla_motors" model="fleet.vehicle.model.brand"> <field name="name">Tesla Motors</field> <field name="image" type="base64" file="fleet/static/img/brand_tesla-motors-image.png"/> </record> diff --git a/addons/website_sale/i18n/website_sale.pot b/addons/website_sale/i18n/website_sale.pot index ff700b10b6c..ed359bf1a12 100644 --- a/addons/website_sale/i18n/website_sale.pot +++ b/addons/website_sale/i18n/website_sale.pot @@ -2655,7 +2655,7 @@ msgid "eCommerce Shipping Costs" msgstr "" #. module: website_sale -#: model_terms:ir.ui.view,arch_db:website_sale.search count +#: model_terms:ir.ui.view,arch_db:website_sale.search_count_box msgid "found)" msgstr "" diff --git a/addons/website_sale/views/templates.xml b/addons/website_sale/views/templates.xml index cff60a50803..c4c9c001343 100644 --- a/addons/website_sale/views/templates.xml +++ b/addons/website_sale/views/templates.xml @@ -109,7 +109,7 @@ </t> </template> - <template id="search count" inherit_id="website.website_search_box" active="False" customize_show="True" name="Show # found"> + <template id="search_count_box" inherit_id="website.website_search_box" active="False" customize_show="True" name="Show # found"> <xpath expr="//button[hasclass('oe_search_button')]" position="inside"> <span t-if='search' class='oe_search_found'> <small>(<t t-esc="search_count"/> found)</small></span> </xpath> diff --git a/addons/website_sale_coupon/tests/test_shop_sale_coupon.py b/addons/website_sale_coupon/tests/test_shop_sale_coupon.py index 24061326689..e34b6904638 100644 --- a/addons/website_sale_coupon/tests/test_shop_sale_coupon.py +++ b/addons/website_sale_coupon/tests/test_shop_sale_coupon.py @@ -11,5 +11,5 @@ class TestUi(HttpCase): def test_01_admin_shop_sale_coupon_tour(self): # pre enable "Show # found" option to avoid race condition... - self.env.ref("website_sale.search count").write({"active": True}) + self.env.ref("website_sale.search_count_box").write({"active": True}) self.start_tour("/", 'shop_sale_coupon', login="admin") diff --git a/debian/control b/debian/control index 2cba3381590..a88c570b198 100644 --- a/debian/control +++ b/debian/control @@ -30,6 +30,7 @@ Depends: python3-mako, python3-mock, python3-passlib, + python3-polib, python3-psutil, python3-psycopg2, python3-pydot, diff --git a/odoo/addons/base/data/res.country.state.csv b/odoo/addons/base/data/res.country.state.csv index 32d84c78980..9a5535bf462 100644 --- a/odoo/addons/base/data/res.country.state.csv +++ b/odoo/addons/base/data/res.country.state.csv @@ -504,7 +504,7 @@ state_mx_nay,mx,"Nayarit","NAY" state_mx_nl,mx,"Nuevo León","NLE" state_mx_oax,mx,"Oaxaca","OAX" state_mx_pue,mx,"Puebla","PUE" -state_mx_q roo,mx,"Quintana Roo","ROO" +state_mx_q_roo,mx,"Quintana Roo","ROO" state_mx_qro,mx,"Querétaro","QUE" state_mx_sin,mx,"Sinaloa","SIN" state_mx_slp,mx,"San Luis Potosí","SLP" diff --git a/odoo/addons/base/models/ir_translation.py b/odoo/addons/base/models/ir_translation.py index bb3918b70c3..9c3b5a18fcc 100644 --- a/odoo/addons/base/models/ir_translation.py +++ b/odoo/addons/base/models/ir_translation.py @@ -55,37 +55,6 @@ class IrTranslationImport(object): """ Feed a translation, as a dictionary, into the cursor """ params = dict(trans_dict, state="translated") - if params['type'] == 'view': - # ugly hack for QWeb views - pending refactoring of translations in master - if params['imd_model'] == 'website': - params['imd_model'] = "ir.ui.view" - # non-QWeb views do not need a matching res_id in case they do not - # have an xml id -> force to 0 to avoid dropping them - elif params['res_id'] is None and not params['imd_name']: - # maybe we should insert this translation for all views of the - # given model? - params['res_id'] = 0 - - # backward compatibility: convert 'field', 'help', 'view' into 'model' - if params['type'] == 'field': - model, field = params['name'].split(',') - params['type'] = 'model' - params['name'] = 'ir.model.fields,field_description' - params['imd_model'] = 'ir.model.fields' - params['imd_name'] = 'field_%s__%s' % (model.replace('.', '_'), field) - - elif params['type'] == 'help': - model, field = params['name'].split(',') - params['type'] = 'model' - params['name'] = 'ir.model.fields,help' - params['imd_model'] = 'ir.model.fields' - params['imd_name'] = 'field_%s__%s' % (model.replace('.', '_'), field) - - elif params['type'] == 'view': - params['type'] = 'model' - params['name'] = 'ir.ui.view,arch_db' - params['imd_model'] = "ir.ui.view" - self._rows.append((params['name'], params['lang'], params['res_id'], params['src'], params['type'], params['imd_model'], params['module'], params['imd_name'], params['value'], diff --git a/odoo/addons/test_translation_import/i18n/fr.po b/odoo/addons/test_translation_import/i18n/fr.po index 3e3abcfe2ab..25056bc043b 100644 --- a/odoo/addons/test_translation_import/i18n/fr.po +++ b/odoo/addons/test_translation_import/i18n/fr.po @@ -24,15 +24,21 @@ msgid "1XBUO5PUYH2RYZSA1FTLRYS8SPCNU1UYXMEYMM25ASV7JC2KTJZQESZYRV9L8CGB" msgstr "1XBUO5PUYH2RYZSA1FTLRYS8SPCNU1UYXMEYMM25ASV7JC2KTJZQESZYRV9L8CGB in french" #. module: test_translation_import +#: selection:test.translation.import,import_type:0 +msgid "Bar" +msgstr "Bar in french" + +# Note: the line in pot is 21 and is 14 here +#. module: test_translation_import #: code:addons/test_translation_import/models.py:14 #, python-format msgid "Ijkl" msgstr "Ijkl in french" #. module: test_translation_import -#: model:ir.model,name:test_translation_import.model_test_translation_import -msgid "test.translation.import" -msgstr "test.translation.import in french" +#: selection:test.translation.import,import_type:0 +msgid "Foo" +msgstr "Foo in french" #. module: test_translation_import #: model:ir.model.fields,help:test_translation_import.field_test_translation_import__name @@ -40,6 +46,11 @@ msgid "Efgh" msgstr "Efgh in french" #. module: test_translation_import +#: model:ir.model,name:test_translation_import.model_test_translation_import +msgid "Test: Translation Import" +msgstr "Test: Import de traductions" + +#. module: test_translation_import #: model:ir.actions.act_window,name:test_translation_import.action_test_translation_import #: model:ir.ui.menu,name:test_translation_import.menu_test_translation_import msgid "Test translation import" diff --git a/odoo/addons/test_translation_import/i18n/fr_BE.po b/odoo/addons/test_translation_import/i18n/fr_BE.po index 1d41cc94dcb..371d1f00606 100644 --- a/odoo/addons/test_translation_import/i18n/fr_BE.po +++ b/odoo/addons/test_translation_import/i18n/fr_BE.po @@ -21,8 +21,8 @@ msgstr "Ijkl in belgian french" #. module: test_translation_import #: model:ir.model,name:test_translation_import.model_test_translation_import -msgid "test.translation.import" -msgstr "test.translation.import in belgian french" +msgid "Test: Translation Import" +msgstr "Test: Import de traductions in belgian french" #. module: test_translation_import #: model:ir.model.fields,help:test_translation_import.field_test_translation_import__name diff --git a/odoo/addons/test_translation_import/i18n/test_translation_import.pot b/odoo/addons/test_translation_import/i18n/test_translation_import.pot index 39333d2b363..d9e87023131 100644 --- a/odoo/addons/test_translation_import/i18n/test_translation_import.pot +++ b/odoo/addons/test_translation_import/i18n/test_translation_import.pot @@ -30,14 +30,29 @@ msgid "1XBUO5PUYH2RYZSA1FTLRYS8SPCNU1UYXMEYMM25ASV7JC2KTJZQESZYRV9L8CGB" msgstr "" #. module: test_translation_import -#: code:addons/test_translation_import/models.py:14 +#: code:addons/test_translation_import/models.py:21 #, python-format -msgid "Ijkl" +msgid "Accounting" msgstr "" #. module: test_translation_import -#: model:ir.model,name:test_translation_import.model_test_translation_import -msgid "test.translation.import" +#: selection:test.translation.import,import_type:0 +msgid "Bar" +msgstr "" + +#. module: test_translation_import +#: model:ir.model.fields,field_description:test_translation_import.field_test_translation_import__create_uid +msgid "Created by" +msgstr "" + +#. module: test_translation_import +#: model:ir.model.fields,field_description:test_translation_import.field_test_translation_import__create_date +msgid "Created on" +msgstr "" + +#. module: test_translation_import +#: model:ir.model.fields,field_description:test_translation_import.field_test_translation_import__display_name +msgid "Display Name" msgstr "" #. module: test_translation_import @@ -46,9 +61,40 @@ msgid "Efgh" msgstr "" #. module: test_translation_import -#: model:ir.actions.act_window,name:test_translation_import.action_test_translation_import -#: model:ir.ui.menu,name:test_translation_import.menu_test_translation_import -msgid "Test translation import" +#: selection:test.translation.import,import_type:0 +msgid "Foo" +msgstr "" + +#. module: test_translation_import +#: model:ir.model.fields,field_description:test_translation_import.field_test_translation_import__id +msgid "ID" +msgstr "" + +#. module: test_translation_import +#: code:addons/test_translation_import/models.py:21 +#, python-format +msgid "Ijkl" +msgstr "" + +#. module: test_translation_import +#: code:addons/test_translation_import/models.py:19 +#, python-format +msgid "Klingon" +msgstr "" + +#. module: test_translation_import +#: model:ir.model.fields,field_description:test_translation_import.field_test_translation_import____last_update +msgid "Last Modified on" +msgstr "" + +#. module: test_translation_import +#: model:ir.model.fields,field_description:test_translation_import.field_test_translation_import__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: test_translation_import +#: model:ir.model.fields,field_description:test_translation_import.field_test_translation_import__write_date +msgid "Last Updated on" msgstr "" #. module: test_translation_import @@ -57,13 +103,21 @@ msgid "Test translation" msgstr "" #. module: test_translation_import +#: model:ir.actions.act_window,name:test_translation_import.action_test_translation_import +#: model:ir.ui.menu,name:test_translation_import.menu_test_translation_import +msgid "Test translation import" +msgstr "" + +#. module: test_translation_import #: code:addons/base/models/arthur.py:42 #: code:addons/base/models/arthur.py:43 +#, python-format msgid "Test translation with two code lines" msgstr "" #. module: test_translation_import #: code:addons/base/models/ford.py:42 +#, python-format msgid "Test translation with a code type but different line number in pot" msgstr "" @@ -71,5 +125,11 @@ msgstr "" #: model:ir.model.fields,field_description:test_translation_import.field_test_translation_import__other_name #: code:addons/base/models/marvin.py:42 #: code:addons/base/models/trillian.py:42 +#, python-format msgid "Test translation with two code type and model" msgstr "" + +#. module: test_translation_import +#: model:ir.model,name:test_translation_import.model_test_translation_import +msgid "Test: Translation Import" +msgstr "" diff --git a/odoo/addons/test_translation_import/models.py b/odoo/addons/test_translation_import/models.py index 9e803190eea..35c3c33d5dc 100644 --- a/odoo/addons/test_translation_import/models.py +++ b/odoo/addons/test_translation_import/models.py @@ -10,6 +10,10 @@ class m(models.TransientModel): name = fields.Char('1XBUO5PUYH2RYZSA1FTLRYS8SPCNU1UYXMEYMM25ASV7JC2KTJZQESZYRV9L8CGB', size=32, help='Efgh') other_name = fields.Char('Test translation with two code type and model') + import_type = fields.Selection([ + ('foo', 'Foo'), + ('bar', 'Bar'), + ]) _('Ijkl') diff --git a/odoo/addons/test_translation_import/tests/test_term_count.py b/odoo/addons/test_translation_import/tests/test_term_count.py index 58716ad6ec5..90bef9417ae 100644 --- a/odoo/addons/test_translation_import/tests/test_term_count.py +++ b/odoo/addons/test_translation_import/tests/test_term_count.py @@ -17,11 +17,32 @@ class TestTermCount(common.TransactionCase): Just make sure we have as many translation entries as we wanted. """ odoo.tools.trans_load(self.cr, 'test_translation_import/i18n/fr.po', 'fr_FR', module_name='test_translation_import', verbose=False) - ids = self.env['ir.translation'].search([ + translations = self.env['ir.translation'].search([ ('lang', '=', 'fr_FR'), ('src', '=', '1XBUO5PUYH2RYZSA1FTLRYS8SPCNU1UYXMEYMM25ASV7JC2KTJZQESZYRV9L8CGB'), - ]) - self.assertEqual(len(ids), 2) + ], order='type') + self.assertEqual(len(translations), 2) + self.assertEqual(translations[0].type, 'code') + self.assertEqual(translations[0].module, 'test_translation_import') + self.assertEqual(translations[0].name, 'addons/test_translation_import/models.py') + self.assertEqual(translations[0].comments, '') + self.assertEqual(translations[0].res_id, 15) + self.assertEqual(translations[1].type, 'model') + self.assertEqual(translations[1].module, 'test_translation_import') + self.assertEqual(translations[1].name, 'ir.model.fields,field_description') + self.assertEqual(translations[1].comments, '') + field = self.env['ir.model.fields'].search([('model', '=', 'test.translation.import'), ('name', '=', 'name')]) + self.assertEqual(translations[1].res_id, field.id) + + translations = self.env['ir.translation'].search([ + ('lang', '=', 'fr_FR'), + ('type', '=', 'selection'), + ('module', '=', 'test_translation_import'), + ], order='src') + self.assertEqual(len(translations), 2) + self.assertEqual(translations[0].name, 'test.translation.import,import_type') + self.assertEqual(translations[0].res_id, 0) + def test_count_term_module(self): """ @@ -34,6 +55,7 @@ class TestTermCount(common.TransactionCase): ('module', '=', 'test_translation_import'), ]) self.assertEqual(len(translations), 1) + self.assertEqual(translations.res_id, 21) def test_noupdate(self): """ diff --git a/odoo/tools/translate.py b/odoo/tools/translate.py index 16ce6af0bfd..160cce4f6e4 100644 --- a/odoo/tools/translate.py +++ b/odoo/tools/translate.py @@ -7,6 +7,7 @@ import io import locale import logging import os +import polib import re import tarfile import tempfile @@ -15,6 +16,7 @@ from collections import defaultdict from datetime import datetime from os.path import join +from pathlib import Path from babel.messages import extract from lxml import etree, html @@ -476,257 +478,275 @@ def unquote(str): """Returns unquoted PO term string, with special PO characters unescaped""" return re_escaped_char.sub(_sub_replacement, str[1:-1]) -# class to handle po files -class PoFile(object): - def __init__(self, buffer): - # TextIOWrapper closes its underlying buffer on close *and* can't - # handle actual file objects (on python 2) - self.buffer = codecs.StreamReaderWriter( - stream=buffer, - Reader=codecs.getreader('utf-8'), - Writer=codecs.getwriter('utf-8') - ) +def TranslationFileReader(source, fileformat='po'): + """ Iterate over translation file to return Odoo translation entries """ + if fileformat == 'csv': + return CSVFileReader(source) + if fileformat == 'po': + return PoFileReader(source) + _logger.info('Bad file format: %s', fileformat) + raise Exception(_('Bad file format: %s') % fileformat) + +class CSVFileReader: + def __init__(self, source): + self.source = pycompat.csv_reader(source, quotechar='"', delimiter=',') + # read the first line of the file (it contains columns titles) + self.fields = next(self.source) def __iter__(self): - self.buffer.seek(0) - self.lines = self._get_lines() - self.lines_count = len(self.lines) - - self.first = True - self.extra_lines= [] - return self - - def _get_lines(self): - lines = self.buffer.readlines() - # remove the BOM (Byte Order Mark): - if len(lines): - lines[0] = lines[0].lstrip(u"\ufeff") - - lines.append('') # ensure that the file ends with at least an empty line - return lines - - def cur_line(self): - return self.lines_count - len(self.lines) - - def next(self): - trans_type = name = res_id = source = trad = module = None - if self.extra_lines: - trans_type, name, res_id, source, trad, comments = self.extra_lines.pop(0) - if not res_id: - res_id = '0' + for entry in self.source: + yield zip(self.fields, entry) + +class PoFileReader: + """ Iterate over po file to return Odoo translation entries """ + def __init__(self, source): + + def get_pot_path(source_name): + # when fileobj is a TemporaryFile, its name is an inter in P3, a string in P2 + if isinstance(source_name, str) and source_name.endswith('.po'): + # Normally the path looks like /path/to/xxx/i18n/lang.po + # and we try to find the corresponding + # /path/to/xxx/i18n/xxx.pot file. + # (Sometimes we have 'i18n_extra' instead of just 'i18n') + path = Path(source_name) + filename = path.parent.parent.name + '.pot' + pot_path = path.with_name(filename) + return pot_path.exists() and str(pot_path) or False + return False + + # polib accepts a path or the file content as a string, not a fileobj + if isinstance(source, str): + self.pofile = polib.pofile(source) + pot_path = get_pot_path(source) else: - comments = [] - targets = [] - line = None - fuzzy = False - while not line: - if 0 == len(self.lines): - raise StopIteration() - line = self.lines.pop(0).strip() - while line.startswith('#'): - if line.startswith('#~ '): - break - if line.startswith('#.'): - line = line[2:].strip() - if not line.startswith('module:'): - comments.append(line) - else: - module = line[7:].strip() - elif line.startswith('#:'): - # Process the `reference` comments. Each line can specify - # multiple targets (e.g. model, view, code, selection, - # ...). For each target, we will return an additional - # entry. - for lpart in line[2:].strip().split(' '): - trans_info = lpart.strip().split(':',2) - if trans_info and len(trans_info) == 2: - # looks like the translation trans_type is missing, which is not - # unexpected because it is not a GetText standard. Default: 'code' - trans_info[:0] = ['code'] - if trans_info and len(trans_info) == 3: - # this is a ref line holding the destination info (model, field, record) - targets.append(trans_info) - elif line.startswith('#,') and (line[2:].strip() == 'fuzzy'): - fuzzy = True - line = self.lines.pop(0).strip() - if not self.lines: - raise StopIteration() - while not line: - # allow empty lines between comments and msgid - line = self.lines.pop(0).strip() - if line.startswith('#~ '): - while line.startswith('#~ ') or not line.strip(): - if 0 == len(self.lines): - raise StopIteration() - line = self.lines.pop(0) - # This has been a deprecated entry, don't return anything - return next(self) - - if not line.startswith('msgid'): - raise Exception("malformed file: bad line: %s" % line) - source = unquote(line[6:]) - line = self.lines.pop(0).strip() - if not source and self.first: - self.first = False - # if the source is "" and it's the first msgid, it's the special - # msgstr with the information about the translate and the - # translator; we skip it - self.extra_lines = [] - while line: - line = self.lines.pop(0).strip() - return next(self) - - while not line.startswith('msgstr'): - if not line: - raise Exception('malformed file at %d'% self.cur_line()) - source += unquote(line) - line = self.lines.pop(0).strip() - - trad = unquote(line[7:]) - line = self.lines.pop(0).strip() - while line: - trad += unquote(line) - line = self.lines.pop(0).strip() - - if targets and not fuzzy: - # Use the first target for the current entry (returned at the - # end of this next() call), and keep the others to generate - # additional entries (returned the next next() calls). - trans_type, name, res_id = targets.pop(0) - code = trans_type == 'code' - for t, n, r in targets: - if t == 'code' and code: continue - if t == 'code': - code = True - self.extra_lines.append((t, n, r, source, trad, comments)) - - if name is None: - if not fuzzy: - _logger.warning('Missing "#:" formatted comment at line %d for the following source:\n\t%s', - self.cur_line(), source[:30]) - return next(self) - return trans_type, name, res_id, source, trad, '\n'.join(comments), module - __next__ = next - - def write_infos(self, modules): + # either a BufferedIOBase or result from NamedTemporaryFile + self.pofile = polib.pofile(source.read().decode()) + pot_path = get_pot_path(source.name) + + if pot_path: + # Make a reader for the POT file + # (Because the POT comments are correct on GitHub but the + # PO comments tends to be outdated. See LP bug 933496.) + self.pofile.merge(polib.pofile(pot_path)) + + def __iter__(self): + for entry in self.pofile: + if entry.obsolete: + continue + + # in case of moduleS keep only the first + match = re.match(r"(module[s]?): (\w+)", entry.comment) + _, module = match.groups() + comments = "\n".join([c for c in entry.comment.split('\n') if not c.startswith('module:')]) + source = entry.msgid + translation = entry.msgstr + found_code_occurrence = False + for occurrence, line_number in entry.occurrences: + match = re.match(r'(model|model_terms):([\w.]+),([\w]+):(\w+)\.(\w+)', occurrence) + if match: + type, model_name, field_name, module, xmlid = match.groups() + yield { + 'type': type, + 'imd_model': model_name, + 'name': model_name+','+field_name, + 'imd_name': xmlid, + 'res_id': None, + 'src': source, + 'value': translation, + 'comments': comments, + 'module': module, + } + continue + + match = re.match(r'(code):([\w/.]+)', occurrence) + if match: + type, name = match.groups() + if found_code_occurrence: + # unicity constrain on code translation + continue + found_code_occurrence = True + yield { + 'type': type, + 'name': name, + 'src': source, + 'value': translation, + 'comments': comments, + 'res_id': int(line_number), + 'module': module, + } + continue + + match = re.match(r'(selection):([\w.]+),([\w]+)', occurrence) + if match: + type, model_name, field_name = match.groups() + yield { + 'type': type, + 'model': model_name, + 'name': model_name+','+field_name, + 'src': source, + 'value': translation, + 'comments': comments, + 'res_id': int(line_number), + 'module': module, + } + continue + + match = re.match(r'(sql_constraint|constraint):([\w.]+)', occurrence) + if match: + type, model = match.groups() + yield { + 'type': type, + 'name': model, + 'src': source, + 'value': translation, + 'comments': comments, + 'res_id': int(line_number), + 'module': module, + } + continue + + _logger.error("malformed po file: unknown occurrence: %s", occurrence) + +def TranslationFileWriter(target, fileformat='po', lang=None, modules=None): + """ Iterate over translation file to return Odoo translation entries """ + if fileformat == 'csv': + return CSVFileWriter(target) + + if fileformat == 'po': + return PoFileWriter(target, modules=modules, lang=lang) + + if fileformat == 'tgz': + return TarFileWriter(target, lang=lang) + + raise Exception(_('Unrecognized extension: must be one of ' + '.csv, .po, or .tgz (received .%s).') % fileformat) + + +class CSVFileWriter: + def __init__(self, target): + self.writer = pycompat.csv_writer(target, dialect='UNIX') + # write header first + self.writer.writerow(("module","type","name","res_id","src","value","comments")) + + + def write_rows(self, rows): + for module, type, name, res_id, src, trad, comments in rows: + comments = '\n'.join(comments) + self.writer.writerow((module, type, name, res_id, src, trad, comments)) + + +class PoFileWriter: + """ Iterate over po file to return Odoo translation entries """ + def __init__(self, target, modules, lang): import odoo.release as release - self.buffer.write(u"# Translation of %(project)s.\n" \ - "# This file contains the translation of the following modules:\n" \ - "%(modules)s" \ - "#\n" \ - "msgid \"\"\n" \ - "msgstr \"\"\n" \ - '''"Project-Id-Version: %(project)s %(version)s\\n"\n''' \ - '''"Report-Msgid-Bugs-To: \\n"\n''' \ - '''"POT-Creation-Date: %(now)s\\n"\n''' \ - '''"PO-Revision-Date: %(now)s\\n"\n''' \ - '''"Last-Translator: <>\\n"\n''' \ - '''"Language-Team: \\n"\n''' \ - '''"MIME-Version: 1.0\\n"\n''' \ - '''"Content-Type: text/plain; charset=UTF-8\\n"\n''' \ - '''"Content-Transfer-Encoding: \\n"\n''' \ - '''"Plural-Forms: \\n"\n''' \ - "\n" - - % { 'project': release.description, - 'version': release.version, - 'modules': ''.join("#\t* %s\n" % m for m in modules), - 'now': datetime.utcnow().strftime('%Y-%m-%d %H:%M')+"+0000", - } - ) - - def write(self, modules, tnrs, source, trad, comments=None): - - plurial = len(modules) > 1 and 's' or '' - self.buffer.write(u"#. module%s: %s\n" % (plurial, ', '.join(modules))) + self.buffer = target + self.lang = lang + self.po = polib.POFile() + + self.po.header = "Translation of %s.\n" \ + "This file contains the translation of the following modules:\n" \ + "%s" % (release.description, ''.join("\t* %s\n" % m for m in modules)) + now = datetime.utcnow().strftime('%Y-%m-%d %H:%M+0000') + self.po.metadata = { + 'Project-Id-Version': "%s %s" % (release.description, release.version), + 'Report-Msgid-Bugs-To': '', + 'POT-Creation-Date': now, + 'PO-Revision-Date': now, + 'Last-Translator': '', + 'Language-Team': '', + 'MIME-Version': '1.0', + 'Content-Type': 'text/plain; charset=UTF-8', + 'Content-Transfer-Encoding': '', + 'Plural-Forms': '', + } + + def write_rows(self, rows): + # we now group the translations by source. That means one translation per source. + grouped_rows = {} + for module, type, name, res_id, src, trad, comments in rows: + row = grouped_rows.setdefault(src, {}) + row.setdefault('modules', set()).add(module) + if not row.get('translation') and trad != src: + row['translation'] = trad + row.setdefault('tnrs', []).append((type, name, res_id)) + row.setdefault('comments', set()).update(comments) + + for src, row in sorted(grouped_rows.items()): + if not self.lang: + # translation template, so no translation value + row['translation'] = '' + elif not row.get('translation'): + row['translation'] = '' + self.add_entry(row['modules'], row['tnrs'], src, row['translation'], row['comments']) + + # buffer expects bytes + self.buffer.write(str(self.po).encode()) + + def add_entry(self, modules, tnrs, source, trad, comments=None): + entry = polib.POEntry( + msgid=source, + msgstr=trad, + ) + plural = len(modules) > 1 and 's' or '' + entry.comment = "module%s: %s" % (plural, ', '.join(modules)) if comments: - self.buffer.write(u''.join(('#. %s\n' % c for c in comments))) + entry.comment += "\n" + "\n".join(comments) code = False for typy, name, res_id in tnrs: - self.buffer.write(u"#: %s:%s:%s\n" % (typy, name, res_id)) if typy == 'code': code = True - + if isinstance(res_id, int) or res_id.isdigit(): + # second term of occurrence must be a digit + # occurrence line at 0 are discarded when rendered to string + entry.occurrences.append((u"%s:%s" % (typy, name), str(res_id))) + else: + entry.occurrences.append((u"%s:%s:%s" % (typy, name, res_id), '')) if code: - # only strings in python code are python formatted - self.buffer.write(u"#, python-format\n") - - msg = ( - u"msgid %s\n" - u"msgstr %s\n\n" - ) % ( - quote(str(source)), - quote(str(trad)) - ) - self.buffer.write(msg) + entry.flags.append("python-format") + self.po.append(entry) + + +class TarFileWriter: + + def __init__(self, target, lang): + self.tar = tarfile.open(fileobj=target, mode='w|gz') + self.lang = lang + + def write_rows(self, rows): + rows_by_module = defaultdict(list) + for row in rows: + module = row[0] + rows_by_module[module].append(row) + for mod, modrows in rows_by_module.items(): + with io.BytesIO() as buf: + po = PoFileWriter(buf, modules=[mod], lang=self.lang) + po.write_rows(modrows) + buf.seek(0) + + info = tarfile.TarInfo( + join(mod, 'i18n', '{basename}.{ext}'.format( + basename=self.lang or mod, + ext='po' if self.lang else 'pot', + ))) + # addfile will read <size> bytes from the buffer so + # size *must* be set first + info.size = len(buf.getvalue()) + + self.tar.addfile(info, fileobj=buf) + + self.tar.close() # Methods to export the translation file def trans_export(lang, modules, buffer, format, cr): - def _process(format, modules, rows, buffer, lang): - if format == 'csv': - writer = pycompat.csv_writer(buffer, dialect='UNIX') - # write header first - writer.writerow(("module","type","name","res_id","src","value","comments")) - for module, type, name, res_id, src, trad, comments in rows: - comments = '\n'.join(comments) - writer.writerow((module, type, name, res_id, src, trad, comments)) - - elif format == 'po': - writer = PoFile(buffer) - writer.write_infos(modules) - - # we now group the translations by source. That means one translation per source. - grouped_rows = {} - for module, type, name, res_id, src, trad, comments in rows: - row = grouped_rows.setdefault(src, {}) - row.setdefault('modules', set()).add(module) - if not row.get('translation') and trad != src: - row['translation'] = trad - row.setdefault('tnrs', []).append((type, name, res_id)) - row.setdefault('comments', set()).update(comments) - - for src, row in sorted(grouped_rows.items()): - if not lang: - # translation template, so no translation value - row['translation'] = '' - elif not row.get('translation'): - row['translation'] = '' - writer.write(row['modules'], row['tnrs'], src, row['translation'], row['comments']) - - elif format == 'tgz': - rows_by_module = defaultdict(list) - for row in rows: - module = row[0] - rows_by_module[module].append(row) - - with tarfile.open(fileobj=buffer, mode='w|gz') as tar: - for mod, modrows in rows_by_module.items(): - with io.BytesIO() as buf: - _process('po', [mod], modrows, buf, lang) - buf.seek(0) - - info = tarfile.TarInfo( - join(mod, 'i18n', '{basename}.{ext}'.format( - basename=lang or mod, - ext='po' if lang else 'pot', - ))) - # addfile will read <size> bytes from the buffer so - # size *must* be set first - info.size = len(buf.getvalue()) - - tar.addfile(info, fileobj=buf) - else: - raise Exception(_('Unrecognized extension: must be one of ' - '.csv, .po, or .tgz (received .%s).') % format) - translations = trans_generate(lang, modules, cr) modules = set(t[0] for t in translations) - _process(format, modules, translations, buffer, lang) + writer = TranslationFileWriter(buffer, fileformat=format, lang=lang, modules=modules) + writer.write_rows(translations) del translations @@ -1032,60 +1052,9 @@ def trans_load_data(cr, fileobj, fileformat, lang, lang_name=None, verbose=True, # lets create the language with locale information Lang.load_lang(lang=lang, lang_name=lang_name) - # Parse also the POT: it will possibly provide additional targets. - # (Because the POT comments are correct on Launchpad but not the - # PO comments due to a Launchpad limitation. See LP bug 933496.) - pot_reader = [] - use_pot_reference = False - # now, the serious things: we read the language file fileobj.seek(0) - if fileformat == 'csv': - reader = pycompat.csv_reader(fileobj, quotechar='"', delimiter=',') - # read the first line of the file (it contains columns titles) - fields = next(reader) - - elif fileformat == 'po': - reader = PoFile(fileobj) - fields = ['type', 'name', 'res_id', 'src', 'value', 'comments', 'module'] - - # Make a reader for the POT file and be somewhat defensive for the - # stable branch. - - # when fileobj is a TemporaryFile, its name is an interget in P3, a string in P2 - if isinstance(fileobj.name, str) and fileobj.name.endswith('.po'): - try: - # Normally the path looks like /path/to/xxx/i18n/lang.po - # and we try to find the corresponding - # /path/to/xxx/i18n/xxx.pot file. - # (Sometimes we have 'i18n_extra' instead of just 'i18n') - addons_module_i18n, _ignored = os.path.split(fileobj.name) - addons_module, i18n_dir = os.path.split(addons_module_i18n) - addons, module = os.path.split(addons_module) - pot_handle = file_open(os.path.join( - addons, module, i18n_dir, module + '.pot'), mode='rb') - pot_reader = PoFile(pot_handle) - use_pot_reference = True - except: - pass - - else: - _logger.info('Bad file format: %s', fileformat) - raise Exception(_('Bad file format: %s') % fileformat) - - # Read the POT references, and keep them indexed by source string. - class Target(object): - def __init__(self): - self.value = None - self.targets = set() # set of (type, name, res_id) - self.comments = None - - pot_targets = defaultdict(Target) - for type, name, res_id, src, _ignored, comments, module in pot_reader: - if type is not None: - target = pot_targets[src] - target.targets.add((type, name, type != 'code' and res_id or 0)) - target.comments = comments + reader = TranslationFileReader(fileobj, fileformat=fileformat) # read the rest of the file irt_cursor = Translation._get_import_cursor() @@ -1098,43 +1067,14 @@ def trans_load_data(cr, fileobj, fileformat, lang, lang_name=None, verbose=True, dic = dict.fromkeys(('type', 'name', 'res_id', 'src', 'value', 'comments', 'imd_model', 'imd_name', 'module')) dic['lang'] = lang - dic.update(zip(fields, row)) + dic.update(row) # do not import empty values if not env.context.get('create_empty_translation', False) and not dic['value']: return - if use_pot_reference: - # discard the target from the POT targets. - src = dic['src'] - target_key = (dic['type'], dic['name'], dic['type'] != 'code' and dic['res_id'] or 0) - target = pot_targets.get(src) - if not target or target_key not in target.targets: - _logger.info("Translation '%s' (%s, %s, %s) not found in reference pot, skipping", - src[:60], dic['type'], dic['name'], dic['res_id']) - return - - target.value = dic['value'] - target.targets.discard(target_key) - - # This would skip terms that fail to specify a res_id - res_id = dic['res_id'] - if not res_id and dic['type'] != 'code': - return - - if isinstance(res_id, int) or \ - (isinstance(res_id, str) and res_id.isdigit()): - dic['res_id'] = int(res_id) - if module_name: - dic['module'] = module_name - else: - # res_id is an xml id - dic['res_id'] = None - dic['imd_model'] = dic['name'].split(',')[0] - if '.' in res_id: - dic['module'], dic['imd_name'] = res_id.split('.', 1) - else: - dic['module'], dic['imd_name'] = module_name, res_id + if dic['type'] == 'code' and module_name: + dic['module'] = module_name irt_cursor.push(dic) @@ -1143,17 +1083,6 @@ def trans_load_data(cr, fileobj, fileformat, lang, lang_name=None, verbose=True, for row in reader: process_row(row) - if use_pot_reference: - # Then process the entries implied by the POT file (which is more - # correct w.r.t. the targets) if some of them remain. - pot_rows = [] - for src, target in pot_targets.items(): - if target.value: - for type, name, res_id in target.targets: - pot_rows.append((type, name, res_id, src, target.value, target.comments)) - for row in pot_rows: - process_row(row) - irt_cursor.finish() Translation.clear_caches() if verbose: diff --git a/requirements.txt b/requirements.txt index 723b96efae5..68bd257ddc6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,6 +21,7 @@ num2words==0.5.6 ofxparse==0.16 passlib==1.6.5 Pillow==4.0.0 +polib==1.1.0 psutil==4.3.1; sys_platform != 'win32' psycopg2==2.7.3.1; sys_platform != 'win32' pydot==1.2.3 diff --git a/setup.cfg b/setup.cfg index 33f97e70429..5b0fa922022 100644 --- a/setup.cfg +++ b/setup.cfg @@ -28,6 +28,7 @@ requires = python3-pillow python3-psutil python3-psycopg2 + python3-polib python3-pydot python3-pyldap python3-pyparsing diff --git a/setup/win32/winpy_requirements.txt b/setup/win32/winpy_requirements.txt index 41d3fd5adff..ad7081536b4 100644 --- a/setup/win32/winpy_requirements.txt +++ b/setup/win32/winpy_requirements.txt @@ -15,6 +15,7 @@ num2words==0.5.4 ofxparse==0.16 passlib==1.6.5 Pillow>=3.4.1 +polib>=1.1.0 psutil>=4.3.1 psycopg2==2.7.1 pydot==1.2.3 |
