Skip to content

Commit 67c5cf7

Browse files
committed
Allow passing multiple keywords with the same funcname.
1 parent 94f4d87 commit 67c5cf7

File tree

4 files changed

+135
-37
lines changed

4 files changed

+135
-37
lines changed
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# SOME DESCRIPTIVE TITLE.
2+
# Copyright (C) YEAR ORGANIZATION
3+
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
4+
#
5+
msgid ""
6+
msgstr ""
7+
"Project-Id-Version: PACKAGE VERSION\n"
8+
"POT-Creation-Date: 2000-01-01 00:00+0000\n"
9+
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
10+
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
11+
"Language-Team: LANGUAGE <[email protected]>\n"
12+
"MIME-Version: 1.0\n"
13+
"Content-Type: text/plain; charset=UTF-8\n"
14+
"Content-Transfer-Encoding: 8bit\n"
15+
"Generated-By: pygettext.py 1.5\n"
16+
17+
18+
#: multiple_keywords.py:3
19+
msgid "bar"
20+
msgstr ""
21+
22+
#: multiple_keywords.py:4
23+
msgctxt "baz"
24+
msgid "qux"
25+
msgstr ""
26+
27+
#: multiple_keywords.py:6
28+
msgctxt "corge"
29+
msgid "grault"
30+
msgstr ""
31+
32+
#: multiple_keywords.py:7
33+
msgctxt "xyzzy"
34+
msgid "foo"
35+
msgid_plural "foos"
36+
msgstr[0] ""
37+
msgstr[1] ""
38+
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from gettext import gettext as foo
2+
3+
foo('bar')
4+
foo('baz', 'qux')
5+
# The 't' specifier is not supported, so this is extracted as pgettext
6+
foo('corge', 'grault', 1)
7+
foo('xyzzy', 'foo', 'foos', 1)

Lib/test/test_tools/test_i18n.py

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919

2020
with imports_under_tool("i18n"):
21-
from pygettext import parse_spec
21+
from pygettext import parse_spec, process_keywords, DEFAULTKEYWORDS
2222

2323

2424
def normalize_POT_file(pot):
@@ -483,16 +483,16 @@ def test_comments_not_extracted_without_tags(self):
483483

484484
def test_parse_keyword_spec(self):
485485
valid = (
486-
('foo', ('foo', {0: 'msgid'})),
487-
('foo:1', ('foo', {0: 'msgid'})),
488-
('foo:1,2', ('foo', {0: 'msgid', 1: 'msgid_plural'})),
489-
('foo:1, 2', ('foo', {0: 'msgid', 1: 'msgid_plural'})),
490-
('foo:1,2c', ('foo', {0: 'msgid', 1: 'msgctxt'})),
491-
('foo:2c,1', ('foo', {0: 'msgid', 1: 'msgctxt'})),
492-
('foo:2c ,1', ('foo', {0: 'msgid', 1: 'msgctxt'})),
493-
('foo:1,2,3c', ('foo', {0: 'msgid', 1: 'msgid_plural', 2: 'msgctxt'})),
494-
('foo:1, 2, 3c', ('foo', {0: 'msgid', 1: 'msgid_plural', 2: 'msgctxt'})),
495-
('foo:3c,1,2', ('foo', {0: 'msgid', 1: 'msgid_plural', 2: 'msgctxt'})),
486+
('foo', ('foo', {'msgid': 0})),
487+
('foo:1', ('foo', {'msgid': 0})),
488+
('foo:1,2', ('foo', {'msgid': 0, 'msgid_plural': 1})),
489+
('foo:1, 2', ('foo', {'msgid': 0, 'msgid_plural': 1})),
490+
('foo:1,2c', ('foo', {'msgid': 0, 'msgctxt': 1})),
491+
('foo:2c,1', ('foo', {'msgid': 0, 'msgctxt': 1})),
492+
('foo:2c ,1', ('foo', {'msgid': 0, 'msgctxt': 1})),
493+
('foo:1,2,3c', ('foo', {'msgid': 0, 'msgid_plural': 1, 'msgctxt': 2})),
494+
('foo:1, 2, 3c', ('foo', {'msgid': 0, 'msgid_plural': 1, 'msgctxt': 2})),
495+
('foo:3c,1,2', ('foo', {'msgid': 0, 'msgid_plural': 1, 'msgctxt': 2})),
496496
)
497497
for spec, expected in valid:
498498
with self.subTest(spec=spec):
@@ -516,6 +516,33 @@ def test_parse_keyword_spec(self):
516516
parse_spec(spec)
517517
self.assertEqual(str(cm.exception), message)
518518

519+
def test_process_keywords(self):
520+
default_keywords = {name: [spec] for name, spec
521+
in DEFAULTKEYWORDS.items()}
522+
inputs = (
523+
(['foo'], True),
524+
(['_:1,2'], True),
525+
(['foo', 'foo:1,2'], True),
526+
(['foo'], False),
527+
(['_:1,2', '_:1c,2,3', 'pgettext'], False),
528+
)
529+
expected = (
530+
{'foo': [{'msgid': 0}]},
531+
{'_': [{'msgid': 0, 'msgid_plural': 1}]},
532+
{'foo': [{'msgid': 0}, {'msgid': 0, 'msgid_plural': 1}]},
533+
default_keywords | {'foo': [{'msgid': 0}]},
534+
default_keywords | {'_': [{'msgid': 0, 'msgid_plural': 1},
535+
{'msgctxt': 0, 'msgid': 1, 'msgid_plural': 2}],
536+
'pgettext': [{'msgid': 0}]},
537+
)
538+
for (keywords, no_default_keywords), expected in zip(inputs, expected):
539+
with self.subTest(keywords=keywords,
540+
no_default_keywords=no_default_keywords):
541+
processed = process_keywords(
542+
keywords,
543+
no_default_keywords=no_default_keywords)
544+
self.assertEqual(processed, expected)
545+
519546

520547
def extract_from_snapshots():
521548
snapshots = {
@@ -526,6 +553,10 @@ def extract_from_snapshots():
526553
'custom_keywords.py': ('--keyword=foo', '--keyword=nfoo:1,2',
527554
'--keyword=pfoo:1c,2',
528555
'--keyword=npfoo:1c,2,3', '--keyword=_:1,2'),
556+
'multiple_keywords.py': ('--keyword=foo:1c,2,3', '--keyword=foo:1c,2',
557+
'--keyword=foo:1,2',
558+
# repeat a keyword to make sure it is extracted only once
559+
'--keyword=foo', '--keyword=foo'),
529560
}
530561

531562
for filename, args in snapshots.items():

Tools/i18n/pygettext.py

Lines changed: 48 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -282,15 +282,15 @@ def getFilesForName(name):
282282
# Key is the function name, value is a dictionary mapping argument positions to the
283283
# type of the argument. The type is one of 'msgid', 'msgid_plural', or 'msgctxt'.
284284
DEFAULTKEYWORDS = {
285-
'_': {0: 'msgid'},
286-
'gettext': {0: 'msgid'},
287-
'ngettext': {0: 'msgid', 1: 'msgid_plural'},
288-
'pgettext': {0: 'msgctxt', 1: 'msgid'},
289-
'npgettext': {0: 'msgctxt', 1: 'msgid', 2: 'msgid_plural'},
290-
'dgettext': {1: 'msgid'},
291-
'dngettext': {1: 'msgid', 2: 'msgid_plural'},
292-
'dpgettext': {1: 'msgctxt', 2: 'msgid'},
293-
'dnpgettext': {1: 'msgctxt', 2: 'msgid', 3: 'msgid_plural'},
285+
'_': {'msgid': 0},
286+
'gettext': {'msgid': 0},
287+
'ngettext': {'msgid': 0, 'msgid_plural': 1},
288+
'pgettext': {'msgctxt': 0, 'msgid': 1},
289+
'npgettext': {'msgctxt': 0, 'msgid': 1, 'msgid_plural': 2},
290+
'dgettext': {'msgid': 1},
291+
'dngettext': {'msgid': 1, 'msgid_plural': 2},
292+
'dpgettext': {'msgctxt': 1, 'msgid': 2},
293+
'dnpgettext': {'msgctxt': 1, 'msgid': 2, 'msgid_plural': 3},
294294
}
295295

296296

@@ -327,7 +327,7 @@ def parse_spec(spec):
327327
parts = spec.strip().split(':', 1)
328328
if len(parts) == 1:
329329
name = parts[0]
330-
return name, {0: 'msgid'}
330+
return name, {'msgid': 0}
331331

332332
name, args = parts
333333
if not args:
@@ -373,7 +373,23 @@ def parse_spec(spec):
373373
raise ValueError(f'Invalid keyword spec {spec!r}: '
374374
'msgctxt cannot appear without msgid')
375375

376-
return name, {v: k for k, v in result.items()}
376+
return name, result
377+
378+
379+
def process_keywords(keywords, *, no_default_keywords):
380+
custom_keywords_list = [parse_spec(spec) for spec in keywords]
381+
custom_keywords = {}
382+
for name, spec in custom_keywords_list:
383+
if name not in custom_keywords:
384+
custom_keywords[name] = []
385+
custom_keywords[name].append(spec)
386+
387+
if no_default_keywords:
388+
return custom_keywords
389+
390+
default_keywords = {name: [spec] for name, spec in DEFAULTKEYWORDS.items()}
391+
# custom keywords override default keywords
392+
return default_keywords | custom_keywords
377393

378394

379395
@dataclass(frozen=True)
@@ -459,37 +475,46 @@ def _extract_docstring(self, node):
459475

460476
def _extract_message(self, node):
461477
func_name = self._get_func_name(node)
462-
spec = self.options.keywords.get(func_name)
463-
if spec is None:
464-
return
478+
specs = self.options.keywords.get(func_name, [])
479+
for spec in specs:
480+
extracted = self._extract_message_with_spec(node, spec)
481+
if extracted:
482+
break
483+
484+
def _extract_message_with_spec(self, node, spec):
485+
"""Extract a gettext call with the given spec.
465486
466-
max_index = max(spec)
487+
Return True if the gettext call was successfully extracted, False
488+
otherwise.
489+
"""
490+
max_index = max(spec.values())
467491
has_var_positional = any(isinstance(arg, ast.Starred) for
468492
arg in node.args[:max_index+1])
469493
if has_var_positional:
470494
print(f'*** {self.filename}:{node.lineno}: Variable positional '
471495
f'arguments are not allowed in gettext calls', file=sys.stderr)
472-
return
496+
return False
473497

474498
if max_index >= len(node.args):
475499
print(f'*** {self.filename}:{node.lineno}: Expected at least '
476-
f'{max(spec) + 1} positional argument(s) in gettext call, '
500+
f'{max_index + 1} positional argument(s) in gettext call, '
477501
f'got {len(node.args)}', file=sys.stderr)
478-
return
502+
return False
479503

480504
msg_data = {}
481-
for position, arg_type in spec.items():
505+
for arg_type, position in spec.items():
482506
arg = node.args[position]
483507
if not self._is_string_const(arg):
484508
print(f'*** {self.filename}:{arg.lineno}: Expected a string '
485509
f'constant for argument {position + 1}, '
486510
f'got {ast.unparse(arg)}', file=sys.stderr)
487-
return
511+
return False
488512
msg_data[arg_type] = arg.value
489513

490514
lineno = node.lineno
491515
comments = self._extract_comments(node)
492516
self._add_message(lineno, **msg_data, comments=comments)
517+
return True
493518

494519
def _extract_comments(self, node):
495520
"""Extract translator comments.
@@ -729,15 +754,12 @@ class Options:
729754

730755
# calculate all keywords
731756
try:
732-
custom_keywords = dict(parse_spec(spec) for spec in options.keywords)
757+
options.keywords = process_keywords(
758+
options.keywords,
759+
no_default_keywords=no_default_keywords)
733760
except ValueError as e:
734761
print(e, file=sys.stderr)
735762
sys.exit(1)
736-
options.keywords = {}
737-
if not no_default_keywords:
738-
options.keywords |= DEFAULTKEYWORDS
739-
# custom keywords override default keywords
740-
options.keywords |= custom_keywords
741763

742764
# initialize list of strings to exclude
743765
if options.excludefilename:

0 commit comments

Comments
 (0)