]> 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')
 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 copy
 import datetime
 import pickle
-import unittest
 from io import StringIO
 
 from babel.dates import UTC, format_datetime
 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
 
 
 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 \
 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."
 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():
 
 
 def test_message_fuzzy():
@@ -360,14 +380,14 @@ def test_message_pluralizable():
     assert catalog.Message(('foo', 'bar')).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
 
 
     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
     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/.
 
 # 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
 
 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
 
 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
 # {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')
 
 
 """).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
 # {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')
 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
 # {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')
 
 
 """.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
 # {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')
 
 
 """.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
 # {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')
 
 
 """.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
 # {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')
 
 
 """.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)
 
         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)
         _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/.
 
 # 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 sys
-import unittest
 from io import BytesIO, StringIO
 
 import pytest
 from io import BytesIO, StringIO
 
 import pytest
@@ -20,10 +18,8 @@ import pytest
 from babel.messages import extract
 
 
 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)
 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())
 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')
 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')
 """)
 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 = _('')
 """)
 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')
 """)
 # -*- 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'
 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
 """)
 )
 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')
 """)
 # -- 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():
 
 
 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
 # 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
 
 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''')
 "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''')
 "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''')
 "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/.
 
 # 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 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.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"), [
 
 
 @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"
 
 
 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,), [])]
     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
 
             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
 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 random
 import sys
 import tempfile
-import unittest
 
 import pytest
 
 from babel import Locale, UnknownLocaleError, localedata
 
 
 
 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():
 
 
 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
 # history and logs, available at https://github.com/python-babel/babel/commits/master/.
 
 import decimal
-import unittest
 from datetime import date
 
 import pytest
 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'])
 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
 # 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
 
 
 import pytest
 
@@ -198,76 +197,24 @@ def test_tokenize_malformed(rule_text):
         plural.tokenize_rule(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 = (
 
 
 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 __future__
 
-import unittest
 from io import BytesIO
 
 import pytest
 from io import BytesIO
 
 import pytest
@@ -46,16 +45,16 @@ def test_pathmatch():
     assert not util.pathmatch('./foo/**.py', 'blah/foo/bar/baz.py')
 
 
     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):
 
 
 def parse_encoding(s):