From 01f7fafe8f2f33576037694c1001e5a438652e64 Mon Sep 17 00:00:00 2001 From: Jean Abou Samra Date: Wed, 14 Jun 2023 21:16:25 +0200 Subject: [PATCH 1/9] Support 't' specifier in keywords Fixes #1014 --- babel/messages/extract.py | 92 ++++++++++++++++++--------------- babel/messages/frontend.py | 36 +++++++++---- tests/messages/test_frontend.py | 11 ++++ 3 files changed, 88 insertions(+), 51 deletions(-) diff --git a/babel/messages/extract.py b/babel/messages/extract.py index b6dce6fdb..1a4def03d 100644 --- a/babel/messages/extract.py +++ b/babel/messages/extract.py @@ -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] @@ -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,) + 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 + 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] + messages = tuple(msgs) + if len(messages) == 1: + messages = messages[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, messages, comments, context def extract_nothing( diff --git a/babel/messages/frontend.py b/babel/messages/frontend.py index af7e1d11d..bc2bd7f32 100644 --- a/babel/messages/frontend.py +++ b/babel/messages/frontend.py @@ -1111,13 +1111,14 @@ def parse_mapping(fileobj, filename=None): def parse_keywords(strings: Iterable[str] = ()): """Parse keywords specifications from the given list of strings. - >>> 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: @@ -1125,16 +1126,31 @@ def parse_keywords(strings: Iterable[str] = ()): 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) + keywords[funcname][number] = inds return keywords diff --git a/tests/messages/test_frontend.py b/tests/messages/test_frontend.py index 8e1e670ca..20068afdb 100644 --- a/tests/messages/test_frontend.py +++ b/tests/messages/test_frontend.py @@ -1320,6 +1320,17 @@ 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 configure_cli_command(cmdline): """ Helper to configure a command class, but not run it just yet. From ab2f84195fb39246206e511d2ab1cad8c4d52c8a Mon Sep 17 00:00:00 2001 From: Jean Abou Samra Date: Sat, 1 Jul 2023 18:59:56 +0200 Subject: [PATCH 2/9] Fix variable reuse across loop bug and add test --- babel/messages/extract.py | 8 ++++---- tests/messages/test_frontend.py | 22 ++++++++++++++++++++-- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/babel/messages/extract.py b/babel/messages/extract.py index 1a4def03d..cca5c9b53 100644 --- a/babel/messages/extract.py +++ b/babel/messages/extract.py @@ -453,13 +453,13 @@ def extract( ) 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 + yield lineno, msgs, comments, context def extract_nothing( diff --git a/tests/messages/test_frontend.py b/tests/messages/test_frontend.py index 20068afdb..ed8bf6d5c 100644 --- a/tests/messages/test_frontend.py +++ b/tests/messages/test_frontend.py @@ -17,7 +17,7 @@ import time import unittest from datetime import datetime -from io import StringIO +from io import BytesIO, StringIO import pytest from freezegun import freeze_time @@ -25,7 +25,7 @@ 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, @@ -1331,6 +1331,24 @@ def test_parse_keywords_with_t(): } } +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. From 907ff98adc5711f01a23dd392000db9856a16d2e Mon Sep 17 00:00:00 2001 From: Jean Abou Samra Date: Tue, 1 Aug 2023 11:33:06 +0200 Subject: [PATCH 3/9] Document --keyword format --- docs/cmdline.rst | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/docs/cmdline.rst b/docs/cmdline.rst index 8d9742fbf..d314f2e93 100644 --- a/docs/cmdline.rst +++ b/docs/cmdline.rst @@ -133,6 +133,45 @@ a collection of source files:: header comment for the catalog +The meaning of ``--keyword`` values is as follows: + +- Pass a simple identifier like ``_`` to extract the first (and only the first) + argument of all function calls to ``_``, + +- To extract other arguments than the first, add a colon and the argument + indices separated by commas. For example, the ``dngettext`` function + typically expects translatable strings as second and third arguments, + so you could pass ``dngettext:2,3``. + +- Some arguments should not be interpreted as translatable strings, but + context strings. For that, append "c" to the argument index. For example: + ``pgettext:1c,2``. + +- In C++ and Python, you may have functions that behave differently + depending on how many arguments they take. For this use case, you can + add an integer followed by "t" after the colon. In this case, the + keyword will only match a function invocation if it has the specified + total number of arguments. For example, if you have a function that + behaves as ``gettext`` (argument is a message) or ``pgettext`` + (arguments are a context and a message) depending on whether it takes + one or two arguments, you can pass + ``--keyword=gettext:1t --keyword=pgettext:1c,2,2t``. + +The default keywords are equivalent to passing :: + + --keyword=_ + --keyword=gettext + --keyword=ngettext:1,2 + --keyword=ugettext + --keyword=ungettext:1,2 + --keyword=dgettext:2 + --keyword=dngettext:2,3 + --keyword=N_ + --keyword=pgettext:1c,2 + --keyword=npgettext:1c,2,3 + + + init ==== From b879f5001ee77396f57791858854d2c32ad9a081 Mon Sep 17 00:00:00 2001 From: Jean Abou Samra Date: Tue, 1 Aug 2023 11:33:20 +0200 Subject: [PATCH 4/9] Document parse_keywords output format and make logic more readable --- babel/messages/frontend.py | 83 ++++++++++++++++++++++---------------- 1 file changed, 48 insertions(+), 35 deletions(-) diff --git a/babel/messages/frontend.py b/babel/messages/frontend.py index bc2bd7f32..1034a9195 100644 --- a/babel/messages/frontend.py +++ b/babel/messages/frontend.py @@ -1107,50 +1107,63 @@ def parse_mapping(fileobj, filename=None): return method_map, options_map +def _parse_spec(s): + inds = [] + number = None + for x in s.split(','): + if x[-1] == 't': + number = int(x[:-1]) + elif x[-1] == 'c': + inds.append((int(x[:-1]), 'c')) + else: + inds.append(int(x)) + return number, tuple(inds) def parse_keywords(strings: Iterable[str] = ()): """Parse keywords specifications from the given list of strings. - >>> 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'),)}) + >>> import pprint + >>> keywords = ['_', 'dgettext:2', 'dngettext:2,3', 'pgettext:1c,2', + ... 'polymorphic:1', 'polymorphic:2,2t', 'polymorphic:3c,3t'] + >>> pprint.pprint(parse_keywords(keywords)) + {'_': None, + 'dgettext': (2,), + 'dngettext': (2, 3), + 'pgettext': ((1, 'c'), 2), + 'polymorphic': {None: (1,), 2: (2,), 3: ((3, 'c'),)}} + + The input keywords are in GNU Gettext style; see :doc:`cmdline` for details. + + The output is a dictionary mapping keyword names to a dictionary of specifications. + Keys in this dictionary are numbers of arguments, where ``None`` means that all numbers + of arguments are matched, and a number means only calls with that number of arguments + are matched (which happens when using the "t" specifier). However, as a special + case for backwards compatibility, if the dictionary of specifications would + be ``{None: x}``, i.e., there is only one specification and it matches all argument + counts, then it is collapsed into just ``x``. + + A specification is either a tuple or None. If a tuple, each element can be either a number + ``n``, meaning that the nth argument should be extracted as a message, or the tuple + ``(n, 'c')``, meaning that the nth argument should be extracted as context for the + messages. A ``None`` specification is equivalent to ``(1,)``, extracting the first + argument. """ 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 number is None: - # For best backwards compatibility, collapse {None: x} into x. - keywords[funcname] = inds - else: - keywords[funcname] = {number: inds} + funcname, spec_str = string.split(':') + number, spec = _parse_spec(spec_str) else: - if isinstance(keywords[funcname], tuple) or keywords[funcname] is None: - keywords[funcname] = {None: keywords[funcname]} - else: - assert isinstance(keywords[funcname], dict) - keywords[funcname][number] = inds + funcname = string + number = None + spec = None + keywords.setdefault(funcname, {})[number] = spec + + # For best backwards compatibility, collapse {None: x} into x. + for k, v in keywords.items(): + if set(v.keys()) == {None}: + keywords[k] = v[None] + return keywords From 3544bc526b3add250313e666b2c29e90ab6eca55 Mon Sep 17 00:00:00 2001 From: Jean Abou Samra Date: Tue, 1 Aug 2023 12:26:21 +0200 Subject: [PATCH 5/9] Use a helper function to make extraction logic more readable --- babel/messages/extract.py | 98 ++++++++++++++++++++------------------- 1 file changed, 51 insertions(+), 47 deletions(-) diff --git a/babel/messages/extract.py b/babel/messages/extract.py index cca5c9b53..e53c3a626 100644 --- a/babel/messages/extract.py +++ b/babel/messages/extract.py @@ -316,6 +316,47 @@ def extract_from_file( options, strip_comment_tags)) +def _match_messages_against_spec(lineno, messages, comments, fileobj, spec): + translatable = [] + context = None + + # last_index is 1 based like the keyword spec + last_index = len(messages) + for index in spec: + if isinstance(index, tuple): # (n, 'c') + context = messages[index[0] - 1] + continue + if last_index < index: + # Not enough arguments + return + message = messages[index - 1] + if message is None: + return + translatable.append(message) + + # An empty string msgid isn't valid, emit a warning + + # 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]: + 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" + ) + return + + translatable = tuple(translatable) + if len(translatable) == 1: + translatable = translatable[0] + + yield lineno, translatable, comments, context + + def extract( method: _ExtractionMethod, fileobj: _FileObj, @@ -401,15 +442,18 @@ def extract( options=options or {}) for lineno, funcname, messages, comments in results: - specs = keywords[funcname] or (1,) if funcname else (1,) - 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 + specs = keywords[funcname] or None if funcname else None + # {None: x} may be collapsed into x for backwards compatibility. + if not isinstance(specs, dict): + specs = {None: specs} + + if strip_comment_tags: + _strip_comment_tags(comments, comment_tags) # None matches all arities. for arity in (None, len(messages)): @@ -417,49 +461,9 @@ def extract( spec = specs[arity] except KeyError: continue - 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 - - # 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 - - msgs = tuple(msgs) - if len(msgs) == 1: - msgs = msgs[0] - - if strip_comment_tags: - _strip_comment_tags(comments, comment_tags) - yield lineno, msgs, comments, context + if spec is None: + spec = (1,) + yield from _match_messages_against_spec(lineno, messages, comments, fileobj, spec) def extract_nothing( From 116e3e98a3f8b873476a3d0fea59e8672627e5eb Mon Sep 17 00:00:00 2001 From: Jean Abou Samra Date: Tue, 1 Aug 2023 12:30:35 +0200 Subject: [PATCH 6/9] Fix typo --- docs/cmdline.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cmdline.rst b/docs/cmdline.rst index d314f2e93..c580e908b 100644 --- a/docs/cmdline.rst +++ b/docs/cmdline.rst @@ -155,7 +155,7 @@ The meaning of ``--keyword`` values is as follows: behaves as ``gettext`` (argument is a message) or ``pgettext`` (arguments are a context and a message) depending on whether it takes one or two arguments, you can pass - ``--keyword=gettext:1t --keyword=pgettext:1c,2,2t``. + ``--keyword=gettext:1,1t --keyword=pgettext:1c,2,2t``. The default keywords are equivalent to passing :: From 237278fafeac0c01473682d7b00b2ee9aca6b4dd Mon Sep 17 00:00:00 2001 From: Jean Abou Samra Date: Tue, 1 Aug 2023 12:33:15 +0200 Subject: [PATCH 7/9] Still typoed :-) --- docs/cmdline.rst | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/cmdline.rst b/docs/cmdline.rst index c580e908b..e1328fe8f 100644 --- a/docs/cmdline.rst +++ b/docs/cmdline.rst @@ -151,11 +151,11 @@ The meaning of ``--keyword`` values is as follows: depending on how many arguments they take. For this use case, you can add an integer followed by "t" after the colon. In this case, the keyword will only match a function invocation if it has the specified - total number of arguments. For example, if you have a function that - behaves as ``gettext`` (argument is a message) or ``pgettext`` - (arguments are a context and a message) depending on whether it takes - one or two arguments, you can pass - ``--keyword=gettext:1,1t --keyword=pgettext:1c,2,2t``. + total number of arguments. For example, if you have a function + ``foo`` that behaves as ``gettext`` (argument is a message) or + ``pgettext`` (arguments are a context and a message) depending on + whether it takes one or two arguments, you can pass + ``--keyword=foo:1,1t --keyword=foo:1c,2,2t``. The default keywords are equivalent to passing :: From 464279df363e1730962acb3aa3ab1512b47a0a28 Mon Sep 17 00:00:00 2001 From: Jean Abou Samra Date: Mon, 4 Sep 2023 17:46:46 +0200 Subject: [PATCH 8/9] Further code review suggestions --- babel/messages/extract.py | 12 +++++++----- babel/messages/frontend.py | 4 ++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/babel/messages/extract.py b/babel/messages/extract.py index e53c3a626..dd78f4a5b 100644 --- a/babel/messages/extract.py +++ b/babel/messages/extract.py @@ -316,7 +316,8 @@ def extract_from_file( options, strip_comment_tags)) -def _match_messages_against_spec(lineno, messages, comments, fileobj, spec): +def _match_messages_against_spec(lineno: int, messages: list[str|None], comments: list[str], + fileobj: _FileObj, spec: tuple[int|tuple, ...]): translatable = [] context = None @@ -334,14 +335,13 @@ def _match_messages_against_spec(lineno, messages, comments, fileobj, spec): return translatable.append(message) - # An empty string msgid isn't valid, emit a warning - # 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 + # An empty string msgid isn't valid, emit a warning if not messages[first_msg_index]: filename = (getattr(fileobj, "name", None) or "(unknown)") sys.stderr.write( @@ -354,7 +354,7 @@ def _match_messages_against_spec(lineno, messages, comments, fileobj, spec): if len(translatable) == 1: translatable = translatable[0] - yield lineno, translatable, comments, context + return lineno, translatable, comments, context def extract( @@ -463,7 +463,9 @@ def extract( continue if spec is None: spec = (1,) - yield from _match_messages_against_spec(lineno, messages, comments, fileobj, spec) + result = _match_messages_against_spec(lineno, messages, comments, fileobj, spec) + if result is not None: + yield result def extract_nothing( diff --git a/babel/messages/frontend.py b/babel/messages/frontend.py index feeadf025..062b73bdd 100644 --- a/babel/messages/frontend.py +++ b/babel/messages/frontend.py @@ -1111,7 +1111,7 @@ def parse_mapping(fileobj, filename=None): return method_map, options_map -def _parse_spec(s): +def _parse_spec(s: str) -> tuple[int | None, tuple[int|tuple[int, str], ...]]: inds = [] number = None for x in s.split(','): @@ -1165,7 +1165,7 @@ def parse_keywords(strings: Iterable[str] = ()): # For best backwards compatibility, collapse {None: x} into x. for k, v in keywords.items(): - if set(v.keys()) == {None}: + if set(v) == {None}: keywords[k] = v[None] return keywords From be33f02f483f5fa0288fd2ce89057027b04ce173 Mon Sep 17 00:00:00 2001 From: Jean Abou Samra Date: Wed, 6 Sep 2023 16:42:06 +0200 Subject: [PATCH 9/9] Fix: add "from __future__ import annotations" --- babel/messages/extract.py | 2 +- babel/messages/frontend.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/babel/messages/extract.py b/babel/messages/extract.py index dd78f4a5b..4fc3da74b 100644 --- a/babel/messages/extract.py +++ b/babel/messages/extract.py @@ -317,7 +317,7 @@ def extract_from_file( def _match_messages_against_spec(lineno: int, messages: list[str|None], comments: list[str], - fileobj: _FileObj, spec: tuple[int|tuple, ...]): + fileobj: _FileObj, spec: tuple[int|tuple[int, str], ...]): translatable = [] context = None diff --git a/babel/messages/frontend.py b/babel/messages/frontend.py index 062b73bdd..0008a9b84 100644 --- a/babel/messages/frontend.py +++ b/babel/messages/frontend.py @@ -8,6 +8,8 @@ :license: BSD, see LICENSE for more details. """ +from __future__ import annotations + import datetime import fnmatch import logging