Skip to content
38 changes: 38 additions & 0 deletions Lib/test/test_tools/i18n_data/multiple_keywords.pot
Original file line number Diff line number Diff line change
@@ -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 <LL@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] ""

11 changes: 11 additions & 0 deletions Lib/test/test_tools/i18n_data/multiple_keywords.py
Original file line number Diff line number Diff line change
@@ -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)
53 changes: 42 additions & 11 deletions Lib/test/test_tools/test_i18n.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@


with imports_under_tool("i18n"):
from pygettext import parse_spec
from pygettext import parse_spec, process_keywords, DEFAULTKEYWORDS


def normalize_POT_file(pot):
Expand Down Expand Up @@ -483,16 +483,16 @@ 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):
Expand All @@ -516,6 +516,33 @@ 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),
)
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}],
'pgettext': [{'msgid': 0}]},
)
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 extract_from_snapshots():
snapshots = {
Expand All @@ -526,6 +553,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'),
}

for filename, args in snapshots.items():
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Allow passing multiple keyword arguments with the same function name in
:program:`pygettext`.
74 changes: 48 additions & 26 deletions Tools/i18n/pygettext.py
Original file line number Diff line number Diff line change
Expand Up @@ -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},
}


Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -373,7 +373,23 @@ 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 process_keywords(keywords, *, no_default_keywords):
custom_keywords_list = [parse_spec(spec) for spec in keywords]
custom_keywords = {}
for name, spec in custom_keywords_list:
if name not in custom_keywords:
custom_keywords[name] = []
custom_keywords[name].append(spec)

if no_default_keywords:
return custom_keywords

default_keywords = {name: [spec] for name, spec in DEFAULTKEYWORDS.items()}
# custom keywords override default keywords
return default_keywords | custom_keywords


@dataclass(frozen=True)
Expand Down Expand Up @@ -459,37 +475,46 @@ 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:
return
specs = self.options.keywords.get(func_name, [])
for spec in specs:
extracted = self._extract_message_with_spec(node, spec)
if extracted:
break

def _extract_message_with_spec(self, node, spec):
"""Extract a gettext call with the given spec.

max_index = max(spec)
Return True if the gettext call was successfully extracted, False
otherwise.
"""
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 False

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'{max_index + 1} positional argument(s) in gettext call, '
f'got {len(node.args)}', file=sys.stderr)
return
return False

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 False
msg_data[arg_type] = arg.value

lineno = node.lineno
comments = self._extract_comments(node)
self._add_message(lineno, **msg_data, comments=comments)
return True

def _extract_comments(self, node):
"""Extract translator comments.
Expand Down Expand Up @@ -729,15 +754,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:
Expand Down
Loading