]> git.ipfire.org Git - thirdparty/babel.git/commitdiff
Unwrap most `unittest` test cases to bare functions (#1241) master
authorAarni Koskela <akx@iki.fi>
Thu, 25 Dec 2025 16:04:41 +0000 (18:04 +0200)
committerGitHub <noreply@github.com>
Thu, 25 Dec 2025 16:04:41 +0000 (18:04 +0200)
25 files changed:
tests/messages/consts.py
tests/messages/frontend/__init__.py [new file with mode: 0644]
tests/messages/frontend/test_cli.py [new file with mode: 0644]
tests/messages/frontend/test_compile.py [new file with mode: 0644]
tests/messages/frontend/test_extract.py [new file with mode: 0644]
tests/messages/frontend/test_frontend.py [new file with mode: 0644]
tests/messages/frontend/test_init.py [new file with mode: 0644]
tests/messages/test_catalog.py
tests/messages/test_checkers.py
tests/messages/test_extract.py
tests/messages/test_extract_python.py [new file with mode: 0644]
tests/messages/test_frontend.py [deleted file]
tests/messages/test_mofile.py
tests/messages/test_pofile.py
tests/messages/test_pofile_read.py [new file with mode: 0644]
tests/messages/test_pofile_write.py [new file with mode: 0644]
tests/messages/utils.py
tests/test_dates.py
tests/test_localedata.py
tests/test_numbers.py
tests/test_numbers_format_decimal.py [new file with mode: 0644]
tests/test_numbers_parsing.py [new file with mode: 0644]
tests/test_plural.py
tests/test_plural_rule_parser.py [new file with mode: 0644]
tests/test_util.py

index 34509b3046629c7597165832a95de0e0f2e80c65..f9e796531fb15189b6b38ec108b1486c0dbb722f 100644 (file)
@@ -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 (file)
index 0000000..e69de29
diff --git a/tests/messages/frontend/test_cli.py b/tests/messages/frontend/test_cli.py
new file mode 100644 (file)
index 0000000..4fea01b
--- /dev/null
@@ -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 <EMAIL@ADDRESS>, {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 <EMAIL@ADDRESS>\n"
+"Language-Team: LANGUAGE <LL@li.org>\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 <EMAIL@ADDRESS>, {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 <EMAIL@ADDRESS>\n"
+"Language-Team: LANGUAGE <LL@li.org>\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 <EMAIL@ADDRESS>, {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 <EMAIL@ADDRESS>\n"
+"Language-Team: LANGUAGE <LL@li.org>\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 <EMAIL@ADDRESS>, 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 <EMAIL@ADDRESS>\n"
+"Language: en_US\n"
+"Language-Team: en_US <LL@li.org>\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 <EMAIL@ADDRESS>, 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 <EMAIL@ADDRESS>\n"
+"Language: ja_JP\n"
+"Language-Team: ja_JP <LL@li.org>\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 <EMAIL@ADDRESS>, 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 <EMAIL@ADDRESS>\n"
+"Language: lv_LV\n"
+"Language-Team: lv_LV <LL@li.org>\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 (file)
index 0000000..6442541
--- /dev/null
@@ -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 (file)
index 0000000..ff06ea7
--- /dev/null
@@ -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 <EMAIL@ADDRESS>, {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 <EMAIL@ADDRESS>\n"
+"Language-Team: LANGUAGE <LL@li.org>\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 <EMAIL@ADDRESS>, {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 <EMAIL@ADDRESS>\n"
+"Language-Team: LANGUAGE <LL@li.org>\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 <EMAIL@ADDRESS>, {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 <EMAIL@ADDRESS>\n"
+"Language-Team: LANGUAGE <LL@li.org>\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 (file)
index 0000000..bb196e1
--- /dev/null
@@ -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 (file)
index 0000000..9e10c2a
--- /dev/null
@@ -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 <EMAIL@ADDRESS>, 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 <EMAIL@ADDRESS>\n"
+"Language: en_US\n"
+"Language-Team: en_US <LL@li.org>\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 <EMAIL@ADDRESS>, 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 <EMAIL@ADDRESS>\n"
+"Language: en_US\n"
+"Language-Team: en_US <LL@li.org>\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 <EMAIL@ADDRESS>, 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 <EMAIL@ADDRESS>\n"
+"Language: lv_LV\n"
+"Language-Team: lv_LV <LL@li.org>\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 <EMAIL@ADDRESS>, 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 <EMAIL@ADDRESS>\n"
+"Language: ja_JP\n"
+"Language-Team: ja_JP <LL@li.org>\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 <EMAIL@ADDRESS>, 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 <EMAIL@ADDRESS>\n"
+"Language: en_US\n"
+"Language-Team: en_US <LL@li.org>\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 <EMAIL@ADDRESS>, 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 <EMAIL@ADDRESS>\n"
+"Language: en_US\n"
+"Language-Team: en_US <LL@li.org>\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
index 487419c59c21cee35a76bb6eef1f2b4ef9fc5857..191a2a4981609e1b252c18a7ec2a1a5883492171 100644 (file)
@@ -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 <foo.bar@example.com>\n" +
-                                  "Language-Team: de <de@example.com>\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 <foo.bar@example.com>'
-        assert mime_headers['Last-Translator'] == 'Foo Bar <foo.bar@example.com>'
-
-        assert cat.language_team == 'de <de@example.com>'
-        assert mime_headers['Language-Team'] == 'de <de@example.com>'
-
-        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 <foo.bar@example.com>\n" +
+                              "Language-Team: de <de@example.com>\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 <foo.bar@example.com>'
+    assert mime_headers['Last-Translator'] == 'Foo Bar <foo.bar@example.com>'
+
+    assert cat.language_team == 'de <de@example.com>'
+    assert mime_headers['Language-Team'] == 'de <de@example.com>'
+
+    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
index e4559f7e938ea9bc2729ad782dcd3e4ff89e38c4..8d4b1a77d72b2606066a490be7e3d68b888b0f39 100644 (file)
@@ -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)
index 31b21e4593e7d4ee5ddf99f8e2a0f446928d6c3b..41eda8903143d7c22ae3a8b2fda4913b92a354f0 100644 (file)
@@ -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 (file)
index 0000000..aa13124
--- /dev/null
@@ -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 (file)
index 581ad4a..0000000
+++ /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 <EMAIL@ADDRESS>, {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 <EMAIL@ADDRESS>\n"
-"Language-Team: LANGUAGE <LL@li.org>\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 <EMAIL@ADDRESS>, {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 <EMAIL@ADDRESS>\n"
-"Language-Team: LANGUAGE <LL@li.org>\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 <EMAIL@ADDRESS>, {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 <EMAIL@ADDRESS>\n"
-"Language-Team: LANGUAGE <LL@li.org>\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 <EMAIL@ADDRESS>, 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 <EMAIL@ADDRESS>\n"
-"Language: en_US\n"
-"Language-Team: en_US <LL@li.org>\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 <EMAIL@ADDRESS>, 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 <EMAIL@ADDRESS>\n"
-"Language: en_US\n"
-"Language-Team: en_US <LL@li.org>\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 <EMAIL@ADDRESS>, 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 <EMAIL@ADDRESS>\n"
-"Language: lv_LV\n"
-"Language-Team: lv_LV <LL@li.org>\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 <EMAIL@ADDRESS>, 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 <EMAIL@ADDRESS>\n"
-"Language: ja_JP\n"
-"Language-Team: ja_JP <LL@li.org>\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 <EMAIL@ADDRESS>, 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 <EMAIL@ADDRESS>\n"
-"Language: en_US\n"
-"Language-Team: en_US <LL@li.org>\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 <EMAIL@ADDRESS>, 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 <EMAIL@ADDRESS>\n"
-"Language: en_US\n"
-"Language-Team: en_US <LL@li.org>\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 <EMAIL@ADDRESS>, {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 <EMAIL@ADDRESS>\n"
-"Language-Team: LANGUAGE <LL@li.org>\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 <EMAIL@ADDRESS>, {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 <EMAIL@ADDRESS>\n"
-"Language-Team: LANGUAGE <LL@li.org>\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 <EMAIL@ADDRESS>, {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 <EMAIL@ADDRESS>\n"
-"Language-Team: LANGUAGE <LL@li.org>\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 <EMAIL@ADDRESS>, 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 <EMAIL@ADDRESS>\n"
-"Language: en_US\n"
-"Language-Team: en_US <LL@li.org>\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 <EMAIL@ADDRESS>, 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 <EMAIL@ADDRESS>\n"
-"Language: ja_JP\n"
-"Language-Team: ja_JP <LL@li.org>\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 <EMAIL@ADDRESS>, 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 <EMAIL@ADDRESS>\n"
-"Language: lv_LV\n"
-"Language-Team: lv_LV <LL@li.org>\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()
index 8d1a89eb0030a84b8ef1d1f100fe3617fc41467c..85f4e9f34c526e89e93d56880c9ea53717a8716e 100644 (file)
 # 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'
index ffc95295c493d572d14071c23621245f9639cc83..cdbb582624543e0b3f72f41f543afe35bfda2b91 100644 (file)
 # 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 <fliegender@zirkus.de>\\n"
-"POT-Creation-Date: 2007-09-27 11:19+0700\\n"
-"PO-Revision-Date: 2007-09-27 21:42-0700\\n"
-"Last-Translator: John <cleese@bavaria.de>\\n"
-"Language-Team: German Lang <de@babel.org>\\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 <EMAIL@ADDRESS>, 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 <EMAIL@ADDRESS>, 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 <EMAIL@ADDRESS>, 2007.
-#
-#, fuzzy
-msgid ""
-msgstr ""
-"Project-Id-Version:  3.15\n"
-"Report-Msgid-Bugs-To: Fliegender Zirkus <fliegender@zirkus.de>\n"
-"POT-Creation-Date: 2007-09-27 11:19+0700\n"
-"PO-Revision-Date: 2007-09-27 21:42-0700\n"
-"Last-Translator: John <cleese@bavaria.de>\n"
-"Language: de\n"
-"Language-Team: German Lang <de@babel.org>\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 <fliegender@zirkus.de>'
-        assert datetime(2007, 9, 27, 11, 19, tzinfo=FixedOffsetTimezone(7 * 60)) == catalog.creation_date
-        assert catalog.last_translator == 'John <cleese@bavaria.de>'
-        assert Locale('de') == catalog.locale
-        assert catalog.language_team == 'German Lang <de@babel.org>'
-        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 <EMAIL@ADDRESS>, 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 (file)
index 0000000..d17f5d4
--- /dev/null
@@ -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 <fliegender@zirkus.de>\\n"
+"POT-Creation-Date: 2007-09-27 11:19+0700\\n"
+"PO-Revision-Date: 2007-09-27 21:42-0700\\n"
+"Last-Translator: John <cleese@bavaria.de>\\n"
+"Language-Team: German Lang <de@babel.org>\\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 <EMAIL@ADDRESS>, 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 <EMAIL@ADDRESS>, 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 <EMAIL@ADDRESS>, 2007.
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version:  3.15\n"
+"Report-Msgid-Bugs-To: Fliegender Zirkus <fliegender@zirkus.de>\n"
+"POT-Creation-Date: 2007-09-27 11:19+0700\n"
+"PO-Revision-Date: 2007-09-27 21:42-0700\n"
+"Last-Translator: John <cleese@bavaria.de>\n"
+"Language: de\n"
+"Language-Team: German Lang <de@babel.org>\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 <fliegender@zirkus.de>'
+    assert datetime(2007, 9, 27, 11, 19, tzinfo=FixedOffsetTimezone(7 * 60)) == catalog.creation_date
+    assert catalog.last_translator == 'John <cleese@bavaria.de>'
+    assert Locale('de') == catalog.locale
+    assert catalog.language_team == 'German Lang <de@babel.org>'
+    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 (file)
index 0000000..0145f79
--- /dev/null
@@ -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 <EMAIL@ADDRESS>, 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 ""'''
index d0797a3377ebcb45da95ea198f96a71a838e96f6..ecd8a2b2668c97ba7598f681878521b85100df72 100644 (file)
@@ -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']
index cd213b7f27c34dd38e773be328a6c2ea0c4d3684..12bb2343335f1903d6ef8e6068fa88aab568c3fd 100644 (file)
@@ -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
index 1879934402e0f38b6981e72f3a51bb03d0c5ebf4..03cbed1dcc91ca077926a6b426c1f7f40456cd80 100644 (file)
@@ -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():
index e9c21662020b1d52b0f7e42a4da96f9015b0e061..4f24f5b88e2c641ae0a03603a0f17158fa71adc6 100644 (file)
@@ -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 (file)
index 0000000..356181b
--- /dev/null
@@ -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 (file)
index 0000000..0b1d03c
--- /dev/null
@@ -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'
index 83f881b231223227fd9f55f6b0bc75287186d84b..bde356bc663886ff55bdc9753f5d107ac2d2f3db 100644 (file)
@@ -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 (file)
index 0000000..32a6901
--- /dev/null
@@ -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))),
+                        ),
+                    ),
+                ),
+            ),
+        ),
+    )
index 1b464e079d325ac7b9ba8ed9794e0d3ef204ec39..a153dd6ffe981ccb56c09edfbbedd45e661a0bac 100644 (file)
@@ -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):