| """Tests for the Tools/i18n/msgfmt.py tool.""" |
| |
| import json |
| import struct |
| import sys |
| import unittest |
| from gettext import GNUTranslations |
| from pathlib import Path |
| |
| from test.support.os_helper import temp_cwd |
| from test.support.script_helper import assert_python_failure, assert_python_ok |
| from test.test_tools import imports_under_tool, skip_if_missing, toolsdir |
| |
| |
| skip_if_missing('i18n') |
| |
| data_dir = (Path(__file__).parent / 'msgfmt_data').resolve() |
| script_dir = Path(toolsdir) / 'i18n' |
| msgfmt_py = script_dir / 'msgfmt.py' |
| |
| with imports_under_tool("i18n"): |
| import msgfmt |
| |
| |
| def compile_messages(po_file, mo_file): |
| assert_python_ok(msgfmt_py, '-o', mo_file, po_file) |
| |
| |
| class CompilationTest(unittest.TestCase): |
| |
| def test_compilation(self): |
| self.maxDiff = None |
| with temp_cwd(): |
| for po_file in data_dir.glob('*.po'): |
| with self.subTest(po_file=po_file): |
| mo_file = po_file.with_suffix('.mo') |
| with open(mo_file, 'rb') as f: |
| expected = GNUTranslations(f) |
| |
| tmp_mo_file = mo_file.name |
| compile_messages(po_file, tmp_mo_file) |
| with open(tmp_mo_file, 'rb') as f: |
| actual = GNUTranslations(f) |
| |
| self.assertDictEqual(actual._catalog, expected._catalog) |
| |
| def test_binary_header(self): |
| with temp_cwd(): |
| tmp_mo_file = 'messages.mo' |
| compile_messages(data_dir / "general.po", tmp_mo_file) |
| with open(tmp_mo_file, 'rb') as f: |
| mo_data = f.read() |
| |
| ( |
| magic, |
| version, |
| num_strings, |
| orig_table_offset, |
| trans_table_offset, |
| hash_table_size, |
| hash_table_offset, |
| ) = struct.unpack("=7I", mo_data[:28]) |
| |
| self.assertEqual(magic, 0x950412de) |
| self.assertEqual(version, 0) |
| self.assertEqual(num_strings, 9) |
| self.assertEqual(orig_table_offset, 28) |
| self.assertEqual(trans_table_offset, 100) |
| self.assertEqual(hash_table_size, 0) |
| self.assertEqual(hash_table_offset, 0) |
| |
| def test_translations(self): |
| with open(data_dir / 'general.mo', 'rb') as f: |
| t = GNUTranslations(f) |
| |
| self.assertEqual(t.gettext('foo'), 'foo') |
| self.assertEqual(t.gettext('bar'), 'baz') |
| self.assertEqual(t.pgettext('abc', 'foo'), 'bar') |
| self.assertEqual(t.pgettext('xyz', 'foo'), 'bar') |
| self.assertEqual(t.gettext('Multilinestring'), 'Multilinetranslation') |
| self.assertEqual(t.gettext('"escapes"'), '"translated"') |
| self.assertEqual(t.gettext('\n newlines \n'), '\n translated \n') |
| self.assertEqual(t.ngettext('One email sent.', '%d emails sent.', 1), |
| 'One email sent.') |
| self.assertEqual(t.ngettext('One email sent.', '%d emails sent.', 2), |
| '%d emails sent.') |
| self.assertEqual(t.npgettext('abc', 'One email sent.', |
| '%d emails sent.', 1), |
| 'One email sent.') |
| self.assertEqual(t.npgettext('abc', 'One email sent.', |
| '%d emails sent.', 2), |
| '%d emails sent.') |
| |
| def test_po_with_bom(self): |
| with temp_cwd(): |
| Path('bom.po').write_bytes(b'\xef\xbb\xbfmsgid "Python"\nmsgstr "Pioton"\n') |
| |
| res = assert_python_failure(msgfmt_py, 'bom.po') |
| err = res.err.decode('utf-8') |
| self.assertIn('The file bom.po starts with a UTF-8 BOM', err) |
| |
| def test_invalid_msgid_plural(self): |
| with temp_cwd(): |
| Path('invalid.po').write_text('''\ |
| msgid_plural "plural" |
| msgstr[0] "singular" |
| ''') |
| |
| res = assert_python_failure(msgfmt_py, 'invalid.po') |
| err = res.err.decode('utf-8') |
| self.assertIn('msgid_plural not preceded by msgid', err) |
| |
| def test_plural_without_msgid_plural(self): |
| with temp_cwd(): |
| Path('invalid.po').write_text('''\ |
| msgid "foo" |
| msgstr[0] "bar" |
| ''') |
| |
| res = assert_python_failure(msgfmt_py, 'invalid.po') |
| err = res.err.decode('utf-8') |
| self.assertIn('plural without msgid_plural', err) |
| |
| def test_indexed_msgstr_without_msgid_plural(self): |
| with temp_cwd(): |
| Path('invalid.po').write_text('''\ |
| msgid "foo" |
| msgid_plural "foos" |
| msgstr "bar" |
| ''') |
| |
| res = assert_python_failure(msgfmt_py, 'invalid.po') |
| err = res.err.decode('utf-8') |
| self.assertIn('indexed msgstr required for plural', err) |
| |
| def test_generic_syntax_error(self): |
| with temp_cwd(): |
| Path('invalid.po').write_text('''\ |
| "foo" |
| ''') |
| |
| res = assert_python_failure(msgfmt_py, 'invalid.po') |
| err = res.err.decode('utf-8') |
| self.assertIn('Syntax error', err) |
| |
| |
| class POParserTest(unittest.TestCase): |
| @classmethod |
| def tearDownClass(cls): |
| # msgfmt uses a global variable to store messages, |
| # clear it after the tests. |
| msgfmt.MESSAGES.clear() |
| |
| def test_strings(self): |
| # Test that the PO parser correctly handles and unescape |
| # strings in the PO file. |
| # The PO file format allows for a variety of escape sequences, |
| # octal and hex escapes. |
| valid_strings = ( |
| # empty strings |
| ('""', ''), |
| ('"" "" ""', ''), |
| # allowed escape sequences |
| (r'"\\"', '\\'), |
| (r'"\""', '"'), |
| (r'"\t"', '\t'), |
| (r'"\n"', '\n'), |
| (r'"\r"', '\r'), |
| (r'"\f"', '\f'), |
| (r'"\a"', '\a'), |
| (r'"\b"', '\b'), |
| (r'"\v"', '\v'), |
| # non-empty strings |
| ('"foo"', 'foo'), |
| ('"foo" "bar"', 'foobar'), |
| ('"foo""bar"', 'foobar'), |
| ('"" "foo" ""', 'foo'), |
| # newlines and tabs |
| (r'"foo\nbar"', 'foo\nbar'), |
| (r'"foo\n" "bar"', 'foo\nbar'), |
| (r'"foo\tbar"', 'foo\tbar'), |
| (r'"foo\t" "bar"', 'foo\tbar'), |
| # escaped quotes |
| (r'"foo\"bar"', 'foo"bar'), |
| (r'"foo\"" "bar"', 'foo"bar'), |
| (r'"foo\\" "bar"', 'foo\\bar'), |
| # octal escapes |
| (r'"\120\171\164\150\157\156"', 'Python'), |
| (r'"\120\171\164" "\150\157\156"', 'Python'), |
| (r'"\"\120\171\164" "\150\157\156\""', '"Python"'), |
| # hex escapes |
| (r'"\x50\x79\x74\x68\x6f\x6e"', 'Python'), |
| (r'"\x50\x79\x74" "\x68\x6f\x6e"', 'Python'), |
| (r'"\"\x50\x79\x74" "\x68\x6f\x6e\""', '"Python"'), |
| ) |
| |
| with temp_cwd(): |
| for po_string, expected in valid_strings: |
| with self.subTest(po_string=po_string): |
| # Construct a PO file with a single entry, |
| # compile it, read it into a catalog and |
| # check the result. |
| po = f'msgid {po_string}\nmsgstr "translation"' |
| Path('messages.po').write_text(po) |
| # Reset the global MESSAGES dictionary |
| msgfmt.MESSAGES.clear() |
| msgfmt.make('messages.po', 'messages.mo') |
| |
| with open('messages.mo', 'rb') as f: |
| actual = GNUTranslations(f) |
| |
| self.assertDictEqual(actual._catalog, {expected: 'translation'}) |
| |
| invalid_strings = ( |
| # "''", # invalid but currently accepted |
| '"', |
| '"""', |
| '"" "', |
| 'foo', |
| '"" "foo', |
| '"foo" foo', |
| '42', |
| '"" 42 ""', |
| # disallowed escape sequences |
| # r'"\'"', # invalid but currently accepted |
| # r'"\e"', # invalid but currently accepted |
| # r'"\8"', # invalid but currently accepted |
| # r'"\9"', # invalid but currently accepted |
| r'"\x"', |
| r'"\u1234"', |
| r'"\N{ROMAN NUMERAL NINE}"' |
| ) |
| with temp_cwd(): |
| for invalid_string in invalid_strings: |
| with self.subTest(string=invalid_string): |
| po = f'msgid {invalid_string}\nmsgstr "translation"' |
| Path('messages.po').write_text(po) |
| # Reset the global MESSAGES dictionary |
| msgfmt.MESSAGES.clear() |
| with self.assertRaises(Exception): |
| msgfmt.make('messages.po', 'messages.mo') |
| |
| |
| class CLITest(unittest.TestCase): |
| |
| def test_help(self): |
| for option in ('--help', '-h'): |
| res = assert_python_ok(msgfmt_py, option) |
| err = res.err.decode('utf-8') |
| self.assertIn('Generate binary message catalog from textual translation description.', err) |
| |
| def test_version(self): |
| for option in ('--version', '-V'): |
| res = assert_python_ok(msgfmt_py, option) |
| out = res.out.decode('utf-8').strip() |
| self.assertEqual('msgfmt.py 1.2', out) |
| |
| def test_invalid_option(self): |
| res = assert_python_failure(msgfmt_py, '--invalid-option') |
| err = res.err.decode('utf-8') |
| self.assertIn('Generate binary message catalog from textual translation description.', err) |
| self.assertIn('option --invalid-option not recognized', err) |
| |
| def test_no_input_file(self): |
| res = assert_python_ok(msgfmt_py) |
| err = res.err.decode('utf-8').replace('\r\n', '\n') |
| self.assertIn('No input file given\n' |
| "Try `msgfmt --help' for more information.", err) |
| |
| def test_nonexistent_file(self): |
| assert_python_failure(msgfmt_py, 'nonexistent.po') |
| |
| |
| def update_catalog_snapshots(): |
| for po_file in data_dir.glob('*.po'): |
| mo_file = po_file.with_suffix('.mo') |
| compile_messages(po_file, mo_file) |
| # Create a human-readable JSON file which is |
| # easier to review than the binary .mo file. |
| with open(mo_file, 'rb') as f: |
| translations = GNUTranslations(f) |
| catalog_file = po_file.with_suffix('.json') |
| with open(catalog_file, 'w') as f: |
| data = translations._catalog.items() |
| data = sorted(data, key=lambda x: (isinstance(x[0], tuple), x[0])) |
| json.dump(data, f, indent=4) |
| f.write('\n') |
| |
| |
| if __name__ == '__main__': |
| if len(sys.argv) > 1 and sys.argv[1] == '--snapshot-update': |
| update_catalog_snapshots() |
| sys.exit(0) |
| unittest.main() |