https://github.com/python/cpython/commit/b6760b7fa5727d3c1f56dcc84e25c6b0efdc80a4
commit: b6760b7fa5727d3c1f56dcc84e25c6b0efdc80a4
branch: main
author: Tomas R. <tomas.ro...@gmail.com>
committer: serhiy-storchaka <storch...@gmail.com>
date: 2025-04-10T11:06:40Z
summary:

gh-130453: pygettext: Allow specifying multiple keywords with the same function 
name (GH-131380)

files:
A Lib/test/test_tools/i18n_data/multiple_keywords.pot
A Lib/test/test_tools/i18n_data/multiple_keywords.py
A Misc/NEWS.d/next/Tools-Demos/2025-03-10-08-19-22.gh-issue-130453.9B0x8k.rst
M Lib/test/test_tools/test_i18n.py
M Tools/i18n/pygettext.py

diff --git a/Lib/test/test_tools/i18n_data/multiple_keywords.pot 
b/Lib/test/test_tools/i18n_data/multiple_keywords.pot
new file mode 100644
index 00000000000000..954cb8e994838a
--- /dev/null
+++ b/Lib/test/test_tools/i18n_data/multiple_keywords.pot
@@ -0,0 +1,38 @@
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) YEAR ORGANIZATION
+# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: PACKAGE VERSION\n"
+"POT-Creation-Date: 2000-01-01 00:00+0000\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
+"Language-Team: LANGUAGE <l...@li.org>\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Generated-By: pygettext.py 1.5\n"
+
+
+#: multiple_keywords.py:3
+msgid "bar"
+msgstr ""
+
+#: multiple_keywords.py:5
+msgctxt "baz"
+msgid "qux"
+msgstr ""
+
+#: multiple_keywords.py:9
+msgctxt "corge"
+msgid "grault"
+msgstr ""
+
+#: multiple_keywords.py:11
+msgctxt "xyzzy"
+msgid "foo"
+msgid_plural "foos"
+msgstr[0] ""
+msgstr[1] ""
+
diff --git a/Lib/test/test_tools/i18n_data/multiple_keywords.py 
b/Lib/test/test_tools/i18n_data/multiple_keywords.py
new file mode 100644
index 00000000000000..7bde349505b839
--- /dev/null
+++ b/Lib/test/test_tools/i18n_data/multiple_keywords.py
@@ -0,0 +1,11 @@
+from gettext import gettext as foo
+
+foo('bar')
+
+foo('baz', 'qux')
+
+# The 't' specifier is not supported, so the following
+# call is extracted as pgettext instead of ngettext.
+foo('corge', 'grault', 1)
+
+foo('xyzzy', 'foo', 'foos', 1)
diff --git a/Lib/test/test_tools/test_i18n.py b/Lib/test/test_tools/test_i18n.py
index 66c33077423229..8416b1bad825eb 100644
--- a/Lib/test/test_tools/test_i18n.py
+++ b/Lib/test/test_tools/test_i18n.py
@@ -18,7 +18,8 @@
 
 
 with imports_under_tool("i18n"):
-    from pygettext import parse_spec
+    from pygettext import (parse_spec, process_keywords, DEFAULTKEYWORDS,
+                           unparse_spec)
 
 
 def normalize_POT_file(pot):
@@ -483,20 +484,22 @@ def test_comments_not_extracted_without_tags(self):
 
     def test_parse_keyword_spec(self):
         valid = (
-            ('foo', ('foo', {0: 'msgid'})),
-            ('foo:1', ('foo', {0: 'msgid'})),
-            ('foo:1,2', ('foo', {0: 'msgid', 1: 'msgid_plural'})),
-            ('foo:1, 2', ('foo', {0: 'msgid', 1: 'msgid_plural'})),
-            ('foo:1,2c', ('foo', {0: 'msgid', 1: 'msgctxt'})),
-            ('foo:2c,1', ('foo', {0: 'msgid', 1: 'msgctxt'})),
-            ('foo:2c ,1', ('foo', {0: 'msgid', 1: 'msgctxt'})),
-            ('foo:1,2,3c', ('foo', {0: 'msgid', 1: 'msgid_plural', 2: 
'msgctxt'})),
-            ('foo:1, 2, 3c', ('foo', {0: 'msgid', 1: 'msgid_plural', 2: 
'msgctxt'})),
-            ('foo:3c,1,2', ('foo', {0: 'msgid', 1: 'msgid_plural', 2: 
'msgctxt'})),
+            ('foo', ('foo', {'msgid': 0})),
+            ('foo:1', ('foo', {'msgid': 0})),
+            ('foo:1,2', ('foo', {'msgid': 0, 'msgid_plural': 1})),
+            ('foo:1, 2', ('foo', {'msgid': 0, 'msgid_plural': 1})),
+            ('foo:1,2c', ('foo', {'msgid': 0, 'msgctxt': 1})),
+            ('foo:2c,1', ('foo', {'msgid': 0, 'msgctxt': 1})),
+            ('foo:2c ,1', ('foo', {'msgid': 0, 'msgctxt': 1})),
+            ('foo:1,2,3c', ('foo', {'msgid': 0, 'msgid_plural': 1, 'msgctxt': 
2})),
+            ('foo:1, 2, 3c', ('foo', {'msgid': 0, 'msgid_plural': 1, 
'msgctxt': 2})),
+            ('foo:3c,1,2', ('foo', {'msgid': 0, 'msgid_plural': 1, 'msgctxt': 
2})),
         )
         for spec, expected in valid:
             with self.subTest(spec=spec):
                 self.assertEqual(parse_spec(spec), expected)
+                # test unparse-parse round-trip
+                self.assertEqual(parse_spec(unparse_spec(*expected)), expected)
 
         invalid = (
             ('foo:', "Invalid keyword spec 'foo:': missing argument 
positions"),
@@ -516,6 +519,70 @@ def test_parse_keyword_spec(self):
                     parse_spec(spec)
                 self.assertEqual(str(cm.exception), message)
 
+    def test_process_keywords(self):
+        default_keywords = {name: [spec] for name, spec
+                            in DEFAULTKEYWORDS.items()}
+        inputs = (
+            (['foo'], True),
+            (['_:1,2'], True),
+            (['foo', 'foo:1,2'], True),
+            (['foo'], False),
+            (['_:1,2', '_:1c,2,3', 'pgettext'], False),
+            # Duplicate entries
+            (['foo', 'foo'], True),
+            (['_'], False)
+        )
+        expected = (
+            {'foo': [{'msgid': 0}]},
+            {'_': [{'msgid': 0, 'msgid_plural': 1}]},
+            {'foo': [{'msgid': 0}, {'msgid': 0, 'msgid_plural': 1}]},
+            default_keywords | {'foo': [{'msgid': 0}]},
+            default_keywords | {'_': [{'msgid': 0, 'msgid_plural': 1},
+                                      {'msgctxt': 0, 'msgid': 1, 
'msgid_plural': 2},
+                                      {'msgid': 0}],
+                                'pgettext': [{'msgid': 0},
+                                             {'msgctxt': 0, 'msgid': 1}]},
+            {'foo': [{'msgid': 0}]},
+            default_keywords,
+        )
+        for (keywords, no_default_keywords), expected in zip(inputs, expected):
+            with self.subTest(keywords=keywords,
+                              no_default_keywords=no_default_keywords):
+                processed = process_keywords(
+                    keywords,
+                    no_default_keywords=no_default_keywords)
+                self.assertEqual(processed, expected)
+
+    def test_multiple_keywords_same_funcname_errors(self):
+        # If at least one keyword spec for a given funcname matches,
+        # no error should be printed.
+        msgids, stderr = self.extract_from_str(dedent('''\
+        _("foo", 42)
+        _(42, "bar")
+        '''), args=('--keyword=_:1', '--keyword=_:2'), with_stderr=True)
+        self.assertIn('foo', msgids)
+        self.assertIn('bar', msgids)
+        self.assertEqual(stderr, b'')
+
+        # If no keyword spec for a given funcname matches,
+        # all errors are printed.
+        msgids, stderr = self.extract_from_str(dedent('''\
+        _(x, 42)
+        _(42, y)
+        '''), args=('--keyword=_:1', '--keyword=_:2'), with_stderr=True,
+              strict=False)
+        self.assertEqual(msgids, [''])
+        # Normalize line endings on Windows
+        stderr = stderr.decode('utf-8').replace('\r', '')
+        self.assertEqual(
+            stderr,
+            '*** test.py:1: No keywords matched gettext call "_":\n'
+            '\tkeyword="_": Expected a string constant for argument 1, got x\n'
+            '\tkeyword="_:2": Expected a string constant for argument 2, got 
42\n'
+            '*** test.py:2: No keywords matched gettext call "_":\n'
+            '\tkeyword="_": Expected a string constant for argument 1, got 
42\n'
+            '\tkeyword="_:2": Expected a string constant for argument 2, got 
y\n')
+
 
 def extract_from_snapshots():
     snapshots = {
@@ -526,6 +593,10 @@ def extract_from_snapshots():
         'custom_keywords.py': ('--keyword=foo', '--keyword=nfoo:1,2',
                                '--keyword=pfoo:1c,2',
                                '--keyword=npfoo:1c,2,3', '--keyword=_:1,2'),
+        'multiple_keywords.py': ('--keyword=foo:1c,2,3', '--keyword=foo:1c,2',
+                                 '--keyword=foo:1,2',
+                                 # repeat a keyword to make sure it is 
extracted only once
+                                 '--keyword=foo', '--keyword=foo'),
         # == Test character escaping
         # Escape ascii and unicode:
         'escapes.py': ('--escape', '--add-comments='),
diff --git 
a/Misc/NEWS.d/next/Tools-Demos/2025-03-10-08-19-22.gh-issue-130453.9B0x8k.rst 
b/Misc/NEWS.d/next/Tools-Demos/2025-03-10-08-19-22.gh-issue-130453.9B0x8k.rst
new file mode 100644
index 00000000000000..fdab48a2f7b25c
--- /dev/null
+++ 
b/Misc/NEWS.d/next/Tools-Demos/2025-03-10-08-19-22.gh-issue-130453.9B0x8k.rst
@@ -0,0 +1,2 @@
+Allow passing multiple keyword arguments with the same function name in
+:program:`pygettext`.
diff --git a/Tools/i18n/pygettext.py b/Tools/i18n/pygettext.py
index a4af1d2be82914..351b47a160e999 100755
--- a/Tools/i18n/pygettext.py
+++ b/Tools/i18n/pygettext.py
@@ -282,15 +282,15 @@ def getFilesForName(name):
 # Key is the function name, value is a dictionary mapping argument positions 
to the
 # type of the argument. The type is one of 'msgid', 'msgid_plural', or 
'msgctxt'.
 DEFAULTKEYWORDS = {
-    '_': {0: 'msgid'},
-    'gettext': {0: 'msgid'},
-    'ngettext': {0: 'msgid', 1: 'msgid_plural'},
-    'pgettext': {0: 'msgctxt', 1: 'msgid'},
-    'npgettext': {0: 'msgctxt', 1: 'msgid', 2: 'msgid_plural'},
-    'dgettext': {1: 'msgid'},
-    'dngettext': {1: 'msgid', 2: 'msgid_plural'},
-    'dpgettext': {1: 'msgctxt', 2: 'msgid'},
-    'dnpgettext': {1: 'msgctxt', 2: 'msgid', 3: 'msgid_plural'},
+    '_': {'msgid': 0},
+    'gettext': {'msgid': 0},
+    'ngettext': {'msgid': 0, 'msgid_plural': 1},
+    'pgettext': {'msgctxt': 0, 'msgid': 1},
+    'npgettext': {'msgctxt': 0, 'msgid': 1, 'msgid_plural': 2},
+    'dgettext': {'msgid': 1},
+    'dngettext': {'msgid': 1, 'msgid_plural': 2},
+    'dpgettext': {'msgctxt': 1, 'msgid': 2},
+    'dnpgettext': {'msgctxt': 1, 'msgid': 2, 'msgid_plural': 3},
 }
 
 
@@ -327,7 +327,7 @@ def parse_spec(spec):
     parts = spec.strip().split(':', 1)
     if len(parts) == 1:
         name = parts[0]
-        return name, {0: 'msgid'}
+        return name, {'msgid': 0}
 
     name, args = parts
     if not args:
@@ -373,7 +373,41 @@ def parse_spec(spec):
         raise ValueError(f'Invalid keyword spec {spec!r}: '
                          'msgctxt cannot appear without msgid')
 
-    return name, {v: k for k, v in result.items()}
+    return name, result
+
+
+def unparse_spec(name, spec):
+    """Unparse a keyword spec dictionary into a string."""
+    if spec == {'msgid': 0}:
+        return name
+
+    parts = []
+    for arg, pos in sorted(spec.items(), key=lambda x: x[1]):
+        if arg == 'msgctxt':
+            parts.append(f'{pos + 1}c')
+        else:
+            parts.append(str(pos + 1))
+    return f'{name}:{','.join(parts)}'
+
+
+def process_keywords(keywords, *, no_default_keywords):
+    custom_keywords = {}
+    for spec in dict.fromkeys(keywords):
+        name, spec = parse_spec(spec)
+        if name not in custom_keywords:
+            custom_keywords[name] = []
+        custom_keywords[name].append(spec)
+
+    if no_default_keywords:
+        return custom_keywords
+
+    # custom keywords override default keywords
+    for name, spec in DEFAULTKEYWORDS.items():
+        if name not in custom_keywords:
+            custom_keywords[name] = []
+        if spec not in custom_keywords[name]:
+            custom_keywords[name].append(spec)
+    return custom_keywords
 
 
 @dataclass(frozen=True)
@@ -459,32 +493,53 @@ def _extract_docstring(self, node):
 
     def _extract_message(self, node):
         func_name = self._get_func_name(node)
-        spec = self.options.keywords.get(func_name)
-        if spec is None:
+        errors = []
+        specs = self.options.keywords.get(func_name, [])
+        for spec in specs:
+            err = self._extract_message_with_spec(node, spec)
+            if err is None:
+                return
+            errors.append(err)
+
+        if not errors:
             return
+        if len(errors) == 1:
+            print(f'*** {self.filename}:{node.lineno}: {errors[0]}',
+                  file=sys.stderr)
+        else:
+            # There are multiple keyword specs for the function name and
+            # none of them could be extracted. Print a general error
+            # message and list the errors for each keyword spec.
+            print(f'*** {self.filename}:{node.lineno}: '
+                  f'No keywords matched gettext call "{func_name}":',
+                  file=sys.stderr)
+            for spec, err in zip(specs, errors, strict=True):
+                unparsed = unparse_spec(func_name, spec)
+                print(f'\tkeyword="{unparsed}": {err}', file=sys.stderr)
+
+    def _extract_message_with_spec(self, node, spec):
+        """Extract a gettext call with the given spec.
 
-        max_index = max(spec)
+        Return None if the gettext call was successfully extracted,
+        otherwise return an error message.
+        """
+        max_index = max(spec.values())
         has_var_positional = any(isinstance(arg, ast.Starred) for
                                  arg in node.args[:max_index+1])
         if has_var_positional:
-            print(f'*** {self.filename}:{node.lineno}: Variable positional '
-                  f'arguments are not allowed in gettext calls', 
file=sys.stderr)
-            return
+            return ('Variable positional arguments are not '
+                    'allowed in gettext calls')
 
         if max_index >= len(node.args):
-            print(f'*** {self.filename}:{node.lineno}: Expected at least '
-                  f'{max(spec) + 1} positional argument(s) in gettext call, '
-                  f'got {len(node.args)}', file=sys.stderr)
-            return
+            return (f'Expected at least {max_index + 1} positional '
+                    f'argument(s) in gettext call, got {len(node.args)}')
 
         msg_data = {}
-        for position, arg_type in spec.items():
+        for arg_type, position in spec.items():
             arg = node.args[position]
             if not self._is_string_const(arg):
-                print(f'*** {self.filename}:{arg.lineno}: Expected a string '
-                      f'constant for argument {position + 1}, '
-                      f'got {ast.unparse(arg)}', file=sys.stderr)
-                return
+                return (f'Expected a string constant for argument '
+                        f'{position + 1}, got {ast.unparse(arg)}')
             msg_data[arg_type] = arg.value
 
         lineno = node.lineno
@@ -729,15 +784,12 @@ class Options:
 
     # calculate all keywords
     try:
-        custom_keywords = dict(parse_spec(spec) for spec in options.keywords)
+        options.keywords = process_keywords(
+            options.keywords,
+            no_default_keywords=no_default_keywords)
     except ValueError as e:
         print(e, file=sys.stderr)
         sys.exit(1)
-    options.keywords = {}
-    if not no_default_keywords:
-        options.keywords |= DEFAULTKEYWORDS
-    # custom keywords override default keywords
-    options.keywords |= custom_keywords
 
     # initialize list of strings to exclude
     if options.excludefilename:

_______________________________________________
Python-checkins mailing list -- python-checkins@python.org
To unsubscribe send an email to python-checkins-le...@python.org
https://mail.python.org/mailman3/lists/python-checkins.python.org/
Member address: arch...@mail-archive.com

Reply via email to