gh-104400: pygettext: use an AST parser instead of a tokenizer (GH-104402)

This greatly simplifies the code and fixes many corner cases.
This commit is contained in:
Tomas R. 2025-02-11 12:51:42 +01:00 committed by GitHub
parent 1da412e574
commit 374abded07
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 174 additions and 251 deletions

View file

@ -15,26 +15,40 @@ msgstr ""
"Generated-By: pygettext.py 1.5\n"
#: docstrings.py:7
#: docstrings.py:1
#, docstring
msgid "Module docstring"
msgstr ""
#: docstrings.py:9
#, docstring
msgid ""
msgstr ""
#: docstrings.py:18
#: docstrings.py:15
#, docstring
msgid "docstring"
msgstr ""
#: docstrings.py:20
#, docstring
msgid ""
"multiline\n"
" docstring\n"
" "
"docstring"
msgstr ""
#: docstrings.py:25
#: docstrings.py:27
#, docstring
msgid "docstring1"
msgstr ""
#: docstrings.py:30
#: docstrings.py:38
#, docstring
msgid "Hello, {}!"
msgid "nested docstring"
msgstr ""
#: docstrings.py:43
#, docstring
msgid "nested class docstring"
msgstr ""

View file

@ -1,3 +1,5 @@
"""Module docstring"""
# Test docstring extraction
from gettext import gettext as _
@ -10,10 +12,10 @@ def test(x):
# Leading empty line
def test2(x):
"""docstring""" # XXX This should be extracted but isn't.
"""docstring"""
# XXX Multiline docstrings should be cleaned with `inspect.cleandoc`.
# Multiline docstrings are cleaned with `inspect.cleandoc`.
def test3(x):
"""multiline
docstring
@ -27,15 +29,15 @@ def test4(x):
def test5(x):
"""Hello, {}!""".format("world!") # XXX This should not be extracted.
"""Hello, {}!""".format("world!") # This should not be extracted.
# Nested docstrings
def test6(x):
def inner(y):
"""nested docstring""" # XXX This should be extracted but isn't.
"""nested docstring"""
class Outer:
class Inner:
"nested class docstring" # XXX This should be extracted but isn't.
"nested class docstring"

View file

@ -19,22 +19,22 @@ msgstr ""
msgid ""
msgstr ""
#: messages.py:19 messages.py:20
#: messages.py:19 messages.py:20 messages.py:21
msgid "parentheses"
msgstr ""
#: messages.py:23
#: messages.py:24
msgid "Hello, world!"
msgstr ""
#: messages.py:26
#: messages.py:27
msgid ""
"Hello,\n"
" multiline!\n"
msgstr ""
#: messages.py:46 messages.py:89 messages.py:90 messages.py:93 messages.py:94
#: messages.py:99
#: messages.py:99 messages.py:100 messages.py:101
msgid "foo"
msgid_plural "foos"
msgstr[0] ""
@ -68,7 +68,7 @@ msgstr ""
msgid "set"
msgstr ""
#: messages.py:63
#: messages.py:62 messages.py:63
msgid "nested string"
msgstr ""
@ -76,6 +76,10 @@ msgstr ""
msgid "baz"
msgstr ""
#: messages.py:71 messages.py:75
msgid "default value"
msgstr ""
#: messages.py:91 messages.py:92 messages.py:95 messages.py:96
msgctxt "context"
msgid "foo"
@ -83,7 +87,13 @@ msgid_plural "foos"
msgstr[0] ""
msgstr[1] ""
#: messages.py:100
#: messages.py:102
msgid "domain foo"
msgstr ""
#: messages.py:118 messages.py:119
msgid "world"
msgid_plural "worlds"
msgstr[0] ""
msgstr[1] ""

View file

@ -18,6 +18,7 @@ _("")
# Extra parentheses
(_("parentheses"))
((_("parentheses")))
_(("parentheses"))
# Multiline strings
_("Hello, "
@ -32,7 +33,6 @@ _()
_(None)
_(1)
_(False)
_(("invalid"))
_(["invalid"])
_({"invalid"})
_("string"[3])
@ -40,7 +40,7 @@ _("string"[:3])
_({"string": "foo"})
# pygettext does not allow keyword arguments, but both xgettext and pybabel do
_(x="kwargs work!")
_(x="kwargs are not allowed!")
# Unusual, but valid arguments
_("foo", "bar")
@ -48,7 +48,7 @@ _("something", x="something else")
# .format()
_("Hello, {}!").format("world") # valid
_("Hello, {}!".format("world")) # invalid, but xgettext and pybabel extract the first string
_("Hello, {}!".format("world")) # invalid, but xgettext extracts the first string
# Nested structures
_("1"), _("2")
@ -59,7 +59,7 @@ obj = {'a': _("A"), 'b': _("B")}
# Nested functions and classes
def test():
_("nested string") # XXX This should be extracted but isn't.
_("nested string")
[_("nested string")]
@ -68,11 +68,11 @@ class Foo:
return _("baz")
def bar(x=_('default value')): # XXX This should be extracted but isn't.
def bar(x=_('default value')):
pass
def baz(x=[_('default value')]): # XXX This should be extracted but isn't.
def baz(x=[_('default value')]):
pass
@ -97,6 +97,8 @@ dnpgettext("domain", "context", "foo", "foos", 1)
# Complex arguments
ngettext("foo", "foos", 42 + (10 - 20))
ngettext("foo", "foos", *args)
ngettext("foo", "foos", **kwargs)
dgettext(["some", {"complex"}, ("argument",)], "domain foo")
# Invalid calls which are not extracted
@ -108,3 +110,10 @@ dgettext('domain')
dngettext('domain', 'foo')
dpgettext('domain', 'context')
dnpgettext('domain', 'context', 'foo')
dgettext(*args, 'foo')
dpgettext(*args, 'context', 'foo')
dnpgettext(*args, 'context', 'foo', 'foos')
# f-strings
f"Hello, {_('world')}!"
f"Hello, {ngettext('world', 'worlds', 3)}!"

View file

@ -87,7 +87,7 @@ class Test_pygettext(unittest.TestCase):
self.maxDiff = None
self.assertEqual(normalize_POT_file(expected), normalize_POT_file(actual))
def extract_from_str(self, module_content, *, args=(), strict=True):
def extract_from_str(self, module_content, *, args=(), strict=True, with_stderr=False):
"""Return all msgids extracted from module_content."""
filename = 'test.py'
with temp_cwd(None):
@ -98,12 +98,18 @@ class Test_pygettext(unittest.TestCase):
self.assertEqual(res.err, b'')
with open('messages.pot', encoding='utf-8') as fp:
data = fp.read()
return self.get_msgids(data)
msgids = self.get_msgids(data)
if not with_stderr:
return msgids
return msgids, res.err
def extract_docstrings_from_str(self, module_content):
"""Return all docstrings extracted from module_content."""
return self.extract_from_str(module_content, args=('--docstrings',), strict=False)
def get_stderr(self, module_content):
return self.extract_from_str(module_content, strict=False, with_stderr=True)[1]
def test_header(self):
"""Make sure the required fields are in the header, according to:
http://www.gnu.org/software/gettext/manual/gettext.html#Header-Entry
@ -407,6 +413,24 @@ class Test_pygettext(unittest.TestCase):
self.assertIn(f'msgid "{text2}"', data)
self.assertNotIn(text3, data)
def test_error_messages(self):
"""Test that pygettext outputs error messages to stderr."""
stderr = self.get_stderr(dedent('''\
_(1+2)
ngettext('foo')
dgettext(*args, 'foo')
'''))
# Normalize line endings on Windows
stderr = stderr.decode('utf-8').replace('\r', '')
self.assertEqual(
stderr,
"*** test.py:1: Expected a string constant for argument 1, got 1 + 2\n"
"*** test.py:2: Expected at least 2 positional argument(s) in gettext call, got 1\n"
"*** test.py:3: Variable positional arguments are not allowed in gettext calls\n"
)
def update_POT_snapshots():
for input_file in DATA_DIR.glob('*.py'):

View file

@ -0,0 +1 @@
Fix several bugs in extraction by switching to an AST parser in :program:`pygettext`.

View file

@ -140,8 +140,6 @@ import importlib.util
import os
import sys
import time
import tokenize
from collections import defaultdict
from dataclasses import dataclass, field
from operator import itemgetter
@ -206,15 +204,6 @@ def escape_nonascii(s, encoding):
return ''.join(escapes[b] for b in s.encode(encoding))
def is_literal_string(s):
return s[0] in '\'"' or (s[0] in 'rRuU' and s[1] in '\'"')
def safe_eval(s):
# unwrap quotes, safely
return eval(s, {'__builtins__':{}}, {})
def normalize(s, encoding):
# This converts the various Python string types into a format that is
# appropriate for .po files, namely much closer to C style.
@ -296,11 +285,6 @@ DEFAULTKEYWORDS = {
}
def matches_spec(message, spec):
"""Check if a message has all the keys defined by the keyword spec."""
return all(key in message for key in spec.values())
@dataclass(frozen=True)
class Location:
filename: str
@ -325,203 +309,91 @@ class Message:
self.is_docstring |= is_docstring
class TokenEater:
class GettextVisitor(ast.NodeVisitor):
def __init__(self, options):
self.__options = options
self.__messages = {}
self.__state = self.__waiting
self.__data = defaultdict(str)
self.__curr_arg = 0
self.__curr_keyword = None
self.__lineno = -1
self.__freshmodule = 1
self.__curfile = None
self.__enclosurecount = 0
super().__init__()
self.options = options
self.filename = None
self.messages = {}
def __call__(self, ttype, tstring, stup, etup, line):
# dispatch
## import token
## print('ttype:', token.tok_name[ttype], 'tstring:', tstring,
## file=sys.stderr)
self.__state(ttype, tstring, stup[0])
def visit_file(self, node, filename):
self.filename = filename
self.visit(node)
@property
def messages(self):
return self.__messages
def visit_Module(self, node):
self._extract_docstring(node)
self.generic_visit(node)
def __waiting(self, ttype, tstring, lineno):
opts = self.__options
# Do docstring extractions, if enabled
if opts.docstrings and not opts.nodocstrings.get(self.__curfile):
# module docstring?
if self.__freshmodule:
if ttype == tokenize.STRING and is_literal_string(tstring):
self.__addentry({'msgid': safe_eval(tstring)}, lineno, is_docstring=True)
self.__freshmodule = 0
return
if ttype in (tokenize.COMMENT, tokenize.NL, tokenize.ENCODING):
return
self.__freshmodule = 0
# class or func/method docstring?
if ttype == tokenize.NAME and tstring in ('class', 'def'):
self.__state = self.__suiteseen
return
if ttype == tokenize.NAME and tstring in ('class', 'def'):
self.__state = self.__ignorenext
return
if ttype == tokenize.NAME and tstring in opts.keywords:
self.__state = self.__keywordseen
self.__curr_keyword = tstring
return
if ttype == tokenize.STRING:
maybe_fstring = ast.parse(tstring, mode='eval').body
if not isinstance(maybe_fstring, ast.JoinedStr):
return
for value in filter(lambda node: isinstance(node, ast.FormattedValue),
maybe_fstring.values):
for call in filter(lambda node: isinstance(node, ast.Call),
ast.walk(value)):
func = call.func
if isinstance(func, ast.Name):
func_name = func.id
elif isinstance(func, ast.Attribute):
func_name = func.attr
else:
continue
visit_ClassDef = visit_FunctionDef = visit_AsyncFunctionDef = visit_Module
if func_name not in opts.keywords:
continue
if len(call.args) != 1:
print((
'*** %(file)s:%(lineno)s: Seen unexpected amount of'
' positional arguments in gettext call: %(source_segment)s'
) % {
'source_segment': ast.get_source_segment(tstring, call) or tstring,
'file': self.__curfile,
'lineno': lineno
}, file=sys.stderr)
continue
if call.keywords:
print((
'*** %(file)s:%(lineno)s: Seen unexpected keyword arguments'
' in gettext call: %(source_segment)s'
) % {
'source_segment': ast.get_source_segment(tstring, call) or tstring,
'file': self.__curfile,
'lineno': lineno
}, file=sys.stderr)
continue
arg = call.args[0]
if not isinstance(arg, ast.Constant):
print((
'*** %(file)s:%(lineno)s: Seen unexpected argument type'
' in gettext call: %(source_segment)s'
) % {
'source_segment': ast.get_source_segment(tstring, call) or tstring,
'file': self.__curfile,
'lineno': lineno
}, file=sys.stderr)
continue
if isinstance(arg.value, str):
self.__curr_keyword = func_name
self.__addentry({'msgid': arg.value}, lineno)
def visit_Call(self, node):
self._extract_message(node)
self.generic_visit(node)
def __suiteseen(self, ttype, tstring, lineno):
# skip over any enclosure pairs until we see the colon
if ttype == tokenize.OP:
if tstring == ':' and self.__enclosurecount == 0:
# we see a colon and we're not in an enclosure: end of def
self.__state = self.__suitedocstring
elif tstring in '([{':
self.__enclosurecount += 1
elif tstring in ')]}':
self.__enclosurecount -= 1
def __suitedocstring(self, ttype, tstring, lineno):
# ignore any intervening noise
if ttype == tokenize.STRING and is_literal_string(tstring):
self.__addentry({'msgid': safe_eval(tstring)}, lineno, is_docstring=True)
self.__state = self.__waiting
elif ttype not in (tokenize.NEWLINE, tokenize.INDENT,
tokenize.COMMENT):
# there was no class docstring
self.__state = self.__waiting
def __keywordseen(self, ttype, tstring, lineno):
if ttype == tokenize.OP and tstring == '(':
self.__data.clear()
self.__curr_arg = 0
self.__enclosurecount = 0
self.__lineno = lineno
self.__state = self.__openseen
else:
self.__state = self.__waiting
def __openseen(self, ttype, tstring, lineno):
spec = self.__options.keywords[self.__curr_keyword]
arg_type = spec.get(self.__curr_arg)
expect_string_literal = arg_type is not None
if ttype == tokenize.OP and self.__enclosurecount == 0:
if tstring == ')':
# We've seen the last of the translatable strings. Record the
# line number of the first line of the strings and update the list
# of messages seen. Reset state for the next batch. If there
# were no strings inside _(), then just ignore this entry.
if self.__data:
self.__addentry(self.__data)
self.__state = self.__waiting
return
elif tstring == ',':
# Advance to the next argument
self.__curr_arg += 1
def _extract_docstring(self, node):
if (not self.options.docstrings or
self.options.nodocstrings.get(self.filename)):
return
if expect_string_literal:
if ttype == tokenize.STRING and is_literal_string(tstring):
self.__data[arg_type] += safe_eval(tstring)
elif ttype not in (tokenize.COMMENT, tokenize.INDENT, tokenize.DEDENT,
tokenize.NEWLINE, tokenize.NL):
# We are inside an argument which is a translatable string and
# we encountered a token that is not a string. This is an error.
self.warn_unexpected_token(tstring)
self.__enclosurecount = 0
self.__state = self.__waiting
elif ttype == tokenize.OP:
if tstring in '([{':
self.__enclosurecount += 1
elif tstring in ')]}':
self.__enclosurecount -= 1
docstring = ast.get_docstring(node)
if docstring is not None:
lineno = node.body[0].lineno # The first statement is the docstring
self._add_message(lineno, docstring, is_docstring=True)
def __ignorenext(self, ttype, tstring, lineno):
self.__state = self.__waiting
def _extract_message(self, node):
func_name = self._get_func_name(node)
spec = self.options.keywords.get(func_name)
if spec is None:
return
def __addentry(self, msg, lineno=None, *, is_docstring=False):
msgid = msg.get('msgid')
if msgid in self.__options.toexclude:
max_index = max(spec)
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
if not is_docstring:
spec = self.__options.keywords[self.__curr_keyword]
if not matches_spec(msg, spec):
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
if lineno is None:
lineno = self.__lineno
msgctxt = msg.get('msgctxt')
msgid_plural = msg.get('msgid_plural')
msg_data = {}
for position, arg_type 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
msg_data[arg_type] = arg.value
lineno = node.lineno
self._add_message(lineno, **msg_data)
def _add_message(
self, lineno, msgid, msgid_plural=None, msgctxt=None, *,
is_docstring=False):
if msgid in self.options.toexclude:
return
key = self._key_for(msgid, msgctxt)
if key in self.__messages:
self.__messages[key].add_location(
self.__curfile,
message = self.messages.get(key)
if message:
message.add_location(
self.filename,
lineno,
msgid_plural,
is_docstring=is_docstring,
)
else:
self.__messages[key] = Message(
self.messages[key] = Message(
msgid=msgid,
msgid_plural=msgid_plural,
msgctxt=msgctxt,
locations={Location(self.__curfile, lineno)},
locations={Location(self.filename, lineno)},
is_docstring=is_docstring,
)
@ -531,19 +403,17 @@ class TokenEater:
return (msgctxt, msgid)
return msgid
def warn_unexpected_token(self, token):
print((
'*** %(file)s:%(lineno)s: Seen unexpected token "%(token)s"'
) % {
'token': token,
'file': self.__curfile,
'lineno': self.__lineno
}, file=sys.stderr)
def set_filename(self, filename):
self.__curfile = filename
self.__freshmodule = 1
def _get_func_name(self, node):
match node.func:
case ast.Name(id=id):
return id
case ast.Attribute(attr=attr):
return attr
case _:
return None
def _is_string_const(self, node):
return isinstance(node, ast.Constant) and isinstance(node.value, str)
def write_pot_file(messages, options, fp):
timestamp = time.strftime('%Y-%m-%d %H:%M%z')
@ -717,31 +587,24 @@ def main():
args = expanded
# slurp through all the files
eater = TokenEater(options)
visitor = GettextVisitor(options)
for filename in args:
if filename == '-':
if options.verbose:
print('Reading standard input')
fp = sys.stdin.buffer
closep = 0
source = sys.stdin.buffer.read()
else:
if options.verbose:
print(f'Working on {filename}')
fp = open(filename, 'rb')
closep = 1
with open(filename, 'rb') as fp:
source = fp.read()
try:
eater.set_filename(filename)
try:
tokens = tokenize.tokenize(fp.readline)
for _token in tokens:
eater(*_token)
except tokenize.TokenError as e:
print('%s: %s, line %d, column %d' % (
e.args[0], filename, e.args[1][0], e.args[1][1]),
file=sys.stderr)
finally:
if closep:
fp.close()
module_tree = ast.parse(source)
except SyntaxError:
continue
visitor.visit_file(module_tree, filename)
# write the output
if options.outfile == '-':
@ -753,7 +616,7 @@ def main():
fp = open(options.outfile, 'w')
closep = 1
try:
write_pot_file(eater.messages, options, fp)
write_pot_file(visitor.messages, options, fp)
finally:
if closep:
fp.close()