Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support 't' specifier in keywords #1015

Merged
merged 10 commits into from
Oct 1, 2023
92 changes: 51 additions & 41 deletions babel/messages/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ class _FileObj(SupportsRead[bytes], SupportsReadline[bytes], Protocol):
def seek(self, __offset: int, __whence: int = ...) -> int: ...
def tell(self) -> int: ...

_Keyword: TypeAlias = tuple[int | tuple[int, int] | tuple[int, str], ...] | None
_SimpleKeyword: TypeAlias = tuple[int | tuple[int, int] | tuple[int, str], ...] | None
_Keyword: TypeAlias = dict[int | None, _SimpleKeyword] | _SimpleKeyword

# 5-tuple of (filename, lineno, messages, comments, context)
_FileExtractionResult: TypeAlias = tuple[str, int, str | tuple[str, ...], list[str], str | None]
Expand Down Expand Up @@ -400,56 +401,65 @@ def extract(
options=options or {})

for lineno, funcname, messages, comments in results:
spec = keywords[funcname] or (1,) if funcname else (1,)
specs = keywords[funcname] or (1,) if funcname else (1,)
akx marked this conversation as resolved.
Show resolved Hide resolved
if not isinstance(specs, dict): # for backwards compatibility
specs = {None: specs}
if not isinstance(messages, (list, tuple)):
messages = [messages]
if not messages:
continue

# Validate the messages against the keyword's specification
context = None
msgs = []
invalid = False
# last_index is 1 based like the keyword spec
last_index = len(messages)
for index in spec:
if isinstance(index, tuple):
context = messages[index[0] - 1]

# None matches all arities.
for arity in (None, len(messages)):
try:
spec = specs[arity]
except KeyError:
continue
jeanas marked this conversation as resolved.
Show resolved Hide resolved
context = None
msgs = []
invalid = False
# last_index is 1 based like the keyword spec
last_index = len(messages)
for index in spec:
if isinstance(index, tuple):
context = messages[index[0] - 1]
continue
if last_index < index:
# Not enough arguments
invalid = True
break
message = messages[index - 1]
if message is None:
invalid = True
break
msgs.append(message)
if invalid:
continue
if last_index < index:
# Not enough arguments
invalid = True
break
message = messages[index - 1]
if message is None:
invalid = True
break
msgs.append(message)
if invalid:
continue

# keyword spec indexes are 1 based, therefore '-1'
if isinstance(spec[0], tuple):
# context-aware *gettext method
first_msg_index = spec[1] - 1
else:
first_msg_index = spec[0] - 1
if not messages[first_msg_index]:
# An empty string msgid isn't valid, emit a warning
filename = (getattr(fileobj, "name", None) or "(unknown)")
sys.stderr.write(
f"{filename}:{lineno}: warning: Empty msgid. It is reserved by GNU gettext: gettext(\"\") "
f"returns the header entry with meta information, not the empty string.\n"
)
continue
# keyword spec indexes are 1 based, therefore '-1'
if isinstance(spec[0], tuple):
# context-aware *gettext method
first_msg_index = spec[1] - 1
else:
first_msg_index = spec[0] - 1
if not messages[first_msg_index]:
# An empty string msgid isn't valid, emit a warning
filename = (getattr(fileobj, "name", None) or "(unknown)")
sys.stderr.write(
f"{filename}:{lineno}: warning: Empty msgid. It is reserved by GNU gettext: gettext(\"\") "
f"returns the header entry with meta information, not the empty string.\n"
)
continue

messages = tuple(msgs)
if len(messages) == 1:
messages = messages[0]
msgs = tuple(msgs)
if len(msgs) == 1:
msgs = msgs[0]

if strip_comment_tags:
_strip_comment_tags(comments, comment_tags)
yield lineno, messages, comments, context
if strip_comment_tags:
_strip_comment_tags(comments, comment_tags)
yield lineno, msgs, comments, context


def extract_nothing(
Expand Down
36 changes: 26 additions & 10 deletions babel/messages/frontend.py
Original file line number Diff line number Diff line change
Expand Up @@ -1111,30 +1111,46 @@ def parse_mapping(fileobj, filename=None):
def parse_keywords(strings: Iterable[str] = ()):
"""Parse keywords specifications from the given list of strings.
jeanas marked this conversation as resolved.
Show resolved Hide resolved

>>> kw = sorted(parse_keywords(['_', 'dgettext:2', 'dngettext:2,3', 'pgettext:1c,2']).items())
>>> kw = sorted(parse_keywords(['_', 'dgettext:2', 'dngettext:2,3', 'pgettext:1c,2', 'polymorphic:1', 'polymorphic:2,2t', 'polymorphic:3c,3t']).items())
>>> for keyword, indices in kw:
... print((keyword, indices))
('_', None)
('dgettext', (2,))
('dngettext', (2, 3))
('pgettext', ((1, 'c'), 2))
('polymorphic', {None: (1,), 2: (2,), 3: ((3, 'c'),)})
"""
keywords = {}
for string in strings:
if ':' in string:
funcname, indices = string.split(':')
else:
funcname, indices = string, None
number = None
if indices:
inds = []
for x in indices.split(','):
if x[-1] == 't':
number = int(x[:-1])
elif x[-1] == 'c':
inds.append((int(x[:-1]), 'c'))
else:
inds.append(int(x))
inds = tuple(inds)
else:
inds = None
if funcname not in keywords:
if indices:
inds = []
for x in indices.split(','):
if x[-1] == 'c':
inds.append((int(x[:-1]), 'c'))
else:
inds.append(int(x))
indices = tuple(inds)
keywords[funcname] = indices
if number is None:
# For best backwards compatibility, collapse {None: x} into x.
keywords[funcname] = inds
else:
keywords[funcname] = {number: inds}
else:
if isinstance(keywords[funcname], tuple) or keywords[funcname] is None:
keywords[funcname] = {None: keywords[funcname]}
else:
assert isinstance(keywords[funcname], dict)
jeanas marked this conversation as resolved.
Show resolved Hide resolved
keywords[funcname][number] = inds
return keywords


Expand Down
33 changes: 31 additions & 2 deletions tests/messages/test_frontend.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,15 @@
import time
import unittest
from datetime import datetime
from io import StringIO
from io import BytesIO, StringIO

import pytest
from freezegun import freeze_time
from setuptools import Distribution

from babel import __version__ as VERSION
from babel.dates import format_datetime
from babel.messages import Catalog, frontend
from babel.messages import Catalog, extract, frontend
from babel.messages.frontend import (
BaseError,
CommandLineInterface,
Expand Down Expand Up @@ -1320,6 +1320,35 @@ def test_parse_keywords():
}


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):
"""
Helper to configure a command class, but not run it just yet.
Expand Down