From d24928694ea7b0cb151898e87e2ea7d16cab36e0 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Thu, 25 Dec 2025 18:04:41 +0200 Subject: [PATCH] Unwrap most `unittest` test cases to bare functions (#1241) --- tests/messages/consts.py | 4 + tests/messages/frontend/__init__.py | 0 tests/messages/frontend/test_cli.py | 709 +++++++++ tests/messages/frontend/test_compile.py | 49 + tests/messages/frontend/test_extract.py | 299 ++++ tests/messages/frontend/test_frontend.py | 411 +++++ tests/messages/frontend/test_init.py | 386 +++++ tests/messages/test_catalog.py | 658 ++++---- tests/messages/test_checkers.py | 309 ++-- tests/messages/test_extract.py | 584 +------ tests/messages/test_extract_python.py | 474 ++++++ tests/messages/test_frontend.py | 1774 ---------------------- tests/messages/test_mofile.py | 109 +- tests/messages/test_pofile.py | 995 +----------- tests/messages/test_pofile_read.py | 582 +++++++ tests/messages/test_pofile_write.py | 441 ++++++ tests/messages/utils.py | 17 + tests/test_dates.py | 1 + tests/test_localedata.py | 68 +- tests/test_numbers.py | 209 --- tests/test_numbers_format_decimal.py | 177 +++ tests/test_numbers_parsing.py | 70 + tests/test_plural.py | 73 +- tests/test_plural_rule_parser.py | 84 + tests/test_util.py | 15 +- 25 files changed, 4410 insertions(+), 4088 deletions(-) create mode 100644 tests/messages/frontend/__init__.py create mode 100644 tests/messages/frontend/test_cli.py create mode 100644 tests/messages/frontend/test_compile.py create mode 100644 tests/messages/frontend/test_extract.py create mode 100644 tests/messages/frontend/test_frontend.py create mode 100644 tests/messages/frontend/test_init.py create mode 100644 tests/messages/test_extract_python.py delete mode 100644 tests/messages/test_frontend.py create mode 100644 tests/messages/test_pofile_read.py create mode 100644 tests/messages/test_pofile_write.py create mode 100644 tests/test_numbers_format_decimal.py create mode 100644 tests/test_numbers_parsing.py create mode 100644 tests/test_plural_rule_parser.py diff --git a/tests/messages/consts.py b/tests/messages/consts.py index 34509b30..f9e79653 100644 --- a/tests/messages/consts.py +++ b/tests/messages/consts.py @@ -10,3 +10,7 @@ data_dir = os.path.join(this_dir, 'data') project_dir = os.path.join(data_dir, 'project') i18n_dir = os.path.join(project_dir, 'i18n') pot_file = os.path.join(i18n_dir, 'temp.pot') + + +def get_po_file_path(locale): + return os.path.join(i18n_dir, locale, 'LC_MESSAGES', 'messages.po') diff --git a/tests/messages/frontend/__init__.py b/tests/messages/frontend/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/messages/frontend/test_cli.py b/tests/messages/frontend/test_cli.py new file mode 100644 index 00000000..4fea01b2 --- /dev/null +++ b/tests/messages/frontend/test_cli.py @@ -0,0 +1,709 @@ +# +# Copyright (C) 2007-2011 Edgewall Software, 2013-2025 the Babel team +# All rights reserved. +# +# This software is licensed as described in the file LICENSE, which +# you should have received as part of this distribution. The terms +# are also available at https://github.com/python-babel/babel/blob/master/LICENSE. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at https://github.com/python-babel/babel/commits/master/. + +from __future__ import annotations + +import logging +import os +import shutil +import sys +import time +import unittest +from datetime import datetime, timedelta +from io import StringIO + +import pytest +from freezegun import freeze_time + +from babel import __version__ as VERSION +from babel.dates import format_datetime +from babel.messages import Catalog, frontend +from babel.messages.frontend import BaseError +from babel.messages.pofile import read_po, write_po +from babel.util import LOCALTZ +from tests.messages.consts import data_dir, get_po_file_path, i18n_dir, pot_file, this_dir + + +class CommandLineInterfaceTestCase(unittest.TestCase): + + def setUp(self): + data_dir = os.path.join(this_dir, 'data') + self.orig_working_dir = os.getcwd() + self.orig_argv = sys.argv + self.orig_stdout = sys.stdout + self.orig_stderr = sys.stderr + sys.argv = ['pybabel'] + sys.stdout = StringIO() + sys.stderr = StringIO() + os.chdir(data_dir) + + self._remove_log_handlers() + self.cli = frontend.CommandLineInterface() + + def tearDown(self): + os.chdir(self.orig_working_dir) + sys.argv = self.orig_argv + sys.stdout = self.orig_stdout + sys.stderr = self.orig_stderr + for dirname in ['lv_LV', 'ja_JP']: + locale_dir = os.path.join(i18n_dir, dirname) + if os.path.isdir(locale_dir): + shutil.rmtree(locale_dir) + self._remove_log_handlers() + + def _remove_log_handlers(self): + # Logging handlers will be reused if possible (#227). This breaks the + # implicit assumption that our newly created StringIO for sys.stderr + # contains the console output. Removing the old handler ensures that a + # new handler with our new StringIO instance will be used. + log = logging.getLogger('babel') + for handler in log.handlers: + log.removeHandler(handler) + + def test_usage(self): + try: + self.cli.run(sys.argv) + self.fail('Expected SystemExit') + except SystemExit as e: + assert e.code == 2 + assert sys.stderr.getvalue().lower() == """\ +usage: pybabel command [options] [args] + +pybabel: error: no valid command or option passed. try the -h/--help option for more information. +""" + + def test_list_locales(self): + """ + Test the command with the --list-locales arg. + """ + result = self.cli.run(sys.argv + ['--list-locales']) + assert not result + output = sys.stdout.getvalue() + assert 'fr_CH' in output + assert 'French (Switzerland)' in output + assert "\nb'" not in output # No bytes repr markers in output + + def _run_init_catalog(self): + i18n_dir = os.path.join(data_dir, 'project', 'i18n') + pot_path = os.path.join(data_dir, 'project', 'i18n', 'messages.pot') + init_argv = sys.argv + ['init', '--locale', 'en_US', '-d', i18n_dir, + '-i', pot_path] + self.cli.run(init_argv) + + def test_no_duplicated_output_for_multiple_runs(self): + self._run_init_catalog() + first_output = sys.stderr.getvalue() + self._run_init_catalog() + second_output = sys.stderr.getvalue()[len(first_output):] + + # in case the log message is not duplicated we should get the same + # output as before + assert first_output == second_output + + def test_frontend_can_log_to_predefined_handler(self): + custom_stream = StringIO() + log = logging.getLogger('babel') + log.addHandler(logging.StreamHandler(custom_stream)) + + self._run_init_catalog() + assert id(sys.stderr) != id(custom_stream) + assert not sys.stderr.getvalue() + assert custom_stream.getvalue() + + def test_help(self): + try: + self.cli.run(sys.argv + ['--help']) + self.fail('Expected SystemExit') + except SystemExit as e: + assert not e.code + content = sys.stdout.getvalue().lower() + assert 'options:' in content + assert all(command in content for command in ('init', 'update', 'compile', 'extract')) + + def assert_pot_file_exists(self): + assert os.path.isfile(pot_file) + + @freeze_time("1994-11-11") + def test_extract_with_default_mapping(self): + self.cli.run(sys.argv + ['extract', + '--copyright-holder', 'FooBar, Inc.', + '--project', 'TestProject', '--version', '0.1', + '--msgid-bugs-address', 'bugs.address@email.tld', + '-c', 'TRANSLATOR', '-c', 'TRANSLATORS:', + '-o', pot_file, 'project']) + self.assert_pot_file_exists() + date = format_datetime(datetime(1994, 11, 11, 00, 00), 'yyyy-MM-dd HH:mmZ', tzinfo=LOCALTZ, locale='en') + expected_content = fr"""# Translations template for TestProject. +# Copyright (C) {time.strftime('%Y')} FooBar, Inc. +# This file is distributed under the same license as the TestProject +# project. +# FIRST AUTHOR , {time.strftime('%Y')}. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: TestProject 0.1\n" +"Report-Msgid-Bugs-To: bugs.address@email.tld\n" +"POT-Creation-Date: {date}\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel {VERSION}\n" + +#. TRANSLATOR: This will be a translator coment, +#. that will include several lines +#: project/file1.py:8 +msgid "bar" +msgstr "" + +#: project/file2.py:9 +msgid "foobar" +msgid_plural "foobars" +msgstr[0] "" +msgstr[1] "" + +#: project/ignored/this_wont_normally_be_here.py:11 +msgid "FooBar" +msgid_plural "FooBars" +msgstr[0] "" +msgstr[1] "" + +""" + with open(pot_file) as f: + actual_content = f.read() + assert expected_content == actual_content + + @freeze_time("1994-11-11") + def test_extract_with_mapping_file(self): + self.cli.run(sys.argv + ['extract', + '--copyright-holder', 'FooBar, Inc.', + '--project', 'TestProject', '--version', '0.1', + '--msgid-bugs-address', 'bugs.address@email.tld', + '--mapping', os.path.join(data_dir, 'mapping.cfg'), + '-c', 'TRANSLATOR', '-c', 'TRANSLATORS:', + '-o', pot_file, 'project']) + self.assert_pot_file_exists() + date = format_datetime(datetime(1994, 11, 11, 00, 00), 'yyyy-MM-dd HH:mmZ', tzinfo=LOCALTZ, locale='en') + expected_content = fr"""# Translations template for TestProject. +# Copyright (C) {time.strftime('%Y')} FooBar, Inc. +# This file is distributed under the same license as the TestProject +# project. +# FIRST AUTHOR , {time.strftime('%Y')}. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: TestProject 0.1\n" +"Report-Msgid-Bugs-To: bugs.address@email.tld\n" +"POT-Creation-Date: {date}\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel {VERSION}\n" + +#. TRANSLATOR: This will be a translator coment, +#. that will include several lines +#: project/file1.py:8 +msgid "bar" +msgstr "" + +#: project/file2.py:9 +msgid "foobar" +msgid_plural "foobars" +msgstr[0] "" +msgstr[1] "" + +""" + with open(pot_file) as f: + actual_content = f.read() + assert expected_content == actual_content + + @freeze_time("1994-11-11") + def test_extract_with_exact_file(self): + """Tests that we can call extract with a particular file and only + strings from that file get extracted. (Note the absence of strings from file1.py) + """ + file_to_extract = os.path.join(data_dir, 'project', 'file2.py') + self.cli.run(sys.argv + ['extract', + '--copyright-holder', 'FooBar, Inc.', + '--project', 'TestProject', '--version', '0.1', + '--msgid-bugs-address', 'bugs.address@email.tld', + '--mapping', os.path.join(data_dir, 'mapping.cfg'), + '-c', 'TRANSLATOR', '-c', 'TRANSLATORS:', + '-o', pot_file, file_to_extract]) + self.assert_pot_file_exists() + date = format_datetime(datetime(1994, 11, 11, 00, 00), 'yyyy-MM-dd HH:mmZ', tzinfo=LOCALTZ, locale='en') + expected_content = fr"""# Translations template for TestProject. +# Copyright (C) {time.strftime('%Y')} FooBar, Inc. +# This file is distributed under the same license as the TestProject +# project. +# FIRST AUTHOR , {time.strftime('%Y')}. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: TestProject 0.1\n" +"Report-Msgid-Bugs-To: bugs.address@email.tld\n" +"POT-Creation-Date: {date}\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel {VERSION}\n" + +#: project/file2.py:9 +msgid "foobar" +msgid_plural "foobars" +msgstr[0] "" +msgstr[1] "" + +""" + with open(pot_file) as f: + actual_content = f.read() + assert expected_content == actual_content + + @freeze_time("1994-11-11") + def test_init_with_output_dir(self): + po_file = get_po_file_path('en_US') + self.cli.run(sys.argv + ['init', + '--locale', 'en_US', + '-d', os.path.join(i18n_dir), + '-i', os.path.join(i18n_dir, 'messages.pot')]) + assert os.path.isfile(po_file) + date = format_datetime(datetime(1994, 11, 11, 00, 00), 'yyyy-MM-dd HH:mmZ', tzinfo=LOCALTZ, locale='en') + expected_content = fr"""# English (United States) translations for TestProject. +# Copyright (C) 2007 FooBar, Inc. +# This file is distributed under the same license as the TestProject +# project. +# FIRST AUTHOR , 2007. +# +msgid "" +msgstr "" +"Project-Id-Version: TestProject 0.1\n" +"Report-Msgid-Bugs-To: bugs.address@email.tld\n" +"POT-Creation-Date: 2007-04-01 15:30+0200\n" +"PO-Revision-Date: {date}\n" +"Last-Translator: FULL NAME \n" +"Language: en_US\n" +"Language-Team: en_US \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel {VERSION}\n" + +#. This will be a translator coment, +#. that will include several lines +#: project/file1.py:8 +msgid "bar" +msgstr "" + +#: project/file2.py:9 +msgid "foobar" +msgid_plural "foobars" +msgstr[0] "" +msgstr[1] "" + +""" + with open(po_file) as f: + actual_content = f.read() + assert expected_content == actual_content + + @freeze_time("1994-11-11") + def test_init_singular_plural_forms(self): + po_file = get_po_file_path('ja_JP') + self.cli.run(sys.argv + ['init', + '--locale', 'ja_JP', + '-d', os.path.join(i18n_dir), + '-i', os.path.join(i18n_dir, 'messages.pot')]) + assert os.path.isfile(po_file) + date = format_datetime(datetime(1994, 11, 11, 00, 00), 'yyyy-MM-dd HH:mmZ', tzinfo=LOCALTZ, locale='en') + expected_content = fr"""# Japanese (Japan) translations for TestProject. +# Copyright (C) 2007 FooBar, Inc. +# This file is distributed under the same license as the TestProject +# project. +# FIRST AUTHOR , 2007. +# +msgid "" +msgstr "" +"Project-Id-Version: TestProject 0.1\n" +"Report-Msgid-Bugs-To: bugs.address@email.tld\n" +"POT-Creation-Date: 2007-04-01 15:30+0200\n" +"PO-Revision-Date: {date}\n" +"Last-Translator: FULL NAME \n" +"Language: ja_JP\n" +"Language-Team: ja_JP \n" +"Plural-Forms: nplurals=1; plural=0;\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel {VERSION}\n" + +#. This will be a translator coment, +#. that will include several lines +#: project/file1.py:8 +msgid "bar" +msgstr "" + +#: project/file2.py:9 +msgid "foobar" +msgid_plural "foobars" +msgstr[0] "" + +""" + with open(po_file) as f: + actual_content = f.read() + assert expected_content == actual_content + + @freeze_time("1994-11-11") + def test_init_more_than_2_plural_forms(self): + po_file = get_po_file_path('lv_LV') + self.cli.run(sys.argv + ['init', + '--locale', 'lv_LV', + '-d', i18n_dir, + '-i', os.path.join(i18n_dir, 'messages.pot')]) + assert os.path.isfile(po_file) + date = format_datetime(datetime(1994, 11, 11, 00, 00), 'yyyy-MM-dd HH:mmZ', tzinfo=LOCALTZ, locale='en') + expected_content = fr"""# Latvian (Latvia) translations for TestProject. +# Copyright (C) 2007 FooBar, Inc. +# This file is distributed under the same license as the TestProject +# project. +# FIRST AUTHOR , 2007. +# +msgid "" +msgstr "" +"Project-Id-Version: TestProject 0.1\n" +"Report-Msgid-Bugs-To: bugs.address@email.tld\n" +"POT-Creation-Date: 2007-04-01 15:30+0200\n" +"PO-Revision-Date: {date}\n" +"Last-Translator: FULL NAME \n" +"Language: lv_LV\n" +"Language-Team: lv_LV \n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n != 0 ? 1 :" +" 2);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel {VERSION}\n" + +#. This will be a translator coment, +#. that will include several lines +#: project/file1.py:8 +msgid "bar" +msgstr "" + +#: project/file2.py:9 +msgid "foobar" +msgid_plural "foobars" +msgstr[0] "" +msgstr[1] "" +msgstr[2] "" + +""" + with open(po_file) as f: + actual_content = f.read() + assert expected_content == actual_content + + def test_compile_catalog(self): + po_file = get_po_file_path('de_DE') + mo_file = po_file.replace('.po', '.mo') + self.cli.run(sys.argv + ['compile', + '--locale', 'de_DE', + '-d', i18n_dir]) + assert not os.path.isfile(mo_file), f'Expected no file at {mo_file!r}' + assert sys.stderr.getvalue() == f'catalog {po_file} is marked as fuzzy, skipping\n' + + def test_compile_fuzzy_catalog(self): + po_file = get_po_file_path('de_DE') + mo_file = po_file.replace('.po', '.mo') + try: + self.cli.run(sys.argv + ['compile', + '--locale', 'de_DE', '--use-fuzzy', + '-d', i18n_dir]) + assert os.path.isfile(mo_file) + assert sys.stderr.getvalue() == f'compiling catalog {po_file} to {mo_file}\n' + finally: + if os.path.isfile(mo_file): + os.unlink(mo_file) + + def test_compile_catalog_with_more_than_2_plural_forms(self): + po_file = get_po_file_path('ru_RU') + mo_file = po_file.replace('.po', '.mo') + try: + self.cli.run(sys.argv + ['compile', + '--locale', 'ru_RU', '--use-fuzzy', + '-d', i18n_dir]) + assert os.path.isfile(mo_file) + assert sys.stderr.getvalue() == f'compiling catalog {po_file} to {mo_file}\n' + finally: + if os.path.isfile(mo_file): + os.unlink(mo_file) + + def test_compile_catalog_multidomain(self): + po_foo = os.path.join(i18n_dir, 'de_DE', 'LC_MESSAGES', 'foo.po') + po_bar = os.path.join(i18n_dir, 'de_DE', 'LC_MESSAGES', 'bar.po') + mo_foo = po_foo.replace('.po', '.mo') + mo_bar = po_bar.replace('.po', '.mo') + try: + self.cli.run(sys.argv + ['compile', + '--locale', 'de_DE', '--domain', 'foo bar', '--use-fuzzy', + '-d', i18n_dir]) + for mo_file in [mo_foo, mo_bar]: + assert os.path.isfile(mo_file) + assert sys.stderr.getvalue() == ( + f'compiling catalog {po_foo} to {mo_foo}\n' + f'compiling catalog {po_bar} to {mo_bar}\n' + ) + + finally: + for mo_file in [mo_foo, mo_bar]: + if os.path.isfile(mo_file): + os.unlink(mo_file) + + def test_update(self): + template = Catalog() + template.add("1") + template.add("2") + template.add("3") + tmpl_file = os.path.join(i18n_dir, 'temp-template.pot') + with open(tmpl_file, "wb") as outfp: + write_po(outfp, template) + po_file = os.path.join(i18n_dir, 'temp1.po') + self.cli.run(sys.argv + ['init', + '-l', 'fi', + '-o', po_file, + '-i', tmpl_file, + ]) + with open(po_file) as infp: + catalog = read_po(infp) + assert len(catalog) == 3 + + # Add another entry to the template + + template.add("4") + + with open(tmpl_file, "wb") as outfp: + write_po(outfp, template) + + self.cli.run(sys.argv + ['update', + '-l', 'fi_FI', + '-o', po_file, + '-i', tmpl_file]) + + with open(po_file) as infp: + catalog = read_po(infp) + assert len(catalog) == 4 # Catalog was updated + + def test_update_pot_creation_date(self): + template = Catalog() + template.add("1") + template.add("2") + template.add("3") + tmpl_file = os.path.join(i18n_dir, 'temp-template.pot') + with open(tmpl_file, "wb") as outfp: + write_po(outfp, template) + po_file = os.path.join(i18n_dir, 'temp1.po') + self.cli.run(sys.argv + ['init', + '-l', 'fi', + '-o', po_file, + '-i', tmpl_file, + ]) + with open(po_file) as infp: + catalog = read_po(infp) + assert len(catalog) == 3 + original_catalog_creation_date = catalog.creation_date + + # Update the template creation date + template.creation_date -= timedelta(minutes=3) + with open(tmpl_file, "wb") as outfp: + write_po(outfp, template) + + self.cli.run(sys.argv + ['update', + '-l', 'fi_FI', + '-o', po_file, + '-i', tmpl_file]) + + with open(po_file) as infp: + catalog = read_po(infp) + # We didn't ignore the creation date, so expect a diff + assert catalog.creation_date != original_catalog_creation_date + + # Reset the "original" + original_catalog_creation_date = catalog.creation_date + + # Update the template creation date again + # This time, pass the ignore flag and expect the times are different + template.creation_date -= timedelta(minutes=5) + with open(tmpl_file, "wb") as outfp: + write_po(outfp, template) + + self.cli.run(sys.argv + ['update', + '-l', 'fi_FI', + '-o', po_file, + '-i', tmpl_file, + '--ignore-pot-creation-date']) + + with open(po_file) as infp: + catalog = read_po(infp) + # We ignored creation date, so it should not have changed + assert catalog.creation_date == original_catalog_creation_date + + def test_check(self): + template = Catalog() + template.add("1") + template.add("2") + template.add("3") + tmpl_file = os.path.join(i18n_dir, 'temp-template.pot') + with open(tmpl_file, "wb") as outfp: + write_po(outfp, template) + po_file = os.path.join(i18n_dir, 'temp1.po') + self.cli.run(sys.argv + ['init', + '-l', 'fi_FI', + '-o', po_file, + '-i', tmpl_file, + ]) + + # Update the catalog file + self.cli.run(sys.argv + ['update', + '-l', 'fi_FI', + '-o', po_file, + '-i', tmpl_file]) + + # Run a check without introducing any changes to the template + self.cli.run(sys.argv + ['update', + '--check', + '-l', 'fi_FI', + '-o', po_file, + '-i', tmpl_file]) + + # Add a new entry and expect the check to fail + template.add("4") + with open(tmpl_file, "wb") as outfp: + write_po(outfp, template) + + with pytest.raises(BaseError): + self.cli.run(sys.argv + ['update', + '--check', + '-l', 'fi_FI', + '-o', po_file, + '-i', tmpl_file]) + + # Write the latest changes to the po-file + self.cli.run(sys.argv + ['update', + '-l', 'fi_FI', + '-o', po_file, + '-i', tmpl_file]) + + # Update an entry and expect the check to fail + template.add("4", locations=[("foo.py", 1)]) + with open(tmpl_file, "wb") as outfp: + write_po(outfp, template) + + with pytest.raises(BaseError): + self.cli.run(sys.argv + ['update', + '--check', + '-l', 'fi_FI', + '-o', po_file, + '-i', tmpl_file]) + + def test_check_pot_creation_date(self): + template = Catalog() + template.add("1") + template.add("2") + template.add("3") + tmpl_file = os.path.join(i18n_dir, 'temp-template.pot') + with open(tmpl_file, "wb") as outfp: + write_po(outfp, template) + po_file = os.path.join(i18n_dir, 'temp1.po') + self.cli.run(sys.argv + ['init', + '-l', 'fi_FI', + '-o', po_file, + '-i', tmpl_file, + ]) + + # Update the catalog file + self.cli.run(sys.argv + ['update', + '-l', 'fi_FI', + '-o', po_file, + '-i', tmpl_file]) + + # Run a check without introducing any changes to the template + self.cli.run(sys.argv + ['update', + '--check', + '-l', 'fi_FI', + '-o', po_file, + '-i', tmpl_file]) + + # Run a check after changing the template creation date + template.creation_date = datetime.now() - timedelta(minutes=5) + with open(tmpl_file, "wb") as outfp: + write_po(outfp, template) + + # Should fail without --ignore-pot-creation-date flag + with pytest.raises(BaseError): + self.cli.run(sys.argv + ['update', + '--check', + '-l', 'fi_FI', + '-o', po_file, + '-i', tmpl_file]) + # Should pass with --ignore-pot-creation-date flag + self.cli.run(sys.argv + ['update', + '--check', + '-l', 'fi_FI', + '-o', po_file, + '-i', tmpl_file, + '--ignore-pot-creation-date']) + + def test_update_init_missing(self): + template = Catalog() + template.add("1") + template.add("2") + template.add("3") + tmpl_file = os.path.join(i18n_dir, 'temp2-template.pot') + with open(tmpl_file, "wb") as outfp: + write_po(outfp, template) + po_file = os.path.join(i18n_dir, 'temp2.po') + + self.cli.run(sys.argv + ['update', + '--init-missing', + '-l', 'fi', + '-o', po_file, + '-i', tmpl_file]) + + with open(po_file) as infp: + catalog = read_po(infp) + assert len(catalog) == 3 + + # Add another entry to the template + + template.add("4") + + with open(tmpl_file, "wb") as outfp: + write_po(outfp, template) + + self.cli.run(sys.argv + ['update', + '--init-missing', + '-l', 'fi_FI', + '-o', po_file, + '-i', tmpl_file]) + + with open(po_file) as infp: + catalog = read_po(infp) + assert len(catalog) == 4 # Catalog was updated diff --git a/tests/messages/frontend/test_compile.py b/tests/messages/frontend/test_compile.py new file mode 100644 index 00000000..64425413 --- /dev/null +++ b/tests/messages/frontend/test_compile.py @@ -0,0 +1,49 @@ +# +# Copyright (C) 2007-2011 Edgewall Software, 2013-2025 the Babel team +# All rights reserved. +# +# This software is licensed as described in the file LICENSE, which +# you should have received as part of this distribution. The terms +# are also available at https://github.com/python-babel/babel/blob/master/LICENSE. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at https://github.com/python-babel/babel/commits/master/. + +from __future__ import annotations + +import os +import unittest + +import pytest + +from babel.messages import frontend +from babel.messages.frontend import OptionError +from tests.messages.consts import TEST_PROJECT_DISTRIBUTION_DATA, data_dir +from tests.messages.utils import Distribution + + +class CompileCatalogTestCase(unittest.TestCase): + + def setUp(self): + self.olddir = os.getcwd() + os.chdir(data_dir) + + self.dist = Distribution(TEST_PROJECT_DISTRIBUTION_DATA) + self.cmd = frontend.CompileCatalog(self.dist) + self.cmd.initialize_options() + + def tearDown(self): + os.chdir(self.olddir) + + def test_no_directory_or_output_file_specified(self): + self.cmd.locale = 'en_US' + self.cmd.input_file = 'dummy' + with pytest.raises(OptionError): + self.cmd.finalize_options() + + def test_no_directory_or_input_file_specified(self): + self.cmd.locale = 'en_US' + self.cmd.output_file = 'dummy' + with pytest.raises(OptionError): + self.cmd.finalize_options() diff --git a/tests/messages/frontend/test_extract.py b/tests/messages/frontend/test_extract.py new file mode 100644 index 00000000..ff06ea74 --- /dev/null +++ b/tests/messages/frontend/test_extract.py @@ -0,0 +1,299 @@ +# +# Copyright (C) 2007-2011 Edgewall Software, 2013-2025 the Babel team +# All rights reserved. +# +# This software is licensed as described in the file LICENSE, which +# you should have received as part of this distribution. The terms +# are also available at https://github.com/python-babel/babel/blob/master/LICENSE. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at https://github.com/python-babel/babel/commits/master/. + +from __future__ import annotations + +import os +import time +import unittest +from datetime import datetime + +import pytest +from freezegun import freeze_time + +from babel import __version__ as VERSION +from babel.dates import format_datetime +from babel.messages import frontend +from babel.messages.frontend import OptionError +from babel.messages.pofile import read_po +from babel.util import LOCALTZ +from tests.messages.consts import TEST_PROJECT_DISTRIBUTION_DATA, data_dir, pot_file, this_dir +from tests.messages.utils import Distribution + + +class ExtractMessagesTestCase(unittest.TestCase): + + def setUp(self): + self.olddir = os.getcwd() + os.chdir(data_dir) + + self.dist = Distribution(TEST_PROJECT_DISTRIBUTION_DATA) + self.cmd = frontend.ExtractMessages(self.dist) + self.cmd.initialize_options() + + def tearDown(self): + if os.path.isfile(pot_file): + os.unlink(pot_file) + + os.chdir(self.olddir) + + def assert_pot_file_exists(self): + assert os.path.isfile(pot_file) + + def test_neither_default_nor_custom_keywords(self): + self.cmd.output_file = 'dummy' + self.cmd.no_default_keywords = True + with pytest.raises(OptionError): + self.cmd.finalize_options() + + def test_no_output_file_specified(self): + with pytest.raises(OptionError): + self.cmd.finalize_options() + + def test_both_sort_output_and_sort_by_file(self): + self.cmd.output_file = 'dummy' + self.cmd.sort_output = True + self.cmd.sort_by_file = True + with pytest.raises(OptionError): + self.cmd.finalize_options() + + def test_invalid_file_or_dir_input_path(self): + self.cmd.input_paths = 'nonexistent_path' + self.cmd.output_file = 'dummy' + with pytest.raises(OptionError): + self.cmd.finalize_options() + + def test_input_paths_is_treated_as_list(self): + self.cmd.input_paths = data_dir + self.cmd.output_file = pot_file + self.cmd.finalize_options() + self.cmd.run() + + with open(pot_file) as f: + catalog = read_po(f) + msg = catalog.get('bar') + assert len(msg.locations) == 1 + assert ('file1.py' in msg.locations[0][0]) + + def test_input_paths_handle_spaces_after_comma(self): + self.cmd.input_paths = f"{this_dir}, {data_dir}" + self.cmd.output_file = pot_file + self.cmd.finalize_options() + assert self.cmd.input_paths == [this_dir, data_dir] + + def test_input_dirs_is_alias_for_input_paths(self): + self.cmd.input_dirs = this_dir + self.cmd.output_file = pot_file + self.cmd.finalize_options() + # Gets listified in `finalize_options`: + assert self.cmd.input_paths == [self.cmd.input_dirs] + + def test_input_dirs_is_mutually_exclusive_with_input_paths(self): + self.cmd.input_dirs = this_dir + self.cmd.input_paths = this_dir + self.cmd.output_file = pot_file + with pytest.raises(OptionError): + self.cmd.finalize_options() + + @freeze_time("1994-11-11") + def test_extraction_with_default_mapping(self): + self.cmd.copyright_holder = 'FooBar, Inc.' + self.cmd.msgid_bugs_address = 'bugs.address@email.tld' + self.cmd.output_file = 'project/i18n/temp.pot' + self.cmd.add_comments = 'TRANSLATOR:,TRANSLATORS:' + + self.cmd.finalize_options() + self.cmd.run() + + self.assert_pot_file_exists() + + date = format_datetime(datetime(1994, 11, 11, 00, 00), 'yyyy-MM-dd HH:mmZ', tzinfo=LOCALTZ, locale='en') + expected_content = fr"""# Translations template for TestProject. +# Copyright (C) {time.strftime('%Y')} FooBar, Inc. +# This file is distributed under the same license as the TestProject +# project. +# FIRST AUTHOR , {time.strftime('%Y')}. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: TestProject 0.1\n" +"Report-Msgid-Bugs-To: bugs.address@email.tld\n" +"POT-Creation-Date: {date}\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel {VERSION}\n" + +#. TRANSLATOR: This will be a translator coment, +#. that will include several lines +#: project/file1.py:8 +msgid "bar" +msgstr "" + +#: project/file2.py:9 +msgid "foobar" +msgid_plural "foobars" +msgstr[0] "" +msgstr[1] "" + +#: project/ignored/this_wont_normally_be_here.py:11 +msgid "FooBar" +msgid_plural "FooBars" +msgstr[0] "" +msgstr[1] "" + +""" + with open(pot_file) as f: + actual_content = f.read() + assert expected_content == actual_content + + @freeze_time("1994-11-11") + def test_extraction_with_mapping_file(self): + self.cmd.copyright_holder = 'FooBar, Inc.' + self.cmd.msgid_bugs_address = 'bugs.address@email.tld' + self.cmd.mapping_file = 'mapping.cfg' + self.cmd.output_file = 'project/i18n/temp.pot' + self.cmd.add_comments = 'TRANSLATOR:,TRANSLATORS:' + + self.cmd.finalize_options() + self.cmd.run() + + self.assert_pot_file_exists() + + date = format_datetime(datetime(1994, 11, 11, 00, 00), 'yyyy-MM-dd HH:mmZ', tzinfo=LOCALTZ, locale='en') + expected_content = fr"""# Translations template for TestProject. +# Copyright (C) {time.strftime('%Y')} FooBar, Inc. +# This file is distributed under the same license as the TestProject +# project. +# FIRST AUTHOR , {time.strftime('%Y')}. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: TestProject 0.1\n" +"Report-Msgid-Bugs-To: bugs.address@email.tld\n" +"POT-Creation-Date: {date}\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel {VERSION}\n" + +#. TRANSLATOR: This will be a translator coment, +#. that will include several lines +#: project/file1.py:8 +msgid "bar" +msgstr "" + +#: project/file2.py:9 +msgid "foobar" +msgid_plural "foobars" +msgstr[0] "" +msgstr[1] "" + +""" + with open(pot_file) as f: + actual_content = f.read() + assert expected_content == actual_content + + @freeze_time("1994-11-11") + def test_extraction_with_mapping_dict(self): + self.dist.message_extractors = { + 'project': [ + ('**/ignored/**.*', 'ignore', None), + ('**.py', 'python', None), + ], + } + self.cmd.copyright_holder = 'FooBar, Inc.' + self.cmd.msgid_bugs_address = 'bugs.address@email.tld' + self.cmd.output_file = 'project/i18n/temp.pot' + self.cmd.add_comments = 'TRANSLATOR:,TRANSLATORS:' + + self.cmd.finalize_options() + self.cmd.run() + + self.assert_pot_file_exists() + + date = format_datetime(datetime(1994, 11, 11, 00, 00), 'yyyy-MM-dd HH:mmZ', tzinfo=LOCALTZ, locale='en') + expected_content = fr"""# Translations template for TestProject. +# Copyright (C) {time.strftime('%Y')} FooBar, Inc. +# This file is distributed under the same license as the TestProject +# project. +# FIRST AUTHOR , {time.strftime('%Y')}. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: TestProject 0.1\n" +"Report-Msgid-Bugs-To: bugs.address@email.tld\n" +"POT-Creation-Date: {date}\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel {VERSION}\n" + +#. TRANSLATOR: This will be a translator coment, +#. that will include several lines +#: project/file1.py:8 +msgid "bar" +msgstr "" + +#: project/file2.py:9 +msgid "foobar" +msgid_plural "foobars" +msgstr[0] "" +msgstr[1] "" + +""" + with open(pot_file) as f: + actual_content = f.read() + assert expected_content == actual_content + + def test_extraction_add_location_file(self): + self.dist.message_extractors = { + 'project': [ + ('**/ignored/**.*', 'ignore', None), + ('**.py', 'python', None), + ], + } + self.cmd.output_file = 'project/i18n/temp.pot' + self.cmd.add_location = 'file' + self.cmd.omit_header = True + + self.cmd.finalize_options() + self.cmd.run() + + self.assert_pot_file_exists() + + expected_content = r"""#: project/file1.py +msgid "bar" +msgstr "" + +#: project/file2.py +msgid "foobar" +msgid_plural "foobars" +msgstr[0] "" +msgstr[1] "" + +""" + with open(pot_file) as f: + actual_content = f.read() + assert expected_content == actual_content diff --git a/tests/messages/frontend/test_frontend.py b/tests/messages/frontend/test_frontend.py new file mode 100644 index 00000000..bb196e1e --- /dev/null +++ b/tests/messages/frontend/test_frontend.py @@ -0,0 +1,411 @@ +# +# Copyright (C) 2007-2011 Edgewall Software, 2013-2025 the Babel team +# All rights reserved. +# +# This software is licensed as described in the file LICENSE, which +# you should have received as part of this distribution. The terms +# are also available at https://github.com/python-babel/babel/blob/master/LICENSE. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at https://github.com/python-babel/babel/commits/master/. + +from __future__ import annotations + +import re +import shlex +from functools import partial +from io import BytesIO, StringIO + +import pytest + +from babel.messages import Catalog, extract, frontend +from babel.messages.frontend import ( + CommandLineInterface, + ExtractMessages, + UpdateCatalog, +) +from babel.messages.pofile import write_po +from tests.messages.consts import project_dir +from tests.messages.utils import CUSTOM_EXTRACTOR_COOKIE + +mapping_cfg = """ +[extractors] +custom = tests.messages.utils:custom_extractor + +# Special extractor for a given Python file +[custom: special.py] +treat = delicious + +# Python source files +[python: **.py] + +# Genshi templates +[genshi: **/templates/**.html] +include_attrs = + +[genshi: **/templates/**.txt] +template_class = genshi.template:TextTemplate +encoding = latin-1 + +# Some custom extractor +[custom: **/custom/*.*] +""" + +mapping_toml = """ +[extractors] +custom = "tests.messages.utils:custom_extractor" + +# Special extractor for a given Python file +[[mappings]] +method = "custom" +pattern = "special.py" +treat = "delightful" + +# Python source files +[[mappings]] +method = "python" +pattern = "**.py" + +# Genshi templates +[[mappings]] +method = "genshi" +pattern = "**/templates/**.html" +include_attrs = "" + +[[mappings]] +method = "genshi" +pattern = "**/templates/**.txt" +template_class = "genshi.template:TextTemplate" +encoding = "latin-1" + +# Some custom extractor +[[mappings]] +method = "custom" +pattern = "**/custom/*.*" +""" + + +@pytest.mark.parametrize( + ("data", "parser", "preprocess", "is_toml"), + [ + ( + mapping_cfg, + frontend.parse_mapping_cfg, + None, + False, + ), + ( + mapping_toml, + frontend._parse_mapping_toml, + None, + True, + ), + ( + mapping_toml, + partial(frontend._parse_mapping_toml, style="pyproject.toml"), + lambda s: re.sub(r"^(\[+)", r"\1tool.babel.", s, flags=re.MULTILINE), + True, + ), + ], + ids=("cfg", "toml", "pyproject-toml"), +) +def test_parse_mapping(data: str, parser, preprocess, is_toml): + if preprocess: + data = preprocess(data) + if is_toml: + buf = BytesIO(data.encode()) + else: + buf = StringIO(data) + + method_map, options_map = parser(buf) + assert len(method_map) == 5 + + assert method_map[1] == ('**.py', 'python') + assert options_map['**.py'] == {} + assert method_map[2] == ('**/templates/**.html', 'genshi') + assert options_map['**/templates/**.html']['include_attrs'] == '' + assert method_map[3] == ('**/templates/**.txt', 'genshi') + assert (options_map['**/templates/**.txt']['template_class'] + == 'genshi.template:TextTemplate') + assert options_map['**/templates/**.txt']['encoding'] == 'latin-1' + assert method_map[4] == ('**/custom/*.*', 'tests.messages.utils:custom_extractor') + assert options_map['**/custom/*.*'] == {} + + +def test_parse_keywords(): + kw = frontend.parse_keywords(['_', 'dgettext:2', + 'dngettext:2,3', 'pgettext:1c,2']) + assert kw == { + '_': None, + 'dgettext': (2,), + 'dngettext': (2, 3), + 'pgettext': ((1, 'c'), 2), + } + + +def test_parse_keywords_with_t(): + kw = frontend.parse_keywords(['_:1', '_:2,2t', '_:2c,3,3t']) + + assert kw == { + '_': { + None: (1,), + 2: (2,), + 3: ((2, 'c'), 3), + }, + } + + +def test_extract_messages_with_t(): + content = rb""" +_("1 arg, arg 1") +_("2 args, arg 1", "2 args, arg 2") +_("3 args, arg 1", "3 args, arg 2", "3 args, arg 3") +_("4 args, arg 1", "4 args, arg 2", "4 args, arg 3", "4 args, arg 4") +""" + kw = frontend.parse_keywords(['_:1', '_:2,2t', '_:2c,3,3t']) + result = list(extract.extract("python", BytesIO(content), kw)) + expected = [(2, '1 arg, arg 1', [], None), + (3, '2 args, arg 1', [], None), + (3, '2 args, arg 2', [], None), + (4, '3 args, arg 1', [], None), + (4, '3 args, arg 3', [], '3 args, arg 2'), + (5, '4 args, arg 1', [], None)] + assert result == expected + + +def configure_cli_command(cmdline: str | list[str]): + """ + Helper to configure a command class, but not run it just yet. + + :param cmdline: The command line (sans the executable name) + :return: Command instance + """ + args = shlex.split(cmdline) if isinstance(cmdline, str) else list(cmdline) + cli = CommandLineInterface() + cmdinst = cli._configure_command(cmdname=args[0], argv=args[1:]) + return cmdinst + + +@pytest.mark.parametrize("split", (False, True)) +@pytest.mark.parametrize("arg_name", ("-k", "--keyword", "--keywords")) +def test_extract_keyword_args_384(split, arg_name): + # This is a regression test for https://github.com/python-babel/babel/issues/384 + # and it also tests that the rest of the forgotten aliases/shorthands implied by + # https://github.com/python-babel/babel/issues/390 are re-remembered (or rather + # that the mechanism for remembering them again works). + + kwarg_specs = [ + "gettext_noop", + "gettext_lazy", + "ngettext_lazy:1,2", + "ugettext_noop", + "ugettext_lazy", + "ungettext_lazy:1,2", + "pgettext_lazy:1c,2", + "npgettext_lazy:1c,2,3", + ] + + if split: # Generate a command line with multiple -ks + kwarg_text = " ".join(f"{arg_name} {kwarg_spec}" for kwarg_spec in kwarg_specs) + else: # Generate a single space-separated -k + specs = ' '.join(kwarg_specs) + kwarg_text = f'{arg_name} "{specs}"' + + # (Both of those invocation styles should be equivalent, so there is no parametrization from here on out) + + cmdinst = configure_cli_command( + f"extract -F babel-django.cfg --add-comments Translators: -o django232.pot {kwarg_text} .", + ) + assert isinstance(cmdinst, ExtractMessages) + assert set(cmdinst.keywords.keys()) == {'_', 'dgettext', 'dngettext', + 'gettext', 'gettext_lazy', + 'gettext_noop', 'N_', 'ngettext', + 'ngettext_lazy', 'npgettext', + 'npgettext_lazy', 'pgettext', + 'pgettext_lazy', 'ugettext', + 'ugettext_lazy', 'ugettext_noop', + 'ungettext', 'ungettext_lazy'} + + +def test_update_catalog_boolean_args(): + cmdinst = configure_cli_command( + "update --init-missing --no-wrap -N --ignore-obsolete --previous -i foo -o foo -l en") + assert isinstance(cmdinst, UpdateCatalog) + assert cmdinst.init_missing is True + assert cmdinst.no_wrap is True + assert cmdinst.no_fuzzy_matching is True + assert cmdinst.ignore_obsolete is True + assert cmdinst.previous is False # Mutually exclusive with no_fuzzy_matching + + + +def test_compile_catalog_dir(tmp_path): + """ + Test that `compile` can compile all locales in a directory. + """ + locales = ("fi_FI", "sv_SE") + for locale in locales: + l_dir = tmp_path / locale / "LC_MESSAGES" + l_dir.mkdir(parents=True) + po_file = l_dir / 'messages.po' + po_file.write_text('msgid "foo"\nmsgstr "bar"\n') + cmdinst = configure_cli_command([ # fmt: skip + 'compile', + '--statistics', + '--use-fuzzy', + '-d', str(tmp_path), + ]) + assert not cmdinst.run() + for locale in locales: + assert (tmp_path / locale / "LC_MESSAGES" / "messages.mo").exists() + + +def test_compile_catalog_explicit(tmp_path): + """ + Test that `compile` can explicitly compile a single catalog. + """ + po_file = tmp_path / 'temp.po' + po_file.write_text('msgid "foo"\nmsgstr "bar"\n') + mo_file = tmp_path / 'temp.mo' + cmdinst = configure_cli_command([ # fmt: skip + 'compile', + '--statistics', + '--use-fuzzy', + '-i', str(po_file), + '-o', str(mo_file), + '-l', 'fi_FI', + ]) + assert not cmdinst.run() + assert mo_file.exists() + + + +@pytest.mark.parametrize("explicit_locale", (None, 'fi_FI'), ids=("implicit", "explicit")) +def test_update_dir(tmp_path, explicit_locale: bool): + """ + Test that `update` can deal with directories too. + """ + template = Catalog() + template.add("1") + template.add("2") + template.add("3") + tmpl_file = (tmp_path / 'temp-template.pot') + with tmpl_file.open("wb") as outfp: + write_po(outfp, template) + locales = ("fi_FI", "sv_SE") + for locale in locales: + l_dir = tmp_path / locale / "LC_MESSAGES" + l_dir.mkdir(parents=True) + po_file = l_dir / 'messages.po' + po_file.touch() + cmdinst = configure_cli_command([ # fmt: skip + 'update', + '-i', str(tmpl_file), + '-d', str(tmp_path), + *(['-l', explicit_locale] if explicit_locale else []), + ]) + assert not cmdinst.run() + for locale in locales: + if explicit_locale and locale != explicit_locale: + continue + assert (tmp_path / locale / "LC_MESSAGES" / "messages.po").stat().st_size > 0 + + +def test_extract_cli_knows_dash_s(): + # This is a regression test for https://github.com/python-babel/babel/issues/390 + cmdinst = configure_cli_command("extract -s -o foo babel") + assert isinstance(cmdinst, ExtractMessages) + assert cmdinst.strip_comments + + +def test_extract_cli_knows_dash_dash_last_dash_translator(): + cmdinst = configure_cli_command('extract --last-translator "FULL NAME EMAIL@ADDRESS" -o foo babel') + assert isinstance(cmdinst, ExtractMessages) + assert cmdinst.last_translator == "FULL NAME EMAIL@ADDRESS" + + +def test_extract_add_location(): + cmdinst = configure_cli_command("extract -o foo babel --add-location full") + assert isinstance(cmdinst, ExtractMessages) + assert cmdinst.add_location == 'full' + assert not cmdinst.no_location + assert cmdinst.include_lineno + + cmdinst = configure_cli_command("extract -o foo babel --add-location file") + assert isinstance(cmdinst, ExtractMessages) + assert cmdinst.add_location == 'file' + assert not cmdinst.no_location + assert not cmdinst.include_lineno + + cmdinst = configure_cli_command("extract -o foo babel --add-location never") + assert isinstance(cmdinst, ExtractMessages) + assert cmdinst.add_location == 'never' + assert cmdinst.no_location + + +def test_extract_error_code(monkeypatch, capsys): + monkeypatch.chdir(project_dir) + cmdinst = configure_cli_command("compile --domain=messages --directory i18n --locale fi_BUGGY") + assert cmdinst.run() == 1 + out, err = capsys.readouterr() + if err: + assert "unknown named placeholder 'merkki'" in err + + +@pytest.mark.parametrize("with_underscore_ignore", (False, True)) +def test_extract_ignore_dirs(monkeypatch, capsys, tmp_path, with_underscore_ignore): + pot_file = tmp_path / 'temp.pot' + monkeypatch.chdir(project_dir) + cmd = f"extract . -o '{pot_file}' --ignore-dirs '*ignored* .*' " + if with_underscore_ignore: + # This also tests that multiple arguments are supported. + cmd += "--ignore-dirs '_*'" + cmdinst = configure_cli_command(cmd) + assert isinstance(cmdinst, ExtractMessages) + assert cmdinst.directory_filter + cmdinst.run() + pot_content = pot_file.read_text() + + # The `ignored` directory is now actually ignored: + assert 'this_wont_normally_be_here' not in pot_content + + # Since we manually set a filter, the otherwise `_hidden` directory is walked into, + # unless we opt in to ignore it again + assert ('ssshhh....' in pot_content) != with_underscore_ignore + assert ('_hidden_by_default' in pot_content) != with_underscore_ignore + + +def test_extract_header_comment(monkeypatch, tmp_path): + pot_file = tmp_path / 'temp.pot' + monkeypatch.chdir(project_dir) + cmdinst = configure_cli_command(f"extract . -o '{pot_file}' --header-comment 'Boing' ") + cmdinst.run() + pot_content = pot_file.read_text() + assert 'Boing' in pot_content + + +@pytest.mark.parametrize("mapping_format", ("toml", "cfg")) +def test_pr_1121(tmp_path, monkeypatch, caplog, mapping_format): + """ + Test that extraction uses the first matching method and options, + instead of the first matching method and last matching options. + + Without the fix in PR #1121, this test would fail, + since the `custom_extractor` isn't passed a delicious treat via + the configuration. + """ + if mapping_format == "cfg": + mapping_file = (tmp_path / "mapping.cfg") + mapping_file.write_text(mapping_cfg) + else: + mapping_file = (tmp_path / "mapping.toml") + mapping_file.write_text(mapping_toml) + (tmp_path / "special.py").write_text("# this file is special") + pot_path = (tmp_path / "output.pot") + monkeypatch.chdir(tmp_path) + cmdinst = configure_cli_command(f"extract . -o {shlex.quote(str(pot_path))} --mapping {shlex.quote(mapping_file.name)}") + assert isinstance(cmdinst, ExtractMessages) + cmdinst.run() + # If the custom extractor didn't run, we wouldn't see the cookie in there. + assert CUSTOM_EXTRACTOR_COOKIE in pot_path.read_text() diff --git a/tests/messages/frontend/test_init.py b/tests/messages/frontend/test_init.py new file mode 100644 index 00000000..9e10c2a5 --- /dev/null +++ b/tests/messages/frontend/test_init.py @@ -0,0 +1,386 @@ +# +# Copyright (C) 2007-2011 Edgewall Software, 2013-2025 the Babel team +# All rights reserved. +# +# This software is licensed as described in the file LICENSE, which +# you should have received as part of this distribution. The terms +# are also available at https://github.com/python-babel/babel/blob/master/LICENSE. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at https://github.com/python-babel/babel/commits/master/. + +from __future__ import annotations + +import os +import shutil +import unittest +from datetime import datetime + +import pytest +from freezegun import freeze_time + +from babel import __version__ as VERSION +from babel.dates import format_datetime +from babel.messages import frontend +from babel.util import LOCALTZ +from tests.messages.consts import ( + TEST_PROJECT_DISTRIBUTION_DATA, + data_dir, + get_po_file_path, + i18n_dir, +) +from tests.messages.utils import Distribution + + +class InitCatalogTestCase(unittest.TestCase): + + def setUp(self): + self.olddir = os.getcwd() + os.chdir(data_dir) + + self.dist = Distribution(TEST_PROJECT_DISTRIBUTION_DATA) + self.cmd = frontend.InitCatalog(self.dist) + self.cmd.initialize_options() + + def tearDown(self): + for dirname in ['en_US', 'ja_JP', 'lv_LV']: + locale_dir = os.path.join(i18n_dir, dirname) + if os.path.isdir(locale_dir): + shutil.rmtree(locale_dir) + + os.chdir(self.olddir) + + def test_no_input_file(self): + self.cmd.locale = 'en_US' + self.cmd.output_file = 'dummy' + with pytest.raises(frontend.OptionError): + self.cmd.finalize_options() + + def test_no_locale(self): + self.cmd.input_file = 'dummy' + self.cmd.output_file = 'dummy' + with pytest.raises(frontend.OptionError): + self.cmd.finalize_options() + + @freeze_time("1994-11-11") + def test_with_output_dir(self): + self.cmd.input_file = 'project/i18n/messages.pot' + self.cmd.locale = 'en_US' + self.cmd.output_dir = 'project/i18n' + + self.cmd.finalize_options() + self.cmd.run() + + po_file = get_po_file_path('en_US') + assert os.path.isfile(po_file) + + date = format_datetime(datetime(1994, 11, 11, 00, 00), 'yyyy-MM-dd HH:mmZ', tzinfo=LOCALTZ, locale='en') + expected_content = fr"""# English (United States) translations for TestProject. +# Copyright (C) 2007 FooBar, Inc. +# This file is distributed under the same license as the TestProject +# project. +# FIRST AUTHOR , 2007. +# +msgid "" +msgstr "" +"Project-Id-Version: TestProject 0.1\n" +"Report-Msgid-Bugs-To: bugs.address@email.tld\n" +"POT-Creation-Date: 2007-04-01 15:30+0200\n" +"PO-Revision-Date: {date}\n" +"Last-Translator: FULL NAME \n" +"Language: en_US\n" +"Language-Team: en_US \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel {VERSION}\n" + +#. This will be a translator coment, +#. that will include several lines +#: project/file1.py:8 +msgid "bar" +msgstr "" + +#: project/file2.py:9 +msgid "foobar" +msgid_plural "foobars" +msgstr[0] "" +msgstr[1] "" + +""" + with open(po_file) as f: + actual_content = f.read() + assert expected_content == actual_content + + @freeze_time("1994-11-11") + def test_keeps_catalog_non_fuzzy(self): + self.cmd.input_file = 'project/i18n/messages_non_fuzzy.pot' + self.cmd.locale = 'en_US' + self.cmd.output_dir = 'project/i18n' + + self.cmd.finalize_options() + self.cmd.run() + + po_file = get_po_file_path('en_US') + assert os.path.isfile(po_file) + + date = format_datetime(datetime(1994, 11, 11, 00, 00), 'yyyy-MM-dd HH:mmZ', tzinfo=LOCALTZ, locale='en') + expected_content = fr"""# English (United States) translations for TestProject. +# Copyright (C) 2007 FooBar, Inc. +# This file is distributed under the same license as the TestProject +# project. +# FIRST AUTHOR , 2007. +# +msgid "" +msgstr "" +"Project-Id-Version: TestProject 0.1\n" +"Report-Msgid-Bugs-To: bugs.address@email.tld\n" +"POT-Creation-Date: 2007-04-01 15:30+0200\n" +"PO-Revision-Date: {date}\n" +"Last-Translator: FULL NAME \n" +"Language: en_US\n" +"Language-Team: en_US \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel {VERSION}\n" + +#. This will be a translator coment, +#. that will include several lines +#: project/file1.py:8 +msgid "bar" +msgstr "" + +#: project/file2.py:9 +msgid "foobar" +msgid_plural "foobars" +msgstr[0] "" +msgstr[1] "" + +""" + with open(po_file) as f: + actual_content = f.read() + assert expected_content == actual_content + + @freeze_time("1994-11-11") + def test_correct_init_more_than_2_plurals(self): + self.cmd.input_file = 'project/i18n/messages.pot' + self.cmd.locale = 'lv_LV' + self.cmd.output_dir = 'project/i18n' + + self.cmd.finalize_options() + self.cmd.run() + + po_file = get_po_file_path('lv_LV') + assert os.path.isfile(po_file) + + date = format_datetime(datetime(1994, 11, 11, 00, 00), 'yyyy-MM-dd HH:mmZ', tzinfo=LOCALTZ, locale='en') + expected_content = fr"""# Latvian (Latvia) translations for TestProject. +# Copyright (C) 2007 FooBar, Inc. +# This file is distributed under the same license as the TestProject +# project. +# FIRST AUTHOR , 2007. +# +msgid "" +msgstr "" +"Project-Id-Version: TestProject 0.1\n" +"Report-Msgid-Bugs-To: bugs.address@email.tld\n" +"POT-Creation-Date: 2007-04-01 15:30+0200\n" +"PO-Revision-Date: {date}\n" +"Last-Translator: FULL NAME \n" +"Language: lv_LV\n" +"Language-Team: lv_LV \n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n != 0 ? 1 :" +" 2);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel {VERSION}\n" + +#. This will be a translator coment, +#. that will include several lines +#: project/file1.py:8 +msgid "bar" +msgstr "" + +#: project/file2.py:9 +msgid "foobar" +msgid_plural "foobars" +msgstr[0] "" +msgstr[1] "" +msgstr[2] "" + +""" + with open(po_file) as f: + actual_content = f.read() + assert expected_content == actual_content + + @freeze_time("1994-11-11") + def test_correct_init_singular_plural_forms(self): + self.cmd.input_file = 'project/i18n/messages.pot' + self.cmd.locale = 'ja_JP' + self.cmd.output_dir = 'project/i18n' + + self.cmd.finalize_options() + self.cmd.run() + + po_file = get_po_file_path('ja_JP') + assert os.path.isfile(po_file) + + date = format_datetime(datetime(1994, 11, 11, 00, 00), 'yyyy-MM-dd HH:mmZ', tzinfo=LOCALTZ, locale='ja_JP') + expected_content = fr"""# Japanese (Japan) translations for TestProject. +# Copyright (C) 2007 FooBar, Inc. +# This file is distributed under the same license as the TestProject +# project. +# FIRST AUTHOR , 2007. +# +msgid "" +msgstr "" +"Project-Id-Version: TestProject 0.1\n" +"Report-Msgid-Bugs-To: bugs.address@email.tld\n" +"POT-Creation-Date: 2007-04-01 15:30+0200\n" +"PO-Revision-Date: {date}\n" +"Last-Translator: FULL NAME \n" +"Language: ja_JP\n" +"Language-Team: ja_JP \n" +"Plural-Forms: nplurals=1; plural=0;\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel {VERSION}\n" + +#. This will be a translator coment, +#. that will include several lines +#: project/file1.py:8 +msgid "bar" +msgstr "" + +#: project/file2.py:9 +msgid "foobar" +msgid_plural "foobars" +msgstr[0] "" + +""" + with open(po_file) as f: + actual_content = f.read() + assert expected_content == actual_content + + @freeze_time("1994-11-11") + def test_supports_no_wrap(self): + self.cmd.input_file = 'project/i18n/long_messages.pot' + self.cmd.locale = 'en_US' + self.cmd.output_dir = 'project/i18n' + + long_message = '"' + 'xxxxx ' * 15 + '"' + + with open('project/i18n/messages.pot', 'rb') as f: + pot_contents = f.read().decode('latin-1') + pot_with_very_long_line = pot_contents.replace('"bar"', long_message) + with open(self.cmd.input_file, 'wb') as f: + f.write(pot_with_very_long_line.encode('latin-1')) + self.cmd.no_wrap = True + + self.cmd.finalize_options() + self.cmd.run() + + po_file = get_po_file_path('en_US') + assert os.path.isfile(po_file) + date = format_datetime(datetime(1994, 11, 11, 00, 00), 'yyyy-MM-dd HH:mmZ', tzinfo=LOCALTZ, locale='en_US') + expected_content = fr"""# English (United States) translations for TestProject. +# Copyright (C) 2007 FooBar, Inc. +# This file is distributed under the same license as the TestProject +# project. +# FIRST AUTHOR , 2007. +# +msgid "" +msgstr "" +"Project-Id-Version: TestProject 0.1\n" +"Report-Msgid-Bugs-To: bugs.address@email.tld\n" +"POT-Creation-Date: 2007-04-01 15:30+0200\n" +"PO-Revision-Date: {date}\n" +"Last-Translator: FULL NAME \n" +"Language: en_US\n" +"Language-Team: en_US \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel {VERSION}\n" + +#. This will be a translator coment, +#. that will include several lines +#: project/file1.py:8 +msgid {long_message} +msgstr "" + +#: project/file2.py:9 +msgid "foobar" +msgid_plural "foobars" +msgstr[0] "" +msgstr[1] "" + +""" + with open(po_file) as f: + actual_content = f.read() + assert expected_content == actual_content + + @freeze_time("1994-11-11") + def test_supports_width(self): + self.cmd.input_file = 'project/i18n/long_messages.pot' + self.cmd.locale = 'en_US' + self.cmd.output_dir = 'project/i18n' + + long_message = '"' + 'xxxxx ' * 15 + '"' + + with open('project/i18n/messages.pot', 'rb') as f: + pot_contents = f.read().decode('latin-1') + pot_with_very_long_line = pot_contents.replace('"bar"', long_message) + with open(self.cmd.input_file, 'wb') as f: + f.write(pot_with_very_long_line.encode('latin-1')) + self.cmd.width = 120 + self.cmd.finalize_options() + self.cmd.run() + + po_file = get_po_file_path('en_US') + assert os.path.isfile(po_file) + date = format_datetime(datetime(1994, 11, 11, 00, 00), 'yyyy-MM-dd HH:mmZ', tzinfo=LOCALTZ, locale='en_US') + expected_content = fr"""# English (United States) translations for TestProject. +# Copyright (C) 2007 FooBar, Inc. +# This file is distributed under the same license as the TestProject +# project. +# FIRST AUTHOR , 2007. +# +msgid "" +msgstr "" +"Project-Id-Version: TestProject 0.1\n" +"Report-Msgid-Bugs-To: bugs.address@email.tld\n" +"POT-Creation-Date: 2007-04-01 15:30+0200\n" +"PO-Revision-Date: {date}\n" +"Last-Translator: FULL NAME \n" +"Language: en_US\n" +"Language-Team: en_US \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel {VERSION}\n" + +#. This will be a translator coment, +#. that will include several lines +#: project/file1.py:8 +msgid {long_message} +msgstr "" + +#: project/file2.py:9 +msgid "foobar" +msgid_plural "foobars" +msgstr[0] "" +msgstr[1] "" + +""" + with open(po_file) as f: + actual_content = f.read() + assert expected_content == actual_content diff --git a/tests/messages/test_catalog.py b/tests/messages/test_catalog.py index 487419c5..191a2a49 100644 --- a/tests/messages/test_catalog.py +++ b/tests/messages/test_catalog.py @@ -13,7 +13,6 @@ import copy import datetime import pickle -import unittest from io import StringIO from babel.dates import UTC, format_datetime @@ -21,215 +20,228 @@ from babel.messages import catalog, pofile from babel.util import FixedOffsetTimezone -class MessageTestCase(unittest.TestCase): - - def test_python_format(self): - assert catalog.PYTHON_FORMAT.search('foo %d bar') - assert catalog.PYTHON_FORMAT.search('foo %s bar') - assert catalog.PYTHON_FORMAT.search('foo %r bar') - assert catalog.PYTHON_FORMAT.search('foo %(name).1f') - assert catalog.PYTHON_FORMAT.search('foo %(name)3.3f') - assert catalog.PYTHON_FORMAT.search('foo %(name)3f') - assert catalog.PYTHON_FORMAT.search('foo %(name)06d') - assert catalog.PYTHON_FORMAT.search('foo %(name)Li') - assert catalog.PYTHON_FORMAT.search('foo %(name)#d') - assert catalog.PYTHON_FORMAT.search('foo %(name)-4.4hs') - assert catalog.PYTHON_FORMAT.search('foo %(name)*.3f') - assert catalog.PYTHON_FORMAT.search('foo %(name).*f') - assert catalog.PYTHON_FORMAT.search('foo %(name)3.*f') - assert catalog.PYTHON_FORMAT.search('foo %(name)*.*f') - assert catalog.PYTHON_FORMAT.search('foo %()s') - - def test_python_brace_format(self): - assert not catalog._has_python_brace_format('') - assert not catalog._has_python_brace_format('foo') - assert not catalog._has_python_brace_format('{') - assert not catalog._has_python_brace_format('}') - assert not catalog._has_python_brace_format('{} {') - assert not catalog._has_python_brace_format('{{}}') - assert catalog._has_python_brace_format('{}') - assert catalog._has_python_brace_format('foo {name}') - assert catalog._has_python_brace_format('foo {name!s}') - assert catalog._has_python_brace_format('foo {name!r}') - assert catalog._has_python_brace_format('foo {name!a}') - assert catalog._has_python_brace_format('foo {name!r:10}') - assert catalog._has_python_brace_format('foo {name!r:10.2}') - assert catalog._has_python_brace_format('foo {name!r:10.2f}') - assert catalog._has_python_brace_format('foo {name!r:10.2f} {name!r:10.2f}') - assert catalog._has_python_brace_format('foo {name!r:10.2f=}') - - def test_translator_comments(self): - mess = catalog.Message('foo', user_comments=['Comment About `foo`']) - assert mess.user_comments == ['Comment About `foo`'] - mess = catalog.Message('foo', - auto_comments=['Comment 1 About `foo`', - 'Comment 2 About `foo`']) - assert mess.auto_comments == ['Comment 1 About `foo`', 'Comment 2 About `foo`'] - - def test_clone_message_object(self): - msg = catalog.Message('foo', locations=[('foo.py', 42)]) - clone = msg.clone() - clone.locations.append(('bar.py', 42)) - assert msg.locations == [('foo.py', 42)] - msg.flags.add('fuzzy') - assert not clone.fuzzy and msg.fuzzy - - -class CatalogTestCase(unittest.TestCase): - - def test_add_returns_message_instance(self): - cat = catalog.Catalog() - message = cat.add('foo') - assert message.id == 'foo' - - def test_two_messages_with_same_singular(self): - cat = catalog.Catalog() - cat.add('foo') - cat.add(('foo', 'foos')) - assert len(cat) == 1 - - def test_duplicate_auto_comment(self): - cat = catalog.Catalog() - cat.add('foo', auto_comments=['A comment']) - cat.add('foo', auto_comments=['A comment', 'Another comment']) - assert cat['foo'].auto_comments == ['A comment', 'Another comment'] - - def test_duplicate_user_comment(self): - cat = catalog.Catalog() - cat.add('foo', user_comments=['A comment']) - cat.add('foo', user_comments=['A comment', 'Another comment']) - assert cat['foo'].user_comments == ['A comment', 'Another comment'] - - def test_duplicate_location(self): - cat = catalog.Catalog() - cat.add('foo', locations=[('foo.py', 1)]) - cat.add('foo', locations=[('foo.py', 1)]) - assert cat['foo'].locations == [('foo.py', 1)] - - def test_update_message_changed_to_plural(self): - cat = catalog.Catalog() - cat.add('foo', 'Voh') - tmpl = catalog.Catalog() - tmpl.add(('foo', 'foos')) - cat.update(tmpl) - assert cat['foo'].string == ('Voh', '') - assert cat['foo'].fuzzy - - def test_update_message_changed_to_simple(self): - cat = catalog.Catalog() - cat.add('foo' 'foos', ('Voh', 'Vöhs')) - tmpl = catalog.Catalog() - tmpl.add('foo') - cat.update(tmpl) - assert cat['foo'].string == 'Voh' - assert cat['foo'].fuzzy - - def test_update_message_updates_comments(self): - cat = catalog.Catalog() - cat['foo'] = catalog.Message('foo', locations=[('main.py', 5)]) - assert cat['foo'].auto_comments == [] - assert cat['foo'].user_comments == [] - # Update cat['foo'] with a new location and a comment - cat['foo'] = catalog.Message('foo', locations=[('main.py', 7)], - user_comments=['Foo Bar comment 1']) - assert cat['foo'].user_comments == ['Foo Bar comment 1'] - # now add yet another location with another comment - cat['foo'] = catalog.Message('foo', locations=[('main.py', 9)], - auto_comments=['Foo Bar comment 2']) - assert cat['foo'].auto_comments == ['Foo Bar comment 2'] - - def test_update_fuzzy_matching_with_case_change(self): - cat = catalog.Catalog() - cat.add('FOO', 'Voh') - cat.add('bar', 'Bahr') - tmpl = catalog.Catalog() - tmpl.add('foo') - cat.update(tmpl) - assert len(cat.obsolete) == 1 - assert 'FOO' not in cat - - assert cat['foo'].string == 'Voh' - assert cat['foo'].fuzzy is True - - def test_update_fuzzy_matching_with_char_change(self): - cat = catalog.Catalog() - cat.add('fo', 'Voh') - cat.add('bar', 'Bahr') - tmpl = catalog.Catalog() - tmpl.add('foo') - cat.update(tmpl) - assert len(cat.obsolete) == 1 - assert 'fo' not in cat - - assert cat['foo'].string == 'Voh' - assert cat['foo'].fuzzy is True - - def test_update_fuzzy_matching_no_msgstr(self): - cat = catalog.Catalog() - cat.add('fo', '') - tmpl = catalog.Catalog() - tmpl.add('fo') - tmpl.add('foo') - cat.update(tmpl) - assert 'fo' in cat - assert 'foo' in cat - - assert cat['fo'].string == '' - assert cat['fo'].fuzzy is False - assert cat['foo'].string is None - assert cat['foo'].fuzzy is False - - def test_update_fuzzy_matching_with_new_context(self): - cat = catalog.Catalog() - cat.add('foo', 'Voh') - cat.add('bar', 'Bahr') - tmpl = catalog.Catalog() - tmpl.add('Foo', context='Menu') - cat.update(tmpl) - assert len(cat.obsolete) == 1 - assert 'foo' not in cat - - message = cat.get('Foo', 'Menu') - assert message.string == 'Voh' - assert message.fuzzy is True - assert message.context == 'Menu' - - def test_update_fuzzy_matching_with_changed_context(self): - cat = catalog.Catalog() - cat.add('foo', 'Voh', context='Menu|File') - cat.add('bar', 'Bahr', context='Menu|File') - tmpl = catalog.Catalog() - tmpl.add('Foo', context='Menu|Edit') - cat.update(tmpl) - assert len(cat.obsolete) == 1 - assert cat.get('Foo', 'Menu|File') is None - - message = cat.get('Foo', 'Menu|Edit') - assert message.string == 'Voh' - assert message.fuzzy is True - assert message.context == 'Menu|Edit' - - def test_update_fuzzy_matching_no_cascading(self): - cat = catalog.Catalog() - cat.add('fo', 'Voh') - cat.add('foo', 'Vohe') - tmpl = catalog.Catalog() - tmpl.add('fo') - tmpl.add('foo') - tmpl.add('fooo') - cat.update(tmpl) - assert 'fo' in cat - assert 'foo' in cat - - assert cat['fo'].string == 'Voh' - assert cat['fo'].fuzzy is False - assert cat['foo'].string == 'Vohe' - assert cat['foo'].fuzzy is False - assert cat['fooo'].string == 'Vohe' - assert cat['fooo'].fuzzy is True - - def test_update_fuzzy_matching_long_string(self): - lipsum = "\ +def test_message_python_format(): + assert catalog.PYTHON_FORMAT.search('foo %d bar') + assert catalog.PYTHON_FORMAT.search('foo %s bar') + assert catalog.PYTHON_FORMAT.search('foo %r bar') + assert catalog.PYTHON_FORMAT.search('foo %(name).1f') + assert catalog.PYTHON_FORMAT.search('foo %(name)3.3f') + assert catalog.PYTHON_FORMAT.search('foo %(name)3f') + assert catalog.PYTHON_FORMAT.search('foo %(name)06d') + assert catalog.PYTHON_FORMAT.search('foo %(name)Li') + assert catalog.PYTHON_FORMAT.search('foo %(name)#d') + assert catalog.PYTHON_FORMAT.search('foo %(name)-4.4hs') + assert catalog.PYTHON_FORMAT.search('foo %(name)*.3f') + assert catalog.PYTHON_FORMAT.search('foo %(name).*f') + assert catalog.PYTHON_FORMAT.search('foo %(name)3.*f') + assert catalog.PYTHON_FORMAT.search('foo %(name)*.*f') + assert catalog.PYTHON_FORMAT.search('foo %()s') + + +def test_message_python_brace_format(): + assert not catalog._has_python_brace_format('') + assert not catalog._has_python_brace_format('foo') + assert not catalog._has_python_brace_format('{') + assert not catalog._has_python_brace_format('}') + assert not catalog._has_python_brace_format('{} {') + assert not catalog._has_python_brace_format('{{}}') + assert catalog._has_python_brace_format('{}') + assert catalog._has_python_brace_format('foo {name}') + assert catalog._has_python_brace_format('foo {name!s}') + assert catalog._has_python_brace_format('foo {name!r}') + assert catalog._has_python_brace_format('foo {name!a}') + assert catalog._has_python_brace_format('foo {name!r:10}') + assert catalog._has_python_brace_format('foo {name!r:10.2}') + assert catalog._has_python_brace_format('foo {name!r:10.2f}') + assert catalog._has_python_brace_format('foo {name!r:10.2f} {name!r:10.2f}') + assert catalog._has_python_brace_format('foo {name!r:10.2f=}') + + +def test_message_translator_comments(): + mess = catalog.Message('foo', user_comments=['Comment About `foo`']) + assert mess.user_comments == ['Comment About `foo`'] + mess = catalog.Message('foo', + auto_comments=['Comment 1 About `foo`', + 'Comment 2 About `foo`']) + assert mess.auto_comments == ['Comment 1 About `foo`', 'Comment 2 About `foo`'] + + +def test_message_clone_message_object(): + msg = catalog.Message('foo', locations=[('foo.py', 42)]) + clone = msg.clone() + clone.locations.append(('bar.py', 42)) + assert msg.locations == [('foo.py', 42)] + msg.flags.add('fuzzy') + assert not clone.fuzzy and msg.fuzzy + + +def test_catalog_add_returns_message_instance(): + cat = catalog.Catalog() + message = cat.add('foo') + assert message.id == 'foo' + + +def test_catalog_two_messages_with_same_singular(): + cat = catalog.Catalog() + cat.add('foo') + cat.add(('foo', 'foos')) + assert len(cat) == 1 + + +def test_catalog_duplicate_auto_comment(): + cat = catalog.Catalog() + cat.add('foo', auto_comments=['A comment']) + cat.add('foo', auto_comments=['A comment', 'Another comment']) + assert cat['foo'].auto_comments == ['A comment', 'Another comment'] + + +def test_catalog_duplicate_user_comment(): + cat = catalog.Catalog() + cat.add('foo', user_comments=['A comment']) + cat.add('foo', user_comments=['A comment', 'Another comment']) + assert cat['foo'].user_comments == ['A comment', 'Another comment'] + + +def test_catalog_duplicate_location(): + cat = catalog.Catalog() + cat.add('foo', locations=[('foo.py', 1)]) + cat.add('foo', locations=[('foo.py', 1)]) + assert cat['foo'].locations == [('foo.py', 1)] + + +def test_catalog_update_message_changed_to_plural(): + cat = catalog.Catalog() + cat.add('foo', 'Voh') + tmpl = catalog.Catalog() + tmpl.add(('foo', 'foos')) + cat.update(tmpl) + assert cat['foo'].string == ('Voh', '') + assert cat['foo'].fuzzy + + +def test_catalog_update_message_changed_to_simple(): + cat = catalog.Catalog() + cat.add('foo' 'foos', ('Voh', 'Vöhs')) + tmpl = catalog.Catalog() + tmpl.add('foo') + cat.update(tmpl) + assert cat['foo'].string == 'Voh' + assert cat['foo'].fuzzy + + +def test_catalog_update_message_updates_comments(): + cat = catalog.Catalog() + cat['foo'] = catalog.Message('foo', locations=[('main.py', 5)]) + assert cat['foo'].auto_comments == [] + assert cat['foo'].user_comments == [] + # Update cat['foo'] with a new location and a comment + cat['foo'] = catalog.Message('foo', locations=[('main.py', 7)], + user_comments=['Foo Bar comment 1']) + assert cat['foo'].user_comments == ['Foo Bar comment 1'] + # now add yet another location with another comment + cat['foo'] = catalog.Message('foo', locations=[('main.py', 9)], + auto_comments=['Foo Bar comment 2']) + assert cat['foo'].auto_comments == ['Foo Bar comment 2'] + + +def test_catalog_update_fuzzy_matching_with_case_change(): + cat = catalog.Catalog() + cat.add('FOO', 'Voh') + cat.add('bar', 'Bahr') + tmpl = catalog.Catalog() + tmpl.add('foo') + cat.update(tmpl) + assert len(cat.obsolete) == 1 + assert 'FOO' not in cat + + assert cat['foo'].string == 'Voh' + assert cat['foo'].fuzzy is True + + +def test_catalog_update_fuzzy_matching_with_char_change(): + cat = catalog.Catalog() + cat.add('fo', 'Voh') + cat.add('bar', 'Bahr') + tmpl = catalog.Catalog() + tmpl.add('foo') + cat.update(tmpl) + assert len(cat.obsolete) == 1 + assert 'fo' not in cat + + assert cat['foo'].string == 'Voh' + assert cat['foo'].fuzzy is True + + +def test_catalog_update_fuzzy_matching_no_msgstr(): + cat = catalog.Catalog() + cat.add('fo', '') + tmpl = catalog.Catalog() + tmpl.add('fo') + tmpl.add('foo') + cat.update(tmpl) + assert 'fo' in cat + assert 'foo' in cat + + assert cat['fo'].string == '' + assert cat['fo'].fuzzy is False + assert cat['foo'].string is None + assert cat['foo'].fuzzy is False + + +def test_catalog_update_fuzzy_matching_with_new_context(): + cat = catalog.Catalog() + cat.add('foo', 'Voh') + cat.add('bar', 'Bahr') + tmpl = catalog.Catalog() + tmpl.add('Foo', context='Menu') + cat.update(tmpl) + assert len(cat.obsolete) == 1 + assert 'foo' not in cat + + message = cat.get('Foo', 'Menu') + assert message.string == 'Voh' + assert message.fuzzy is True + assert message.context == 'Menu' + + +def test_catalog_update_fuzzy_matching_with_changed_context(): + cat = catalog.Catalog() + cat.add('foo', 'Voh', context='Menu|File') + cat.add('bar', 'Bahr', context='Menu|File') + tmpl = catalog.Catalog() + tmpl.add('Foo', context='Menu|Edit') + cat.update(tmpl) + assert len(cat.obsolete) == 1 + assert cat.get('Foo', 'Menu|File') is None + + message = cat.get('Foo', 'Menu|Edit') + assert message.string == 'Voh' + assert message.fuzzy is True + assert message.context == 'Menu|Edit' + + +def test_catalog_update_fuzzy_matching_no_cascading(): + cat = catalog.Catalog() + cat.add('fo', 'Voh') + cat.add('foo', 'Vohe') + tmpl = catalog.Catalog() + tmpl.add('fo') + tmpl.add('foo') + tmpl.add('fooo') + cat.update(tmpl) + assert 'fo' in cat + assert 'foo' in cat + + assert cat['fo'].string == 'Voh' + assert cat['fo'].fuzzy is False + assert cat['foo'].string == 'Vohe' + assert cat['foo'].fuzzy is False + assert cat['fooo'].string == 'Vohe' + assert cat['fooo'].fuzzy is True + + +def test_catalog_update_fuzzy_matching_long_string(): + lipsum = "\ Lorem Ipsum is simply dummy text of the printing and typesetting \ industry. Lorem Ipsum has been the industry's standard dummy text ever \ since the 1500s, when an unknown printer took a galley of type and \ @@ -239,113 +251,121 @@ remaining essentially unchanged. It was popularised in the 1960s with \ the release of Letraset sheets containing Lorem Ipsum passages, and \ more recently with desktop publishing software like Aldus PageMaker \ including versions of Lorem Ipsum." - cat = catalog.Catalog() - cat.add("ZZZZZZ " + lipsum, "foo") - tmpl = catalog.Catalog() - tmpl.add(lipsum + " ZZZZZZ") - cat.update(tmpl) - assert cat[lipsum + " ZZZZZZ"].fuzzy is True - assert len(cat.obsolete) == 0 - - def test_update_without_fuzzy_matching(self): - cat = catalog.Catalog() - cat.add('fo', 'Voh') - cat.add('bar', 'Bahr') - tmpl = catalog.Catalog() - tmpl.add('foo') - cat.update(tmpl, no_fuzzy_matching=True) - assert len(cat.obsolete) == 2 - - def test_fuzzy_matching_regarding_plurals(self): - cat = catalog.Catalog() - cat.add(('foo', 'foh'), ('foo', 'foh')) - ru = copy.copy(cat) - ru.locale = 'ru_RU' - ru.update(cat) - assert ru['foo'].fuzzy is True - ru = copy.copy(cat) - ru.locale = 'ru_RU' - ru['foo'].string = ('foh', 'fohh', 'fohhh') - ru.update(cat) - assert ru['foo'].fuzzy is False - - def test_update_no_template_mutation(self): - tmpl = catalog.Catalog() - tmpl.add('foo') - cat1 = catalog.Catalog() - cat1.add('foo', 'Voh') - cat1.update(tmpl) - cat2 = catalog.Catalog() - cat2.update(tmpl) - - assert cat2['foo'].string is None - assert cat2['foo'].fuzzy is False - - def test_update_po_updates_pot_creation_date(self): - template = catalog.Catalog() - localized_catalog = copy.deepcopy(template) - localized_catalog.locale = 'de_DE' - assert template.mime_headers != localized_catalog.mime_headers - assert template.creation_date == localized_catalog.creation_date - template.creation_date = datetime.datetime.now() - \ - datetime.timedelta(minutes=5) - localized_catalog.update(template) - assert template.creation_date == localized_catalog.creation_date - - def test_update_po_ignores_pot_creation_date(self): - template = catalog.Catalog() - localized_catalog = copy.deepcopy(template) - localized_catalog.locale = 'de_DE' - assert template.mime_headers != localized_catalog.mime_headers - assert template.creation_date == localized_catalog.creation_date - template.creation_date = datetime.datetime.now() - \ - datetime.timedelta(minutes=5) - localized_catalog.update(template, update_creation_date=False) - assert template.creation_date != localized_catalog.creation_date - - def test_update_po_keeps_po_revision_date(self): - template = catalog.Catalog() - localized_catalog = copy.deepcopy(template) - localized_catalog.locale = 'de_DE' - fake_rev_date = datetime.datetime.now() - datetime.timedelta(days=5) - localized_catalog.revision_date = fake_rev_date - assert template.mime_headers != localized_catalog.mime_headers - assert template.creation_date == localized_catalog.creation_date - template.creation_date = datetime.datetime.now() - \ - datetime.timedelta(minutes=5) - localized_catalog.update(template) - assert localized_catalog.revision_date == fake_rev_date - - def test_stores_datetime_correctly(self): - localized = catalog.Catalog() - localized.locale = 'de_DE' - localized[''] = catalog.Message('', - "POT-Creation-Date: 2009-03-09 15:47-0700\n" + - "PO-Revision-Date: 2009-03-09 15:47-0700\n") - for key, value in localized.mime_headers: - if key in ('POT-Creation-Date', 'PO-Revision-Date'): - assert value == '2009-03-09 15:47-0700' - - def test_mime_headers_contain_same_information_as_attributes(self): - cat = catalog.Catalog() - cat[''] = catalog.Message('', - "Last-Translator: Foo Bar \n" + - "Language-Team: de \n" + - "POT-Creation-Date: 2009-03-01 11:20+0200\n" + - "PO-Revision-Date: 2009-03-09 15:47-0700\n") - assert cat.locale is None - mime_headers = dict(cat.mime_headers) - - assert cat.last_translator == 'Foo Bar ' - assert mime_headers['Last-Translator'] == 'Foo Bar ' - - assert cat.language_team == 'de ' - assert mime_headers['Language-Team'] == 'de ' - - dt = datetime.datetime(2009, 3, 9, 15, 47, tzinfo=FixedOffsetTimezone(-7 * 60)) - assert cat.revision_date == dt - formatted_dt = format_datetime(dt, 'yyyy-MM-dd HH:mmZ', locale='en') - assert mime_headers['PO-Revision-Date'] == formatted_dt + cat = catalog.Catalog() + cat.add("ZZZZZZ " + lipsum, "foo") + tmpl = catalog.Catalog() + tmpl.add(lipsum + " ZZZZZZ") + cat.update(tmpl) + assert cat[lipsum + " ZZZZZZ"].fuzzy is True + assert len(cat.obsolete) == 0 + + +def test_catalog_update_without_fuzzy_matching(): + cat = catalog.Catalog() + cat.add('fo', 'Voh') + cat.add('bar', 'Bahr') + tmpl = catalog.Catalog() + tmpl.add('foo') + cat.update(tmpl, no_fuzzy_matching=True) + assert len(cat.obsolete) == 2 + + +def test_catalog_fuzzy_matching_regarding_plurals(): + cat = catalog.Catalog() + cat.add(('foo', 'foh'), ('foo', 'foh')) + ru = copy.copy(cat) + ru.locale = 'ru_RU' + ru.update(cat) + assert ru['foo'].fuzzy is True + ru = copy.copy(cat) + ru.locale = 'ru_RU' + ru['foo'].string = ('foh', 'fohh', 'fohhh') + ru.update(cat) + assert ru['foo'].fuzzy is False + + +def test_catalog_update_no_template_mutation(): + tmpl = catalog.Catalog() + tmpl.add('foo') + cat1 = catalog.Catalog() + cat1.add('foo', 'Voh') + cat1.update(tmpl) + cat2 = catalog.Catalog() + cat2.update(tmpl) + + assert cat2['foo'].string is None + assert cat2['foo'].fuzzy is False + + +def test_catalog_update_po_updates_pot_creation_date(): + template = catalog.Catalog() + localized_catalog = copy.deepcopy(template) + localized_catalog.locale = 'de_DE' + assert template.mime_headers != localized_catalog.mime_headers + assert template.creation_date == localized_catalog.creation_date + template.creation_date = datetime.datetime.now() - \ + datetime.timedelta(minutes=5) + localized_catalog.update(template) + assert template.creation_date == localized_catalog.creation_date + + +def test_catalog_update_po_ignores_pot_creation_date(): + template = catalog.Catalog() + localized_catalog = copy.deepcopy(template) + localized_catalog.locale = 'de_DE' + assert template.mime_headers != localized_catalog.mime_headers + assert template.creation_date == localized_catalog.creation_date + template.creation_date = datetime.datetime.now() - \ + datetime.timedelta(minutes=5) + localized_catalog.update(template, update_creation_date=False) + assert template.creation_date != localized_catalog.creation_date + + +def test_catalog_update_po_keeps_po_revision_date(): + template = catalog.Catalog() + localized_catalog = copy.deepcopy(template) + localized_catalog.locale = 'de_DE' + fake_rev_date = datetime.datetime.now() - datetime.timedelta(days=5) + localized_catalog.revision_date = fake_rev_date + assert template.mime_headers != localized_catalog.mime_headers + assert template.creation_date == localized_catalog.creation_date + template.creation_date = datetime.datetime.now() - \ + datetime.timedelta(minutes=5) + localized_catalog.update(template) + assert localized_catalog.revision_date == fake_rev_date + + +def test_catalog_stores_datetime_correctly(): + localized = catalog.Catalog() + localized.locale = 'de_DE' + localized[''] = catalog.Message('', + "POT-Creation-Date: 2009-03-09 15:47-0700\n" + + "PO-Revision-Date: 2009-03-09 15:47-0700\n") + for key, value in localized.mime_headers: + if key in ('POT-Creation-Date', 'PO-Revision-Date'): + assert value == '2009-03-09 15:47-0700' + + +def test_catalog_mime_headers_contain_same_information_as_attributes(): + cat = catalog.Catalog() + cat[''] = catalog.Message('', + "Last-Translator: Foo Bar \n" + + "Language-Team: de \n" + + "POT-Creation-Date: 2009-03-01 11:20+0200\n" + + "PO-Revision-Date: 2009-03-09 15:47-0700\n") + assert cat.locale is None + mime_headers = dict(cat.mime_headers) + + assert cat.last_translator == 'Foo Bar ' + assert mime_headers['Last-Translator'] == 'Foo Bar ' + + assert cat.language_team == 'de ' + assert mime_headers['Language-Team'] == 'de ' + + dt = datetime.datetime(2009, 3, 9, 15, 47, tzinfo=FixedOffsetTimezone(-7 * 60)) + assert cat.revision_date == dt + formatted_dt = format_datetime(dt, 'yyyy-MM-dd HH:mmZ', locale='en') + assert mime_headers['PO-Revision-Date'] == formatted_dt def test_message_fuzzy(): @@ -360,14 +380,14 @@ def test_message_pluralizable(): assert catalog.Message(('foo', 'bar')).pluralizable -def test_message_python_format(): +def test_message_python_format_2(): assert not catalog.Message('foo').python_format assert not catalog.Message(('foo', 'foo')).python_format assert catalog.Message('foo %(name)s bar').python_format assert catalog.Message(('foo %(name)s', 'foo %(name)s')).python_format -def test_message_python_brace_format(): +def test_message_python_brace_format_2(): assert not catalog.Message('foo').python_brace_format assert not catalog.Message(('foo', 'foo')).python_brace_format assert catalog.Message('foo {name} bar').python_brace_format diff --git a/tests/messages/test_checkers.py b/tests/messages/test_checkers.py index e4559f7e..8d4b1a77 100644 --- a/tests/messages/test_checkers.py +++ b/tests/messages/test_checkers.py @@ -10,7 +10,6 @@ # individuals. For the exact contribution history, see the revision # history and logs, available at https://github.com/python-babel/babel/commits/master/. -import unittest from datetime import datetime from io import BytesIO @@ -26,20 +25,19 @@ from babel.messages.plurals import PLURALS from babel.messages.pofile import read_po from babel.util import LOCALTZ +# the last msgstr[idx] is always missing except for singular plural forms -class CheckersTestCase(unittest.TestCase): - # the last msgstr[idx] is always missing except for singular plural forms - def test_1_num_plurals_checkers(self): - for _locale in [p for p in PLURALS if PLURALS[p][0] == 1]: - try: - locale = Locale.parse(_locale) - except UnknownLocaleError: - # Just an alias? Not what we're testing here, let's continue - continue - date = format_datetime(datetime.now(LOCALTZ), 'yyyy-MM-dd HH:mmZ', tzinfo=LOCALTZ, locale=_locale) - plural = PLURALS[_locale][0] - po_file = (f"""\ +def test_1_num_plurals_checkers(): + for _locale in [p for p in PLURALS if PLURALS[p][0] == 1]: + try: + locale = Locale.parse(_locale) + except UnknownLocaleError: + # Just an alias? Not what we're testing here, let's continue + continue + date = format_datetime(datetime.now(LOCALTZ), 'yyyy-MM-dd HH:mmZ', tzinfo=LOCALTZ, locale=_locale) + plural = PLURALS[_locale][0] + po_file = (f"""\ # {locale.english_name} translations for TestProject. # Copyright (C) 2007 FooBar, Inc. # This file is distributed under the same license as the TestProject @@ -73,32 +71,33 @@ msgstr[0] "" """).encode('utf-8') - # This test will fail for revisions <= 406 because so far - # catalog.num_plurals was neglected - catalog = read_po(BytesIO(po_file), _locale) - message = catalog['foobar'] - checkers.num_plurals(catalog, message) - - def test_2_num_plurals_checkers(self): - # in this testcase we add an extra msgstr[idx], we should be - # disregarding it - for _locale in [p for p in PLURALS if PLURALS[p][0] == 2]: - if _locale in ['nn', 'no']: - _locale = 'nn_NO' - num_plurals = PLURALS[_locale.split('_')[0]][0] - plural_expr = PLURALS[_locale.split('_')[0]][1] - else: - num_plurals = PLURALS[_locale][0] - plural_expr = PLURALS[_locale][1] - try: - locale = Locale(_locale) - date = format_datetime(datetime.now(LOCALTZ), - 'yyyy-MM-dd HH:mmZ', - tzinfo=LOCALTZ, locale=_locale) - except UnknownLocaleError: - # Just an alias? Not what we're testing here, let's continue - continue - po_file = f"""\ + # This test will fail for revisions <= 406 because so far + # catalog.num_plurals was neglected + catalog = read_po(BytesIO(po_file), _locale) + message = catalog['foobar'] + checkers.num_plurals(catalog, message) + + +def test_2_num_plurals_checkers(): + # in this testcase we add an extra msgstr[idx], we should be + # disregarding it + for _locale in [p for p in PLURALS if PLURALS[p][0] == 2]: + if _locale in ['nn', 'no']: + _locale = 'nn_NO' + num_plurals = PLURALS[_locale.split('_')[0]][0] + plural_expr = PLURALS[_locale.split('_')[0]][1] + else: + num_plurals = PLURALS[_locale][0] + plural_expr = PLURALS[_locale][1] + try: + locale = Locale(_locale) + date = format_datetime(datetime.now(LOCALTZ), + 'yyyy-MM-dd HH:mmZ', + tzinfo=LOCALTZ, locale=_locale) + except UnknownLocaleError: + # Just an alias? Not what we're testing here, let's continue + continue + po_file = f"""\ # {locale.english_name} translations for TestProject. # Copyright (C) 2007 FooBar, Inc. # This file is distributed under the same license as the TestProject @@ -133,19 +132,20 @@ msgstr[1] "" msgstr[2] "" """.encode('utf-8') - # we should be adding the missing msgstr[0] - - # This test will fail for revisions <= 406 because so far - # catalog.num_plurals was neglected - catalog = read_po(BytesIO(po_file), _locale) - message = catalog['foobar'] - checkers.num_plurals(catalog, message) - - def test_3_num_plurals_checkers(self): - for _locale in [p for p in PLURALS if PLURALS[p][0] == 3]: - plural = format_datetime(datetime.now(LOCALTZ), 'yyyy-MM-dd HH:mmZ', tzinfo=LOCALTZ, locale=_locale) - english_name = Locale.parse(_locale).english_name - po_file = fr"""\ + # we should be adding the missing msgstr[0] + + # This test will fail for revisions <= 406 because so far + # catalog.num_plurals was neglected + catalog = read_po(BytesIO(po_file), _locale) + message = catalog['foobar'] + checkers.num_plurals(catalog, message) + + +def test_3_num_plurals_checkers(): + for _locale in [p for p in PLURALS if PLURALS[p][0] == 3]: + plural = format_datetime(datetime.now(LOCALTZ), 'yyyy-MM-dd HH:mmZ', tzinfo=LOCALTZ, locale=_locale) + english_name = Locale.parse(_locale).english_name + po_file = fr"""\ # {english_name} translations for TestProject. # Copyright (C) 2007 FooBar, Inc. # This file is distributed under the same license as the TestProject @@ -180,18 +180,19 @@ msgstr[1] "" """.encode('utf-8') - # This test will fail for revisions <= 406 because so far - # catalog.num_plurals was neglected - catalog = read_po(BytesIO(po_file), _locale) - message = catalog['foobar'] - checkers.num_plurals(catalog, message) - - def test_4_num_plurals_checkers(self): - for _locale in [p for p in PLURALS if PLURALS[p][0] == 4]: - date = format_datetime(datetime.now(LOCALTZ), 'yyyy-MM-dd HH:mmZ', tzinfo=LOCALTZ, locale=_locale) - english_name = Locale.parse(_locale).english_name - plural = PLURALS[_locale][0] - po_file = fr"""\ + # This test will fail for revisions <= 406 because so far + # catalog.num_plurals was neglected + catalog = read_po(BytesIO(po_file), _locale) + message = catalog['foobar'] + checkers.num_plurals(catalog, message) + + +def test_4_num_plurals_checkers(): + for _locale in [p for p in PLURALS if PLURALS[p][0] == 4]: + date = format_datetime(datetime.now(LOCALTZ), 'yyyy-MM-dd HH:mmZ', tzinfo=LOCALTZ, locale=_locale) + english_name = Locale.parse(_locale).english_name + plural = PLURALS[_locale][0] + po_file = fr"""\ # {english_name} translations for TestProject. # Copyright (C) 2007 FooBar, Inc. # This file is distributed under the same license as the TestProject @@ -227,18 +228,19 @@ msgstr[2] "" """.encode('utf-8') - # This test will fail for revisions <= 406 because so far - # catalog.num_plurals was neglected - catalog = read_po(BytesIO(po_file), _locale) - message = catalog['foobar'] - checkers.num_plurals(catalog, message) - - def test_5_num_plurals_checkers(self): - for _locale in [p for p in PLURALS if PLURALS[p][0] == 5]: - date = format_datetime(datetime.now(LOCALTZ), 'yyyy-MM-dd HH:mmZ', tzinfo=LOCALTZ, locale=_locale) - english_name = Locale.parse(_locale).english_name - plural = PLURALS[_locale][0] - po_file = fr"""\ + # This test will fail for revisions <= 406 because so far + # catalog.num_plurals was neglected + catalog = read_po(BytesIO(po_file), _locale) + message = catalog['foobar'] + checkers.num_plurals(catalog, message) + + +def test_5_num_plurals_checkers(): + for _locale in [p for p in PLURALS if PLURALS[p][0] == 5]: + date = format_datetime(datetime.now(LOCALTZ), 'yyyy-MM-dd HH:mmZ', tzinfo=LOCALTZ, locale=_locale) + english_name = Locale.parse(_locale).english_name + plural = PLURALS[_locale][0] + po_file = fr"""\ # {english_name} translations for TestProject. # Copyright (C) 2007 FooBar, Inc. # This file is distributed under the same license as the TestProject @@ -275,18 +277,19 @@ msgstr[3] "" """.encode('utf-8') - # This test will fail for revisions <= 406 because so far - # catalog.num_plurals was neglected - catalog = read_po(BytesIO(po_file), _locale) - message = catalog['foobar'] - checkers.num_plurals(catalog, message) - - def test_6_num_plurals_checkers(self): - for _locale in [p for p in PLURALS if PLURALS[p][0] == 6]: - english_name = Locale.parse(_locale).english_name - date = format_datetime(datetime.now(LOCALTZ), 'yyyy-MM-dd HH:mmZ', tzinfo=LOCALTZ, locale=_locale) - plural = PLURALS[_locale][0] - po_file = fr"""\ + # This test will fail for revisions <= 406 because so far + # catalog.num_plurals was neglected + catalog = read_po(BytesIO(po_file), _locale) + message = catalog['foobar'] + checkers.num_plurals(catalog, message) + + +def test_6_num_plurals_checkers(): + for _locale in [p for p in PLURALS if PLURALS[p][0] == 6]: + english_name = Locale.parse(_locale).english_name + date = format_datetime(datetime.now(LOCALTZ), 'yyyy-MM-dd HH:mmZ', tzinfo=LOCALTZ, locale=_locale) + plural = PLURALS[_locale][0] + po_file = fr"""\ # {english_name} translations for TestProject. # Copyright (C) 2007 FooBar, Inc. # This file is distributed under the same license as the TestProject @@ -324,70 +327,72 @@ msgstr[4] "" """.encode('utf-8') - # This test will fail for revisions <= 406 because so far - # catalog.num_plurals was neglected - catalog = read_po(BytesIO(po_file), _locale) - message = catalog['foobar'] - checkers.num_plurals(catalog, message) - - -class TestPythonFormat: - @pytest.mark.parametrize(('msgid', 'msgstr'), [ - ('foo %s', 'foo'), - (('foo %s', 'bar'), ('foo', 'bar')), - (('foo', 'bar %s'), ('foo', 'bar')), - (('foo %s', 'bar'), ('foo')), - (('foo %s', 'bar %d'), ('foo %s', 'bar %d', 'baz')), - (('foo %s', 'bar %d'), ('foo %s', 'bar %d', 'baz %d', 'qux')), - ]) - def test_python_format_invalid(self, msgid, msgstr): - msg = Message(msgid, msgstr) - with pytest.raises(TranslationError): - python_format(None, msg) - - @pytest.mark.parametrize(('msgid', 'msgstr'), [ - ('foo', 'foo'), - ('foo', 'foo %s'), - ('foo %s', ''), - (('foo %s', 'bar %d'), ('foo %s', 'bar %d')), - (('foo %s', 'bar %d'), ('foo %s', 'bar %d', 'baz %d')), - (('foo', 'bar %s'), ('foo')), - (('foo', 'bar %s'), ('', '')), - (('foo', 'bar %s'), ('foo', '')), - (('foo %s', 'bar %d'), ('foo %s', '')), - ]) - def test_python_format_valid(self, msgid, msgstr): - msg = Message(msgid, msgstr) + # This test will fail for revisions <= 406 because so far + # catalog.num_plurals was neglected + catalog = read_po(BytesIO(po_file), _locale) + message = catalog['foobar'] + checkers.num_plurals(catalog, message) + + +@pytest.mark.parametrize(('msgid', 'msgstr'), [ + ('foo %s', 'foo'), + (('foo %s', 'bar'), ('foo', 'bar')), + (('foo', 'bar %s'), ('foo', 'bar')), + (('foo %s', 'bar'), ('foo')), + (('foo %s', 'bar %d'), ('foo %s', 'bar %d', 'baz')), + (('foo %s', 'bar %d'), ('foo %s', 'bar %d', 'baz %d', 'qux')), +]) +def test_python_format_invalid(msgid, msgstr): + msg = Message(msgid, msgstr) + with pytest.raises(TranslationError): python_format(None, msg) - @pytest.mark.parametrize(('msgid', 'msgstr', 'error'), [ - ('%s %(foo)s', '%s %(foo)s', 'format string mixes positional and named placeholders'), - ('foo %s', 'foo', 'placeholders are incompatible'), - ('%s', '%(foo)s', 'the format strings are of different kinds'), - ('%s', '%s %d', 'positional format placeholders are unbalanced'), - ('%s', '%d', "incompatible format for placeholder 1: 's' and 'd' are not compatible"), - ('%s %s %d', '%s %s %s', "incompatible format for placeholder 3: 'd' and 's' are not compatible"), - ('%(foo)s', '%(bar)s', "unknown named placeholder 'bar'"), - ('%(foo)s', '%(bar)d', "unknown named placeholder 'bar'"), - ('%(foo)s', '%(foo)d', "incompatible format for placeholder 'foo': 'd' and 's' are not compatible"), - ]) - def test__validate_format_invalid(self, msgid, msgstr, error): - with pytest.raises(TranslationError, match=error): - _validate_format(msgid, msgstr) - - @pytest.mark.parametrize(('msgid', 'msgstr'), [ - ('foo', 'foo'), - ('foo', 'foo %s'), - ('%s foo', 'foo %s'), - ('%i', '%d'), - ('%d', '%u'), - ('%x', '%X'), - ('%f', '%F'), - ('%F', '%g'), - ('%g', '%G'), - ('%(foo)s', 'foo'), - ('%(foo)s', '%(foo)s %(foo)s'), - ('%(bar)s foo %(n)d', '%(n)d foo %(bar)s'), - ]) - def test__validate_format_valid(self, msgid, msgstr): + +@pytest.mark.parametrize(('msgid', 'msgstr'), [ + ('foo', 'foo'), + ('foo', 'foo %s'), + ('foo %s', ''), + (('foo %s', 'bar %d'), ('foo %s', 'bar %d')), + (('foo %s', 'bar %d'), ('foo %s', 'bar %d', 'baz %d')), + (('foo', 'bar %s'), ('foo')), + (('foo', 'bar %s'), ('', '')), + (('foo', 'bar %s'), ('foo', '')), + (('foo %s', 'bar %d'), ('foo %s', '')), +]) +def test_python_format_valid(msgid, msgstr): + msg = Message(msgid, msgstr) + python_format(None, msg) + + +@pytest.mark.parametrize(('msgid', 'msgstr', 'error'), [ + ('%s %(foo)s', '%s %(foo)s', 'format string mixes positional and named placeholders'), + ('foo %s', 'foo', 'placeholders are incompatible'), + ('%s', '%(foo)s', 'the format strings are of different kinds'), + ('%s', '%s %d', 'positional format placeholders are unbalanced'), + ('%s', '%d', "incompatible format for placeholder 1: 's' and 'd' are not compatible"), + ('%s %s %d', '%s %s %s', "incompatible format for placeholder 3: 'd' and 's' are not compatible"), + ('%(foo)s', '%(bar)s', "unknown named placeholder 'bar'"), + ('%(foo)s', '%(bar)d', "unknown named placeholder 'bar'"), + ('%(foo)s', '%(foo)d', "incompatible format for placeholder 'foo': 'd' and 's' are not compatible"), +]) +def test__validate_format_invalid(msgid, msgstr, error): + with pytest.raises(TranslationError, match=error): _validate_format(msgid, msgstr) + + +@pytest.mark.parametrize(('msgid', 'msgstr'), [ + ('foo', 'foo'), + ('foo', 'foo %s'), + ('%s foo', 'foo %s'), + ('%i', '%d'), + ('%d', '%u'), + ('%x', '%X'), + ('%f', '%F'), + ('%F', '%g'), + ('%g', '%G'), + ('%(foo)s', 'foo'), + ('%(foo)s', '%(foo)s %(foo)s'), + ('%(bar)s foo %(n)d', '%(n)d foo %(bar)s'), +]) +def test__validate_format_valid(msgid, msgstr): + _validate_format(msgid, msgstr) diff --git a/tests/messages/test_extract.py b/tests/messages/test_extract.py index 31b21e45..41eda890 100644 --- a/tests/messages/test_extract.py +++ b/tests/messages/test_extract.py @@ -10,9 +10,7 @@ # individuals. For the exact contribution history, see the revision # history and logs, available at https://github.com/python-babel/babel/commits/master/. -import codecs import sys -import unittest from io import BytesIO, StringIO import pytest @@ -20,10 +18,8 @@ import pytest from babel.messages import extract -class ExtractPythonTestCase(unittest.TestCase): - - def test_nested_calls(self): - buf = BytesIO(b"""\ +def test_invalid_filter(): + buf = BytesIO(b"""\ msg1 = _(i18n_arg.replace(r'\"', '"')) msg2 = ungettext(i18n_arg.replace(r'\"', '"'), multi_arg.replace(r'\"', '"'), 2) msg3 = ungettext("Babel", multi_arg.replace(r'\"', '"'), 2) @@ -33,452 +29,26 @@ msg6 = ungettext(arg0, 'bunnies', random.randint(1, 2)) msg7 = _(hello.there) msg8 = gettext('Rabbit') msg9 = dgettext('wiki', model.addPage()) -msg10 = dngettext(getDomain(), 'Page', 'Pages', 3) -msg11 = ngettext( - "bunny", - "bunnies", - len(bunnies) -) -""") - messages = list(extract.extract_python(buf, - extract.DEFAULT_KEYWORDS.keys(), - [], {})) - assert messages == [ - (1, '_', None, []), - (2, 'ungettext', (None, None, None), []), - (3, 'ungettext', ('Babel', None, None), []), - (4, 'ungettext', (None, 'Babels', None), []), - (5, 'ungettext', ('bunny', 'bunnies', None), []), - (6, 'ungettext', (None, 'bunnies', None), []), - (7, '_', None, []), - (8, 'gettext', 'Rabbit', []), - (9, 'dgettext', ('wiki', None), []), - (10, 'dngettext', (None, 'Page', 'Pages', None), []), - (12, 'ngettext', ('bunny', 'bunnies', None), []), - ] - - def test_extract_default_encoding_ascii(self): - buf = BytesIO(b'_("a")') - messages = list(extract.extract_python( - buf, list(extract.DEFAULT_KEYWORDS), [], {}, - )) - # Should work great in both py2 and py3 - assert messages == [(1, '_', 'a', [])] - - def test_extract_default_encoding_utf8(self): - buf = BytesIO('_("☃")'.encode('UTF-8')) - messages = list(extract.extract_python( - buf, list(extract.DEFAULT_KEYWORDS), [], {}, - )) - assert messages == [(1, '_', '☃', [])] - - def test_nested_comments(self): - buf = BytesIO(b"""\ -msg = ngettext('pylon', # TRANSLATORS: shouldn't be - 'pylons', # TRANSLATORS: seeing this - count) -""") - messages = list(extract.extract_python(buf, ('ngettext',), - ['TRANSLATORS:'], {})) - assert messages == [(1, 'ngettext', ('pylon', 'pylons', None), [])] - - def test_comments_with_calls_that_spawn_multiple_lines(self): - buf = BytesIO(b"""\ -# NOTE: This Comment SHOULD Be Extracted -add_notice(req, ngettext("Catalog deleted.", - "Catalogs deleted.", len(selected))) - -# NOTE: This Comment SHOULD Be Extracted -add_notice(req, _("Locale deleted.")) - - -# NOTE: This Comment SHOULD Be Extracted -add_notice(req, ngettext("Foo deleted.", "Foos deleted.", len(selected))) - -# NOTE: This Comment SHOULD Be Extracted -# NOTE: And This One Too -add_notice(req, ngettext("Bar deleted.", - "Bars deleted.", len(selected))) -""") - messages = list(extract.extract_python(buf, ('ngettext', '_'), ['NOTE:'], - - {'strip_comment_tags': False})) - assert messages[0] == (2, 'ngettext', ('Catalog deleted.', 'Catalogs deleted.', None), ['NOTE: This Comment SHOULD Be Extracted']) - assert messages[1] == (6, '_', 'Locale deleted.', ['NOTE: This Comment SHOULD Be Extracted']) - assert messages[2] == (10, 'ngettext', ('Foo deleted.', 'Foos deleted.', None), ['NOTE: This Comment SHOULD Be Extracted']) - assert messages[3] == (14, 'ngettext', ('Bar deleted.', 'Bars deleted.', None), ['NOTE: This Comment SHOULD Be Extracted', 'NOTE: And This One Too']) - - def test_declarations(self): - buf = BytesIO(b"""\ -class gettext(object): - pass -def render_body(context,x,y=_('Page arg 1'),z=_('Page arg 2'),**pageargs): - pass -def ngettext(y='arg 1',z='arg 2',**pageargs): - pass -class Meta: - verbose_name = _('log entry') -""") - messages = list(extract.extract_python(buf, - extract.DEFAULT_KEYWORDS.keys(), - [], {})) - assert messages == [ - (3, '_', 'Page arg 1', []), - (3, '_', 'Page arg 2', []), - (8, '_', 'log entry', []), - ] - - def test_multiline(self): - buf = BytesIO(b"""\ -msg1 = ngettext('pylon', - 'pylons', count) -msg2 = ngettext('elvis', - 'elvises', - count) -""") - messages = list(extract.extract_python(buf, ('ngettext',), [], {})) - assert messages == [ - (1, 'ngettext', ('pylon', 'pylons', None), []), - (3, 'ngettext', ('elvis', 'elvises', None), []), - ] - - def test_npgettext(self): - buf = BytesIO(b"""\ -msg1 = npgettext('Strings','pylon', - 'pylons', count) -msg2 = npgettext('Strings','elvis', - 'elvises', - count) -""") - messages = list(extract.extract_python(buf, ('npgettext',), [], {})) - assert messages == [ - (1, 'npgettext', ('Strings', 'pylon', 'pylons', None), []), - (3, 'npgettext', ('Strings', 'elvis', 'elvises', None), []), - ] - buf = BytesIO(b"""\ -msg = npgettext('Strings', 'pylon', # TRANSLATORS: shouldn't be - 'pylons', # TRANSLATORS: seeing this - count) -""") - messages = list(extract.extract_python(buf, ('npgettext',), - ['TRANSLATORS:'], {})) - assert messages == [ - (1, 'npgettext', ('Strings', 'pylon', 'pylons', None), []), - ] - - def test_triple_quoted_strings(self): - buf = BytesIO(b"""\ -msg1 = _('''pylons''') -msg2 = ngettext(r'''elvis''', \"\"\"elvises\"\"\", count) -msg2 = ngettext(\"\"\"elvis\"\"\", 'elvises', count) -""") - messages = list(extract.extract_python(buf, - extract.DEFAULT_KEYWORDS.keys(), - [], {})) - assert messages == [ - (1, '_', 'pylons', []), - (2, 'ngettext', ('elvis', 'elvises', None), []), - (3, 'ngettext', ('elvis', 'elvises', None), []), - ] - - def test_multiline_strings(self): - buf = BytesIO(b"""\ -_('''This module provides internationalization and localization -support for your Python programs by providing an interface to the GNU -gettext message catalog library.''') -""") - messages = list(extract.extract_python(buf, - extract.DEFAULT_KEYWORDS.keys(), - [], {})) - assert messages == [ - (1, '_', - 'This module provides internationalization and localization\n' - 'support for your Python programs by providing an interface to ' - 'the GNU\ngettext message catalog library.', []), - ] - - def test_concatenated_strings(self): - buf = BytesIO(b"""\ -foobar = _('foo' 'bar') -""") - messages = list(extract.extract_python(buf, - extract.DEFAULT_KEYWORDS.keys(), - [], {})) - assert messages[0][2] == 'foobar' - - def test_unicode_string_arg(self): - buf = BytesIO(b"msg = _('Foo Bar')") - messages = list(extract.extract_python(buf, ('_',), [], {})) - assert messages[0][2] == 'Foo Bar' - - def test_comment_tag(self): - buf = BytesIO(b""" -# NOTE: A translation comment -msg = _('Foo Bar') -""") - messages = list(extract.extract_python(buf, ('_',), ['NOTE:'], {})) - assert messages[0][2] == 'Foo Bar' - assert messages[0][3] == ['NOTE: A translation comment'] - - def test_comment_tag_multiline(self): - buf = BytesIO(b""" -# NOTE: A translation comment -# with a second line -msg = _('Foo Bar') -""") - messages = list(extract.extract_python(buf, ('_',), ['NOTE:'], {})) - assert messages[0][2] == 'Foo Bar' - assert messages[0][3] == ['NOTE: A translation comment', 'with a second line'] - - def test_translator_comments_with_previous_non_translator_comments(self): - buf = BytesIO(b""" -# This shouldn't be in the output -# because it didn't start with a comment tag -# NOTE: A translation comment -# with a second line -msg = _('Foo Bar') -""") - messages = list(extract.extract_python(buf, ('_',), ['NOTE:'], {})) - assert messages[0][2] == 'Foo Bar' - assert messages[0][3] == ['NOTE: A translation comment', 'with a second line'] - - def test_comment_tags_not_on_start_of_comment(self): - buf = BytesIO(b""" -# This shouldn't be in the output -# because it didn't start with a comment tag -# do NOTE: this will not be a translation comment -# NOTE: This one will be -msg = _('Foo Bar') -""") - messages = list(extract.extract_python(buf, ('_',), ['NOTE:'], {})) - assert messages[0][2] == 'Foo Bar' - assert messages[0][3] == ['NOTE: This one will be'] - - def test_multiple_comment_tags(self): - buf = BytesIO(b""" -# NOTE1: A translation comment for tag1 -# with a second line -msg = _('Foo Bar1') - -# NOTE2: A translation comment for tag2 -msg = _('Foo Bar2') -""") - messages = list(extract.extract_python(buf, ('_',), - ['NOTE1:', 'NOTE2:'], {})) - assert messages[0][2] == 'Foo Bar1' - assert messages[0][3] == ['NOTE1: A translation comment for tag1', 'with a second line'] - assert messages[1][2] == 'Foo Bar2' - assert messages[1][3] == ['NOTE2: A translation comment for tag2'] - - def test_two_succeeding_comments(self): - buf = BytesIO(b""" -# NOTE: one -# NOTE: two -msg = _('Foo Bar') -""") - messages = list(extract.extract_python(buf, ('_',), ['NOTE:'], {})) - assert messages[0][2] == 'Foo Bar' - assert messages[0][3] == ['NOTE: one', 'NOTE: two'] - - def test_invalid_translator_comments(self): - buf = BytesIO(b""" -# NOTE: this shouldn't apply to any messages -hello = 'there' - -msg = _('Foo Bar') +msg10 = dngettext(domain, 'Page', 'Pages', 3) """) - messages = list(extract.extract_python(buf, ('_',), ['NOTE:'], {})) - assert messages[0][2] == 'Foo Bar' - assert messages[0][3] == [] + messages = \ + list(extract.extract('python', buf, extract.DEFAULT_KEYWORDS, [], + {})) + assert messages == [ + (5, ('bunny', 'bunnies'), [], None), + (8, 'Rabbit', [], None), + (10, ('Page', 'Pages'), [], None), + ] - def test_invalid_translator_comments2(self): - buf = BytesIO(b""" -# NOTE: Hi! -hithere = _('Hi there!') -# NOTE: you should not be seeing this in the .po -rows = [[v for v in range(0,10)] for row in range(0,10)] +def test_invalid_extract_method(): + buf = BytesIO(b'') + with pytest.raises(ValueError): + list(extract.extract('spam', buf)) -# this (NOTE:) should not show up either -hello = _('Hello') -""") - messages = list(extract.extract_python(buf, ('_',), ['NOTE:'], {})) - assert messages[0][2] == 'Hi there!' - assert messages[0][3] == ['NOTE: Hi!'] - assert messages[1][2] == 'Hello' - assert messages[1][3] == [] - - def test_invalid_translator_comments3(self): - buf = BytesIO(b""" -# NOTE: Hi, - -# there! -hithere = _('Hi there!') -""") - messages = list(extract.extract_python(buf, ('_',), ['NOTE:'], {})) - assert messages[0][2] == 'Hi there!' - assert messages[0][3] == [] - - def test_comment_tag_with_leading_space(self): - buf = BytesIO(b""" - #: A translation comment - #: with leading spaces -msg = _('Foo Bar') -""") - messages = list(extract.extract_python(buf, ('_',), [':'], {})) - assert messages[0][2] == 'Foo Bar' - assert messages[0][3] == [': A translation comment', ': with leading spaces'] - def test_different_signatures(self): - buf = BytesIO(b""" -foo = _('foo', 'bar') -n = ngettext('hello', 'there', n=3) -n = ngettext(n=3, 'hello', 'there') -n = ngettext(n=3, *messages) -n = ngettext() -n = ngettext('foo') -""") - messages = list(extract.extract_python(buf, ('_', 'ngettext'), [], {})) - assert messages[0][2] == ('foo', 'bar') - assert messages[1][2] == ('hello', 'there', None) - assert messages[2][2] == (None, 'hello', 'there') - assert messages[3][2] == (None, None) - assert messages[4][2] is None - assert messages[5][2] == 'foo' - - def test_utf8_message(self): - buf = BytesIO(""" -# NOTE: hello -msg = _('Bonjour à tous') -""".encode('utf-8')) - messages = list(extract.extract_python(buf, ('_',), ['NOTE:'], - {'encoding': 'utf-8'})) - assert messages[0][2] == 'Bonjour à tous' - assert messages[0][3] == ['NOTE: hello'] - - def test_utf8_message_with_magic_comment(self): - buf = BytesIO("""# -*- coding: utf-8 -*- -# NOTE: hello -msg = _('Bonjour à tous') -""".encode('utf-8')) - messages = list(extract.extract_python(buf, ('_',), ['NOTE:'], {})) - assert messages[0][2] == 'Bonjour à tous' - assert messages[0][3] == ['NOTE: hello'] - - def test_utf8_message_with_utf8_bom(self): - buf = BytesIO(codecs.BOM_UTF8 + """ -# NOTE: hello -msg = _('Bonjour à tous') -""".encode('utf-8')) - messages = list(extract.extract_python(buf, ('_',), ['NOTE:'], {})) - assert messages[0][2] == 'Bonjour à tous' - assert messages[0][3] == ['NOTE: hello'] - - def test_utf8_message_with_utf8_bom_and_magic_comment(self): - buf = BytesIO(codecs.BOM_UTF8 + """# -*- coding: utf-8 -*- -# NOTE: hello -msg = _('Bonjour à tous') -""".encode('utf-8')) - messages = list(extract.extract_python(buf, ('_',), ['NOTE:'], {})) - assert messages[0][2] == 'Bonjour à tous' - assert messages[0][3] == ['NOTE: hello'] - - def test_utf8_bom_with_latin_magic_comment_fails(self): - buf = BytesIO(codecs.BOM_UTF8 + """# -*- coding: latin-1 -*- -# NOTE: hello -msg = _('Bonjour à tous') -""".encode('utf-8')) - with pytest.raises(SyntaxError): - list(extract.extract_python(buf, ('_',), ['NOTE:'], {})) - - def test_utf8_raw_strings_match_unicode_strings(self): - buf = BytesIO(codecs.BOM_UTF8 + """ -msg = _('Bonjour à tous') -msgu = _('Bonjour à tous') -""".encode('utf-8')) - messages = list(extract.extract_python(buf, ('_',), ['NOTE:'], {})) - assert messages[0][2] == 'Bonjour à tous' - assert messages[0][2] == messages[1][2] - - def test_extract_strip_comment_tags(self): - buf = BytesIO(b"""\ -#: This is a comment with a very simple -#: prefix specified -_('Servus') - -# NOTE: This is a multiline comment with -# a prefix too -_('Babatschi')""") - messages = list(extract.extract('python', buf, comment_tags=['NOTE:', ':'], - strip_comment_tags=True)) - assert messages[0][1] == 'Servus' - assert messages[0][2] == ['This is a comment with a very simple', 'prefix specified'] - assert messages[1][1] == 'Babatschi' - assert messages[1][2] == ['This is a multiline comment with', 'a prefix too'] - - def test_nested_messages(self): - buf = BytesIO(b""" -# NOTE: First -_('Hello, {name}!', name=_('Foo Bar')) - -# NOTE: Second -_('Hello, {name1} and {name2}!', name1=_('Heungsub'), - name2=_('Armin')) - -# NOTE: Third -_('Hello, {0} and {1}!', _('Heungsub'), - _('Armin')) -""") - messages = list(extract.extract_python(buf, ('_',), ['NOTE:'], {})) - assert messages[0][2] == ('Hello, {name}!', None) - assert messages[0][3] == ['NOTE: First'] - assert messages[1][2] == 'Foo Bar' - assert messages[1][3] == [] - assert messages[2][2] == ('Hello, {name1} and {name2}!', None) - assert messages[2][3] == ['NOTE: Second'] - assert messages[3][2] == 'Heungsub' - assert messages[3][3] == [] - assert messages[4][2] == 'Armin' - assert messages[4][3] == [] - assert messages[5][2] == ('Hello, {0} and {1}!', None) - assert messages[5][3] == ['NOTE: Third'] - assert messages[6][2] == 'Heungsub' - assert messages[6][3] == [] - assert messages[7][2] == 'Armin' - assert messages[7][3] == [] - - -class ExtractTestCase(unittest.TestCase): - - def test_invalid_filter(self): - buf = BytesIO(b"""\ -msg1 = _(i18n_arg.replace(r'\"', '"')) -msg2 = ungettext(i18n_arg.replace(r'\"', '"'), multi_arg.replace(r'\"', '"'), 2) -msg3 = ungettext("Babel", multi_arg.replace(r'\"', '"'), 2) -msg4 = ungettext(i18n_arg.replace(r'\"', '"'), "Babels", 2) -msg5 = ungettext('bunny', 'bunnies', random.randint(1, 2)) -msg6 = ungettext(arg0, 'bunnies', random.randint(1, 2)) -msg7 = _(hello.there) -msg8 = gettext('Rabbit') -msg9 = dgettext('wiki', model.addPage()) -msg10 = dngettext(domain, 'Page', 'Pages', 3) -""") - messages = \ - list(extract.extract('python', buf, extract.DEFAULT_KEYWORDS, [], - {})) - assert messages == [ - (5, ('bunny', 'bunnies'), [], None), - (8, 'Rabbit', [], None), - (10, ('Page', 'Pages'), [], None), - ] - - def test_invalid_extract_method(self): - buf = BytesIO(b'') - with pytest.raises(ValueError): - list(extract.extract('spam', buf)) - - def test_different_signatures(self): - buf = BytesIO(b""" +def test_different_signatures(): + buf = BytesIO(b""" foo = _('foo', 'bar') n = ngettext('hello', 'there', n=3) n = ngettext(n=3, 'hello', 'there') @@ -486,81 +56,87 @@ n = ngettext(n=3, *messages) n = ngettext() n = ngettext('foo') """) - messages = \ - list(extract.extract('python', buf, extract.DEFAULT_KEYWORDS, [], - {})) - assert len(messages) == 2 - assert messages[0][1] == 'foo' - assert messages[1][1] == ('hello', 'there') - - def test_empty_string_msgid(self): - buf = BytesIO(b"""\ + messages = \ + list(extract.extract('python', buf, extract.DEFAULT_KEYWORDS, [], + {})) + assert len(messages) == 2 + assert messages[0][1] == 'foo' + assert messages[1][1] == ('hello', 'there') + + +def test_empty_string_msgid(): + buf = BytesIO(b"""\ msg = _('') """) - stderr = sys.stderr - sys.stderr = StringIO() - try: - messages = \ - list(extract.extract('python', buf, extract.DEFAULT_KEYWORDS, - [], {})) - assert messages == [] - assert 'warning: Empty msgid.' in sys.stderr.getvalue() - finally: - sys.stderr = stderr - - def test_warn_if_empty_string_msgid_found_in_context_aware_extraction_method(self): - buf = BytesIO(b"\nmsg = pgettext('ctxt', '')\n") - stderr = sys.stderr - sys.stderr = StringIO() - try: - messages = extract.extract('python', buf) - assert list(messages) == [] - assert 'warning: Empty msgid.' in sys.stderr.getvalue() - finally: - sys.stderr = stderr - - def test_extract_allows_callable(self): - def arbitrary_extractor(fileobj, keywords, comment_tags, options): - return [(1, None, (), ())] - for x in extract.extract(arbitrary_extractor, BytesIO(b"")): - assert x[0] == 1 - - def test_future(self): - buf = BytesIO(br""" + stderr = sys.stderr + sys.stderr = StringIO() + try: + messages = \ + list(extract.extract('python', buf, extract.DEFAULT_KEYWORDS, + [], {})) + assert messages == [] + assert 'warning: Empty msgid.' in sys.stderr.getvalue() + finally: + sys.stderr = stderr + + +def test_warn_if_empty_string_msgid_found_in_context_aware_extraction_method(): + buf = BytesIO(b"\nmsg = pgettext('ctxt', '')\n") + stderr = sys.stderr + sys.stderr = StringIO() + try: + messages = extract.extract('python', buf) + assert list(messages) == [] + assert 'warning: Empty msgid.' in sys.stderr.getvalue() + finally: + sys.stderr = stderr + + +def test_extract_allows_callable(): + def arbitrary_extractor(fileobj, keywords, comment_tags, options): + return [(1, None, (), ())] + for x in extract.extract(arbitrary_extractor, BytesIO(b"")): + assert x[0] == 1 + + +def test_future(): + buf = BytesIO(br""" # -*- coding: utf-8 -*- from __future__ import unicode_literals nbsp = _('\xa0') """) - messages = list(extract.extract('python', buf, - extract.DEFAULT_KEYWORDS, [], {})) - assert messages[0][1] == '\xa0' + messages = list(extract.extract('python', buf, + extract.DEFAULT_KEYWORDS, [], {})) + assert messages[0][1] == '\xa0' + - def test_f_strings(self): - buf = BytesIO(br""" +def test_f_strings(): + buf = BytesIO(br""" t1 = _('foobar') t2 = _(f'spameggs' f'feast') # should be extracted; constant parts only t2 = _(f'spameggs' 'kerroshampurilainen') # should be extracted (mixing f with no f) t3 = _(f'''whoa! a ''' # should be extracted (continues on following lines) f'flying shark' - '... hello' +'... hello' ) t4 = _(f'spameggs {t1}') # should not be extracted """) - messages = list(extract.extract('python', buf, extract.DEFAULT_KEYWORDS, [], {})) - assert len(messages) == 4 - assert messages[0][1] == 'foobar' - assert messages[1][1] == 'spameggsfeast' - assert messages[2][1] == 'spameggskerroshampurilainen' - assert messages[3][1] == 'whoa! a flying shark... hello' - - def test_f_strings_non_utf8(self): - buf = BytesIO(b""" + messages = list(extract.extract('python', buf, extract.DEFAULT_KEYWORDS, [], {})) + assert len(messages) == 4 + assert messages[0][1] == 'foobar' + assert messages[1][1] == 'spameggsfeast' + assert messages[2][1] == 'spameggskerroshampurilainen' + assert messages[3][1] == 'whoa! a flying shark... hello' + + +def test_f_strings_non_utf8(): + buf = BytesIO(b""" # -- coding: latin-1 -- t2 = _(f'\xe5\xe4\xf6' f'\xc5\xc4\xd6') """) - messages = list(extract.extract('python', buf, extract.DEFAULT_KEYWORDS, [], {})) - assert len(messages) == 1 - assert messages[0][1] == 'åäöÅÄÖ' + messages = list(extract.extract('python', buf, extract.DEFAULT_KEYWORDS, [], {})) + assert len(messages) == 1 + assert messages[0][1] == 'åäöÅÄÖ' def test_issue_1195(): diff --git a/tests/messages/test_extract_python.py b/tests/messages/test_extract_python.py new file mode 100644 index 00000000..aa13124f --- /dev/null +++ b/tests/messages/test_extract_python.py @@ -0,0 +1,474 @@ +# +# Copyright (C) 2007-2011 Edgewall Software, 2013-2025 the Babel team +# All rights reserved. +# +# This software is licensed as described in the file LICENSE, which +# you should have received as part of this distribution. The terms +# are also available at https://github.com/python-babel/babel/blob/master/LICENSE. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at https://github.com/python-babel/babel/commits/master/. + +import codecs +from io import BytesIO + +import pytest + +from babel.messages import extract + + +def test_nested_calls(): + buf = BytesIO(b"""\ +msg1 = _(i18n_arg.replace(r'\"', '"')) +msg2 = ungettext(i18n_arg.replace(r'\"', '"'), multi_arg.replace(r'\"', '"'), 2) +msg3 = ungettext("Babel", multi_arg.replace(r'\"', '"'), 2) +msg4 = ungettext(i18n_arg.replace(r'\"', '"'), "Babels", 2) +msg5 = ungettext('bunny', 'bunnies', random.randint(1, 2)) +msg6 = ungettext(arg0, 'bunnies', random.randint(1, 2)) +msg7 = _(hello.there) +msg8 = gettext('Rabbit') +msg9 = dgettext('wiki', model.addPage()) +msg10 = dngettext(getDomain(), 'Page', 'Pages', 3) +msg11 = ngettext( +"bunny", +"bunnies", +len(bunnies) +) +""") + messages = list(extract.extract_python(buf, + extract.DEFAULT_KEYWORDS.keys(), + [], {})) + assert messages == [ + (1, '_', None, []), + (2, 'ungettext', (None, None, None), []), + (3, 'ungettext', ('Babel', None, None), []), + (4, 'ungettext', (None, 'Babels', None), []), + (5, 'ungettext', ('bunny', 'bunnies', None), []), + (6, 'ungettext', (None, 'bunnies', None), []), + (7, '_', None, []), + (8, 'gettext', 'Rabbit', []), + (9, 'dgettext', ('wiki', None), []), + (10, 'dngettext', (None, 'Page', 'Pages', None), []), + (12, 'ngettext', ('bunny', 'bunnies', None), []), + ] + + +def test_extract_default_encoding_ascii(): + buf = BytesIO(b'_("a")') + messages = list(extract.extract_python( + buf, list(extract.DEFAULT_KEYWORDS), [], {}, + )) + # Should work great in both py2 and py3 + assert messages == [(1, '_', 'a', [])] + + +def test_extract_default_encoding_utf8(): + buf = BytesIO('_("☃")'.encode('UTF-8')) + messages = list(extract.extract_python( + buf, list(extract.DEFAULT_KEYWORDS), [], {}, + )) + assert messages == [(1, '_', '☃', [])] + + +def test_nested_comments(): + buf = BytesIO(b"""\ +msg = ngettext('pylon', # TRANSLATORS: shouldn't be + 'pylons', # TRANSLATORS: seeing this + count) +""") + messages = list(extract.extract_python(buf, ('ngettext',), + ['TRANSLATORS:'], {})) + assert messages == [(1, 'ngettext', ('pylon', 'pylons', None), [])] + + +def test_comments_with_calls_that_spawn_multiple_lines(): + buf = BytesIO(b"""\ +# NOTE: This Comment SHOULD Be Extracted +add_notice(req, ngettext("Catalog deleted.", + "Catalogs deleted.", len(selected))) + +# NOTE: This Comment SHOULD Be Extracted +add_notice(req, _("Locale deleted.")) + + +# NOTE: This Comment SHOULD Be Extracted +add_notice(req, ngettext("Foo deleted.", "Foos deleted.", len(selected))) + +# NOTE: This Comment SHOULD Be Extracted +# NOTE: And This One Too +add_notice(req, ngettext("Bar deleted.", + "Bars deleted.", len(selected))) +""") + messages = list(extract.extract_python(buf, ('ngettext', '_'), ['NOTE:'], + + {'strip_comment_tags': False})) + assert messages[0] == (2, 'ngettext', ('Catalog deleted.', 'Catalogs deleted.', None), ['NOTE: This Comment SHOULD Be Extracted']) + assert messages[1] == (6, '_', 'Locale deleted.', ['NOTE: This Comment SHOULD Be Extracted']) + assert messages[2] == (10, 'ngettext', ('Foo deleted.', 'Foos deleted.', None), ['NOTE: This Comment SHOULD Be Extracted']) + assert messages[3] == (14, 'ngettext', ('Bar deleted.', 'Bars deleted.', None), ['NOTE: This Comment SHOULD Be Extracted', 'NOTE: And This One Too']) + + +def test_declarations(): + buf = BytesIO(b"""\ +class gettext(object): +pass +def render_body(context,x,y=_('Page arg 1'),z=_('Page arg 2'),**pageargs): +pass +def ngettext(y='arg 1',z='arg 2',**pageargs): +pass +class Meta: +verbose_name = _('log entry') +""") + messages = list(extract.extract_python(buf, + extract.DEFAULT_KEYWORDS.keys(), + [], {})) + assert messages == [ + (3, '_', 'Page arg 1', []), + (3, '_', 'Page arg 2', []), + (8, '_', 'log entry', []), + ] + + +def test_multiline(): + buf = BytesIO(b"""\ +msg1 = ngettext('pylon', + 'pylons', count) +msg2 = ngettext('elvis', + 'elvises', + count) +""") + messages = list(extract.extract_python(buf, ('ngettext',), [], {})) + assert messages == [ + (1, 'ngettext', ('pylon', 'pylons', None), []), + (3, 'ngettext', ('elvis', 'elvises', None), []), + ] + + +def test_npgettext(): + buf = BytesIO(b"""\ +msg1 = npgettext('Strings','pylon', + 'pylons', count) +msg2 = npgettext('Strings','elvis', + 'elvises', + count) +""") + messages = list(extract.extract_python(buf, ('npgettext',), [], {})) + assert messages == [ + (1, 'npgettext', ('Strings', 'pylon', 'pylons', None), []), + (3, 'npgettext', ('Strings', 'elvis', 'elvises', None), []), + ] + buf = BytesIO(b"""\ +msg = npgettext('Strings', 'pylon', # TRANSLATORS: shouldn't be + 'pylons', # TRANSLATORS: seeing this + count) +""") + messages = list(extract.extract_python(buf, ('npgettext',), + ['TRANSLATORS:'], {})) + assert messages == [ + (1, 'npgettext', ('Strings', 'pylon', 'pylons', None), []), + ] + + +def test_triple_quoted_strings(): + buf = BytesIO(b"""\ +msg1 = _('''pylons''') +msg2 = ngettext(r'''elvis''', \"\"\"elvises\"\"\", count) +msg2 = ngettext(\"\"\"elvis\"\"\", 'elvises', count) +""") + messages = list(extract.extract_python(buf, + extract.DEFAULT_KEYWORDS.keys(), + [], {})) + assert messages == [ + (1, '_', 'pylons', []), + (2, 'ngettext', ('elvis', 'elvises', None), []), + (3, 'ngettext', ('elvis', 'elvises', None), []), + ] + + +def test_multiline_strings(): + buf = BytesIO(b"""\ +_('''This module provides internationalization and localization +support for your Python programs by providing an interface to the GNU +gettext message catalog library.''') +""") + messages = list(extract.extract_python(buf, + extract.DEFAULT_KEYWORDS.keys(), + [], {})) + assert messages == [ + (1, '_', + 'This module provides internationalization and localization\n' + 'support for your Python programs by providing an interface to ' + 'the GNU\ngettext message catalog library.', []), + ] + + +def test_concatenated_strings(): + buf = BytesIO(b"""\ +foobar = _('foo' 'bar') +""") + messages = list(extract.extract_python(buf, + extract.DEFAULT_KEYWORDS.keys(), + [], {})) + assert messages[0][2] == 'foobar' + + +def test_unicode_string_arg(): + buf = BytesIO(b"msg = _('Foo Bar')") + messages = list(extract.extract_python(buf, ('_',), [], {})) + assert messages[0][2] == 'Foo Bar' + + +def test_comment_tag(): + buf = BytesIO(b""" +# NOTE: A translation comment +msg = _('Foo Bar') +""") + messages = list(extract.extract_python(buf, ('_',), ['NOTE:'], {})) + assert messages[0][2] == 'Foo Bar' + assert messages[0][3] == ['NOTE: A translation comment'] + + +def test_comment_tag_multiline(): + buf = BytesIO(b""" +# NOTE: A translation comment +# with a second line +msg = _('Foo Bar') +""") + messages = list(extract.extract_python(buf, ('_',), ['NOTE:'], {})) + assert messages[0][2] == 'Foo Bar' + assert messages[0][3] == ['NOTE: A translation comment', 'with a second line'] + + +def test_translator_comments_with_previous_non_translator_comments(): + buf = BytesIO(b""" +# This shouldn't be in the output +# because it didn't start with a comment tag +# NOTE: A translation comment +# with a second line +msg = _('Foo Bar') +""") + messages = list(extract.extract_python(buf, ('_',), ['NOTE:'], {})) + assert messages[0][2] == 'Foo Bar' + assert messages[0][3] == ['NOTE: A translation comment', 'with a second line'] + + +def test_comment_tags_not_on_start_of_comment(): + buf = BytesIO(b""" +# This shouldn't be in the output +# because it didn't start with a comment tag +# do NOTE: this will not be a translation comment +# NOTE: This one will be +msg = _('Foo Bar') +""") + messages = list(extract.extract_python(buf, ('_',), ['NOTE:'], {})) + assert messages[0][2] == 'Foo Bar' + assert messages[0][3] == ['NOTE: This one will be'] + + +def test_multiple_comment_tags(): + buf = BytesIO(b""" +# NOTE1: A translation comment for tag1 +# with a second line +msg = _('Foo Bar1') + +# NOTE2: A translation comment for tag2 +msg = _('Foo Bar2') +""") + messages = list(extract.extract_python(buf, ('_',), + ['NOTE1:', 'NOTE2:'], {})) + assert messages[0][2] == 'Foo Bar1' + assert messages[0][3] == ['NOTE1: A translation comment for tag1', 'with a second line'] + assert messages[1][2] == 'Foo Bar2' + assert messages[1][3] == ['NOTE2: A translation comment for tag2'] + + +def test_two_succeeding_comments(): + buf = BytesIO(b""" +# NOTE: one +# NOTE: two +msg = _('Foo Bar') +""") + messages = list(extract.extract_python(buf, ('_',), ['NOTE:'], {})) + assert messages[0][2] == 'Foo Bar' + assert messages[0][3] == ['NOTE: one', 'NOTE: two'] + + +def test_invalid_translator_comments(): + buf = BytesIO(b""" +# NOTE: this shouldn't apply to any messages +hello = 'there' + +msg = _('Foo Bar') +""") + messages = list(extract.extract_python(buf, ('_',), ['NOTE:'], {})) + assert messages[0][2] == 'Foo Bar' + assert messages[0][3] == [] + + +def test_invalid_translator_comments2(): + buf = BytesIO(b""" +# NOTE: Hi! +hithere = _('Hi there!') + +# NOTE: you should not be seeing this in the .po +rows = [[v for v in range(0,10)] for row in range(0,10)] + +# this (NOTE:) should not show up either +hello = _('Hello') +""") + messages = list(extract.extract_python(buf, ('_',), ['NOTE:'], {})) + assert messages[0][2] == 'Hi there!' + assert messages[0][3] == ['NOTE: Hi!'] + assert messages[1][2] == 'Hello' + assert messages[1][3] == [] + + +def test_invalid_translator_comments3(): + buf = BytesIO(b""" +# NOTE: Hi, + +# there! +hithere = _('Hi there!') +""") + messages = list(extract.extract_python(buf, ('_',), ['NOTE:'], {})) + assert messages[0][2] == 'Hi there!' + assert messages[0][3] == [] + + +def test_comment_tag_with_leading_space(): + buf = BytesIO(b""" +#: A translation comment +#: with leading spaces +msg = _('Foo Bar') +""") + messages = list(extract.extract_python(buf, ('_',), [':'], {})) + assert messages[0][2] == 'Foo Bar' + assert messages[0][3] == [': A translation comment', ': with leading spaces'] + + +def test_different_signatures(): + buf = BytesIO(b""" +foo = _('foo', 'bar') +n = ngettext('hello', 'there', n=3) +n = ngettext(n=3, 'hello', 'there') +n = ngettext(n=3, *messages) +n = ngettext() +n = ngettext('foo') +""") + messages = list(extract.extract_python(buf, ('_', 'ngettext'), [], {})) + assert messages[0][2] == ('foo', 'bar') + assert messages[1][2] == ('hello', 'there', None) + assert messages[2][2] == (None, 'hello', 'there') + assert messages[3][2] == (None, None) + assert messages[4][2] is None + assert messages[5][2] == 'foo' + + +def test_utf8_message(): + buf = BytesIO(""" +# NOTE: hello +msg = _('Bonjour à tous') +""".encode('utf-8')) + messages = list(extract.extract_python(buf, ('_',), ['NOTE:'], + {'encoding': 'utf-8'})) + assert messages[0][2] == 'Bonjour à tous' + assert messages[0][3] == ['NOTE: hello'] + + +def test_utf8_message_with_magic_comment(): + buf = BytesIO("""# -*- coding: utf-8 -*- +# NOTE: hello +msg = _('Bonjour à tous') +""".encode('utf-8')) + messages = list(extract.extract_python(buf, ('_',), ['NOTE:'], {})) + assert messages[0][2] == 'Bonjour à tous' + assert messages[0][3] == ['NOTE: hello'] + + +def test_utf8_message_with_utf8_bom(): + buf = BytesIO(codecs.BOM_UTF8 + """ +# NOTE: hello +msg = _('Bonjour à tous') +""".encode('utf-8')) + messages = list(extract.extract_python(buf, ('_',), ['NOTE:'], {})) + assert messages[0][2] == 'Bonjour à tous' + assert messages[0][3] == ['NOTE: hello'] + + +def test_utf8_message_with_utf8_bom_and_magic_comment(): + buf = BytesIO(codecs.BOM_UTF8 + """# -*- coding: utf-8 -*- +# NOTE: hello +msg = _('Bonjour à tous') +""".encode('utf-8')) + messages = list(extract.extract_python(buf, ('_',), ['NOTE:'], {})) + assert messages[0][2] == 'Bonjour à tous' + assert messages[0][3] == ['NOTE: hello'] + + +def test_utf8_bom_with_latin_magic_comment_fails(): + buf = BytesIO(codecs.BOM_UTF8 + """# -*- coding: latin-1 -*- +# NOTE: hello +msg = _('Bonjour à tous') +""".encode('utf-8')) + with pytest.raises(SyntaxError): + list(extract.extract_python(buf, ('_',), ['NOTE:'], {})) + + +def test_utf8_raw_strings_match_unicode_strings(): + buf = BytesIO(codecs.BOM_UTF8 + """ +msg = _('Bonjour à tous') +msgu = _('Bonjour à tous') +""".encode('utf-8')) + messages = list(extract.extract_python(buf, ('_',), ['NOTE:'], {})) + assert messages[0][2] == 'Bonjour à tous' + assert messages[0][2] == messages[1][2] + + +def test_extract_strip_comment_tags(): + buf = BytesIO(b"""\ +#: This is a comment with a very simple +#: prefix specified +_('Servus') + +# NOTE: This is a multiline comment with +# a prefix too +_('Babatschi')""") + messages = list(extract.extract('python', buf, comment_tags=['NOTE:', ':'], + strip_comment_tags=True)) + assert messages[0][1] == 'Servus' + assert messages[0][2] == ['This is a comment with a very simple', 'prefix specified'] + assert messages[1][1] == 'Babatschi' + assert messages[1][2] == ['This is a multiline comment with', 'a prefix too'] + + +def test_nested_messages(): + buf = BytesIO(b""" +# NOTE: First +_('Hello, {name}!', name=_('Foo Bar')) + +# NOTE: Second +_('Hello, {name1} and {name2}!', name1=_('Heungsub'), +name2=_('Armin')) + +# NOTE: Third +_('Hello, {0} and {1}!', _('Heungsub'), +_('Armin')) +""") + messages = list(extract.extract_python(buf, ('_',), ['NOTE:'], {})) + assert messages[0][2] == ('Hello, {name}!', None) + assert messages[0][3] == ['NOTE: First'] + assert messages[1][2] == 'Foo Bar' + assert messages[1][3] == [] + assert messages[2][2] == ('Hello, {name1} and {name2}!', None) + assert messages[2][3] == ['NOTE: Second'] + assert messages[3][2] == 'Heungsub' + assert messages[3][3] == [] + assert messages[4][2] == 'Armin' + assert messages[4][3] == [] + assert messages[5][2] == ('Hello, {0} and {1}!', None) + assert messages[5][3] == ['NOTE: Third'] + assert messages[6][2] == 'Heungsub' + assert messages[6][3] == [] + assert messages[7][2] == 'Armin' + assert messages[7][3] == [] diff --git a/tests/messages/test_frontend.py b/tests/messages/test_frontend.py deleted file mode 100644 index 581ad4a8..00000000 --- a/tests/messages/test_frontend.py +++ /dev/null @@ -1,1774 +0,0 @@ -# -# Copyright (C) 2007-2011 Edgewall Software, 2013-2025 the Babel team -# All rights reserved. -# -# This software is licensed as described in the file LICENSE, which -# you should have received as part of this distribution. The terms -# are also available at https://github.com/python-babel/babel/blob/master/LICENSE. -# -# This software consists of voluntary contributions made by many -# individuals. For the exact contribution history, see the revision -# history and logs, available at https://github.com/python-babel/babel/commits/master/. -from __future__ import annotations - -import logging -import os -import re -import shlex -import shutil -import sys -import time -import unittest -from datetime import datetime, timedelta -from functools import partial -from io import BytesIO, StringIO - -import pytest -from freezegun import freeze_time - -from babel import __version__ as VERSION -from babel.dates import format_datetime -from babel.messages import Catalog, extract, frontend -from babel.messages.frontend import ( - BaseError, - CommandLineInterface, - ExtractMessages, - OptionError, - UpdateCatalog, -) -from babel.messages.pofile import read_po, write_po -from babel.util import LOCALTZ -from tests.messages.consts import ( - TEST_PROJECT_DISTRIBUTION_DATA, - data_dir, - i18n_dir, - pot_file, - project_dir, - this_dir, -) -from tests.messages.utils import CUSTOM_EXTRACTOR_COOKIE - - -def _po_file(locale): - return os.path.join(i18n_dir, locale, 'LC_MESSAGES', 'messages.po') - - -class Distribution: # subset of distutils.dist.Distribution - def __init__(self, attrs: dict) -> None: - self.attrs = attrs - - def get_name(self) -> str: - return self.attrs['name'] - - def get_version(self) -> str: - return self.attrs['version'] - - @property - def packages(self) -> list[str]: - return self.attrs['packages'] - - -class CompileCatalogTestCase(unittest.TestCase): - - def setUp(self): - self.olddir = os.getcwd() - os.chdir(data_dir) - - self.dist = Distribution(TEST_PROJECT_DISTRIBUTION_DATA) - self.cmd = frontend.CompileCatalog(self.dist) - self.cmd.initialize_options() - - def tearDown(self): - os.chdir(self.olddir) - - def test_no_directory_or_output_file_specified(self): - self.cmd.locale = 'en_US' - self.cmd.input_file = 'dummy' - with pytest.raises(OptionError): - self.cmd.finalize_options() - - def test_no_directory_or_input_file_specified(self): - self.cmd.locale = 'en_US' - self.cmd.output_file = 'dummy' - with pytest.raises(OptionError): - self.cmd.finalize_options() - - -class ExtractMessagesTestCase(unittest.TestCase): - - def setUp(self): - self.olddir = os.getcwd() - os.chdir(data_dir) - - self.dist = Distribution(TEST_PROJECT_DISTRIBUTION_DATA) - self.cmd = frontend.ExtractMessages(self.dist) - self.cmd.initialize_options() - - def tearDown(self): - if os.path.isfile(pot_file): - os.unlink(pot_file) - - os.chdir(self.olddir) - - def assert_pot_file_exists(self): - assert os.path.isfile(pot_file) - - def test_neither_default_nor_custom_keywords(self): - self.cmd.output_file = 'dummy' - self.cmd.no_default_keywords = True - with pytest.raises(OptionError): - self.cmd.finalize_options() - - def test_no_output_file_specified(self): - with pytest.raises(OptionError): - self.cmd.finalize_options() - - def test_both_sort_output_and_sort_by_file(self): - self.cmd.output_file = 'dummy' - self.cmd.sort_output = True - self.cmd.sort_by_file = True - with pytest.raises(OptionError): - self.cmd.finalize_options() - - def test_invalid_file_or_dir_input_path(self): - self.cmd.input_paths = 'nonexistent_path' - self.cmd.output_file = 'dummy' - with pytest.raises(OptionError): - self.cmd.finalize_options() - - def test_input_paths_is_treated_as_list(self): - self.cmd.input_paths = data_dir - self.cmd.output_file = pot_file - self.cmd.finalize_options() - self.cmd.run() - - with open(pot_file) as f: - catalog = read_po(f) - msg = catalog.get('bar') - assert len(msg.locations) == 1 - assert ('file1.py' in msg.locations[0][0]) - - def test_input_paths_handle_spaces_after_comma(self): - self.cmd.input_paths = f"{this_dir}, {data_dir}" - self.cmd.output_file = pot_file - self.cmd.finalize_options() - assert self.cmd.input_paths == [this_dir, data_dir] - - def test_input_dirs_is_alias_for_input_paths(self): - self.cmd.input_dirs = this_dir - self.cmd.output_file = pot_file - self.cmd.finalize_options() - # Gets listified in `finalize_options`: - assert self.cmd.input_paths == [self.cmd.input_dirs] - - def test_input_dirs_is_mutually_exclusive_with_input_paths(self): - self.cmd.input_dirs = this_dir - self.cmd.input_paths = this_dir - self.cmd.output_file = pot_file - with pytest.raises(OptionError): - self.cmd.finalize_options() - - @freeze_time("1994-11-11") - def test_extraction_with_default_mapping(self): - self.cmd.copyright_holder = 'FooBar, Inc.' - self.cmd.msgid_bugs_address = 'bugs.address@email.tld' - self.cmd.output_file = 'project/i18n/temp.pot' - self.cmd.add_comments = 'TRANSLATOR:,TRANSLATORS:' - - self.cmd.finalize_options() - self.cmd.run() - - self.assert_pot_file_exists() - - date = format_datetime(datetime(1994, 11, 11, 00, 00), 'yyyy-MM-dd HH:mmZ', tzinfo=LOCALTZ, locale='en') - expected_content = fr"""# Translations template for TestProject. -# Copyright (C) {time.strftime('%Y')} FooBar, Inc. -# This file is distributed under the same license as the TestProject -# project. -# FIRST AUTHOR , {time.strftime('%Y')}. -# -#, fuzzy -msgid "" -msgstr "" -"Project-Id-Version: TestProject 0.1\n" -"Report-Msgid-Bugs-To: bugs.address@email.tld\n" -"POT-Creation-Date: {date}\n" -"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: FULL NAME \n" -"Language-Team: LANGUAGE \n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=utf-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel {VERSION}\n" - -#. TRANSLATOR: This will be a translator coment, -#. that will include several lines -#: project/file1.py:8 -msgid "bar" -msgstr "" - -#: project/file2.py:9 -msgid "foobar" -msgid_plural "foobars" -msgstr[0] "" -msgstr[1] "" - -#: project/ignored/this_wont_normally_be_here.py:11 -msgid "FooBar" -msgid_plural "FooBars" -msgstr[0] "" -msgstr[1] "" - -""" - with open(pot_file) as f: - actual_content = f.read() - assert expected_content == actual_content - - @freeze_time("1994-11-11") - def test_extraction_with_mapping_file(self): - self.cmd.copyright_holder = 'FooBar, Inc.' - self.cmd.msgid_bugs_address = 'bugs.address@email.tld' - self.cmd.mapping_file = 'mapping.cfg' - self.cmd.output_file = 'project/i18n/temp.pot' - self.cmd.add_comments = 'TRANSLATOR:,TRANSLATORS:' - - self.cmd.finalize_options() - self.cmd.run() - - self.assert_pot_file_exists() - - date = format_datetime(datetime(1994, 11, 11, 00, 00), 'yyyy-MM-dd HH:mmZ', tzinfo=LOCALTZ, locale='en') - expected_content = fr"""# Translations template for TestProject. -# Copyright (C) {time.strftime('%Y')} FooBar, Inc. -# This file is distributed under the same license as the TestProject -# project. -# FIRST AUTHOR , {time.strftime('%Y')}. -# -#, fuzzy -msgid "" -msgstr "" -"Project-Id-Version: TestProject 0.1\n" -"Report-Msgid-Bugs-To: bugs.address@email.tld\n" -"POT-Creation-Date: {date}\n" -"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: FULL NAME \n" -"Language-Team: LANGUAGE \n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=utf-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel {VERSION}\n" - -#. TRANSLATOR: This will be a translator coment, -#. that will include several lines -#: project/file1.py:8 -msgid "bar" -msgstr "" - -#: project/file2.py:9 -msgid "foobar" -msgid_plural "foobars" -msgstr[0] "" -msgstr[1] "" - -""" - with open(pot_file) as f: - actual_content = f.read() - assert expected_content == actual_content - - @freeze_time("1994-11-11") - def test_extraction_with_mapping_dict(self): - self.dist.message_extractors = { - 'project': [ - ('**/ignored/**.*', 'ignore', None), - ('**.py', 'python', None), - ], - } - self.cmd.copyright_holder = 'FooBar, Inc.' - self.cmd.msgid_bugs_address = 'bugs.address@email.tld' - self.cmd.output_file = 'project/i18n/temp.pot' - self.cmd.add_comments = 'TRANSLATOR:,TRANSLATORS:' - - self.cmd.finalize_options() - self.cmd.run() - - self.assert_pot_file_exists() - - date = format_datetime(datetime(1994, 11, 11, 00, 00), 'yyyy-MM-dd HH:mmZ', tzinfo=LOCALTZ, locale='en') - expected_content = fr"""# Translations template for TestProject. -# Copyright (C) {time.strftime('%Y')} FooBar, Inc. -# This file is distributed under the same license as the TestProject -# project. -# FIRST AUTHOR , {time.strftime('%Y')}. -# -#, fuzzy -msgid "" -msgstr "" -"Project-Id-Version: TestProject 0.1\n" -"Report-Msgid-Bugs-To: bugs.address@email.tld\n" -"POT-Creation-Date: {date}\n" -"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: FULL NAME \n" -"Language-Team: LANGUAGE \n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=utf-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel {VERSION}\n" - -#. TRANSLATOR: This will be a translator coment, -#. that will include several lines -#: project/file1.py:8 -msgid "bar" -msgstr "" - -#: project/file2.py:9 -msgid "foobar" -msgid_plural "foobars" -msgstr[0] "" -msgstr[1] "" - -""" - with open(pot_file) as f: - actual_content = f.read() - assert expected_content == actual_content - - def test_extraction_add_location_file(self): - self.dist.message_extractors = { - 'project': [ - ('**/ignored/**.*', 'ignore', None), - ('**.py', 'python', None), - ], - } - self.cmd.output_file = 'project/i18n/temp.pot' - self.cmd.add_location = 'file' - self.cmd.omit_header = True - - self.cmd.finalize_options() - self.cmd.run() - - self.assert_pot_file_exists() - - expected_content = r"""#: project/file1.py -msgid "bar" -msgstr "" - -#: project/file2.py -msgid "foobar" -msgid_plural "foobars" -msgstr[0] "" -msgstr[1] "" - -""" - with open(pot_file) as f: - actual_content = f.read() - assert expected_content == actual_content - - -class InitCatalogTestCase(unittest.TestCase): - - def setUp(self): - self.olddir = os.getcwd() - os.chdir(data_dir) - - self.dist = Distribution(TEST_PROJECT_DISTRIBUTION_DATA) - self.cmd = frontend.InitCatalog(self.dist) - self.cmd.initialize_options() - - def tearDown(self): - for dirname in ['en_US', 'ja_JP', 'lv_LV']: - locale_dir = os.path.join(i18n_dir, dirname) - if os.path.isdir(locale_dir): - shutil.rmtree(locale_dir) - - os.chdir(self.olddir) - - def test_no_input_file(self): - self.cmd.locale = 'en_US' - self.cmd.output_file = 'dummy' - with pytest.raises(OptionError): - self.cmd.finalize_options() - - def test_no_locale(self): - self.cmd.input_file = 'dummy' - self.cmd.output_file = 'dummy' - with pytest.raises(OptionError): - self.cmd.finalize_options() - - @freeze_time("1994-11-11") - def test_with_output_dir(self): - self.cmd.input_file = 'project/i18n/messages.pot' - self.cmd.locale = 'en_US' - self.cmd.output_dir = 'project/i18n' - - self.cmd.finalize_options() - self.cmd.run() - - po_file = _po_file('en_US') - assert os.path.isfile(po_file) - - date = format_datetime(datetime(1994, 11, 11, 00, 00), 'yyyy-MM-dd HH:mmZ', tzinfo=LOCALTZ, locale='en') - expected_content = fr"""# English (United States) translations for TestProject. -# Copyright (C) 2007 FooBar, Inc. -# This file is distributed under the same license as the TestProject -# project. -# FIRST AUTHOR , 2007. -# -msgid "" -msgstr "" -"Project-Id-Version: TestProject 0.1\n" -"Report-Msgid-Bugs-To: bugs.address@email.tld\n" -"POT-Creation-Date: 2007-04-01 15:30+0200\n" -"PO-Revision-Date: {date}\n" -"Last-Translator: FULL NAME \n" -"Language: en_US\n" -"Language-Team: en_US \n" -"Plural-Forms: nplurals=2; plural=(n != 1);\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=utf-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel {VERSION}\n" - -#. This will be a translator coment, -#. that will include several lines -#: project/file1.py:8 -msgid "bar" -msgstr "" - -#: project/file2.py:9 -msgid "foobar" -msgid_plural "foobars" -msgstr[0] "" -msgstr[1] "" - -""" - with open(po_file) as f: - actual_content = f.read() - assert expected_content == actual_content - - @freeze_time("1994-11-11") - def test_keeps_catalog_non_fuzzy(self): - self.cmd.input_file = 'project/i18n/messages_non_fuzzy.pot' - self.cmd.locale = 'en_US' - self.cmd.output_dir = 'project/i18n' - - self.cmd.finalize_options() - self.cmd.run() - - po_file = _po_file('en_US') - assert os.path.isfile(po_file) - - date = format_datetime(datetime(1994, 11, 11, 00, 00), 'yyyy-MM-dd HH:mmZ', tzinfo=LOCALTZ, locale='en') - expected_content = fr"""# English (United States) translations for TestProject. -# Copyright (C) 2007 FooBar, Inc. -# This file is distributed under the same license as the TestProject -# project. -# FIRST AUTHOR , 2007. -# -msgid "" -msgstr "" -"Project-Id-Version: TestProject 0.1\n" -"Report-Msgid-Bugs-To: bugs.address@email.tld\n" -"POT-Creation-Date: 2007-04-01 15:30+0200\n" -"PO-Revision-Date: {date}\n" -"Last-Translator: FULL NAME \n" -"Language: en_US\n" -"Language-Team: en_US \n" -"Plural-Forms: nplurals=2; plural=(n != 1);\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=utf-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel {VERSION}\n" - -#. This will be a translator coment, -#. that will include several lines -#: project/file1.py:8 -msgid "bar" -msgstr "" - -#: project/file2.py:9 -msgid "foobar" -msgid_plural "foobars" -msgstr[0] "" -msgstr[1] "" - -""" - with open(po_file) as f: - actual_content = f.read() - assert expected_content == actual_content - - @freeze_time("1994-11-11") - def test_correct_init_more_than_2_plurals(self): - self.cmd.input_file = 'project/i18n/messages.pot' - self.cmd.locale = 'lv_LV' - self.cmd.output_dir = 'project/i18n' - - self.cmd.finalize_options() - self.cmd.run() - - po_file = _po_file('lv_LV') - assert os.path.isfile(po_file) - - date = format_datetime(datetime(1994, 11, 11, 00, 00), 'yyyy-MM-dd HH:mmZ', tzinfo=LOCALTZ, locale='en') - expected_content = fr"""# Latvian (Latvia) translations for TestProject. -# Copyright (C) 2007 FooBar, Inc. -# This file is distributed under the same license as the TestProject -# project. -# FIRST AUTHOR , 2007. -# -msgid "" -msgstr "" -"Project-Id-Version: TestProject 0.1\n" -"Report-Msgid-Bugs-To: bugs.address@email.tld\n" -"POT-Creation-Date: 2007-04-01 15:30+0200\n" -"PO-Revision-Date: {date}\n" -"Last-Translator: FULL NAME \n" -"Language: lv_LV\n" -"Language-Team: lv_LV \n" -"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n != 0 ? 1 :" -" 2);\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=utf-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel {VERSION}\n" - -#. This will be a translator coment, -#. that will include several lines -#: project/file1.py:8 -msgid "bar" -msgstr "" - -#: project/file2.py:9 -msgid "foobar" -msgid_plural "foobars" -msgstr[0] "" -msgstr[1] "" -msgstr[2] "" - -""" - with open(po_file) as f: - actual_content = f.read() - assert expected_content == actual_content - - @freeze_time("1994-11-11") - def test_correct_init_singular_plural_forms(self): - self.cmd.input_file = 'project/i18n/messages.pot' - self.cmd.locale = 'ja_JP' - self.cmd.output_dir = 'project/i18n' - - self.cmd.finalize_options() - self.cmd.run() - - po_file = _po_file('ja_JP') - assert os.path.isfile(po_file) - - date = format_datetime(datetime(1994, 11, 11, 00, 00), 'yyyy-MM-dd HH:mmZ', tzinfo=LOCALTZ, locale='ja_JP') - expected_content = fr"""# Japanese (Japan) translations for TestProject. -# Copyright (C) 2007 FooBar, Inc. -# This file is distributed under the same license as the TestProject -# project. -# FIRST AUTHOR , 2007. -# -msgid "" -msgstr "" -"Project-Id-Version: TestProject 0.1\n" -"Report-Msgid-Bugs-To: bugs.address@email.tld\n" -"POT-Creation-Date: 2007-04-01 15:30+0200\n" -"PO-Revision-Date: {date}\n" -"Last-Translator: FULL NAME \n" -"Language: ja_JP\n" -"Language-Team: ja_JP \n" -"Plural-Forms: nplurals=1; plural=0;\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=utf-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel {VERSION}\n" - -#. This will be a translator coment, -#. that will include several lines -#: project/file1.py:8 -msgid "bar" -msgstr "" - -#: project/file2.py:9 -msgid "foobar" -msgid_plural "foobars" -msgstr[0] "" - -""" - with open(po_file) as f: - actual_content = f.read() - assert expected_content == actual_content - - @freeze_time("1994-11-11") - def test_supports_no_wrap(self): - self.cmd.input_file = 'project/i18n/long_messages.pot' - self.cmd.locale = 'en_US' - self.cmd.output_dir = 'project/i18n' - - long_message = '"' + 'xxxxx ' * 15 + '"' - - with open('project/i18n/messages.pot', 'rb') as f: - pot_contents = f.read().decode('latin-1') - pot_with_very_long_line = pot_contents.replace('"bar"', long_message) - with open(self.cmd.input_file, 'wb') as f: - f.write(pot_with_very_long_line.encode('latin-1')) - self.cmd.no_wrap = True - - self.cmd.finalize_options() - self.cmd.run() - - po_file = _po_file('en_US') - assert os.path.isfile(po_file) - date = format_datetime(datetime(1994, 11, 11, 00, 00), 'yyyy-MM-dd HH:mmZ', tzinfo=LOCALTZ, locale='en_US') - expected_content = fr"""# English (United States) translations for TestProject. -# Copyright (C) 2007 FooBar, Inc. -# This file is distributed under the same license as the TestProject -# project. -# FIRST AUTHOR , 2007. -# -msgid "" -msgstr "" -"Project-Id-Version: TestProject 0.1\n" -"Report-Msgid-Bugs-To: bugs.address@email.tld\n" -"POT-Creation-Date: 2007-04-01 15:30+0200\n" -"PO-Revision-Date: {date}\n" -"Last-Translator: FULL NAME \n" -"Language: en_US\n" -"Language-Team: en_US \n" -"Plural-Forms: nplurals=2; plural=(n != 1);\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=utf-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel {VERSION}\n" - -#. This will be a translator coment, -#. that will include several lines -#: project/file1.py:8 -msgid {long_message} -msgstr "" - -#: project/file2.py:9 -msgid "foobar" -msgid_plural "foobars" -msgstr[0] "" -msgstr[1] "" - -""" - with open(po_file) as f: - actual_content = f.read() - assert expected_content == actual_content - - @freeze_time("1994-11-11") - def test_supports_width(self): - self.cmd.input_file = 'project/i18n/long_messages.pot' - self.cmd.locale = 'en_US' - self.cmd.output_dir = 'project/i18n' - - long_message = '"' + 'xxxxx ' * 15 + '"' - - with open('project/i18n/messages.pot', 'rb') as f: - pot_contents = f.read().decode('latin-1') - pot_with_very_long_line = pot_contents.replace('"bar"', long_message) - with open(self.cmd.input_file, 'wb') as f: - f.write(pot_with_very_long_line.encode('latin-1')) - self.cmd.width = 120 - self.cmd.finalize_options() - self.cmd.run() - - po_file = _po_file('en_US') - assert os.path.isfile(po_file) - date = format_datetime(datetime(1994, 11, 11, 00, 00), 'yyyy-MM-dd HH:mmZ', tzinfo=LOCALTZ, locale='en_US') - expected_content = fr"""# English (United States) translations for TestProject. -# Copyright (C) 2007 FooBar, Inc. -# This file is distributed under the same license as the TestProject -# project. -# FIRST AUTHOR , 2007. -# -msgid "" -msgstr "" -"Project-Id-Version: TestProject 0.1\n" -"Report-Msgid-Bugs-To: bugs.address@email.tld\n" -"POT-Creation-Date: 2007-04-01 15:30+0200\n" -"PO-Revision-Date: {date}\n" -"Last-Translator: FULL NAME \n" -"Language: en_US\n" -"Language-Team: en_US \n" -"Plural-Forms: nplurals=2; plural=(n != 1);\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=utf-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel {VERSION}\n" - -#. This will be a translator coment, -#. that will include several lines -#: project/file1.py:8 -msgid {long_message} -msgstr "" - -#: project/file2.py:9 -msgid "foobar" -msgid_plural "foobars" -msgstr[0] "" -msgstr[1] "" - -""" - with open(po_file) as f: - actual_content = f.read() - assert expected_content == actual_content - - -class CommandLineInterfaceTestCase(unittest.TestCase): - - def setUp(self): - data_dir = os.path.join(this_dir, 'data') - self.orig_working_dir = os.getcwd() - self.orig_argv = sys.argv - self.orig_stdout = sys.stdout - self.orig_stderr = sys.stderr - sys.argv = ['pybabel'] - sys.stdout = StringIO() - sys.stderr = StringIO() - os.chdir(data_dir) - - self._remove_log_handlers() - self.cli = frontend.CommandLineInterface() - - def tearDown(self): - os.chdir(self.orig_working_dir) - sys.argv = self.orig_argv - sys.stdout = self.orig_stdout - sys.stderr = self.orig_stderr - for dirname in ['lv_LV', 'ja_JP']: - locale_dir = os.path.join(i18n_dir, dirname) - if os.path.isdir(locale_dir): - shutil.rmtree(locale_dir) - self._remove_log_handlers() - - def _remove_log_handlers(self): - # Logging handlers will be reused if possible (#227). This breaks the - # implicit assumption that our newly created StringIO for sys.stderr - # contains the console output. Removing the old handler ensures that a - # new handler with our new StringIO instance will be used. - log = logging.getLogger('babel') - for handler in log.handlers: - log.removeHandler(handler) - - def test_usage(self): - try: - self.cli.run(sys.argv) - self.fail('Expected SystemExit') - except SystemExit as e: - assert e.code == 2 - assert sys.stderr.getvalue().lower() == """\ -usage: pybabel command [options] [args] - -pybabel: error: no valid command or option passed. try the -h/--help option for more information. -""" - - def test_list_locales(self): - """ - Test the command with the --list-locales arg. - """ - result = self.cli.run(sys.argv + ['--list-locales']) - assert not result - output = sys.stdout.getvalue() - assert 'fr_CH' in output - assert 'French (Switzerland)' in output - assert "\nb'" not in output # No bytes repr markers in output - - def _run_init_catalog(self): - i18n_dir = os.path.join(data_dir, 'project', 'i18n') - pot_path = os.path.join(data_dir, 'project', 'i18n', 'messages.pot') - init_argv = sys.argv + ['init', '--locale', 'en_US', '-d', i18n_dir, - '-i', pot_path] - self.cli.run(init_argv) - - def test_no_duplicated_output_for_multiple_runs(self): - self._run_init_catalog() - first_output = sys.stderr.getvalue() - self._run_init_catalog() - second_output = sys.stderr.getvalue()[len(first_output):] - - # in case the log message is not duplicated we should get the same - # output as before - assert first_output == second_output - - def test_frontend_can_log_to_predefined_handler(self): - custom_stream = StringIO() - log = logging.getLogger('babel') - log.addHandler(logging.StreamHandler(custom_stream)) - - self._run_init_catalog() - assert id(sys.stderr) != id(custom_stream) - assert not sys.stderr.getvalue() - assert custom_stream.getvalue() - - def test_help(self): - try: - self.cli.run(sys.argv + ['--help']) - self.fail('Expected SystemExit') - except SystemExit as e: - assert not e.code - content = sys.stdout.getvalue().lower() - assert 'options:' in content - assert all(command in content for command in ('init', 'update', 'compile', 'extract')) - - def assert_pot_file_exists(self): - assert os.path.isfile(pot_file) - - @freeze_time("1994-11-11") - def test_extract_with_default_mapping(self): - self.cli.run(sys.argv + ['extract', - '--copyright-holder', 'FooBar, Inc.', - '--project', 'TestProject', '--version', '0.1', - '--msgid-bugs-address', 'bugs.address@email.tld', - '-c', 'TRANSLATOR', '-c', 'TRANSLATORS:', - '-o', pot_file, 'project']) - self.assert_pot_file_exists() - date = format_datetime(datetime(1994, 11, 11, 00, 00), 'yyyy-MM-dd HH:mmZ', tzinfo=LOCALTZ, locale='en') - expected_content = fr"""# Translations template for TestProject. -# Copyright (C) {time.strftime('%Y')} FooBar, Inc. -# This file is distributed under the same license as the TestProject -# project. -# FIRST AUTHOR , {time.strftime('%Y')}. -# -#, fuzzy -msgid "" -msgstr "" -"Project-Id-Version: TestProject 0.1\n" -"Report-Msgid-Bugs-To: bugs.address@email.tld\n" -"POT-Creation-Date: {date}\n" -"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: FULL NAME \n" -"Language-Team: LANGUAGE \n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=utf-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel {VERSION}\n" - -#. TRANSLATOR: This will be a translator coment, -#. that will include several lines -#: project/file1.py:8 -msgid "bar" -msgstr "" - -#: project/file2.py:9 -msgid "foobar" -msgid_plural "foobars" -msgstr[0] "" -msgstr[1] "" - -#: project/ignored/this_wont_normally_be_here.py:11 -msgid "FooBar" -msgid_plural "FooBars" -msgstr[0] "" -msgstr[1] "" - -""" - with open(pot_file) as f: - actual_content = f.read() - assert expected_content == actual_content - - @freeze_time("1994-11-11") - def test_extract_with_mapping_file(self): - self.cli.run(sys.argv + ['extract', - '--copyright-holder', 'FooBar, Inc.', - '--project', 'TestProject', '--version', '0.1', - '--msgid-bugs-address', 'bugs.address@email.tld', - '--mapping', os.path.join(data_dir, 'mapping.cfg'), - '-c', 'TRANSLATOR', '-c', 'TRANSLATORS:', - '-o', pot_file, 'project']) - self.assert_pot_file_exists() - date = format_datetime(datetime(1994, 11, 11, 00, 00), 'yyyy-MM-dd HH:mmZ', tzinfo=LOCALTZ, locale='en') - expected_content = fr"""# Translations template for TestProject. -# Copyright (C) {time.strftime('%Y')} FooBar, Inc. -# This file is distributed under the same license as the TestProject -# project. -# FIRST AUTHOR , {time.strftime('%Y')}. -# -#, fuzzy -msgid "" -msgstr "" -"Project-Id-Version: TestProject 0.1\n" -"Report-Msgid-Bugs-To: bugs.address@email.tld\n" -"POT-Creation-Date: {date}\n" -"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: FULL NAME \n" -"Language-Team: LANGUAGE \n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=utf-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel {VERSION}\n" - -#. TRANSLATOR: This will be a translator coment, -#. that will include several lines -#: project/file1.py:8 -msgid "bar" -msgstr "" - -#: project/file2.py:9 -msgid "foobar" -msgid_plural "foobars" -msgstr[0] "" -msgstr[1] "" - -""" - with open(pot_file) as f: - actual_content = f.read() - assert expected_content == actual_content - - @freeze_time("1994-11-11") - def test_extract_with_exact_file(self): - """Tests that we can call extract with a particular file and only - strings from that file get extracted. (Note the absence of strings from file1.py) - """ - file_to_extract = os.path.join(data_dir, 'project', 'file2.py') - self.cli.run(sys.argv + ['extract', - '--copyright-holder', 'FooBar, Inc.', - '--project', 'TestProject', '--version', '0.1', - '--msgid-bugs-address', 'bugs.address@email.tld', - '--mapping', os.path.join(data_dir, 'mapping.cfg'), - '-c', 'TRANSLATOR', '-c', 'TRANSLATORS:', - '-o', pot_file, file_to_extract]) - self.assert_pot_file_exists() - date = format_datetime(datetime(1994, 11, 11, 00, 00), 'yyyy-MM-dd HH:mmZ', tzinfo=LOCALTZ, locale='en') - expected_content = fr"""# Translations template for TestProject. -# Copyright (C) {time.strftime('%Y')} FooBar, Inc. -# This file is distributed under the same license as the TestProject -# project. -# FIRST AUTHOR , {time.strftime('%Y')}. -# -#, fuzzy -msgid "" -msgstr "" -"Project-Id-Version: TestProject 0.1\n" -"Report-Msgid-Bugs-To: bugs.address@email.tld\n" -"POT-Creation-Date: {date}\n" -"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: FULL NAME \n" -"Language-Team: LANGUAGE \n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=utf-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel {VERSION}\n" - -#: project/file2.py:9 -msgid "foobar" -msgid_plural "foobars" -msgstr[0] "" -msgstr[1] "" - -""" - with open(pot_file) as f: - actual_content = f.read() - assert expected_content == actual_content - - @freeze_time("1994-11-11") - def test_init_with_output_dir(self): - po_file = _po_file('en_US') - self.cli.run(sys.argv + ['init', - '--locale', 'en_US', - '-d', os.path.join(i18n_dir), - '-i', os.path.join(i18n_dir, 'messages.pot')]) - assert os.path.isfile(po_file) - date = format_datetime(datetime(1994, 11, 11, 00, 00), 'yyyy-MM-dd HH:mmZ', tzinfo=LOCALTZ, locale='en') - expected_content = fr"""# English (United States) translations for TestProject. -# Copyright (C) 2007 FooBar, Inc. -# This file is distributed under the same license as the TestProject -# project. -# FIRST AUTHOR , 2007. -# -msgid "" -msgstr "" -"Project-Id-Version: TestProject 0.1\n" -"Report-Msgid-Bugs-To: bugs.address@email.tld\n" -"POT-Creation-Date: 2007-04-01 15:30+0200\n" -"PO-Revision-Date: {date}\n" -"Last-Translator: FULL NAME \n" -"Language: en_US\n" -"Language-Team: en_US \n" -"Plural-Forms: nplurals=2; plural=(n != 1);\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=utf-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel {VERSION}\n" - -#. This will be a translator coment, -#. that will include several lines -#: project/file1.py:8 -msgid "bar" -msgstr "" - -#: project/file2.py:9 -msgid "foobar" -msgid_plural "foobars" -msgstr[0] "" -msgstr[1] "" - -""" - with open(po_file) as f: - actual_content = f.read() - assert expected_content == actual_content - - @freeze_time("1994-11-11") - def test_init_singular_plural_forms(self): - po_file = _po_file('ja_JP') - self.cli.run(sys.argv + ['init', - '--locale', 'ja_JP', - '-d', os.path.join(i18n_dir), - '-i', os.path.join(i18n_dir, 'messages.pot')]) - assert os.path.isfile(po_file) - date = format_datetime(datetime(1994, 11, 11, 00, 00), 'yyyy-MM-dd HH:mmZ', tzinfo=LOCALTZ, locale='en') - expected_content = fr"""# Japanese (Japan) translations for TestProject. -# Copyright (C) 2007 FooBar, Inc. -# This file is distributed under the same license as the TestProject -# project. -# FIRST AUTHOR , 2007. -# -msgid "" -msgstr "" -"Project-Id-Version: TestProject 0.1\n" -"Report-Msgid-Bugs-To: bugs.address@email.tld\n" -"POT-Creation-Date: 2007-04-01 15:30+0200\n" -"PO-Revision-Date: {date}\n" -"Last-Translator: FULL NAME \n" -"Language: ja_JP\n" -"Language-Team: ja_JP \n" -"Plural-Forms: nplurals=1; plural=0;\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=utf-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel {VERSION}\n" - -#. This will be a translator coment, -#. that will include several lines -#: project/file1.py:8 -msgid "bar" -msgstr "" - -#: project/file2.py:9 -msgid "foobar" -msgid_plural "foobars" -msgstr[0] "" - -""" - with open(po_file) as f: - actual_content = f.read() - assert expected_content == actual_content - - @freeze_time("1994-11-11") - def test_init_more_than_2_plural_forms(self): - po_file = _po_file('lv_LV') - self.cli.run(sys.argv + ['init', - '--locale', 'lv_LV', - '-d', i18n_dir, - '-i', os.path.join(i18n_dir, 'messages.pot')]) - assert os.path.isfile(po_file) - date = format_datetime(datetime(1994, 11, 11, 00, 00), 'yyyy-MM-dd HH:mmZ', tzinfo=LOCALTZ, locale='en') - expected_content = fr"""# Latvian (Latvia) translations for TestProject. -# Copyright (C) 2007 FooBar, Inc. -# This file is distributed under the same license as the TestProject -# project. -# FIRST AUTHOR , 2007. -# -msgid "" -msgstr "" -"Project-Id-Version: TestProject 0.1\n" -"Report-Msgid-Bugs-To: bugs.address@email.tld\n" -"POT-Creation-Date: 2007-04-01 15:30+0200\n" -"PO-Revision-Date: {date}\n" -"Last-Translator: FULL NAME \n" -"Language: lv_LV\n" -"Language-Team: lv_LV \n" -"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n != 0 ? 1 :" -" 2);\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=utf-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel {VERSION}\n" - -#. This will be a translator coment, -#. that will include several lines -#: project/file1.py:8 -msgid "bar" -msgstr "" - -#: project/file2.py:9 -msgid "foobar" -msgid_plural "foobars" -msgstr[0] "" -msgstr[1] "" -msgstr[2] "" - -""" - with open(po_file) as f: - actual_content = f.read() - assert expected_content == actual_content - - def test_compile_catalog(self): - po_file = _po_file('de_DE') - mo_file = po_file.replace('.po', '.mo') - self.cli.run(sys.argv + ['compile', - '--locale', 'de_DE', - '-d', i18n_dir]) - assert not os.path.isfile(mo_file), f'Expected no file at {mo_file!r}' - assert sys.stderr.getvalue() == f'catalog {po_file} is marked as fuzzy, skipping\n' - - def test_compile_fuzzy_catalog(self): - po_file = _po_file('de_DE') - mo_file = po_file.replace('.po', '.mo') - try: - self.cli.run(sys.argv + ['compile', - '--locale', 'de_DE', '--use-fuzzy', - '-d', i18n_dir]) - assert os.path.isfile(mo_file) - assert sys.stderr.getvalue() == f'compiling catalog {po_file} to {mo_file}\n' - finally: - if os.path.isfile(mo_file): - os.unlink(mo_file) - - def test_compile_catalog_with_more_than_2_plural_forms(self): - po_file = _po_file('ru_RU') - mo_file = po_file.replace('.po', '.mo') - try: - self.cli.run(sys.argv + ['compile', - '--locale', 'ru_RU', '--use-fuzzy', - '-d', i18n_dir]) - assert os.path.isfile(mo_file) - assert sys.stderr.getvalue() == f'compiling catalog {po_file} to {mo_file}\n' - finally: - if os.path.isfile(mo_file): - os.unlink(mo_file) - - def test_compile_catalog_multidomain(self): - po_foo = os.path.join(i18n_dir, 'de_DE', 'LC_MESSAGES', 'foo.po') - po_bar = os.path.join(i18n_dir, 'de_DE', 'LC_MESSAGES', 'bar.po') - mo_foo = po_foo.replace('.po', '.mo') - mo_bar = po_bar.replace('.po', '.mo') - try: - self.cli.run(sys.argv + ['compile', - '--locale', 'de_DE', '--domain', 'foo bar', '--use-fuzzy', - '-d', i18n_dir]) - for mo_file in [mo_foo, mo_bar]: - assert os.path.isfile(mo_file) - assert sys.stderr.getvalue() == ( - f'compiling catalog {po_foo} to {mo_foo}\n' - f'compiling catalog {po_bar} to {mo_bar}\n' - ) - - finally: - for mo_file in [mo_foo, mo_bar]: - if os.path.isfile(mo_file): - os.unlink(mo_file) - - def test_update(self): - template = Catalog() - template.add("1") - template.add("2") - template.add("3") - tmpl_file = os.path.join(i18n_dir, 'temp-template.pot') - with open(tmpl_file, "wb") as outfp: - write_po(outfp, template) - po_file = os.path.join(i18n_dir, 'temp1.po') - self.cli.run(sys.argv + ['init', - '-l', 'fi', - '-o', po_file, - '-i', tmpl_file, - ]) - with open(po_file) as infp: - catalog = read_po(infp) - assert len(catalog) == 3 - - # Add another entry to the template - - template.add("4") - - with open(tmpl_file, "wb") as outfp: - write_po(outfp, template) - - self.cli.run(sys.argv + ['update', - '-l', 'fi_FI', - '-o', po_file, - '-i', tmpl_file]) - - with open(po_file) as infp: - catalog = read_po(infp) - assert len(catalog) == 4 # Catalog was updated - - def test_update_pot_creation_date(self): - template = Catalog() - template.add("1") - template.add("2") - template.add("3") - tmpl_file = os.path.join(i18n_dir, 'temp-template.pot') - with open(tmpl_file, "wb") as outfp: - write_po(outfp, template) - po_file = os.path.join(i18n_dir, 'temp1.po') - self.cli.run(sys.argv + ['init', - '-l', 'fi', - '-o', po_file, - '-i', tmpl_file, - ]) - with open(po_file) as infp: - catalog = read_po(infp) - assert len(catalog) == 3 - original_catalog_creation_date = catalog.creation_date - - # Update the template creation date - template.creation_date -= timedelta(minutes=3) - with open(tmpl_file, "wb") as outfp: - write_po(outfp, template) - - self.cli.run(sys.argv + ['update', - '-l', 'fi_FI', - '-o', po_file, - '-i', tmpl_file]) - - with open(po_file) as infp: - catalog = read_po(infp) - # We didn't ignore the creation date, so expect a diff - assert catalog.creation_date != original_catalog_creation_date - - # Reset the "original" - original_catalog_creation_date = catalog.creation_date - - # Update the template creation date again - # This time, pass the ignore flag and expect the times are different - template.creation_date -= timedelta(minutes=5) - with open(tmpl_file, "wb") as outfp: - write_po(outfp, template) - - self.cli.run(sys.argv + ['update', - '-l', 'fi_FI', - '-o', po_file, - '-i', tmpl_file, - '--ignore-pot-creation-date']) - - with open(po_file) as infp: - catalog = read_po(infp) - # We ignored creation date, so it should not have changed - assert catalog.creation_date == original_catalog_creation_date - - def test_check(self): - template = Catalog() - template.add("1") - template.add("2") - template.add("3") - tmpl_file = os.path.join(i18n_dir, 'temp-template.pot') - with open(tmpl_file, "wb") as outfp: - write_po(outfp, template) - po_file = os.path.join(i18n_dir, 'temp1.po') - self.cli.run(sys.argv + ['init', - '-l', 'fi_FI', - '-o', po_file, - '-i', tmpl_file, - ]) - - # Update the catalog file - self.cli.run(sys.argv + ['update', - '-l', 'fi_FI', - '-o', po_file, - '-i', tmpl_file]) - - # Run a check without introducing any changes to the template - self.cli.run(sys.argv + ['update', - '--check', - '-l', 'fi_FI', - '-o', po_file, - '-i', tmpl_file]) - - # Add a new entry and expect the check to fail - template.add("4") - with open(tmpl_file, "wb") as outfp: - write_po(outfp, template) - - with pytest.raises(BaseError): - self.cli.run(sys.argv + ['update', - '--check', - '-l', 'fi_FI', - '-o', po_file, - '-i', tmpl_file]) - - # Write the latest changes to the po-file - self.cli.run(sys.argv + ['update', - '-l', 'fi_FI', - '-o', po_file, - '-i', tmpl_file]) - - # Update an entry and expect the check to fail - template.add("4", locations=[("foo.py", 1)]) - with open(tmpl_file, "wb") as outfp: - write_po(outfp, template) - - with pytest.raises(BaseError): - self.cli.run(sys.argv + ['update', - '--check', - '-l', 'fi_FI', - '-o', po_file, - '-i', tmpl_file]) - - def test_check_pot_creation_date(self): - template = Catalog() - template.add("1") - template.add("2") - template.add("3") - tmpl_file = os.path.join(i18n_dir, 'temp-template.pot') - with open(tmpl_file, "wb") as outfp: - write_po(outfp, template) - po_file = os.path.join(i18n_dir, 'temp1.po') - self.cli.run(sys.argv + ['init', - '-l', 'fi_FI', - '-o', po_file, - '-i', tmpl_file, - ]) - - # Update the catalog file - self.cli.run(sys.argv + ['update', - '-l', 'fi_FI', - '-o', po_file, - '-i', tmpl_file]) - - # Run a check without introducing any changes to the template - self.cli.run(sys.argv + ['update', - '--check', - '-l', 'fi_FI', - '-o', po_file, - '-i', tmpl_file]) - - # Run a check after changing the template creation date - template.creation_date = datetime.now() - timedelta(minutes=5) - with open(tmpl_file, "wb") as outfp: - write_po(outfp, template) - - # Should fail without --ignore-pot-creation-date flag - with pytest.raises(BaseError): - self.cli.run(sys.argv + ['update', - '--check', - '-l', 'fi_FI', - '-o', po_file, - '-i', tmpl_file]) - # Should pass with --ignore-pot-creation-date flag - self.cli.run(sys.argv + ['update', - '--check', - '-l', 'fi_FI', - '-o', po_file, - '-i', tmpl_file, - '--ignore-pot-creation-date']) - - def test_update_init_missing(self): - template = Catalog() - template.add("1") - template.add("2") - template.add("3") - tmpl_file = os.path.join(i18n_dir, 'temp2-template.pot') - with open(tmpl_file, "wb") as outfp: - write_po(outfp, template) - po_file = os.path.join(i18n_dir, 'temp2.po') - - self.cli.run(sys.argv + ['update', - '--init-missing', - '-l', 'fi', - '-o', po_file, - '-i', tmpl_file]) - - with open(po_file) as infp: - catalog = read_po(infp) - assert len(catalog) == 3 - - # Add another entry to the template - - template.add("4") - - with open(tmpl_file, "wb") as outfp: - write_po(outfp, template) - - self.cli.run(sys.argv + ['update', - '--init-missing', - '-l', 'fi_FI', - '-o', po_file, - '-i', tmpl_file]) - - with open(po_file) as infp: - catalog = read_po(infp) - assert len(catalog) == 4 # Catalog was updated - - -mapping_cfg = """ -[extractors] -custom = tests.messages.utils:custom_extractor - -# Special extractor for a given Python file -[custom: special.py] -treat = delicious - -# Python source files -[python: **.py] - -# Genshi templates -[genshi: **/templates/**.html] -include_attrs = - -[genshi: **/templates/**.txt] -template_class = genshi.template:TextTemplate -encoding = latin-1 - -# Some custom extractor -[custom: **/custom/*.*] -""" - -mapping_toml = """ -[extractors] -custom = "tests.messages.utils:custom_extractor" - -# Special extractor for a given Python file -[[mappings]] -method = "custom" -pattern = "special.py" -treat = "delightful" - -# Python source files -[[mappings]] -method = "python" -pattern = "**.py" - -# Genshi templates -[[mappings]] -method = "genshi" -pattern = "**/templates/**.html" -include_attrs = "" - -[[mappings]] -method = "genshi" -pattern = "**/templates/**.txt" -template_class = "genshi.template:TextTemplate" -encoding = "latin-1" - -# Some custom extractor -[[mappings]] -method = "custom" -pattern = "**/custom/*.*" -""" - - -@pytest.mark.parametrize( - ("data", "parser", "preprocess", "is_toml"), - [ - ( - mapping_cfg, - frontend.parse_mapping_cfg, - None, - False, - ), - ( - mapping_toml, - frontend._parse_mapping_toml, - None, - True, - ), - ( - mapping_toml, - partial(frontend._parse_mapping_toml, style="pyproject.toml"), - lambda s: re.sub(r"^(\[+)", r"\1tool.babel.", s, flags=re.MULTILINE), - True, - ), - ], - ids=("cfg", "toml", "pyproject-toml"), -) -def test_parse_mapping(data: str, parser, preprocess, is_toml): - if preprocess: - data = preprocess(data) - if is_toml: - buf = BytesIO(data.encode()) - else: - buf = StringIO(data) - - method_map, options_map = parser(buf) - assert len(method_map) == 5 - - assert method_map[1] == ('**.py', 'python') - assert options_map['**.py'] == {} - assert method_map[2] == ('**/templates/**.html', 'genshi') - assert options_map['**/templates/**.html']['include_attrs'] == '' - assert method_map[3] == ('**/templates/**.txt', 'genshi') - assert (options_map['**/templates/**.txt']['template_class'] - == 'genshi.template:TextTemplate') - assert options_map['**/templates/**.txt']['encoding'] == 'latin-1' - assert method_map[4] == ('**/custom/*.*', 'tests.messages.utils:custom_extractor') - assert options_map['**/custom/*.*'] == {} - - -def test_parse_keywords(): - kw = frontend.parse_keywords(['_', 'dgettext:2', - 'dngettext:2,3', 'pgettext:1c,2']) - assert kw == { - '_': None, - 'dgettext': (2,), - 'dngettext': (2, 3), - 'pgettext': ((1, 'c'), 2), - } - - -def test_parse_keywords_with_t(): - kw = frontend.parse_keywords(['_:1', '_:2,2t', '_:2c,3,3t']) - - assert kw == { - '_': { - None: (1,), - 2: (2,), - 3: ((2, 'c'), 3), - }, - } - - -def test_extract_messages_with_t(): - content = rb""" -_("1 arg, arg 1") -_("2 args, arg 1", "2 args, arg 2") -_("3 args, arg 1", "3 args, arg 2", "3 args, arg 3") -_("4 args, arg 1", "4 args, arg 2", "4 args, arg 3", "4 args, arg 4") -""" - kw = frontend.parse_keywords(['_:1', '_:2,2t', '_:2c,3,3t']) - result = list(extract.extract("python", BytesIO(content), kw)) - expected = [(2, '1 arg, arg 1', [], None), - (3, '2 args, arg 1', [], None), - (3, '2 args, arg 2', [], None), - (4, '3 args, arg 1', [], None), - (4, '3 args, arg 3', [], '3 args, arg 2'), - (5, '4 args, arg 1', [], None)] - assert result == expected - - -def configure_cli_command(cmdline: str | list[str]): - """ - Helper to configure a command class, but not run it just yet. - - :param cmdline: The command line (sans the executable name) - :return: Command instance - """ - args = shlex.split(cmdline) if isinstance(cmdline, str) else list(cmdline) - cli = CommandLineInterface() - cmdinst = cli._configure_command(cmdname=args[0], argv=args[1:]) - return cmdinst - - -@pytest.mark.parametrize("split", (False, True)) -@pytest.mark.parametrize("arg_name", ("-k", "--keyword", "--keywords")) -def test_extract_keyword_args_384(split, arg_name): - # This is a regression test for https://github.com/python-babel/babel/issues/384 - # and it also tests that the rest of the forgotten aliases/shorthands implied by - # https://github.com/python-babel/babel/issues/390 are re-remembered (or rather - # that the mechanism for remembering them again works). - - kwarg_specs = [ - "gettext_noop", - "gettext_lazy", - "ngettext_lazy:1,2", - "ugettext_noop", - "ugettext_lazy", - "ungettext_lazy:1,2", - "pgettext_lazy:1c,2", - "npgettext_lazy:1c,2,3", - ] - - if split: # Generate a command line with multiple -ks - kwarg_text = " ".join(f"{arg_name} {kwarg_spec}" for kwarg_spec in kwarg_specs) - else: # Generate a single space-separated -k - specs = ' '.join(kwarg_specs) - kwarg_text = f'{arg_name} "{specs}"' - - # (Both of those invocation styles should be equivalent, so there is no parametrization from here on out) - - cmdinst = configure_cli_command( - f"extract -F babel-django.cfg --add-comments Translators: -o django232.pot {kwarg_text} .", - ) - assert isinstance(cmdinst, ExtractMessages) - assert set(cmdinst.keywords.keys()) == {'_', 'dgettext', 'dngettext', - 'gettext', 'gettext_lazy', - 'gettext_noop', 'N_', 'ngettext', - 'ngettext_lazy', 'npgettext', - 'npgettext_lazy', 'pgettext', - 'pgettext_lazy', 'ugettext', - 'ugettext_lazy', 'ugettext_noop', - 'ungettext', 'ungettext_lazy'} - - -def test_update_catalog_boolean_args(): - cmdinst = configure_cli_command( - "update --init-missing --no-wrap -N --ignore-obsolete --previous -i foo -o foo -l en") - assert isinstance(cmdinst, UpdateCatalog) - assert cmdinst.init_missing is True - assert cmdinst.no_wrap is True - assert cmdinst.no_fuzzy_matching is True - assert cmdinst.ignore_obsolete is True - assert cmdinst.previous is False # Mutually exclusive with no_fuzzy_matching - - - -def test_compile_catalog_dir(tmp_path): - """ - Test that `compile` can compile all locales in a directory. - """ - locales = ("fi_FI", "sv_SE") - for locale in locales: - l_dir = tmp_path / locale / "LC_MESSAGES" - l_dir.mkdir(parents=True) - po_file = l_dir / 'messages.po' - po_file.write_text('msgid "foo"\nmsgstr "bar"\n') - cmdinst = configure_cli_command([ # fmt: skip - 'compile', - '--statistics', - '--use-fuzzy', - '-d', str(tmp_path), - ]) - assert not cmdinst.run() - for locale in locales: - assert (tmp_path / locale / "LC_MESSAGES" / "messages.mo").exists() - - -def test_compile_catalog_explicit(tmp_path): - """ - Test that `compile` can explicitly compile a single catalog. - """ - po_file = tmp_path / 'temp.po' - po_file.write_text('msgid "foo"\nmsgstr "bar"\n') - mo_file = tmp_path / 'temp.mo' - cmdinst = configure_cli_command([ # fmt: skip - 'compile', - '--statistics', - '--use-fuzzy', - '-i', str(po_file), - '-o', str(mo_file), - '-l', 'fi_FI', - ]) - assert not cmdinst.run() - assert mo_file.exists() - - - -@pytest.mark.parametrize("explicit_locale", (None, 'fi_FI'), ids=("implicit", "explicit")) -def test_update_dir(tmp_path, explicit_locale: bool): - """ - Test that `update` can deal with directories too. - """ - template = Catalog() - template.add("1") - template.add("2") - template.add("3") - tmpl_file = (tmp_path / 'temp-template.pot') - with tmpl_file.open("wb") as outfp: - write_po(outfp, template) - locales = ("fi_FI", "sv_SE") - for locale in locales: - l_dir = tmp_path / locale / "LC_MESSAGES" - l_dir.mkdir(parents=True) - po_file = l_dir / 'messages.po' - po_file.touch() - cmdinst = configure_cli_command([ # fmt: skip - 'update', - '-i', str(tmpl_file), - '-d', str(tmp_path), - *(['-l', explicit_locale] if explicit_locale else []), - ]) - assert not cmdinst.run() - for locale in locales: - if explicit_locale and locale != explicit_locale: - continue - assert (tmp_path / locale / "LC_MESSAGES" / "messages.po").stat().st_size > 0 - - -def test_extract_cli_knows_dash_s(): - # This is a regression test for https://github.com/python-babel/babel/issues/390 - cmdinst = configure_cli_command("extract -s -o foo babel") - assert isinstance(cmdinst, ExtractMessages) - assert cmdinst.strip_comments - - -def test_extract_cli_knows_dash_dash_last_dash_translator(): - cmdinst = configure_cli_command('extract --last-translator "FULL NAME EMAIL@ADDRESS" -o foo babel') - assert isinstance(cmdinst, ExtractMessages) - assert cmdinst.last_translator == "FULL NAME EMAIL@ADDRESS" - - -def test_extract_add_location(): - cmdinst = configure_cli_command("extract -o foo babel --add-location full") - assert isinstance(cmdinst, ExtractMessages) - assert cmdinst.add_location == 'full' - assert not cmdinst.no_location - assert cmdinst.include_lineno - - cmdinst = configure_cli_command("extract -o foo babel --add-location file") - assert isinstance(cmdinst, ExtractMessages) - assert cmdinst.add_location == 'file' - assert not cmdinst.no_location - assert not cmdinst.include_lineno - - cmdinst = configure_cli_command("extract -o foo babel --add-location never") - assert isinstance(cmdinst, ExtractMessages) - assert cmdinst.add_location == 'never' - assert cmdinst.no_location - - -def test_extract_error_code(monkeypatch, capsys): - monkeypatch.chdir(project_dir) - cmdinst = configure_cli_command("compile --domain=messages --directory i18n --locale fi_BUGGY") - assert cmdinst.run() == 1 - out, err = capsys.readouterr() - if err: - assert "unknown named placeholder 'merkki'" in err - - -@pytest.mark.parametrize("with_underscore_ignore", (False, True)) -def test_extract_ignore_dirs(monkeypatch, capsys, tmp_path, with_underscore_ignore): - pot_file = tmp_path / 'temp.pot' - monkeypatch.chdir(project_dir) - cmd = f"extract . -o '{pot_file}' --ignore-dirs '*ignored* .*' " - if with_underscore_ignore: - # This also tests that multiple arguments are supported. - cmd += "--ignore-dirs '_*'" - cmdinst = configure_cli_command(cmd) - assert isinstance(cmdinst, ExtractMessages) - assert cmdinst.directory_filter - cmdinst.run() - pot_content = pot_file.read_text() - - # The `ignored` directory is now actually ignored: - assert 'this_wont_normally_be_here' not in pot_content - - # Since we manually set a filter, the otherwise `_hidden` directory is walked into, - # unless we opt in to ignore it again - assert ('ssshhh....' in pot_content) != with_underscore_ignore - assert ('_hidden_by_default' in pot_content) != with_underscore_ignore - - -def test_extract_header_comment(monkeypatch, tmp_path): - pot_file = tmp_path / 'temp.pot' - monkeypatch.chdir(project_dir) - cmdinst = configure_cli_command(f"extract . -o '{pot_file}' --header-comment 'Boing' ") - cmdinst.run() - pot_content = pot_file.read_text() - assert 'Boing' in pot_content - - -@pytest.mark.parametrize("mapping_format", ("toml", "cfg")) -def test_pr_1121(tmp_path, monkeypatch, caplog, mapping_format): - """ - Test that extraction uses the first matching method and options, - instead of the first matching method and last matching options. - - Without the fix in PR #1121, this test would fail, - since the `custom_extractor` isn't passed a delicious treat via - the configuration. - """ - if mapping_format == "cfg": - mapping_file = (tmp_path / "mapping.cfg") - mapping_file.write_text(mapping_cfg) - else: - mapping_file = (tmp_path / "mapping.toml") - mapping_file.write_text(mapping_toml) - (tmp_path / "special.py").write_text("# this file is special") - pot_path = (tmp_path / "output.pot") - monkeypatch.chdir(tmp_path) - cmdinst = configure_cli_command(f"extract . -o {shlex.quote(str(pot_path))} --mapping {shlex.quote(mapping_file.name)}") - assert isinstance(cmdinst, ExtractMessages) - cmdinst.run() - # If the custom extractor didn't run, we wouldn't see the cookie in there. - assert CUSTOM_EXTRACTOR_COOKIE in pot_path.read_text() diff --git a/tests/messages/test_mofile.py b/tests/messages/test_mofile.py index 8d1a89eb..85f4e9f3 100644 --- a/tests/messages/test_mofile.py +++ b/tests/messages/test_mofile.py @@ -11,79 +11,76 @@ # history and logs, available at https://github.com/python-babel/babel/commits/master/. import os -import unittest from io import BytesIO from babel.messages import Catalog, mofile from babel.support import Translations +data_dir = os.path.join(os.path.dirname(__file__), 'data') -class ReadMoTestCase(unittest.TestCase): - def setUp(self): - self.datadir = os.path.join(os.path.dirname(__file__), 'data') +def test_basics(): + mo_path = os.path.join(data_dir, 'project', 'i18n', 'de', + 'LC_MESSAGES', 'messages.mo') + with open(mo_path, 'rb') as mo_file: + catalog = mofile.read_mo(mo_file) + assert len(catalog) == 2 + assert catalog.project == 'TestProject' + assert catalog.version == '0.1' + assert catalog['bar'].string == 'Stange' + assert catalog['foobar'].string == ['Fuhstange', 'Fuhstangen'] - def test_basics(self): - mo_path = os.path.join(self.datadir, 'project', 'i18n', 'de', - 'LC_MESSAGES', 'messages.mo') - with open(mo_path, 'rb') as mo_file: - catalog = mofile.read_mo(mo_file) - assert len(catalog) == 2 - assert catalog.project == 'TestProject' - assert catalog.version == '0.1' - assert catalog['bar'].string == 'Stange' - assert catalog['foobar'].string == ['Fuhstange', 'Fuhstangen'] -class WriteMoTestCase(unittest.TestCase): - - def test_sorting(self): - # Ensure the header is sorted to the first entry so that its charset - # can be applied to all subsequent messages by GNUTranslations - # (ensuring all messages are safely converted to unicode) - catalog = Catalog(locale='en_US') - catalog.add('', '''\ +def test_sorting(): + # Ensure the header is sorted to the first entry so that its charset + # can be applied to all subsequent messages by GNUTranslations + # (ensuring all messages are safely converted to unicode) + catalog = Catalog(locale='en_US') + catalog.add('', '''\ "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n''') - catalog.add('foo', 'Voh') - catalog.add(('There is', 'There are'), ('Es gibt', 'Es gibt')) - catalog.add('Fizz', '') - catalog.add(('Fuzz', 'Fuzzes'), ('', '')) - buf = BytesIO() - mofile.write_mo(buf, catalog) - buf.seek(0) - translations = Translations(fp=buf) - assert translations.ugettext('foo') == 'Voh' - assert translations.ungettext('There is', 'There are', 1) == 'Es gibt' - assert translations.ugettext('Fizz') == 'Fizz' - assert translations.ugettext('Fuzz') == 'Fuzz' - assert translations.ugettext('Fuzzes') == 'Fuzzes' + catalog.add('foo', 'Voh') + catalog.add(('There is', 'There are'), ('Es gibt', 'Es gibt')) + catalog.add('Fizz', '') + catalog.add(('Fuzz', 'Fuzzes'), ('', '')) + buf = BytesIO() + mofile.write_mo(buf, catalog) + buf.seek(0) + translations = Translations(fp=buf) + assert translations.ugettext('foo') == 'Voh' + assert translations.ungettext('There is', 'There are', 1) == 'Es gibt' + assert translations.ugettext('Fizz') == 'Fizz' + assert translations.ugettext('Fuzz') == 'Fuzz' + assert translations.ugettext('Fuzzes') == 'Fuzzes' + + +def test_more_plural_forms(): + catalog2 = Catalog(locale='ru_RU') + catalog2.add(('Fuzz', 'Fuzzes'), ('', '', '')) + buf = BytesIO() + mofile.write_mo(buf, catalog2) - def test_more_plural_forms(self): - catalog2 = Catalog(locale='ru_RU') - catalog2.add(('Fuzz', 'Fuzzes'), ('', '', '')) - buf = BytesIO() - mofile.write_mo(buf, catalog2) - def test_empty_translation_with_fallback(self): - catalog1 = Catalog(locale='fr_FR') - catalog1.add('', '''\ +def test_empty_translation_with_fallback(): + catalog1 = Catalog(locale='fr_FR') + catalog1.add('', '''\ "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n''') - catalog1.add('Fuzz', '') - buf1 = BytesIO() - mofile.write_mo(buf1, catalog1) - buf1.seek(0) - catalog2 = Catalog(locale='fr') - catalog2.add('', '''\ + catalog1.add('Fuzz', '') + buf1 = BytesIO() + mofile.write_mo(buf1, catalog1) + buf1.seek(0) + catalog2 = Catalog(locale='fr') + catalog2.add('', '''\ "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n''') - catalog2.add('Fuzz', 'Flou') - buf2 = BytesIO() - mofile.write_mo(buf2, catalog2) - buf2.seek(0) + catalog2.add('Fuzz', 'Flou') + buf2 = BytesIO() + mofile.write_mo(buf2, catalog2) + buf2.seek(0) - translations = Translations(fp=buf1) - translations.add_fallback(Translations(fp=buf2)) + translations = Translations(fp=buf1) + translations.add_fallback(Translations(fp=buf2)) - assert translations.ugettext('Fuzz') == 'Flou' + assert translations.ugettext('Fuzz') == 'Flou' diff --git a/tests/messages/test_pofile.py b/tests/messages/test_pofile.py index ffc95295..cdbb5826 100644 --- a/tests/messages/test_pofile.py +++ b/tests/messages/test_pofile.py @@ -10,991 +10,48 @@ # individuals. For the exact contribution history, see the revision # history and logs, available at https://github.com/python-babel/babel/commits/master/. -import unittest -from datetime import datetime from io import BytesIO, StringIO import pytest from babel.core import Locale from babel.messages import pofile -from babel.messages.catalog import Catalog, Message +from babel.messages.catalog import Catalog from babel.messages.pofile import _enclose_filename_if_necessary, _extract_locations -from babel.util import FixedOffsetTimezone -class ReadPoTestCase(unittest.TestCase): - - def test_preserve_locale(self): - buf = StringIO(r'''msgid "foo" -msgstr "Voh"''') - catalog = pofile.read_po(buf, locale='en_US') - assert Locale('en', 'US') == catalog.locale - - def test_locale_gets_overridden_by_file(self): - buf = StringIO(r''' -msgid "" -msgstr "" -"Language: en_US\n"''') - catalog = pofile.read_po(buf, locale='de') - assert Locale('en', 'US') == catalog.locale - buf = StringIO(r''' -msgid "" -msgstr "" -"Language: ko-KR\n"''') - catalog = pofile.read_po(buf, locale='de') - assert Locale('ko', 'KR') == catalog.locale - - def test_preserve_domain(self): - buf = StringIO(r'''msgid "foo" -msgstr "Voh"''') - catalog = pofile.read_po(buf, domain='mydomain') - assert catalog.domain == 'mydomain' - - def test_applies_specified_encoding_during_read(self): - buf = BytesIO(''' -msgid "" -msgstr "" -"Project-Id-Version: 3.15\\n" -"Report-Msgid-Bugs-To: Fliegender Zirkus \\n" -"POT-Creation-Date: 2007-09-27 11:19+0700\\n" -"PO-Revision-Date: 2007-09-27 21:42-0700\\n" -"Last-Translator: John \\n" -"Language-Team: German Lang \\n" -"Plural-Forms: nplurals=2; plural=(n != 1);\\n" -"MIME-Version: 1.0\\n" -"Content-Type: text/plain; charset=iso-8859-1\\n" -"Content-Transfer-Encoding: 8bit\\n" -"Generated-By: Babel 1.0dev-r313\\n" - -msgid "foo" -msgstr "bär"'''.encode('iso-8859-1')) - catalog = pofile.read_po(buf, locale='de_DE') - assert catalog.get('foo').string == 'bär' - - def test_encoding_header_read(self): - buf = BytesIO(b'msgid ""\nmsgstr ""\n"Content-Type: text/plain; charset=mac_roman\\n"\n') - catalog = pofile.read_po(buf, locale='xx_XX') - assert catalog.charset == 'mac_roman' - - def test_plural_forms_header_parsed(self): - buf = BytesIO(b'msgid ""\nmsgstr ""\n"Plural-Forms: nplurals=42; plural=(n % 11);\\n"\n') - catalog = pofile.read_po(buf, locale='xx_XX') - assert catalog.plural_expr == '(n % 11)' - assert catalog.num_plurals == 42 - - def test_read_multiline(self): - buf = StringIO(r'''msgid "" -"Here's some text that\n" -"includesareallylongwordthatmightbutshouldnt" -" throw us into an infinite " -"loop\n" -msgstr ""''') - catalog = pofile.read_po(buf) - assert len(catalog) == 1 - message = list(catalog)[1] - assert message.id == ( - "Here's some text that\nincludesareallylongwordthat" - "mightbutshouldnt throw us into an infinite loop\n" - ) - - def test_fuzzy_header(self): - buf = StringIO(r''' -# Translations template for AReallyReallyLongNameForAProject. -# Copyright (C) 2007 ORGANIZATION -# This file is distributed under the same license as the -# AReallyReallyLongNameForAProject project. -# FIRST AUTHOR , 2007. -# -#, fuzzy -''') - catalog = pofile.read_po(buf) - assert len(list(catalog)) == 1 - assert list(catalog)[0].fuzzy - - def test_not_fuzzy_header(self): - buf = StringIO(r''' -# Translations template for AReallyReallyLongNameForAProject. -# Copyright (C) 2007 ORGANIZATION -# This file is distributed under the same license as the -# AReallyReallyLongNameForAProject project. -# FIRST AUTHOR , 2007. -# -''') - catalog = pofile.read_po(buf) - assert len(list(catalog)) == 1 - assert not list(catalog)[0].fuzzy - - def test_header_entry(self): - buf = StringIO(r''' -# SOME DESCRIPTIVE TITLE. -# Copyright (C) 2007 THE PACKAGE'S COPYRIGHT HOLDER -# This file is distributed under the same license as the PACKAGE package. -# FIRST AUTHOR , 2007. -# -#, fuzzy -msgid "" -msgstr "" -"Project-Id-Version: 3.15\n" -"Report-Msgid-Bugs-To: Fliegender Zirkus \n" -"POT-Creation-Date: 2007-09-27 11:19+0700\n" -"PO-Revision-Date: 2007-09-27 21:42-0700\n" -"Last-Translator: John \n" -"Language: de\n" -"Language-Team: German Lang \n" -"Plural-Forms: nplurals=2; plural=(n != 1);\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=iso-8859-2\n" -"Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 1.0dev-r313\n" -''') - catalog = pofile.read_po(buf) - assert len(list(catalog)) == 1 - assert catalog.version == '3.15' - assert catalog.msgid_bugs_address == 'Fliegender Zirkus ' - assert datetime(2007, 9, 27, 11, 19, tzinfo=FixedOffsetTimezone(7 * 60)) == catalog.creation_date - assert catalog.last_translator == 'John ' - assert Locale('de') == catalog.locale - assert catalog.language_team == 'German Lang ' - assert catalog.charset == 'iso-8859-2' - assert list(catalog)[0].fuzzy - - def test_obsolete_message(self): - buf = StringIO(r'''# This is an obsolete message -#~ msgid "foo" -#~ msgstr "Voh" - -# This message is not obsolete -#: main.py:1 -msgid "bar" -msgstr "Bahr" -''') - catalog = pofile.read_po(buf) - assert len(catalog) == 1 - assert len(catalog.obsolete) == 1 - message = catalog.obsolete['foo'] - assert message.id == 'foo' - assert message.string == 'Voh' - assert message.user_comments == ['This is an obsolete message'] - - def test_obsolete_message_ignored(self): - buf = StringIO(r'''# This is an obsolete message -#~ msgid "foo" -#~ msgstr "Voh" - -# This message is not obsolete -#: main.py:1 -msgid "bar" -msgstr "Bahr" -''') - catalog = pofile.read_po(buf, ignore_obsolete=True) - assert len(catalog) == 1 - assert len(catalog.obsolete) == 0 - - def test_multi_line_obsolete_message(self): - buf = StringIO(r'''# This is an obsolete message -#~ msgid "" -#~ "foo" -#~ "foo" -#~ msgstr "" -#~ "Voh" -#~ "Vooooh" - -# This message is not obsolete -#: main.py:1 -msgid "bar" -msgstr "Bahr" -''') - catalog = pofile.read_po(buf) - assert len(catalog.obsolete) == 1 - message = catalog.obsolete['foofoo'] - assert message.id == 'foofoo' - assert message.string == 'VohVooooh' - assert message.user_comments == ['This is an obsolete message'] - - def test_unit_following_multi_line_obsolete_message(self): - buf = StringIO(r'''# This is an obsolete message -#~ msgid "" -#~ "foo" -#~ "fooooooo" -#~ msgstr "" -#~ "Voh" -#~ "Vooooh" - -# This message is not obsolete -#: main.py:1 -msgid "bar" -msgstr "Bahr" -''') - catalog = pofile.read_po(buf) - assert len(catalog) == 1 - message = catalog['bar'] - assert message.id == 'bar' - assert message.string == 'Bahr' - assert message.user_comments == ['This message is not obsolete'] - - def test_unit_before_obsolete_is_not_obsoleted(self): - buf = StringIO(r''' -# This message is not obsolete -#: main.py:1 -msgid "bar" -msgstr "Bahr" - -# This is an obsolete message -#~ msgid "" -#~ "foo" -#~ "fooooooo" -#~ msgstr "" -#~ "Voh" -#~ "Vooooh" -''') - catalog = pofile.read_po(buf) - assert len(catalog) == 1 - message = catalog['bar'] - assert message.id == 'bar' - assert message.string == 'Bahr' - assert message.user_comments == ['This message is not obsolete'] - - def test_with_context(self): - buf = BytesIO(b'''# Some string in the menu -#: main.py:1 -msgctxt "Menu" -msgid "foo" -msgstr "Voh" - -# Another string in the menu -#: main.py:2 -msgctxt "Menu" -msgid "bar" -msgstr "Bahr" -''') - catalog = pofile.read_po(buf, ignore_obsolete=True) - assert len(catalog) == 2 - message = catalog.get('foo', context='Menu') - assert message.context == 'Menu' - message = catalog.get('bar', context='Menu') - assert message.context == 'Menu' - - # And verify it pass through write_po - out_buf = BytesIO() - pofile.write_po(out_buf, catalog, omit_header=True) - assert out_buf.getvalue().strip() == buf.getvalue().strip() - - def test_obsolete_message_with_context(self): - buf = StringIO(''' -# This message is not obsolete -msgid "baz" -msgstr "Bazczch" - -# This is an obsolete message -#~ msgctxt "other" -#~ msgid "foo" -#~ msgstr "Voh" - -# This message is not obsolete -#: main.py:1 -msgid "bar" -msgstr "Bahr" -''') - catalog = pofile.read_po(buf) - assert len(catalog) == 2 - assert len(catalog.obsolete) == 1 - message = catalog.obsolete[("foo", "other")] - assert message.context == 'other' - assert message.string == 'Voh' - - def test_obsolete_messages_with_context(self): - buf = StringIO(''' -# This is an obsolete message -#~ msgctxt "apple" -#~ msgid "foo" -#~ msgstr "Foo" - -# This is an obsolete message with the same id but different context -#~ msgctxt "orange" -#~ msgid "foo" -#~ msgstr "Bar" -''') - catalog = pofile.read_po(buf) - assert len(catalog) == 0 - assert len(catalog.obsolete) == 2 - assert 'foo' not in catalog.obsolete - - apple_msg = catalog.obsolete[('foo', 'apple')] - assert apple_msg.id == 'foo' - assert apple_msg.string == 'Foo' - assert apple_msg.user_comments == ['This is an obsolete message'] - - orange_msg = catalog.obsolete[('foo', 'orange')] - assert orange_msg.id == 'foo' - assert orange_msg.string == 'Bar' - assert orange_msg.user_comments == ['This is an obsolete message with the same id but different context'] - - def test_obsolete_messages_roundtrip(self): - buf = StringIO('''\ -# This message is not obsolete -#: main.py:1 -msgid "bar" -msgstr "Bahr" - -# This is an obsolete message -#~ msgid "foo" -#~ msgstr "Voh" - -# This is an obsolete message -#~ msgctxt "apple" -#~ msgid "foo" -#~ msgstr "Foo" - -# This is an obsolete message with the same id but different context -#~ msgctxt "orange" -#~ msgid "foo" -#~ msgstr "Bar" - -''') - generated_po_file = ''.join(pofile.generate_po(pofile.read_po(buf), omit_header=True)) - assert buf.getvalue() == generated_po_file - - def test_multiline_context(self): - buf = StringIO(''' -msgctxt "a really long " -"message context " -"why?" -msgid "mid" -msgstr "mst" - ''') - catalog = pofile.read_po(buf) - assert len(catalog) == 1 - message = catalog.get('mid', context="a really long message context why?") - assert message is not None - assert message.context == 'a really long message context why?' - - def test_with_context_two(self): - buf = BytesIO(b'''msgctxt "Menu" -msgid "foo" -msgstr "Voh" - -msgctxt "Mannu" -msgid "bar" -msgstr "Bahr" -''') - catalog = pofile.read_po(buf, ignore_obsolete=True) - assert len(catalog) == 2 - message = catalog.get('foo', context='Menu') - assert message.context == 'Menu' - message = catalog.get('bar', context='Mannu') - assert message.context == 'Mannu' - - # And verify it pass through write_po - out_buf = BytesIO() - pofile.write_po(out_buf, catalog, omit_header=True) - assert out_buf.getvalue().strip() == buf.getvalue().strip(), out_buf.getvalue() - - def test_single_plural_form(self): - buf = StringIO(r'''msgid "foo" -msgid_plural "foos" -msgstr[0] "Voh"''') - catalog = pofile.read_po(buf, locale='ja_JP') - assert len(catalog) == 1 - assert catalog.num_plurals == 1 - message = catalog['foo'] - assert len(message.string) == 1 - - def test_singular_plural_form(self): - buf = StringIO(r'''msgid "foo" -msgid_plural "foos" -msgstr[0] "Voh" -msgstr[1] "Vohs"''') - catalog = pofile.read_po(buf, locale='nl_NL') - assert len(catalog) == 1 - assert catalog.num_plurals == 2 - message = catalog['foo'] - assert len(message.string) == 2 - - def test_more_than_two_plural_forms(self): - buf = StringIO(r'''msgid "foo" -msgid_plural "foos" -msgstr[0] "Voh" -msgstr[1] "Vohs" -msgstr[2] "Vohss"''') - catalog = pofile.read_po(buf, locale='lv_LV') - assert len(catalog) == 1 - assert catalog.num_plurals == 3 - message = catalog['foo'] - assert len(message.string) == 3 - assert message.string[2] == 'Vohss' - - def test_plural_with_square_brackets(self): - buf = StringIO(r'''msgid "foo" -msgid_plural "foos" -msgstr[0] "Voh [text]" -msgstr[1] "Vohs [text]"''') - catalog = pofile.read_po(buf, locale='nb_NO') - assert len(catalog) == 1 - assert catalog.num_plurals == 2 - message = catalog['foo'] - assert len(message.string) == 2 - - def test_obsolete_plural_with_square_brackets(self): - buf = StringIO('''\ -#~ msgid "foo" -#~ msgid_plural "foos" -#~ msgstr[0] "Voh [text]" -#~ msgstr[1] "Vohs [text]" -''') - catalog = pofile.read_po(buf, locale='nb_NO') - assert len(catalog) == 0 - assert len(catalog.obsolete) == 1 - assert catalog.num_plurals == 2 - message = catalog.obsolete['foo'] - assert len(message.string) == 2 - assert message.string[0] == 'Voh [text]' - assert message.string[1] == 'Vohs [text]' - - def test_missing_plural(self): - buf = StringIO('''\ -msgid "" -msgstr "" -"Plural-Forms: nplurals=3; plural=(n < 2) ? n : 2;\n" - -msgid "foo" -msgid_plural "foos" -msgstr[0] "Voh [text]" -msgstr[1] "Vohs [text]" -''') - catalog = pofile.read_po(buf, locale='nb_NO') - assert len(catalog) == 1 - assert catalog.num_plurals == 3 - message = catalog['foo'] - assert len(message.string) == 3 - assert message.string[0] == 'Voh [text]' - assert message.string[1] == 'Vohs [text]' - assert message.string[2] == '' - - def test_missing_plural_in_the_middle(self): - buf = StringIO('''\ -msgid "" -msgstr "" -"Plural-Forms: nplurals=3; plural=(n < 2) ? n : 2;\n" - -msgid "foo" -msgid_plural "foos" -msgstr[0] "Voh [text]" -msgstr[2] "Vohs [text]" -''') - catalog = pofile.read_po(buf, locale='nb_NO') - assert len(catalog) == 1 - assert catalog.num_plurals == 3 - message = catalog['foo'] - assert len(message.string) == 3 - assert message.string[0] == 'Voh [text]' - assert message.string[1] == '' - assert message.string[2] == 'Vohs [text]' - - def test_with_location(self): - buf = StringIO('''\ -#: main.py:1 \u2068filename with whitespace.py\u2069:123 -msgid "foo" -msgstr "bar" -''') - catalog = pofile.read_po(buf, locale='de_DE') - assert len(catalog) == 1 - message = catalog['foo'] - assert message.string == 'bar' - assert message.locations == [("main.py", 1), ("filename with whitespace.py", 123)] - - - def test_abort_invalid_po_file(self): - invalid_po = ''' - msgctxt "" - "{\"checksum\": 2148532640, \"cxt\": \"collector_thankyou\", \"id\": " - "270005359}" - msgid "" - "Thank you very much for your time.\n" - "If you have any questions regarding this survey, please contact Fulano " - "at nadie@blah.com" - msgstr "Merci de prendre le temps de remplir le sondage. - Pour toute question, veuillez communiquer avec Fulano à nadie@blah.com - " - ''' - invalid_po_2 = ''' - msgctxt "" - "{\"checksum\": 2148532640, \"cxt\": \"collector_thankyou\", \"id\": " - "270005359}" - msgid "" - "Thank you very much for your time.\n" - "If you have any questions regarding this survey, please contact Fulano " - "at fulano@blah.com." - msgstr "Merci de prendre le temps de remplir le sondage. - Pour toute question, veuillez communiquer avec Fulano a fulano@blah.com - " - ''' - # Catalog not created, throws Unicode Error - buf = StringIO(invalid_po) - output = pofile.read_po(buf, locale='fr', abort_invalid=False) - assert isinstance(output, Catalog) - - # Catalog not created, throws PoFileError - buf = StringIO(invalid_po_2) - with pytest.raises(pofile.PoFileError): - pofile.read_po(buf, locale='fr', abort_invalid=True) - - # Catalog is created with warning, no abort - buf = StringIO(invalid_po_2) - output = pofile.read_po(buf, locale='fr', abort_invalid=False) - assert isinstance(output, Catalog) - - # Catalog not created, aborted with PoFileError - buf = StringIO(invalid_po_2) - with pytest.raises(pofile.PoFileError): - pofile.read_po(buf, locale='fr', abort_invalid=True) - - def test_invalid_pofile_with_abort_flag(self): - parser = pofile.PoFileParser(None, abort_invalid=True) - lineno = 10 - line = 'Algo esta mal' - msg = 'invalid file' - with pytest.raises(pofile.PoFileError): - parser._invalid_pofile(line, lineno, msg) - - -class WritePoTestCase(unittest.TestCase): - - def test_join_locations(self): - catalog = Catalog() - catalog.add('foo', locations=[('main.py', 1)]) - catalog.add('foo', locations=[('utils.py', 3)]) - buf = BytesIO() - pofile.write_po(buf, catalog, omit_header=True) - assert buf.getvalue().strip() == b'''#: main.py:1 utils.py:3 -msgid "foo" -msgstr ""''' - - def test_write_po_file_with_specified_charset(self): - catalog = Catalog(charset='iso-8859-1') - catalog.add('foo', 'äöü', locations=[('main.py', 1)]) - buf = BytesIO() - pofile.write_po(buf, catalog, omit_header=False) - po_file = buf.getvalue().strip() - assert b'"Content-Type: text/plain; charset=iso-8859-1\\n"' in po_file - assert 'msgstr "äöü"'.encode('iso-8859-1') in po_file - - def test_duplicate_comments(self): - catalog = Catalog() - catalog.add('foo', auto_comments=['A comment']) - catalog.add('foo', auto_comments=['A comment']) - buf = BytesIO() - pofile.write_po(buf, catalog, omit_header=True) - assert buf.getvalue().strip() == b'''#. A comment -msgid "foo" -msgstr ""''' - - def test_wrap_long_lines(self): - text = """Here's some text where -white space and line breaks matter, and should - -not be removed - -""" - catalog = Catalog() - catalog.add(text, locations=[('main.py', 1)]) - buf = BytesIO() - pofile.write_po(buf, catalog, no_location=True, omit_header=True, - width=42) - assert buf.getvalue().strip() == b'''msgid "" -"Here's some text where\\n" -"white space and line breaks matter, and" -" should\\n" -"\\n" -"not be removed\\n" -"\\n" -msgstr ""''' - - def test_wrap_long_lines_with_long_word(self): - text = """Here's some text that -includesareallylongwordthatmightbutshouldnt throw us into an infinite loop -""" - catalog = Catalog() - catalog.add(text, locations=[('main.py', 1)]) - buf = BytesIO() - pofile.write_po(buf, catalog, no_location=True, omit_header=True, - width=32) - assert buf.getvalue().strip() == b'''msgid "" -"Here's some text that\\n" -"includesareallylongwordthatmightbutshouldnt" -" throw us into an infinite " -"loop\\n" -msgstr ""''' - - def test_wrap_long_lines_in_header(self): - """ - Verify that long lines in the header comment are wrapped correctly. - """ - catalog = Catalog(project='AReallyReallyLongNameForAProject', - revision_date=datetime(2007, 4, 1)) - buf = BytesIO() - pofile.write_po(buf, catalog) - assert b'\n'.join(buf.getvalue().splitlines()[:7]) == b'''\ -# Translations template for AReallyReallyLongNameForAProject. -# Copyright (C) 2007 ORGANIZATION -# This file is distributed under the same license as the -# AReallyReallyLongNameForAProject project. -# FIRST AUTHOR , 2007. -# -#, fuzzy''' - - def test_wrap_locations_with_hyphens(self): - catalog = Catalog() - catalog.add('foo', locations=[ - ('doupy/templates/base/navmenu.inc.html.py', 60), - ]) - catalog.add('foo', locations=[ - ('doupy/templates/job-offers/helpers.html', 22), - ]) - buf = BytesIO() - pofile.write_po(buf, catalog, omit_header=True) - assert buf.getvalue().strip() == b'''#: doupy/templates/base/navmenu.inc.html.py:60 -#: doupy/templates/job-offers/helpers.html:22 -msgid "foo" -msgstr ""''' - - def test_no_wrap_and_width_behaviour_on_comments(self): - catalog = Catalog() - catalog.add("Pretty dam long message id, which must really be big " - "to test this wrap behaviour, if not it won't work.", - locations=[("fake.py", n) for n in range(1, 30)]) - buf = BytesIO() - pofile.write_po(buf, catalog, width=None, omit_header=True) - assert buf.getvalue().lower() == b"""\ -#: fake.py:1 fake.py:2 fake.py:3 fake.py:4 fake.py:5 fake.py:6 fake.py:7 -#: fake.py:8 fake.py:9 fake.py:10 fake.py:11 fake.py:12 fake.py:13 fake.py:14 -#: fake.py:15 fake.py:16 fake.py:17 fake.py:18 fake.py:19 fake.py:20 fake.py:21 -#: fake.py:22 fake.py:23 fake.py:24 fake.py:25 fake.py:26 fake.py:27 fake.py:28 -#: fake.py:29 -msgid "pretty dam long message id, which must really be big to test this wrap behaviour, if not it won't work." -msgstr "" - -""" - buf = BytesIO() - pofile.write_po(buf, catalog, width=100, omit_header=True) - assert buf.getvalue().lower() == b"""\ -#: fake.py:1 fake.py:2 fake.py:3 fake.py:4 fake.py:5 fake.py:6 fake.py:7 fake.py:8 fake.py:9 fake.py:10 -#: fake.py:11 fake.py:12 fake.py:13 fake.py:14 fake.py:15 fake.py:16 fake.py:17 fake.py:18 fake.py:19 -#: fake.py:20 fake.py:21 fake.py:22 fake.py:23 fake.py:24 fake.py:25 fake.py:26 fake.py:27 fake.py:28 -#: fake.py:29 -msgid "" -"pretty dam long message id, which must really be big to test this wrap behaviour, if not it won't" -" work." -msgstr "" - -""" - - def test_pot_with_translator_comments(self): - catalog = Catalog() - catalog.add('foo', locations=[('main.py', 1)], - auto_comments=['Comment About `foo`']) - catalog.add('bar', locations=[('utils.py', 3)], - user_comments=['Comment About `bar` with', - 'multiple lines.']) - buf = BytesIO() - pofile.write_po(buf, catalog, omit_header=True) - assert buf.getvalue().strip() == b'''#. Comment About `foo` -#: main.py:1 -msgid "foo" -msgstr "" - -# Comment About `bar` with -# multiple lines. -#: utils.py:3 -msgid "bar" -msgstr ""''' - - def test_po_with_obsolete_message(self): - catalog = Catalog() - catalog.add('foo', 'Voh', locations=[('main.py', 1)]) - catalog.obsolete['bar'] = Message('bar', 'Bahr', - locations=[('utils.py', 3)], - user_comments=['User comment']) - buf = BytesIO() - pofile.write_po(buf, catalog, omit_header=True) - assert buf.getvalue().strip() == b'''#: main.py:1 -msgid "foo" -msgstr "Voh" - -# User comment -#~ msgid "bar" -#~ msgstr "Bahr"''' - - def test_po_with_multiline_obsolete_message(self): - catalog = Catalog() - catalog.add('foo', 'Voh', locations=[('main.py', 1)]) - msgid = r"""Here's a message that covers -multiple lines, and should still be handled -correctly. -""" - msgstr = r"""Here's a message that covers -multiple lines, and should still be handled -correctly. -""" - catalog.obsolete[msgid] = Message(msgid, msgstr, - locations=[('utils.py', 3)]) - buf = BytesIO() - pofile.write_po(buf, catalog, omit_header=True) - assert buf.getvalue().strip() == b'''#: main.py:1 -msgid "foo" -msgstr "Voh" - -#~ msgid "" -#~ "Here's a message that covers\\n" -#~ "multiple lines, and should still be handled\\n" -#~ "correctly.\\n" -#~ msgstr "" -#~ "Here's a message that covers\\n" -#~ "multiple lines, and should still be handled\\n" -#~ "correctly.\\n"''' - - def test_po_with_obsolete_message_ignored(self): - catalog = Catalog() - catalog.add('foo', 'Voh', locations=[('main.py', 1)]) - catalog.obsolete['bar'] = Message('bar', 'Bahr', - locations=[('utils.py', 3)], - user_comments=['User comment']) - buf = BytesIO() - pofile.write_po(buf, catalog, omit_header=True, ignore_obsolete=True) - assert buf.getvalue().strip() == b'''#: main.py:1 -msgid "foo" -msgstr "Voh"''' - - def test_po_with_previous_msgid(self): - catalog = Catalog() - catalog.add('foo', 'Voh', locations=[('main.py', 1)], - previous_id='fo') - buf = BytesIO() - pofile.write_po(buf, catalog, omit_header=True, include_previous=True) - assert buf.getvalue().strip() == b'''#: main.py:1 -#| msgid "fo" -msgid "foo" -msgstr "Voh"''' - - def test_po_with_previous_msgid_plural(self): - catalog = Catalog() - catalog.add(('foo', 'foos'), ('Voh', 'Voeh'), - locations=[('main.py', 1)], previous_id=('fo', 'fos')) - buf = BytesIO() - pofile.write_po(buf, catalog, omit_header=True, include_previous=True) - assert buf.getvalue().strip() == b'''#: main.py:1 -#| msgid "fo" -#| msgid_plural "fos" -msgid "foo" -msgid_plural "foos" -msgstr[0] "Voh" -msgstr[1] "Voeh"''' - - def test_sorted_po(self): - catalog = Catalog() - catalog.add('bar', locations=[('utils.py', 3)], - user_comments=['Comment About `bar` with', - 'multiple lines.']) - catalog.add(('foo', 'foos'), ('Voh', 'Voeh'), - locations=[('main.py', 1)]) - buf = BytesIO() - pofile.write_po(buf, catalog, sort_output=True) - value = buf.getvalue().strip() - assert b'''\ -# Comment About `bar` with -# multiple lines. -#: utils.py:3 -msgid "bar" -msgstr "" - -#: main.py:1 -msgid "foo" -msgid_plural "foos" -msgstr[0] "Voh" -msgstr[1] "Voeh"''' in value - assert value.find(b'msgid ""') < value.find(b'msgid "bar"') < value.find(b'msgid "foo"') - - def test_sorted_po_context(self): - catalog = Catalog() - catalog.add(('foo', 'foos'), ('Voh', 'Voeh'), - locations=[('main.py', 1)], - context='there') - catalog.add(('foo', 'foos'), ('Voh', 'Voeh'), - locations=[('main.py', 1)]) - catalog.add(('foo', 'foos'), ('Voh', 'Voeh'), - locations=[('main.py', 1)], - context='here') - buf = BytesIO() - pofile.write_po(buf, catalog, sort_output=True) - value = buf.getvalue().strip() - # We expect the foo without ctx, followed by "here" foo and "there" foo - assert b'''\ -#: main.py:1 -msgid "foo" -msgid_plural "foos" -msgstr[0] "Voh" -msgstr[1] "Voeh" - -#: main.py:1 -msgctxt "here" -msgid "foo" -msgid_plural "foos" -msgstr[0] "Voh" -msgstr[1] "Voeh" - -#: main.py:1 -msgctxt "there" -msgid "foo" -msgid_plural "foos" -msgstr[0] "Voh" -msgstr[1] "Voeh"''' in value - - def test_file_sorted_po(self): - catalog = Catalog() - catalog.add('bar', locations=[('utils.py', 3)]) - catalog.add(('foo', 'foos'), ('Voh', 'Voeh'), locations=[('main.py', 1)]) - buf = BytesIO() - pofile.write_po(buf, catalog, sort_by_file=True) - value = buf.getvalue().strip() - assert value.find(b'main.py') < value.find(b'utils.py') - - def test_file_with_no_lineno(self): - catalog = Catalog() - catalog.add('bar', locations=[('utils.py', None)], - user_comments=['Comment About `bar` with', - 'multiple lines.']) - buf = BytesIO() - pofile.write_po(buf, catalog, sort_output=True) - value = buf.getvalue().strip() - assert b'''\ -# Comment About `bar` with -# multiple lines. -#: utils.py -msgid "bar" -msgstr ""''' in value - - def test_silent_location_fallback(self): - buf = BytesIO(b'''\ -#: broken_file.py -msgid "missing line number" -msgstr "" - -#: broken_file.py:broken_line_number -msgid "broken line number" -msgstr ""''') - catalog = pofile.read_po(buf) - assert catalog['missing line number'].locations == [('broken_file.py', None)] - assert catalog['broken line number'].locations == [] - - def test_include_lineno(self): - catalog = Catalog() - catalog.add('foo', locations=[('main.py', 1)]) - catalog.add('foo', locations=[('utils.py', 3)]) - buf = BytesIO() - pofile.write_po(buf, catalog, omit_header=True, include_lineno=True) - assert buf.getvalue().strip() == b'''#: main.py:1 utils.py:3 -msgid "foo" -msgstr ""''' - - def test_no_include_lineno(self): - catalog = Catalog() - catalog.add('foo', locations=[('main.py', 1)]) - catalog.add('foo', locations=[('main.py', 2)]) - catalog.add('foo', locations=[('utils.py', 3)]) - buf = BytesIO() - pofile.write_po(buf, catalog, omit_header=True, include_lineno=False) - assert buf.getvalue().strip() == b'''#: main.py utils.py -msgid "foo" -msgstr ""''' - - def test_white_space_in_location(self): - catalog = Catalog() - catalog.add('foo', locations=[('main.py', 1)]) - catalog.add('foo', locations=[('utils b.py', 3)]) - buf = BytesIO() - pofile.write_po(buf, catalog, omit_header=True, include_lineno=True) - assert buf.getvalue().strip() == b'''#: main.py:1 \xe2\x81\xa8utils b.py\xe2\x81\xa9:3 -msgid "foo" -msgstr ""''' - - def test_white_space_in_location_already_enclosed(self): - catalog = Catalog() - catalog.add('foo', locations=[('main.py', 1)]) - catalog.add('foo', locations=[('\u2068utils b.py\u2069', 3)]) - buf = BytesIO() - pofile.write_po(buf, catalog, omit_header=True, include_lineno=True) - assert buf.getvalue().strip() == b'''#: main.py:1 \xe2\x81\xa8utils b.py\xe2\x81\xa9:3 -msgid "foo" -msgstr ""''' - - def test_tab_in_location(self): - catalog = Catalog() - catalog.add('foo', locations=[('main.py', 1)]) - catalog.add('foo', locations=[('utils\tb.py', 3)]) - buf = BytesIO() - pofile.write_po(buf, catalog, omit_header=True, include_lineno=True) - assert buf.getvalue().strip() == b'''#: main.py:1 \xe2\x81\xa8utils b.py\xe2\x81\xa9:3 -msgid "foo" -msgstr ""''' - - def test_tab_in_location_already_enclosed(self): - catalog = Catalog() - catalog.add('foo', locations=[('main.py', 1)]) - catalog.add('foo', locations=[('\u2068utils\tb.py\u2069', 3)]) - buf = BytesIO() - pofile.write_po(buf, catalog, omit_header=True, include_lineno=True) - assert buf.getvalue().strip() == b'''#: main.py:1 \xe2\x81\xa8utils b.py\xe2\x81\xa9:3 -msgid "foo" -msgstr ""''' - - - def test_wrap_with_enclosed_file_locations(self): - # Ensure that file names containing white space are not wrapped regardless of the --width parameter - catalog = Catalog() - catalog.add('foo', locations=[('\u2068test utils.py\u2069', 1)]) - catalog.add('foo', locations=[('\u2068test utils.py\u2069', 3)]) - buf = BytesIO() - pofile.write_po(buf, catalog, omit_header=True, include_lineno=True, width=1) - assert buf.getvalue().strip() == b'''#: \xe2\x81\xa8test utils.py\xe2\x81\xa9:1 -#: \xe2\x81\xa8test utils.py\xe2\x81\xa9:3 -msgid "foo" -msgstr ""''' - - -class RoundtripPoTestCase(unittest.TestCase): +def test_enclosed_filenames_in_location_comment(): + catalog = Catalog() + catalog.add("foo", lineno=2, locations=[("main 1.py", 1)], string="") + catalog.add("bar", lineno=6, locations=[("other.py", 2)], string="") + catalog.add("baz", lineno=10, locations=[("main 1.py", 3), ("other.py", 4)], string="") + buf = BytesIO() + pofile.write_po(buf, catalog, omit_header=True, include_lineno=True) + buf.seek(0) + catalog2 = pofile.read_po(buf) + assert True is catalog.is_identical(catalog2) - def test_enclosed_filenames_in_location_comment(self): - catalog = Catalog() - catalog.add("foo", lineno=2, locations=[("main 1.py", 1)], string="") - catalog.add("bar", lineno=6, locations=[("other.py", 2)], string="") - catalog.add("baz", lineno=10, locations=[("main 1.py", 3), ("other.py", 4)], string="") - buf = BytesIO() - pofile.write_po(buf, catalog, omit_header=True, include_lineno=True) - buf.seek(0) - catalog2 = pofile.read_po(buf) - assert True is catalog.is_identical(catalog2) +def test_unescape(): + escaped = '"Say:\\n \\"hello, world!\\"\\n"' + unescaped = 'Say:\n "hello, world!"\n' + assert unescaped != escaped + assert unescaped == pofile.unescape(escaped) -class PofileFunctionsTestCase(unittest.TestCase): - def test_unescape(self): - escaped = '"Say:\\n \\"hello, world!\\"\\n"' - unescaped = 'Say:\n "hello, world!"\n' - assert unescaped != escaped - assert unescaped == pofile.unescape(escaped) +def test_unescape_of_quoted_newline(): + # regression test for #198 + assert pofile.unescape(r'"\\n"') == '\\n' - def test_unescape_of_quoted_newline(self): - # regression test for #198 - assert pofile.unescape(r'"\\n"') == '\\n' - def test_denormalize_on_msgstr_without_empty_first_line(self): - # handle irregular multi-line msgstr (no "" as first line) - # gracefully (#171) - msgstr = '"multi-line\\n"\n" translation"' - expected_denormalized = 'multi-line\n translation' +def test_denormalize_on_msgstr_without_empty_first_line(): + # handle irregular multi-line msgstr (no "" as first line) + # gracefully (#171) + msgstr = '"multi-line\\n"\n" translation"' + expected_denormalized = 'multi-line\n translation' - assert expected_denormalized == pofile.denormalize(msgstr) - assert expected_denormalized == pofile.denormalize(f'""\n{msgstr}') + assert expected_denormalized == pofile.denormalize(msgstr) + assert expected_denormalized == pofile.denormalize(f'""\n{msgstr}') @pytest.mark.parametrize(("line", "locations"), [ diff --git a/tests/messages/test_pofile_read.py b/tests/messages/test_pofile_read.py new file mode 100644 index 00000000..d17f5d4a --- /dev/null +++ b/tests/messages/test_pofile_read.py @@ -0,0 +1,582 @@ +# +# Copyright (C) 2007-2011 Edgewall Software, 2013-2025 the Babel team +# All rights reserved. +# +# This software is licensed as described in the file LICENSE, which +# you should have received as part of this distribution. The terms +# are also available at https://github.com/python-babel/babel/blob/master/LICENSE. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at https://github.com/python-babel/babel/commits/master/. + +from datetime import datetime +from io import BytesIO, StringIO + +import pytest + +from babel import Locale +from babel.messages import Catalog, pofile +from babel.util import FixedOffsetTimezone + + +def test_preserve_locale(): + buf = StringIO(r'''msgid "foo" +msgstr "Voh"''') + catalog = pofile.read_po(buf, locale='en_US') + assert Locale('en', 'US') == catalog.locale + + +def test_locale_gets_overridden_by_file(): + buf = StringIO(r''' +msgid "" +msgstr "" +"Language: en_US\n"''') + catalog = pofile.read_po(buf, locale='de') + assert Locale('en', 'US') == catalog.locale + buf = StringIO(r''' +msgid "" +msgstr "" +"Language: ko-KR\n"''') + catalog = pofile.read_po(buf, locale='de') + assert Locale('ko', 'KR') == catalog.locale + + +def test_preserve_domain(): + buf = StringIO(r'''msgid "foo" +msgstr "Voh"''') + catalog = pofile.read_po(buf, domain='mydomain') + assert catalog.domain == 'mydomain' + + +def test_applies_specified_encoding_during_read(): + buf = BytesIO(''' +msgid "" +msgstr "" +"Project-Id-Version: 3.15\\n" +"Report-Msgid-Bugs-To: Fliegender Zirkus \\n" +"POT-Creation-Date: 2007-09-27 11:19+0700\\n" +"PO-Revision-Date: 2007-09-27 21:42-0700\\n" +"Last-Translator: John \\n" +"Language-Team: German Lang \\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\\n" +"MIME-Version: 1.0\\n" +"Content-Type: text/plain; charset=iso-8859-1\\n" +"Content-Transfer-Encoding: 8bit\\n" +"Generated-By: Babel 1.0dev-r313\\n" + +msgid "foo" +msgstr "bär"'''.encode('iso-8859-1')) + catalog = pofile.read_po(buf, locale='de_DE') + assert catalog.get('foo').string == 'bär' + + +def test_encoding_header_read(): + buf = BytesIO(b'msgid ""\nmsgstr ""\n"Content-Type: text/plain; charset=mac_roman\\n"\n') + catalog = pofile.read_po(buf, locale='xx_XX') + assert catalog.charset == 'mac_roman' + + +def test_plural_forms_header_parsed(): + buf = BytesIO(b'msgid ""\nmsgstr ""\n"Plural-Forms: nplurals=42; plural=(n % 11);\\n"\n') + catalog = pofile.read_po(buf, locale='xx_XX') + assert catalog.plural_expr == '(n % 11)' + assert catalog.num_plurals == 42 + + +def test_read_multiline(): + buf = StringIO(r'''msgid "" +"Here's some text that\n" +"includesareallylongwordthatmightbutshouldnt" +" throw us into an infinite " +"loop\n" +msgstr ""''') + catalog = pofile.read_po(buf) + assert len(catalog) == 1 + message = list(catalog)[1] + assert message.id == ( + "Here's some text that\nincludesareallylongwordthat" + "mightbutshouldnt throw us into an infinite loop\n" + ) + + +def test_fuzzy_header(): + buf = StringIO(r''' +# Translations template for AReallyReallyLongNameForAProject. +# Copyright (C) 2007 ORGANIZATION +# This file is distributed under the same license as the +# AReallyReallyLongNameForAProject project. +# FIRST AUTHOR , 2007. +# +#, fuzzy +''') + catalog = pofile.read_po(buf) + assert len(list(catalog)) == 1 + assert list(catalog)[0].fuzzy + + +def test_not_fuzzy_header(): + buf = StringIO(r''' +# Translations template for AReallyReallyLongNameForAProject. +# Copyright (C) 2007 ORGANIZATION +# This file is distributed under the same license as the +# AReallyReallyLongNameForAProject project. +# FIRST AUTHOR , 2007. +# +''') + catalog = pofile.read_po(buf) + assert len(list(catalog)) == 1 + assert not list(catalog)[0].fuzzy + + +def test_header_entry(): + buf = StringIO(r''' +# SOME DESCRIPTIVE TITLE. +# Copyright (C) 2007 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , 2007. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: 3.15\n" +"Report-Msgid-Bugs-To: Fliegender Zirkus \n" +"POT-Creation-Date: 2007-09-27 11:19+0700\n" +"PO-Revision-Date: 2007-09-27 21:42-0700\n" +"Last-Translator: John \n" +"Language: de\n" +"Language-Team: German Lang \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=iso-8859-2\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 1.0dev-r313\n" +''') + catalog = pofile.read_po(buf) + assert len(list(catalog)) == 1 + assert catalog.version == '3.15' + assert catalog.msgid_bugs_address == 'Fliegender Zirkus ' + assert datetime(2007, 9, 27, 11, 19, tzinfo=FixedOffsetTimezone(7 * 60)) == catalog.creation_date + assert catalog.last_translator == 'John ' + assert Locale('de') == catalog.locale + assert catalog.language_team == 'German Lang ' + assert catalog.charset == 'iso-8859-2' + assert list(catalog)[0].fuzzy + + +def test_obsolete_message(): + buf = StringIO(r'''# This is an obsolete message +#~ msgid "foo" +#~ msgstr "Voh" + +# This message is not obsolete +#: main.py:1 +msgid "bar" +msgstr "Bahr" +''') + catalog = pofile.read_po(buf) + assert len(catalog) == 1 + assert len(catalog.obsolete) == 1 + message = catalog.obsolete['foo'] + assert message.id == 'foo' + assert message.string == 'Voh' + assert message.user_comments == ['This is an obsolete message'] + + +def test_obsolete_message_ignored(): + buf = StringIO(r'''# This is an obsolete message +#~ msgid "foo" +#~ msgstr "Voh" + +# This message is not obsolete +#: main.py:1 +msgid "bar" +msgstr "Bahr" +''') + catalog = pofile.read_po(buf, ignore_obsolete=True) + assert len(catalog) == 1 + assert len(catalog.obsolete) == 0 + + +def test_multi_line_obsolete_message(): + buf = StringIO(r'''# This is an obsolete message +#~ msgid "" +#~ "foo" +#~ "foo" +#~ msgstr "" +#~ "Voh" +#~ "Vooooh" + +# This message is not obsolete +#: main.py:1 +msgid "bar" +msgstr "Bahr" +''') + catalog = pofile.read_po(buf) + assert len(catalog.obsolete) == 1 + message = catalog.obsolete['foofoo'] + assert message.id == 'foofoo' + assert message.string == 'VohVooooh' + assert message.user_comments == ['This is an obsolete message'] + + +def test_unit_following_multi_line_obsolete_message(): + buf = StringIO(r'''# This is an obsolete message +#~ msgid "" +#~ "foo" +#~ "fooooooo" +#~ msgstr "" +#~ "Voh" +#~ "Vooooh" + +# This message is not obsolete +#: main.py:1 +msgid "bar" +msgstr "Bahr" +''') + catalog = pofile.read_po(buf) + assert len(catalog) == 1 + message = catalog['bar'] + assert message.id == 'bar' + assert message.string == 'Bahr' + assert message.user_comments == ['This message is not obsolete'] + + +def test_unit_before_obsolete_is_not_obsoleted(): + buf = StringIO(r''' +# This message is not obsolete +#: main.py:1 +msgid "bar" +msgstr "Bahr" + +# This is an obsolete message +#~ msgid "" +#~ "foo" +#~ "fooooooo" +#~ msgstr "" +#~ "Voh" +#~ "Vooooh" +''') + catalog = pofile.read_po(buf) + assert len(catalog) == 1 + message = catalog['bar'] + assert message.id == 'bar' + assert message.string == 'Bahr' + assert message.user_comments == ['This message is not obsolete'] + + +def test_with_context(): + buf = BytesIO(b'''# Some string in the menu +#: main.py:1 +msgctxt "Menu" +msgid "foo" +msgstr "Voh" + +# Another string in the menu +#: main.py:2 +msgctxt "Menu" +msgid "bar" +msgstr "Bahr" +''') + catalog = pofile.read_po(buf, ignore_obsolete=True) + assert len(catalog) == 2 + message = catalog.get('foo', context='Menu') + assert message.context == 'Menu' + message = catalog.get('bar', context='Menu') + assert message.context == 'Menu' + + # And verify it pass through write_po + out_buf = BytesIO() + pofile.write_po(out_buf, catalog, omit_header=True) + assert out_buf.getvalue().strip() == buf.getvalue().strip() + + +def test_obsolete_message_with_context(): + buf = StringIO(''' +# This message is not obsolete +msgid "baz" +msgstr "Bazczch" + +# This is an obsolete message +#~ msgctxt "other" +#~ msgid "foo" +#~ msgstr "Voh" + +# This message is not obsolete +#: main.py:1 +msgid "bar" +msgstr "Bahr" +''') + catalog = pofile.read_po(buf) + assert len(catalog) == 2 + assert len(catalog.obsolete) == 1 + message = catalog.obsolete[("foo", "other")] + assert message.context == 'other' + assert message.string == 'Voh' + + +def test_obsolete_messages_with_context(): + buf = StringIO(''' +# This is an obsolete message +#~ msgctxt "apple" +#~ msgid "foo" +#~ msgstr "Foo" + +# This is an obsolete message with the same id but different context +#~ msgctxt "orange" +#~ msgid "foo" +#~ msgstr "Bar" +''') + catalog = pofile.read_po(buf) + assert len(catalog) == 0 + assert len(catalog.obsolete) == 2 + assert 'foo' not in catalog.obsolete + + apple_msg = catalog.obsolete[('foo', 'apple')] + assert apple_msg.id == 'foo' + assert apple_msg.string == 'Foo' + assert apple_msg.user_comments == ['This is an obsolete message'] + + orange_msg = catalog.obsolete[('foo', 'orange')] + assert orange_msg.id == 'foo' + assert orange_msg.string == 'Bar' + assert orange_msg.user_comments == ['This is an obsolete message with the same id but different context'] + + +def test_obsolete_messages_roundtrip(): + buf = StringIO('''\ +# This message is not obsolete +#: main.py:1 +msgid "bar" +msgstr "Bahr" + +# This is an obsolete message +#~ msgid "foo" +#~ msgstr "Voh" + +# This is an obsolete message +#~ msgctxt "apple" +#~ msgid "foo" +#~ msgstr "Foo" + +# This is an obsolete message with the same id but different context +#~ msgctxt "orange" +#~ msgid "foo" +#~ msgstr "Bar" + +''') + generated_po_file = ''.join(pofile.generate_po(pofile.read_po(buf), omit_header=True)) + assert buf.getvalue() == generated_po_file + + +def test_multiline_context(): + buf = StringIO(''' +msgctxt "a really long " +"message context " +"why?" +msgid "mid" +msgstr "mst" + ''') + catalog = pofile.read_po(buf) + assert len(catalog) == 1 + message = catalog.get('mid', context="a really long message context why?") + assert message is not None + assert message.context == 'a really long message context why?' + + +def test_with_context_two(): + buf = BytesIO(b'''msgctxt "Menu" +msgid "foo" +msgstr "Voh" + +msgctxt "Mannu" +msgid "bar" +msgstr "Bahr" +''') + catalog = pofile.read_po(buf, ignore_obsolete=True) + assert len(catalog) == 2 + message = catalog.get('foo', context='Menu') + assert message.context == 'Menu' + message = catalog.get('bar', context='Mannu') + assert message.context == 'Mannu' + + # And verify it pass through write_po + out_buf = BytesIO() + pofile.write_po(out_buf, catalog, omit_header=True) + assert out_buf.getvalue().strip() == buf.getvalue().strip(), out_buf.getvalue() + + +def test_single_plural_form(): + buf = StringIO(r'''msgid "foo" +msgid_plural "foos" +msgstr[0] "Voh"''') + catalog = pofile.read_po(buf, locale='ja_JP') + assert len(catalog) == 1 + assert catalog.num_plurals == 1 + message = catalog['foo'] + assert len(message.string) == 1 + + +def test_singular_plural_form(): + buf = StringIO(r'''msgid "foo" +msgid_plural "foos" +msgstr[0] "Voh" +msgstr[1] "Vohs"''') + catalog = pofile.read_po(buf, locale='nl_NL') + assert len(catalog) == 1 + assert catalog.num_plurals == 2 + message = catalog['foo'] + assert len(message.string) == 2 + + +def test_more_than_two_plural_forms(): + buf = StringIO(r'''msgid "foo" +msgid_plural "foos" +msgstr[0] "Voh" +msgstr[1] "Vohs" +msgstr[2] "Vohss"''') + catalog = pofile.read_po(buf, locale='lv_LV') + assert len(catalog) == 1 + assert catalog.num_plurals == 3 + message = catalog['foo'] + assert len(message.string) == 3 + assert message.string[2] == 'Vohss' + + +def test_plural_with_square_brackets(): + buf = StringIO(r'''msgid "foo" +msgid_plural "foos" +msgstr[0] "Voh [text]" +msgstr[1] "Vohs [text]"''') + catalog = pofile.read_po(buf, locale='nb_NO') + assert len(catalog) == 1 + assert catalog.num_plurals == 2 + message = catalog['foo'] + assert len(message.string) == 2 + + +def test_obsolete_plural_with_square_brackets(): + buf = StringIO('''\ +#~ msgid "foo" +#~ msgid_plural "foos" +#~ msgstr[0] "Voh [text]" +#~ msgstr[1] "Vohs [text]" +''') + catalog = pofile.read_po(buf, locale='nb_NO') + assert len(catalog) == 0 + assert len(catalog.obsolete) == 1 + assert catalog.num_plurals == 2 + message = catalog.obsolete['foo'] + assert len(message.string) == 2 + assert message.string[0] == 'Voh [text]' + assert message.string[1] == 'Vohs [text]' + + +def test_missing_plural(): + buf = StringIO('''\ +msgid "" +msgstr "" +"Plural-Forms: nplurals=3; plural=(n < 2) ? n : 2;\n" + +msgid "foo" +msgid_plural "foos" +msgstr[0] "Voh [text]" +msgstr[1] "Vohs [text]" +''') + catalog = pofile.read_po(buf, locale='nb_NO') + assert len(catalog) == 1 + assert catalog.num_plurals == 3 + message = catalog['foo'] + assert len(message.string) == 3 + assert message.string[0] == 'Voh [text]' + assert message.string[1] == 'Vohs [text]' + assert message.string[2] == '' + + +def test_missing_plural_in_the_middle(): + buf = StringIO('''\ +msgid "" +msgstr "" +"Plural-Forms: nplurals=3; plural=(n < 2) ? n : 2;\n" + +msgid "foo" +msgid_plural "foos" +msgstr[0] "Voh [text]" +msgstr[2] "Vohs [text]" +''') + catalog = pofile.read_po(buf, locale='nb_NO') + assert len(catalog) == 1 + assert catalog.num_plurals == 3 + message = catalog['foo'] + assert len(message.string) == 3 + assert message.string[0] == 'Voh [text]' + assert message.string[1] == '' + assert message.string[2] == 'Vohs [text]' + + +def test_with_location(): + buf = StringIO('''\ +#: main.py:1 \u2068filename with whitespace.py\u2069:123 +msgid "foo" +msgstr "bar" +''') + catalog = pofile.read_po(buf, locale='de_DE') + assert len(catalog) == 1 + message = catalog['foo'] + assert message.string == 'bar' + assert message.locations == [("main.py", 1), ("filename with whitespace.py", 123)] + + +def test_abort_invalid_po_file(): + invalid_po = ''' + msgctxt "" + "{\"checksum\": 2148532640, \"cxt\": \"collector_thankyou\", \"id\": " + "270005359}" + msgid "" + "Thank you very much for your time.\n" + "If you have any questions regarding this survey, please contact Fulano " + "at nadie@blah.com" + msgstr "Merci de prendre le temps de remplir le sondage. + Pour toute question, veuillez communiquer avec Fulano à nadie@blah.com + " + ''' + invalid_po_2 = ''' + msgctxt "" + "{\"checksum\": 2148532640, \"cxt\": \"collector_thankyou\", \"id\": " + "270005359}" + msgid "" + "Thank you very much for your time.\n" + "If you have any questions regarding this survey, please contact Fulano " + "at fulano@blah.com." + msgstr "Merci de prendre le temps de remplir le sondage. + Pour toute question, veuillez communiquer avec Fulano a fulano@blah.com + " + ''' + # Catalog not created, throws Unicode Error + buf = StringIO(invalid_po) + output = pofile.read_po(buf, locale='fr', abort_invalid=False) + assert isinstance(output, Catalog) + + # Catalog not created, throws PoFileError + buf = StringIO(invalid_po_2) + with pytest.raises(pofile.PoFileError): + pofile.read_po(buf, locale='fr', abort_invalid=True) + + # Catalog is created with warning, no abort + buf = StringIO(invalid_po_2) + output = pofile.read_po(buf, locale='fr', abort_invalid=False) + assert isinstance(output, Catalog) + + # Catalog not created, aborted with PoFileError + buf = StringIO(invalid_po_2) + with pytest.raises(pofile.PoFileError): + pofile.read_po(buf, locale='fr', abort_invalid=True) + + +def test_invalid_pofile_with_abort_flag(): + parser = pofile.PoFileParser(None, abort_invalid=True) + lineno = 10 + line = 'Algo esta mal' + msg = 'invalid file' + with pytest.raises(pofile.PoFileError): + parser._invalid_pofile(line, lineno, msg) diff --git a/tests/messages/test_pofile_write.py b/tests/messages/test_pofile_write.py new file mode 100644 index 00000000..0145f792 --- /dev/null +++ b/tests/messages/test_pofile_write.py @@ -0,0 +1,441 @@ +# +# Copyright (C) 2007-2011 Edgewall Software, 2013-2025 the Babel team +# All rights reserved. +# +# This software is licensed as described in the file LICENSE, which +# you should have received as part of this distribution. The terms +# are also available at https://github.com/python-babel/babel/blob/master/LICENSE. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at https://github.com/python-babel/babel/commits/master/. + +from datetime import datetime +from io import BytesIO + +from babel.messages import Catalog, Message, pofile + + +def test_join_locations(): + catalog = Catalog() + catalog.add('foo', locations=[('main.py', 1)]) + catalog.add('foo', locations=[('utils.py', 3)]) + buf = BytesIO() + pofile.write_po(buf, catalog, omit_header=True) + assert buf.getvalue().strip() == b'''#: main.py:1 utils.py:3 +msgid "foo" +msgstr ""''' + + +def test_write_po_file_with_specified_charset(): + catalog = Catalog(charset='iso-8859-1') + catalog.add('foo', 'äöü', locations=[('main.py', 1)]) + buf = BytesIO() + pofile.write_po(buf, catalog, omit_header=False) + po_file = buf.getvalue().strip() + assert b'"Content-Type: text/plain; charset=iso-8859-1\\n"' in po_file + assert 'msgstr "äöü"'.encode('iso-8859-1') in po_file + + +def test_duplicate_comments(): + catalog = Catalog() + catalog.add('foo', auto_comments=['A comment']) + catalog.add('foo', auto_comments=['A comment']) + buf = BytesIO() + pofile.write_po(buf, catalog, omit_header=True) + assert buf.getvalue().strip() == b'''#. A comment +msgid "foo" +msgstr ""''' + + +def test_wrap_long_lines(): + text = """Here's some text where +white space and line breaks matter, and should + +not be removed + +""" + catalog = Catalog() + catalog.add(text, locations=[('main.py', 1)]) + buf = BytesIO() + pofile.write_po(buf, catalog, no_location=True, omit_header=True, + width=42) + assert buf.getvalue().strip() == b'''msgid "" +"Here's some text where\\n" +"white space and line breaks matter, and" +" should\\n" +"\\n" +"not be removed\\n" +"\\n" +msgstr ""''' + + +def test_wrap_long_lines_with_long_word(): + text = """Here's some text that +includesareallylongwordthatmightbutshouldnt throw us into an infinite loop +""" + catalog = Catalog() + catalog.add(text, locations=[('main.py', 1)]) + buf = BytesIO() + pofile.write_po(buf, catalog, no_location=True, omit_header=True, + width=32) + assert buf.getvalue().strip() == b'''msgid "" +"Here's some text that\\n" +"includesareallylongwordthatmightbutshouldnt" +" throw us into an infinite " +"loop\\n" +msgstr ""''' + + +def test_wrap_long_lines_in_header(): + """ + Verify that long lines in the header comment are wrapped correctly. + """ + catalog = Catalog(project='AReallyReallyLongNameForAProject', + revision_date=datetime(2007, 4, 1)) + buf = BytesIO() + pofile.write_po(buf, catalog) + assert b'\n'.join(buf.getvalue().splitlines()[:7]) == b'''\ +# Translations template for AReallyReallyLongNameForAProject. +# Copyright (C) 2007 ORGANIZATION +# This file is distributed under the same license as the +# AReallyReallyLongNameForAProject project. +# FIRST AUTHOR , 2007. +# +#, fuzzy''' + + +def test_wrap_locations_with_hyphens(): + catalog = Catalog() + catalog.add('foo', locations=[ + ('doupy/templates/base/navmenu.inc.html.py', 60), + ]) + catalog.add('foo', locations=[ + ('doupy/templates/job-offers/helpers.html', 22), + ]) + buf = BytesIO() + pofile.write_po(buf, catalog, omit_header=True) + assert buf.getvalue().strip() == b'''#: doupy/templates/base/navmenu.inc.html.py:60 +#: doupy/templates/job-offers/helpers.html:22 +msgid "foo" +msgstr ""''' + + +def test_no_wrap_and_width_behaviour_on_comments(): + catalog = Catalog() + catalog.add("Pretty dam long message id, which must really be big " + "to test this wrap behaviour, if not it won't work.", + locations=[("fake.py", n) for n in range(1, 30)]) + buf = BytesIO() + pofile.write_po(buf, catalog, width=None, omit_header=True) + assert buf.getvalue().lower() == b"""\ +#: fake.py:1 fake.py:2 fake.py:3 fake.py:4 fake.py:5 fake.py:6 fake.py:7 +#: fake.py:8 fake.py:9 fake.py:10 fake.py:11 fake.py:12 fake.py:13 fake.py:14 +#: fake.py:15 fake.py:16 fake.py:17 fake.py:18 fake.py:19 fake.py:20 fake.py:21 +#: fake.py:22 fake.py:23 fake.py:24 fake.py:25 fake.py:26 fake.py:27 fake.py:28 +#: fake.py:29 +msgid "pretty dam long message id, which must really be big to test this wrap behaviour, if not it won't work." +msgstr "" + +""" + buf = BytesIO() + pofile.write_po(buf, catalog, width=100, omit_header=True) + assert buf.getvalue().lower() == b"""\ +#: fake.py:1 fake.py:2 fake.py:3 fake.py:4 fake.py:5 fake.py:6 fake.py:7 fake.py:8 fake.py:9 fake.py:10 +#: fake.py:11 fake.py:12 fake.py:13 fake.py:14 fake.py:15 fake.py:16 fake.py:17 fake.py:18 fake.py:19 +#: fake.py:20 fake.py:21 fake.py:22 fake.py:23 fake.py:24 fake.py:25 fake.py:26 fake.py:27 fake.py:28 +#: fake.py:29 +msgid "" +"pretty dam long message id, which must really be big to test this wrap behaviour, if not it won't" +" work." +msgstr "" + +""" + + +def test_pot_with_translator_comments(): + catalog = Catalog() + catalog.add('foo', locations=[('main.py', 1)], + auto_comments=['Comment About `foo`']) + catalog.add('bar', locations=[('utils.py', 3)], + user_comments=['Comment About `bar` with', + 'multiple lines.']) + buf = BytesIO() + pofile.write_po(buf, catalog, omit_header=True) + assert buf.getvalue().strip() == b'''#. Comment About `foo` +#: main.py:1 +msgid "foo" +msgstr "" + +# Comment About `bar` with +# multiple lines. +#: utils.py:3 +msgid "bar" +msgstr ""''' + + +def test_po_with_obsolete_message(): + catalog = Catalog() + catalog.add('foo', 'Voh', locations=[('main.py', 1)]) + catalog.obsolete['bar'] = Message('bar', 'Bahr', + locations=[('utils.py', 3)], + user_comments=['User comment']) + buf = BytesIO() + pofile.write_po(buf, catalog, omit_header=True) + assert buf.getvalue().strip() == b'''#: main.py:1 +msgid "foo" +msgstr "Voh" + +# User comment +#~ msgid "bar" +#~ msgstr "Bahr"''' + + +def test_po_with_multiline_obsolete_message(): + catalog = Catalog() + catalog.add('foo', 'Voh', locations=[('main.py', 1)]) + msgid = r"""Here's a message that covers +multiple lines, and should still be handled +correctly. +""" + msgstr = r"""Here's a message that covers +multiple lines, and should still be handled +correctly. +""" + catalog.obsolete[msgid] = Message(msgid, msgstr, + locations=[('utils.py', 3)]) + buf = BytesIO() + pofile.write_po(buf, catalog, omit_header=True) + assert buf.getvalue().strip() == b'''#: main.py:1 +msgid "foo" +msgstr "Voh" + +#~ msgid "" +#~ "Here's a message that covers\\n" +#~ "multiple lines, and should still be handled\\n" +#~ "correctly.\\n" +#~ msgstr "" +#~ "Here's a message that covers\\n" +#~ "multiple lines, and should still be handled\\n" +#~ "correctly.\\n"''' + + +def test_po_with_obsolete_message_ignored(): + catalog = Catalog() + catalog.add('foo', 'Voh', locations=[('main.py', 1)]) + catalog.obsolete['bar'] = Message('bar', 'Bahr', + locations=[('utils.py', 3)], + user_comments=['User comment']) + buf = BytesIO() + pofile.write_po(buf, catalog, omit_header=True, ignore_obsolete=True) + assert buf.getvalue().strip() == b'''#: main.py:1 +msgid "foo" +msgstr "Voh"''' + + +def test_po_with_previous_msgid(): + catalog = Catalog() + catalog.add('foo', 'Voh', locations=[('main.py', 1)], + previous_id='fo') + buf = BytesIO() + pofile.write_po(buf, catalog, omit_header=True, include_previous=True) + assert buf.getvalue().strip() == b'''#: main.py:1 +#| msgid "fo" +msgid "foo" +msgstr "Voh"''' + + +def test_po_with_previous_msgid_plural(): + catalog = Catalog() + catalog.add(('foo', 'foos'), ('Voh', 'Voeh'), + locations=[('main.py', 1)], previous_id=('fo', 'fos')) + buf = BytesIO() + pofile.write_po(buf, catalog, omit_header=True, include_previous=True) + assert buf.getvalue().strip() == b'''#: main.py:1 +#| msgid "fo" +#| msgid_plural "fos" +msgid "foo" +msgid_plural "foos" +msgstr[0] "Voh" +msgstr[1] "Voeh"''' + + +def test_sorted_po(): + catalog = Catalog() + catalog.add('bar', locations=[('utils.py', 3)], + user_comments=['Comment About `bar` with', + 'multiple lines.']) + catalog.add(('foo', 'foos'), ('Voh', 'Voeh'), + locations=[('main.py', 1)]) + buf = BytesIO() + pofile.write_po(buf, catalog, sort_output=True) + value = buf.getvalue().strip() + assert b'''\ +# Comment About `bar` with +# multiple lines. +#: utils.py:3 +msgid "bar" +msgstr "" + +#: main.py:1 +msgid "foo" +msgid_plural "foos" +msgstr[0] "Voh" +msgstr[1] "Voeh"''' in value + assert value.find(b'msgid ""') < value.find(b'msgid "bar"') < value.find(b'msgid "foo"') + + +def test_sorted_po_context(): + catalog = Catalog() + catalog.add(('foo', 'foos'), ('Voh', 'Voeh'), + locations=[('main.py', 1)], + context='there') + catalog.add(('foo', 'foos'), ('Voh', 'Voeh'), + locations=[('main.py', 1)]) + catalog.add(('foo', 'foos'), ('Voh', 'Voeh'), + locations=[('main.py', 1)], + context='here') + buf = BytesIO() + pofile.write_po(buf, catalog, sort_output=True) + value = buf.getvalue().strip() + # We expect the foo without ctx, followed by "here" foo and "there" foo + assert b'''\ +#: main.py:1 +msgid "foo" +msgid_plural "foos" +msgstr[0] "Voh" +msgstr[1] "Voeh" + +#: main.py:1 +msgctxt "here" +msgid "foo" +msgid_plural "foos" +msgstr[0] "Voh" +msgstr[1] "Voeh" + +#: main.py:1 +msgctxt "there" +msgid "foo" +msgid_plural "foos" +msgstr[0] "Voh" +msgstr[1] "Voeh"''' in value + + +def test_file_sorted_po(): + catalog = Catalog() + catalog.add('bar', locations=[('utils.py', 3)]) + catalog.add(('foo', 'foos'), ('Voh', 'Voeh'), locations=[('main.py', 1)]) + buf = BytesIO() + pofile.write_po(buf, catalog, sort_by_file=True) + value = buf.getvalue().strip() + assert value.find(b'main.py') < value.find(b'utils.py') + + +def test_file_with_no_lineno(): + catalog = Catalog() + catalog.add('bar', locations=[('utils.py', None)], + user_comments=['Comment About `bar` with', + 'multiple lines.']) + buf = BytesIO() + pofile.write_po(buf, catalog, sort_output=True) + value = buf.getvalue().strip() + assert b'''\ +# Comment About `bar` with +# multiple lines. +#: utils.py +msgid "bar" +msgstr ""''' in value + + +def test_silent_location_fallback(): + buf = BytesIO(b'''\ +#: broken_file.py +msgid "missing line number" +msgstr "" + +#: broken_file.py:broken_line_number +msgid "broken line number" +msgstr ""''') + catalog = pofile.read_po(buf) + assert catalog['missing line number'].locations == [('broken_file.py', None)] + assert catalog['broken line number'].locations == [] + + +def test_include_lineno(): + catalog = Catalog() + catalog.add('foo', locations=[('main.py', 1)]) + catalog.add('foo', locations=[('utils.py', 3)]) + buf = BytesIO() + pofile.write_po(buf, catalog, omit_header=True, include_lineno=True) + assert buf.getvalue().strip() == b'''#: main.py:1 utils.py:3 +msgid "foo" +msgstr ""''' + + +def test_no_include_lineno(): + catalog = Catalog() + catalog.add('foo', locations=[('main.py', 1)]) + catalog.add('foo', locations=[('main.py', 2)]) + catalog.add('foo', locations=[('utils.py', 3)]) + buf = BytesIO() + pofile.write_po(buf, catalog, omit_header=True, include_lineno=False) + assert buf.getvalue().strip() == b'''#: main.py utils.py +msgid "foo" +msgstr ""''' + + +def test_white_space_in_location(): + catalog = Catalog() + catalog.add('foo', locations=[('main.py', 1)]) + catalog.add('foo', locations=[('utils b.py', 3)]) + buf = BytesIO() + pofile.write_po(buf, catalog, omit_header=True, include_lineno=True) + assert buf.getvalue().strip() == b'''#: main.py:1 \xe2\x81\xa8utils b.py\xe2\x81\xa9:3 +msgid "foo" +msgstr ""''' + + +def test_white_space_in_location_already_enclosed(): + catalog = Catalog() + catalog.add('foo', locations=[('main.py', 1)]) + catalog.add('foo', locations=[('\u2068utils b.py\u2069', 3)]) + buf = BytesIO() + pofile.write_po(buf, catalog, omit_header=True, include_lineno=True) + assert buf.getvalue().strip() == b'''#: main.py:1 \xe2\x81\xa8utils b.py\xe2\x81\xa9:3 +msgid "foo" +msgstr ""''' + + +def test_tab_in_location(): + catalog = Catalog() + catalog.add('foo', locations=[('main.py', 1)]) + catalog.add('foo', locations=[('utils\tb.py', 3)]) + buf = BytesIO() + pofile.write_po(buf, catalog, omit_header=True, include_lineno=True) + assert buf.getvalue().strip() == b'''#: main.py:1 \xe2\x81\xa8utils b.py\xe2\x81\xa9:3 +msgid "foo" +msgstr ""''' + + +def test_tab_in_location_already_enclosed(): + catalog = Catalog() + catalog.add('foo', locations=[('main.py', 1)]) + catalog.add('foo', locations=[('\u2068utils\tb.py\u2069', 3)]) + buf = BytesIO() + pofile.write_po(buf, catalog, omit_header=True, include_lineno=True) + assert buf.getvalue().strip() == b'''#: main.py:1 \xe2\x81\xa8utils b.py\xe2\x81\xa9:3 +msgid "foo" +msgstr ""''' + + +def test_wrap_with_enclosed_file_locations(): + # Ensure that file names containing white space are not wrapped regardless of the --width parameter + catalog = Catalog() + catalog.add('foo', locations=[('\u2068test utils.py\u2069', 1)]) + catalog.add('foo', locations=[('\u2068test utils.py\u2069', 3)]) + buf = BytesIO() + pofile.write_po(buf, catalog, omit_header=True, include_lineno=True, width=1) + assert buf.getvalue().strip() == b'''#: \xe2\x81\xa8test utils.py\xe2\x81\xa9:1 +#: \xe2\x81\xa8test utils.py\xe2\x81\xa9:3 +msgid "foo" +msgstr ""''' diff --git a/tests/messages/utils.py b/tests/messages/utils.py index d0797a33..ecd8a2b2 100644 --- a/tests/messages/utils.py +++ b/tests/messages/utils.py @@ -1,3 +1,5 @@ +from __future__ import annotations + CUSTOM_EXTRACTOR_COOKIE = "custom extractor was here" @@ -5,3 +7,18 @@ def custom_extractor(fileobj, keywords, comment_tags, options): if "treat" not in options: raise RuntimeError(f"The custom extractor refuses to run without a delicious treat; got {options!r}") return [(1, next(iter(keywords)), (CUSTOM_EXTRACTOR_COOKIE,), [])] + + +class Distribution: # subset of distutils.dist.Distribution + def __init__(self, attrs: dict) -> None: + self.attrs = attrs + + def get_name(self) -> str: + return self.attrs['name'] + + def get_version(self) -> str: + return self.attrs['version'] + + @property + def packages(self) -> list[str]: + return self.attrs['packages'] diff --git a/tests/test_dates.py b/tests/test_dates.py index cd213b7f..12bb2343 100644 --- a/tests/test_dates.py +++ b/tests/test_dates.py @@ -799,6 +799,7 @@ def test_week_numbering_isocalendar(): expected = '%04d-W%02d-%d' % value.isocalendar() assert week_number(value) == expected + def test_week_numbering_monday_mindays_4(): locale = Locale.parse('de_DE') assert locale.first_week_day == 0 diff --git a/tests/test_localedata.py b/tests/test_localedata.py index 18799344..03cbed1d 100644 --- a/tests/test_localedata.py +++ b/tests/test_localedata.py @@ -15,46 +15,46 @@ import pickle import random import sys import tempfile -import unittest import pytest from babel import Locale, UnknownLocaleError, localedata -class MergeResolveTestCase(unittest.TestCase): - - def test_merge_items(self): - d = {1: 'foo', 3: 'baz'} - localedata.merge(d, {1: 'Foo', 2: 'Bar'}) - assert d == {1: 'Foo', 2: 'Bar', 3: 'baz'} - - def test_merge_nested_dict(self): - d1 = {'x': {'a': 1, 'b': 2, 'c': 3}} - d2 = {'x': {'a': 1, 'b': 12, 'd': 14}} - localedata.merge(d1, d2) - assert d1 == {'x': {'a': 1, 'b': 12, 'c': 3, 'd': 14}} - - def test_merge_nested_dict_no_overlap(self): - d1 = {'x': {'a': 1, 'b': 2}} - d2 = {'y': {'a': 11, 'b': 12}} - localedata.merge(d1, d2) - assert d1 == {'x': {'a': 1, 'b': 2}, 'y': {'a': 11, 'b': 12}} - - def test_merge_with_alias_and_resolve(self): - alias = localedata.Alias('x') - d1 = { - 'x': {'a': 1, 'b': 2, 'c': 3}, - 'y': alias, - } - d2 = { - 'x': {'a': 1, 'b': 12, 'd': 14}, - 'y': {'b': 22, 'e': 25}, - } - localedata.merge(d1, d2) - assert d1 == {'x': {'a': 1, 'b': 12, 'c': 3, 'd': 14}, 'y': (alias, {'b': 22, 'e': 25})} - d = localedata.LocaleDataDict(d1) - assert dict(d.items()) == {'x': {'a': 1, 'b': 12, 'c': 3, 'd': 14}, 'y': {'a': 1, 'b': 22, 'c': 3, 'd': 14, 'e': 25}} +def test_merge_items(): + d = {1: 'foo', 3: 'baz'} + localedata.merge(d, {1: 'Foo', 2: 'Bar'}) + assert d == {1: 'Foo', 2: 'Bar', 3: 'baz'} + + +def test_merge_nested_dict(): + d1 = {'x': {'a': 1, 'b': 2, 'c': 3}} + d2 = {'x': {'a': 1, 'b': 12, 'd': 14}} + localedata.merge(d1, d2) + assert d1 == {'x': {'a': 1, 'b': 12, 'c': 3, 'd': 14}} + + +def test_merge_nested_dict_no_overlap(): + d1 = {'x': {'a': 1, 'b': 2}} + d2 = {'y': {'a': 11, 'b': 12}} + localedata.merge(d1, d2) + assert d1 == {'x': {'a': 1, 'b': 2}, 'y': {'a': 11, 'b': 12}} + + +def test_merge_with_alias_and_resolve(): + alias = localedata.Alias('x') + d1 = { + 'x': {'a': 1, 'b': 2, 'c': 3}, + 'y': alias, + } + d2 = { + 'x': {'a': 1, 'b': 12, 'd': 14}, + 'y': {'b': 22, 'e': 25}, + } + localedata.merge(d1, d2) + assert d1 == {'x': {'a': 1, 'b': 12, 'c': 3, 'd': 14}, 'y': (alias, {'b': 22, 'e': 25})} + d = localedata.LocaleDataDict(d1) + assert dict(d.items()) == {'x': {'a': 1, 'b': 12, 'c': 3, 'd': 14}, 'y': {'a': 1, 'b': 22, 'c': 3, 'd': 14, 'e': 25}} def test_load(): diff --git a/tests/test_numbers.py b/tests/test_numbers.py index e9c21662..4f24f5b8 100644 --- a/tests/test_numbers.py +++ b/tests/test_numbers.py @@ -11,7 +11,6 @@ # history and logs, available at https://github.com/python-babel/babel/commits/master/. import decimal -import unittest from datetime import date import pytest @@ -29,214 +28,6 @@ from babel.numbers import ( ) -class FormatDecimalTestCase(unittest.TestCase): - - def test_patterns(self): - assert numbers.format_decimal(12345, '##0', locale='en_US') == '12345' - assert numbers.format_decimal(6.5, '0.00', locale='sv') == '6,50' - assert numbers.format_decimal((10.0 ** 20), '#.00', locale='en_US') == '100000000000000000000.00' - # regression test for #183, fraction digits were not correctly cut - # if the input was a float value and the value had more than 7 - # significant digits - assert numbers.format_decimal(12345678.051, '#,##0.00', locale='en_US') == '12,345,678.05' - - def test_subpatterns(self): - assert numbers.format_decimal((- 12345), '#,##0.##;-#', locale='en_US') == '-12,345' - assert numbers.format_decimal((- 12345), '#,##0.##;(#)', locale='en_US') == '(12,345)' - - def test_default_rounding(self): - """ - Testing Round-Half-Even (Banker's rounding) - - A '5' is rounded to the closest 'even' number - """ - assert numbers.format_decimal(5.5, '0', locale='sv') == '6' - assert numbers.format_decimal(6.5, '0', locale='sv') == '6' - assert numbers.format_decimal(1.2325, locale='sv') == '1,232' - assert numbers.format_decimal(1.2335, locale='sv') == '1,234' - - def test_significant_digits(self): - """Test significant digits patterns""" - assert numbers.format_decimal(123004, '@@', locale='en_US') == '120000' - assert numbers.format_decimal(1.12, '@', locale='sv') == '1' - assert numbers.format_decimal(1.1, '@@', locale='sv') == '1,1' - assert numbers.format_decimal(1.1, '@@@@@##', locale='sv') == '1,1000' - assert numbers.format_decimal(0.0001, '@@@', locale='sv') == '0,000100' - assert numbers.format_decimal(0.0001234, '@@@', locale='sv') == '0,000123' - assert numbers.format_decimal(0.0001234, '@@@#', locale='sv') == '0,0001234' - assert numbers.format_decimal(0.0001234, '@@@#', locale='sv') == '0,0001234' - assert numbers.format_decimal(0.12345, '@@@', locale='sv') == '0,123' - assert numbers.format_decimal(3.14159, '@@##', locale='sv') == '3,142' - assert numbers.format_decimal(1.23004, '@@##', locale='sv') == '1,23' - assert numbers.format_decimal(1230.04, '@@,@@', locale='en_US') == '12,30' - assert numbers.format_decimal(123.41, '@@##', locale='en_US') == '123.4' - assert numbers.format_decimal(1, '@@', locale='en_US') == '1.0' - assert numbers.format_decimal(0, '@', locale='en_US') == '0' - assert numbers.format_decimal(0.1, '@', locale='en_US') == '0.1' - assert numbers.format_decimal(0.1, '@#', locale='en_US') == '0.1' - assert numbers.format_decimal(0.1, '@@', locale='en_US') == '0.10' - - def test_decimals(self): - """Test significant digits patterns""" - assert numbers.format_decimal(decimal.Decimal('1.2345'), '#.00', locale='en_US') == '1.23' - assert numbers.format_decimal(decimal.Decimal('1.2345000'), '#.00', locale='en_US') == '1.23' - assert numbers.format_decimal(decimal.Decimal('1.2345000'), '@@', locale='en_US') == '1.2' - assert numbers.format_decimal(decimal.Decimal('12345678901234567890.12345'), '#.00', locale='en_US') == '12345678901234567890.12' - - def test_scientific_notation(self): - assert numbers.format_scientific(0.1, '#E0', locale='en_US') == '1E-1' - assert numbers.format_scientific(0.01, '#E0', locale='en_US') == '1E-2' - assert numbers.format_scientific(10, '#E0', locale='en_US') == '1E1' - assert numbers.format_scientific(1234, '0.###E0', locale='en_US') == '1.234E3' - assert numbers.format_scientific(1234, '0.#E0', locale='en_US') == '1.2E3' - # Exponent grouping - assert numbers.format_scientific(12345, '##0.####E0', locale='en_US') == '1.2345E4' - # Minimum number of int digits - assert numbers.format_scientific(12345, '00.###E0', locale='en_US') == '12.345E3' - assert numbers.format_scientific(-12345.6, '00.###E0', locale='en_US') == '-12.346E3' - assert numbers.format_scientific(-0.01234, '00.###E0', locale='en_US') == '-12.34E-3' - # Custom pattern suffix - assert numbers.format_scientific(123.45, '#.##E0 m/s', locale='en_US') == '1.23E2 m/s' - # Exponent patterns - assert numbers.format_scientific(123.45, '#.##E00 m/s', locale='en_US') == '1.23E02 m/s' - assert numbers.format_scientific(0.012345, '#.##E00 m/s', locale='en_US') == '1.23E-02 m/s' - assert numbers.format_scientific(decimal.Decimal('12345'), '#.##E+00 m/s', locale='en_US') == '1.23E+04 m/s' - # 0 (see ticket #99) - assert numbers.format_scientific(0, '#E0', locale='en_US') == '0E0' - - def test_formatting_of_very_small_decimals(self): - # previously formatting very small decimals could lead to a type error - # because the Decimal->string conversion was too simple (see #214) - number = decimal.Decimal("7E-7") - assert numbers.format_decimal(number, format="@@@", locale='en_US') == '0.000000700' - - def test_nan_and_infinity(self): - assert numbers.format_decimal(decimal.Decimal('Infinity'), locale='en_US') == '∞' - assert numbers.format_decimal(decimal.Decimal('-Infinity'), locale='en_US') == '-∞' - assert numbers.format_decimal(decimal.Decimal('NaN'), locale='en_US') == 'NaN' - assert numbers.format_compact_decimal(decimal.Decimal('Infinity'), locale='en_US', format_type="short") == '∞' - assert numbers.format_compact_decimal(decimal.Decimal('-Infinity'), locale='en_US', format_type="short") == '-∞' - assert numbers.format_compact_decimal(decimal.Decimal('NaN'), locale='en_US', format_type="short") == 'NaN' - assert numbers.format_currency(decimal.Decimal('Infinity'), 'USD', locale='en_US') == '$∞' - assert numbers.format_currency(decimal.Decimal('-Infinity'), 'USD', locale='en_US') == '-$∞' - - def test_group_separator(self): - assert numbers.format_decimal(29567.12, locale='en_US', group_separator=False) == '29567.12' - assert numbers.format_decimal(29567.12, locale='fr_CA', group_separator=False) == '29567,12' - assert numbers.format_decimal(29567.12, locale='pt_BR', group_separator=False) == '29567,12' - assert numbers.format_currency(1099.98, 'USD', locale='en_US', group_separator=False) == '$1099.98' - assert numbers.format_currency(101299.98, 'EUR', locale='fr_CA', group_separator=False) == '101299,98\xa0€' - assert numbers.format_currency(101299.98, 'EUR', locale='en_US', group_separator=False, format_type='name') == '101299.98 euros' - assert numbers.format_percent(251234.1234, locale='sv_SE', group_separator=False) == '25123412\xa0%' - - assert numbers.format_decimal(29567.12, locale='en_US', group_separator=True) == '29,567.12' - assert numbers.format_decimal(29567.12, locale='fr_CA', group_separator=True) == '29\xa0567,12' - assert numbers.format_decimal(29567.12, locale='pt_BR', group_separator=True) == '29.567,12' - assert numbers.format_currency(1099.98, 'USD', locale='en_US', group_separator=True) == '$1,099.98' - assert numbers.format_currency(101299.98, 'EUR', locale='fr_CA', group_separator=True) == '101\xa0299,98\xa0€' - assert numbers.format_currency(101299.98, 'EUR', locale='en_US', group_separator=True, format_type='name') == '101,299.98 euros' - assert numbers.format_percent(251234.1234, locale='sv_SE', group_separator=True) == '25\xa0123\xa0412\xa0%' - - def test_compact(self): - assert numbers.format_compact_decimal(1, locale='en_US', format_type="short") == '1' - assert numbers.format_compact_decimal(999, locale='en_US', format_type="short") == '999' - assert numbers.format_compact_decimal(1000, locale='en_US', format_type="short") == '1K' - assert numbers.format_compact_decimal(9000, locale='en_US', format_type="short") == '9K' - assert numbers.format_compact_decimal(9123, locale='en_US', format_type="short", fraction_digits=2) == '9.12K' - assert numbers.format_compact_decimal(10000, locale='en_US', format_type="short") == '10K' - assert numbers.format_compact_decimal(10000, locale='en_US', format_type="short", fraction_digits=2) == '10K' - assert numbers.format_compact_decimal(1000000, locale='en_US', format_type="short") == '1M' - assert numbers.format_compact_decimal(9000999, locale='en_US', format_type="short") == '9M' - assert numbers.format_compact_decimal(9000900099, locale='en_US', format_type="short", fraction_digits=5) == '9.0009B' - assert numbers.format_compact_decimal(1, locale='en_US', format_type="long") == '1' - assert numbers.format_compact_decimal(999, locale='en_US', format_type="long") == '999' - assert numbers.format_compact_decimal(1000, locale='en_US', format_type="long") == '1 thousand' - assert numbers.format_compact_decimal(9000, locale='en_US', format_type="long") == '9 thousand' - assert numbers.format_compact_decimal(9000, locale='en_US', format_type="long", fraction_digits=2) == '9 thousand' - assert numbers.format_compact_decimal(10000, locale='en_US', format_type="long") == '10 thousand' - assert numbers.format_compact_decimal(10000, locale='en_US', format_type="long", fraction_digits=2) == '10 thousand' - assert numbers.format_compact_decimal(1000000, locale='en_US', format_type="long") == '1 million' - assert numbers.format_compact_decimal(9999999, locale='en_US', format_type="long") == '10 million' - assert numbers.format_compact_decimal(9999999999, locale='en_US', format_type="long", fraction_digits=5) == '10 billion' - assert numbers.format_compact_decimal(1, locale='ja_JP', format_type="short") == '1' - assert numbers.format_compact_decimal(999, locale='ja_JP', format_type="short") == '999' - assert numbers.format_compact_decimal(1000, locale='ja_JP', format_type="short") == '1000' - assert numbers.format_compact_decimal(9123, locale='ja_JP', format_type="short") == '9123' - assert numbers.format_compact_decimal(10000, locale='ja_JP', format_type="short") == '1万' - assert numbers.format_compact_decimal(1234567, locale='ja_JP', format_type="short") == '123万' - assert numbers.format_compact_decimal(-1, locale='en_US', format_type="short") == '-1' - assert numbers.format_compact_decimal(-1234, locale='en_US', format_type="short", fraction_digits=2) == '-1.23K' - assert numbers.format_compact_decimal(-123456789, format_type='short', locale='en_US') == '-123M' - assert numbers.format_compact_decimal(-123456789, format_type='long', locale='en_US') == '-123 million' - assert numbers.format_compact_decimal(2345678, locale='mk', format_type='long') == '2 милиони' - assert numbers.format_compact_decimal(21000000, locale='mk', format_type='long') == '21 милион' - assert numbers.format_compact_decimal(21345, locale="gv", format_type="short") == '21K' - assert numbers.format_compact_decimal(1000, locale='it', format_type='long') == 'mille' - assert numbers.format_compact_decimal(1234, locale='it', format_type='long') == '1 mila' - assert numbers.format_compact_decimal(1000, locale='fr', format_type='long') == 'mille' - assert numbers.format_compact_decimal(1234, locale='fr', format_type='long') == '1 millier' - assert numbers.format_compact_decimal( - 12345, format_type="short", locale='ar_EG', fraction_digits=2, numbering_system='default', - ) == '12٫34\xa0ألف' - assert numbers.format_compact_decimal( - 12345, format_type="short", locale='ar_EG', fraction_digits=2, numbering_system='latn', - ) == '12.34\xa0ألف' - - -class NumberParsingTestCase(unittest.TestCase): - - def test_can_parse_decimals(self): - assert decimal.Decimal('1099.98') == numbers.parse_decimal('1,099.98', locale='en_US') - assert decimal.Decimal('1099.98') == numbers.parse_decimal('1.099,98', locale='de') - assert decimal.Decimal('1099.98') == numbers.parse_decimal('1,099.98', locale='ar', numbering_system="default") - assert decimal.Decimal('1099.98') == numbers.parse_decimal('1٬099٫98', locale='ar_EG', numbering_system="default") - with pytest.raises(numbers.NumberFormatError): - numbers.parse_decimal('2,109,998', locale='de') - with pytest.raises(numbers.UnsupportedNumberingSystemError): - numbers.parse_decimal('2,109,998', locale='de', numbering_system="unknown") - - def test_parse_decimal_strict_mode(self): - # Numbers with a misplaced grouping symbol should be rejected - with pytest.raises(numbers.NumberFormatError) as info: - numbers.parse_decimal('11.11', locale='de', strict=True) - assert info.value.suggestions == ['1.111', '11,11'] - # Numbers with two misplaced grouping symbols should be rejected - with pytest.raises(numbers.NumberFormatError) as info: - numbers.parse_decimal('80.00.00', locale='de', strict=True) - assert info.value.suggestions == ['800.000'] - # Partially grouped numbers should be rejected - with pytest.raises(numbers.NumberFormatError) as info: - numbers.parse_decimal('2000,000', locale='en_US', strict=True) - assert info.value.suggestions == ['2,000,000', '2,000'] - # Numbers with duplicate grouping symbols should be rejected - with pytest.raises(numbers.NumberFormatError) as info: - numbers.parse_decimal('0,,000', locale='en_US', strict=True) - assert info.value.suggestions == ['0'] - # Return only suggestion for 0 on strict - with pytest.raises(numbers.NumberFormatError) as info: - numbers.parse_decimal('0.00', locale='de', strict=True) - assert info.value.suggestions == ['0'] - # Properly formatted numbers should be accepted - assert str(numbers.parse_decimal('1.001', locale='de', strict=True)) == '1001' - # Trailing zeroes should be accepted - assert str(numbers.parse_decimal('3.00', locale='en_US', strict=True)) == '3.00' - # Numbers with a grouping symbol and no trailing zeroes should be accepted - assert str(numbers.parse_decimal('3,400.6', locale='en_US', strict=True)) == '3400.6' - # Numbers with a grouping symbol and trailing zeroes (not all zeroes after decimal) should be accepted - assert str(numbers.parse_decimal('3,400.60', locale='en_US', strict=True)) == '3400.60' - # Numbers with a grouping symbol and trailing zeroes (all zeroes after decimal) should be accepted - assert str(numbers.parse_decimal('3,400.00', locale='en_US', strict=True)) == '3400.00' - assert str(numbers.parse_decimal('3,400.0000', locale='en_US', strict=True)) == '3400.0000' - # Numbers with a grouping symbol and no decimal part should be accepted - assert str(numbers.parse_decimal('3,800', locale='en_US', strict=True)) == '3800' - # Numbers without any grouping symbol should be accepted - assert str(numbers.parse_decimal('2000.1', locale='en_US', strict=True)) == '2000.1' - # Numbers without any grouping symbol and no decimal should be accepted - assert str(numbers.parse_decimal('2580', locale='en_US', strict=True)) == '2580' - # High precision numbers should be accepted - assert str(numbers.parse_decimal('5,000001', locale='fr', strict=True)) == '5.000001' - - def test_list_currencies(): assert isinstance(list_currencies(), set) assert list_currencies().issuperset(['BAD', 'BAM', 'KRO']) diff --git a/tests/test_numbers_format_decimal.py b/tests/test_numbers_format_decimal.py new file mode 100644 index 00000000..356181b6 --- /dev/null +++ b/tests/test_numbers_format_decimal.py @@ -0,0 +1,177 @@ +# +# Copyright (C) 2007-2011 Edgewall Software, 2013-2025 the Babel team +# All rights reserved. +# +# This software is licensed as described in the file LICENSE, which +# you should have received as part of this distribution. The terms +# are also available at https://github.com/python-babel/babel/blob/master/LICENSE. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at https://github.com/python-babel/babel/commits/master/. + + +import decimal + +from babel import numbers + + +def test_patterns(): + assert numbers.format_decimal(12345, '##0', locale='en_US') == '12345' + assert numbers.format_decimal(6.5, '0.00', locale='sv') == '6,50' + assert numbers.format_decimal((10.0 ** 20), '#.00', locale='en_US') == '100000000000000000000.00' + # regression test for #183, fraction digits were not correctly cut + # if the input was a float value and the value had more than 7 + # significant digits + assert numbers.format_decimal(12345678.051, '#,##0.00', locale='en_US') == '12,345,678.05' + + +def test_subpatterns(): + assert numbers.format_decimal((- 12345), '#,##0.##;-#', locale='en_US') == '-12,345' + assert numbers.format_decimal((- 12345), '#,##0.##;(#)', locale='en_US') == '(12,345)' + + +def test_default_rounding(): + """ + Testing Round-Half-Even (Banker's rounding) + + A '5' is rounded to the closest 'even' number + """ + assert numbers.format_decimal(5.5, '0', locale='sv') == '6' + assert numbers.format_decimal(6.5, '0', locale='sv') == '6' + assert numbers.format_decimal(1.2325, locale='sv') == '1,232' + assert numbers.format_decimal(1.2335, locale='sv') == '1,234' + + +def test_significant_digits(): + """Test significant digits patterns""" + assert numbers.format_decimal(123004, '@@', locale='en_US') == '120000' + assert numbers.format_decimal(1.12, '@', locale='sv') == '1' + assert numbers.format_decimal(1.1, '@@', locale='sv') == '1,1' + assert numbers.format_decimal(1.1, '@@@@@##', locale='sv') == '1,1000' + assert numbers.format_decimal(0.0001, '@@@', locale='sv') == '0,000100' + assert numbers.format_decimal(0.0001234, '@@@', locale='sv') == '0,000123' + assert numbers.format_decimal(0.0001234, '@@@#', locale='sv') == '0,0001234' + assert numbers.format_decimal(0.0001234, '@@@#', locale='sv') == '0,0001234' + assert numbers.format_decimal(0.12345, '@@@', locale='sv') == '0,123' + assert numbers.format_decimal(3.14159, '@@##', locale='sv') == '3,142' + assert numbers.format_decimal(1.23004, '@@##', locale='sv') == '1,23' + assert numbers.format_decimal(1230.04, '@@,@@', locale='en_US') == '12,30' + assert numbers.format_decimal(123.41, '@@##', locale='en_US') == '123.4' + assert numbers.format_decimal(1, '@@', locale='en_US') == '1.0' + assert numbers.format_decimal(0, '@', locale='en_US') == '0' + assert numbers.format_decimal(0.1, '@', locale='en_US') == '0.1' + assert numbers.format_decimal(0.1, '@#', locale='en_US') == '0.1' + assert numbers.format_decimal(0.1, '@@', locale='en_US') == '0.10' + + +def test_decimals(): + """Test significant digits patterns""" + assert numbers.format_decimal(decimal.Decimal('1.2345'), '#.00', locale='en_US') == '1.23' + assert numbers.format_decimal(decimal.Decimal('1.2345000'), '#.00', locale='en_US') == '1.23' + assert numbers.format_decimal(decimal.Decimal('1.2345000'), '@@', locale='en_US') == '1.2' + assert numbers.format_decimal(decimal.Decimal('12345678901234567890.12345'), '#.00', locale='en_US') == '12345678901234567890.12' + + +def test_scientific_notation(): + assert numbers.format_scientific(0.1, '#E0', locale='en_US') == '1E-1' + assert numbers.format_scientific(0.01, '#E0', locale='en_US') == '1E-2' + assert numbers.format_scientific(10, '#E0', locale='en_US') == '1E1' + assert numbers.format_scientific(1234, '0.###E0', locale='en_US') == '1.234E3' + assert numbers.format_scientific(1234, '0.#E0', locale='en_US') == '1.2E3' + # Exponent grouping + assert numbers.format_scientific(12345, '##0.####E0', locale='en_US') == '1.2345E4' + # Minimum number of int digits + assert numbers.format_scientific(12345, '00.###E0', locale='en_US') == '12.345E3' + assert numbers.format_scientific(-12345.6, '00.###E0', locale='en_US') == '-12.346E3' + assert numbers.format_scientific(-0.01234, '00.###E0', locale='en_US') == '-12.34E-3' + # Custom pattern suffix + assert numbers.format_scientific(123.45, '#.##E0 m/s', locale='en_US') == '1.23E2 m/s' + # Exponent patterns + assert numbers.format_scientific(123.45, '#.##E00 m/s', locale='en_US') == '1.23E02 m/s' + assert numbers.format_scientific(0.012345, '#.##E00 m/s', locale='en_US') == '1.23E-02 m/s' + assert numbers.format_scientific(decimal.Decimal('12345'), '#.##E+00 m/s', locale='en_US') == '1.23E+04 m/s' + # 0 (see ticket #99) + assert numbers.format_scientific(0, '#E0', locale='en_US') == '0E0' + + +def test_formatting_of_very_small_decimals(): + # previously formatting very small decimals could lead to a type error + # because the Decimal->string conversion was too simple (see #214) + number = decimal.Decimal("7E-7") + assert numbers.format_decimal(number, format="@@@", locale='en_US') == '0.000000700' + + +def test_nan_and_infinity(): + assert numbers.format_decimal(decimal.Decimal('Infinity'), locale='en_US') == '∞' + assert numbers.format_decimal(decimal.Decimal('-Infinity'), locale='en_US') == '-∞' + assert numbers.format_decimal(decimal.Decimal('NaN'), locale='en_US') == 'NaN' + assert numbers.format_compact_decimal(decimal.Decimal('Infinity'), locale='en_US', format_type="short") == '∞' + assert numbers.format_compact_decimal(decimal.Decimal('-Infinity'), locale='en_US', format_type="short") == '-∞' + assert numbers.format_compact_decimal(decimal.Decimal('NaN'), locale='en_US', format_type="short") == 'NaN' + assert numbers.format_currency(decimal.Decimal('Infinity'), 'USD', locale='en_US') == '$∞' + assert numbers.format_currency(decimal.Decimal('-Infinity'), 'USD', locale='en_US') == '-$∞' + + +def test_group_separator(): + assert numbers.format_decimal(29567.12, locale='en_US', group_separator=False) == '29567.12' + assert numbers.format_decimal(29567.12, locale='fr_CA', group_separator=False) == '29567,12' + assert numbers.format_decimal(29567.12, locale='pt_BR', group_separator=False) == '29567,12' + assert numbers.format_currency(1099.98, 'USD', locale='en_US', group_separator=False) == '$1099.98' + assert numbers.format_currency(101299.98, 'EUR', locale='fr_CA', group_separator=False) == '101299,98\xa0€' + assert numbers.format_currency(101299.98, 'EUR', locale='en_US', group_separator=False, format_type='name') == '101299.98 euros' + assert numbers.format_percent(251234.1234, locale='sv_SE', group_separator=False) == '25123412\xa0%' + + assert numbers.format_decimal(29567.12, locale='en_US', group_separator=True) == '29,567.12' + assert numbers.format_decimal(29567.12, locale='fr_CA', group_separator=True) == '29\xa0567,12' + assert numbers.format_decimal(29567.12, locale='pt_BR', group_separator=True) == '29.567,12' + assert numbers.format_currency(1099.98, 'USD', locale='en_US', group_separator=True) == '$1,099.98' + assert numbers.format_currency(101299.98, 'EUR', locale='fr_CA', group_separator=True) == '101\xa0299,98\xa0€' + assert numbers.format_currency(101299.98, 'EUR', locale='en_US', group_separator=True, format_type='name') == '101,299.98 euros' + assert numbers.format_percent(251234.1234, locale='sv_SE', group_separator=True) == '25\xa0123\xa0412\xa0%' + + +def test_compact(): + assert numbers.format_compact_decimal(1, locale='en_US', format_type="short") == '1' + assert numbers.format_compact_decimal(999, locale='en_US', format_type="short") == '999' + assert numbers.format_compact_decimal(1000, locale='en_US', format_type="short") == '1K' + assert numbers.format_compact_decimal(9000, locale='en_US', format_type="short") == '9K' + assert numbers.format_compact_decimal(9123, locale='en_US', format_type="short", fraction_digits=2) == '9.12K' + assert numbers.format_compact_decimal(10000, locale='en_US', format_type="short") == '10K' + assert numbers.format_compact_decimal(10000, locale='en_US', format_type="short", fraction_digits=2) == '10K' + assert numbers.format_compact_decimal(1000000, locale='en_US', format_type="short") == '1M' + assert numbers.format_compact_decimal(9000999, locale='en_US', format_type="short") == '9M' + assert numbers.format_compact_decimal(9000900099, locale='en_US', format_type="short", fraction_digits=5) == '9.0009B' + assert numbers.format_compact_decimal(1, locale='en_US', format_type="long") == '1' + assert numbers.format_compact_decimal(999, locale='en_US', format_type="long") == '999' + assert numbers.format_compact_decimal(1000, locale='en_US', format_type="long") == '1 thousand' + assert numbers.format_compact_decimal(9000, locale='en_US', format_type="long") == '9 thousand' + assert numbers.format_compact_decimal(9000, locale='en_US', format_type="long", fraction_digits=2) == '9 thousand' + assert numbers.format_compact_decimal(10000, locale='en_US', format_type="long") == '10 thousand' + assert numbers.format_compact_decimal(10000, locale='en_US', format_type="long", fraction_digits=2) == '10 thousand' + assert numbers.format_compact_decimal(1000000, locale='en_US', format_type="long") == '1 million' + assert numbers.format_compact_decimal(9999999, locale='en_US', format_type="long") == '10 million' + assert numbers.format_compact_decimal(9999999999, locale='en_US', format_type="long", fraction_digits=5) == '10 billion' + assert numbers.format_compact_decimal(1, locale='ja_JP', format_type="short") == '1' + assert numbers.format_compact_decimal(999, locale='ja_JP', format_type="short") == '999' + assert numbers.format_compact_decimal(1000, locale='ja_JP', format_type="short") == '1000' + assert numbers.format_compact_decimal(9123, locale='ja_JP', format_type="short") == '9123' + assert numbers.format_compact_decimal(10000, locale='ja_JP', format_type="short") == '1万' + assert numbers.format_compact_decimal(1234567, locale='ja_JP', format_type="short") == '123万' + assert numbers.format_compact_decimal(-1, locale='en_US', format_type="short") == '-1' + assert numbers.format_compact_decimal(-1234, locale='en_US', format_type="short", fraction_digits=2) == '-1.23K' + assert numbers.format_compact_decimal(-123456789, format_type='short', locale='en_US') == '-123M' + assert numbers.format_compact_decimal(-123456789, format_type='long', locale='en_US') == '-123 million' + assert numbers.format_compact_decimal(2345678, locale='mk', format_type='long') == '2 милиони' + assert numbers.format_compact_decimal(21000000, locale='mk', format_type='long') == '21 милион' + assert numbers.format_compact_decimal(21345, locale="gv", format_type="short") == '21K' + assert numbers.format_compact_decimal(1000, locale='it', format_type='long') == 'mille' + assert numbers.format_compact_decimal(1234, locale='it', format_type='long') == '1 mila' + assert numbers.format_compact_decimal(1000, locale='fr', format_type='long') == 'mille' + assert numbers.format_compact_decimal(1234, locale='fr', format_type='long') == '1 millier' + assert numbers.format_compact_decimal( + 12345, format_type="short", locale='ar_EG', fraction_digits=2, numbering_system='default', + ) == '12٫34\xa0ألف' + assert numbers.format_compact_decimal( + 12345, format_type="short", locale='ar_EG', fraction_digits=2, numbering_system='latn', + ) == '12.34\xa0ألف' diff --git a/tests/test_numbers_parsing.py b/tests/test_numbers_parsing.py new file mode 100644 index 00000000..0b1d03ca --- /dev/null +++ b/tests/test_numbers_parsing.py @@ -0,0 +1,70 @@ +# +# Copyright (C) 2007-2011 Edgewall Software, 2013-2025 the Babel team +# All rights reserved. +# +# This software is licensed as described in the file LICENSE, which +# you should have received as part of this distribution. The terms +# are also available at https://github.com/python-babel/babel/blob/master/LICENSE. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at https://github.com/python-babel/babel/commits/master/. + +import decimal + +import pytest + +from babel import numbers + + +def test_can_parse_decimals(): + assert decimal.Decimal('1099.98') == numbers.parse_decimal('1,099.98', locale='en_US') + assert decimal.Decimal('1099.98') == numbers.parse_decimal('1.099,98', locale='de') + assert decimal.Decimal('1099.98') == numbers.parse_decimal('1,099.98', locale='ar', numbering_system="default") + assert decimal.Decimal('1099.98') == numbers.parse_decimal('1٬099٫98', locale='ar_EG', numbering_system="default") + with pytest.raises(numbers.NumberFormatError): + numbers.parse_decimal('2,109,998', locale='de') + with pytest.raises(numbers.UnsupportedNumberingSystemError): + numbers.parse_decimal('2,109,998', locale='de', numbering_system="unknown") + + +def test_parse_decimal_strict_mode(): + # Numbers with a misplaced grouping symbol should be rejected + with pytest.raises(numbers.NumberFormatError) as info: + numbers.parse_decimal('11.11', locale='de', strict=True) + assert info.value.suggestions == ['1.111', '11,11'] + # Numbers with two misplaced grouping symbols should be rejected + with pytest.raises(numbers.NumberFormatError) as info: + numbers.parse_decimal('80.00.00', locale='de', strict=True) + assert info.value.suggestions == ['800.000'] + # Partially grouped numbers should be rejected + with pytest.raises(numbers.NumberFormatError) as info: + numbers.parse_decimal('2000,000', locale='en_US', strict=True) + assert info.value.suggestions == ['2,000,000', '2,000'] + # Numbers with duplicate grouping symbols should be rejected + with pytest.raises(numbers.NumberFormatError) as info: + numbers.parse_decimal('0,,000', locale='en_US', strict=True) + assert info.value.suggestions == ['0'] + # Return only suggestion for 0 on strict + with pytest.raises(numbers.NumberFormatError) as info: + numbers.parse_decimal('0.00', locale='de', strict=True) + assert info.value.suggestions == ['0'] + # Properly formatted numbers should be accepted + assert str(numbers.parse_decimal('1.001', locale='de', strict=True)) == '1001' + # Trailing zeroes should be accepted + assert str(numbers.parse_decimal('3.00', locale='en_US', strict=True)) == '3.00' + # Numbers with a grouping symbol and no trailing zeroes should be accepted + assert str(numbers.parse_decimal('3,400.6', locale='en_US', strict=True)) == '3400.6' + # Numbers with a grouping symbol and trailing zeroes (not all zeroes after decimal) should be accepted + assert str(numbers.parse_decimal('3,400.60', locale='en_US', strict=True)) == '3400.60' + # Numbers with a grouping symbol and trailing zeroes (all zeroes after decimal) should be accepted + assert str(numbers.parse_decimal('3,400.00', locale='en_US', strict=True)) == '3400.00' + assert str(numbers.parse_decimal('3,400.0000', locale='en_US', strict=True)) == '3400.0000' + # Numbers with a grouping symbol and no decimal part should be accepted + assert str(numbers.parse_decimal('3,800', locale='en_US', strict=True)) == '3800' + # Numbers without any grouping symbol should be accepted + assert str(numbers.parse_decimal('2000.1', locale='en_US', strict=True)) == '2000.1' + # Numbers without any grouping symbol and no decimal should be accepted + assert str(numbers.parse_decimal('2580', locale='en_US', strict=True)) == '2580' + # High precision numbers should be accepted + assert str(numbers.parse_decimal('5,000001', locale='fr', strict=True)) == '5.000001' diff --git a/tests/test_plural.py b/tests/test_plural.py index 83f881b2..bde356bc 100644 --- a/tests/test_plural.py +++ b/tests/test_plural.py @@ -10,7 +10,6 @@ # individuals. For the exact contribution history, see the revision # history and logs, available at https://github.com/python-babel/babel/commits/master/. import decimal -import unittest import pytest @@ -198,76 +197,24 @@ def test_tokenize_malformed(rule_text): plural.tokenize_rule(rule_text) -class TestNextTokenTestCase(unittest.TestCase): +def test_next_token_empty(): + assert not plural.test_next_token([], '') - def test_empty(self): - assert not plural.test_next_token([], '') - def test_type_ok_and_no_value(self): - assert plural.test_next_token([('word', 'and')], 'word') +def test_next_token_type_ok_and_no_value(): + assert plural.test_next_token([('word', 'and')], 'word') - def test_type_ok_and_not_value(self): - assert not plural.test_next_token([('word', 'and')], 'word', 'or') - def test_type_ok_and_value_ok(self): - assert plural.test_next_token([('word', 'and')], 'word', 'and') +def test_next_token_type_ok_and_not_value(): + assert not plural.test_next_token([('word', 'and')], 'word', 'or') - def test_type_not_ok_and_value_ok(self): - assert not plural.test_next_token([('abc', 'and')], 'word', 'and') +def test_next_token_type_ok_and_value_ok(): + assert plural.test_next_token([('word', 'and')], 'word', 'and') -def make_range_list(*values): - ranges = [] - for v in values: - if isinstance(v, int): - val_node = plural.value_node(v) - ranges.append((val_node, val_node)) - else: - assert isinstance(v, tuple) - ranges.append((plural.value_node(v[0]), - plural.value_node(v[1]))) - return plural.range_list_node(ranges) - -class PluralRuleParserTestCase(unittest.TestCase): - - def setUp(self): - self.n = plural.ident_node('n') - - def n_eq(self, v): - return 'relation', ('in', self.n, make_range_list(v)) - - def test_error_when_unexpected_end(self): - with pytest.raises(plural.RuleError): - plural._Parser('n =') - - def test_eq_relation(self): - assert plural._Parser('n = 1').ast == self.n_eq(1) - - def test_in_range_relation(self): - assert plural._Parser('n = 2..4').ast == \ - ('relation', ('in', self.n, make_range_list((2, 4)))) - - def test_negate(self): - assert plural._Parser('n != 1').ast == plural.negate(self.n_eq(1)) - - def test_or(self): - assert plural._Parser('n = 1 or n = 2').ast ==\ - ('or', (self.n_eq(1), self.n_eq(2))) - - def test_and(self): - assert plural._Parser('n = 1 and n = 2').ast ==\ - ('and', (self.n_eq(1), self.n_eq(2))) - - def test_or_and(self): - assert plural._Parser('n = 0 or n != 1 and n % 100 = 1..19').ast == \ - ('or', (self.n_eq(0), - ('and', (plural.negate(self.n_eq(1)), - ('relation', ('in', - ('mod', (self.n, - plural.value_node(100))), - (make_range_list((1, 19))))))), - )) +def test_next_token_type_not_ok_and_value_ok(): + assert not plural.test_next_token([('abc', 'and')], 'word', 'and') EXTRACT_OPERANDS_TESTS = ( diff --git a/tests/test_plural_rule_parser.py b/tests/test_plural_rule_parser.py new file mode 100644 index 00000000..32a69014 --- /dev/null +++ b/tests/test_plural_rule_parser.py @@ -0,0 +1,84 @@ +# +# Copyright (C) 2007-2011 Edgewall Software, 2013-2025 the Babel team +# All rights reserved. +# +# This software is licensed as described in the file LICENSE, which +# you should have received as part of this distribution. The terms +# are also available at https://github.com/python-babel/babel/blob/master/LICENSE. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at https://github.com/python-babel/babel/commits/master/. + +import pytest + +from babel import plural + +N_NODE = plural.ident_node('n') + + +def make_range_list(*values): + ranges = [] + for v in values: + if isinstance(v, int): + val_node = plural.value_node(v) + ranges.append((val_node, val_node)) + else: + assert isinstance(v, tuple) + ranges.append((plural.value_node(v[0]), plural.value_node(v[1]))) + return plural.range_list_node(ranges) + + +def n_eq(v): + return 'relation', ('in', N_NODE, make_range_list(v)) + + +def test_error_when_unexpected_end(): + with pytest.raises(plural.RuleError): + plural._Parser('n =') + + +def test_eq_relation(): + assert plural._Parser('n = 1').ast == n_eq(1) + + +def test_in_range_relation(): + assert plural._Parser('n = 2..4').ast == ( + 'relation', + ('in', N_NODE, make_range_list((2, 4))), + ) + + +def test_negate(): + assert plural._Parser('n != 1').ast == plural.negate(n_eq(1)) + + +def test_or(): + assert plural._Parser('n = 1 or n = 2').ast == ('or', (n_eq(1), n_eq(2))) + + +def test_and(): + assert plural._Parser('n = 1 and n = 2').ast == ('and', (n_eq(1), n_eq(2))) + + +def test_or_and(): + assert plural._Parser('n = 0 or n != 1 and n % 100 = 1..19').ast == ( + 'or', + ( + n_eq(0), + ( + 'and', + ( + plural.negate(n_eq(1)), + ( + 'relation', + ( + 'in', + ('mod', (N_NODE, plural.value_node(100))), + (make_range_list((1, 19))), + ), + ), + ), + ), + ), + ) diff --git a/tests/test_util.py b/tests/test_util.py index 1b464e07..a153dd6f 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -12,7 +12,6 @@ import __future__ -import unittest from io import BytesIO import pytest @@ -46,16 +45,16 @@ def test_pathmatch(): assert not util.pathmatch('./foo/**.py', 'blah/foo/bar/baz.py') -class FixedOffsetTimezoneTestCase(unittest.TestCase): +def test_fixed_zone_negative_offset(): + assert util.FixedOffsetTimezone(-60).zone == 'Etc/GMT-60' - def test_zone_negative_offset(self): - assert util.FixedOffsetTimezone(-60).zone == 'Etc/GMT-60' - def test_zone_zero_offset(self): - assert util.FixedOffsetTimezone(0).zone == 'Etc/GMT+0' +def test_fixed_zone_zero_offset(): + assert util.FixedOffsetTimezone(0).zone == 'Etc/GMT+0' - def test_zone_positive_offset(self): - assert util.FixedOffsetTimezone(330).zone == 'Etc/GMT+330' + +def test_fixed_zone_positive_offset(): + assert util.FixedOffsetTimezone(330).zone == 'Etc/GMT+330' def parse_encoding(s): -- 2.47.3