From 08512c70046dccbd1d1d7cb1e22365b2117249d8 Mon Sep 17 00:00:00 2001 From: Tomas Roun Date: Sun, 12 Jan 2025 22:10:47 +0100 Subject: [PATCH 1/3] Add support for python-brace-format --- babel/messages/catalog.py | 29 +++++++++++++++++++++++++++++ tests/messages/test_catalog.py | 27 +++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/babel/messages/catalog.py b/babel/messages/catalog.py index 1036ab826..83671d8ea 100644 --- a/babel/messages/catalog.py +++ b/babel/messages/catalog.py @@ -16,6 +16,7 @@ from difflib import SequenceMatcher from email import message_from_string from heapq import nlargest +from string import Formatter from typing import TYPE_CHECKING from babel import __version__ as VERSION @@ -69,6 +70,15 @@ def get_close_matches(word, possibilities, n=3, cutoff=0.6): ''', re.VERBOSE) +def _has_python_brace_format(string: str) -> bool: + fmt = Formatter() + try: + parsed = list(fmt.parse(string)) + except ValueError: + return False + return any(True for _, field_name, *_ in parsed if field_name is not None) + + def _parse_datetime_header(value: str) -> datetime.datetime: match = re.match(r'^(?P.*?)(?P[+-]\d{4})?$', value) @@ -140,6 +150,10 @@ def __init__( self.flags.add('python-format') else: self.flags.discard('python-format') + if id and self.python_brace_format: + self.flags.add('python-brace-format') + else: + self.flags.discard('python-brace-format') self.auto_comments = list(distinct(auto_comments)) self.user_comments = list(distinct(user_comments)) if isinstance(previous_id, str): @@ -252,6 +266,21 @@ def python_format(self) -> bool: ids = [ids] return any(PYTHON_FORMAT.search(id) for id in ids) + @property + def python_brace_format(self) -> bool: + """Whether the message contains Python f-string parameters. + + >>> Message('Hello, {name}!').python_brace_format + True + >>> Message(('One apple', '{count} apples')).python_brace_format + True + + :type: `bool`""" + ids = self.id + if not isinstance(ids, (list, tuple)): + ids = [ids] + return any(_has_python_brace_format(id) for id in ids) + class TranslationError(Exception): """Exception thrown by translation checkers when invalid message diff --git a/tests/messages/test_catalog.py b/tests/messages/test_catalog.py index 2df85debb..0f5b7dea5 100644 --- a/tests/messages/test_catalog.py +++ b/tests/messages/test_catalog.py @@ -39,6 +39,24 @@ def test_python_format(self): assert catalog.PYTHON_FORMAT.search('foo %(name)*.*f') assert catalog.PYTHON_FORMAT.search('foo %()s') + def test_python_brace_format(self): + assert not catalog._has_python_brace_format('') + assert not catalog._has_python_brace_format('foo') + assert not catalog._has_python_brace_format('{') + assert not catalog._has_python_brace_format('}') + assert not catalog._has_python_brace_format('{} {') + assert not catalog._has_python_brace_format('{{}}') + assert catalog._has_python_brace_format('{}') + assert catalog._has_python_brace_format('foo {name}') + assert catalog._has_python_brace_format('foo {name!s}') + assert catalog._has_python_brace_format('foo {name!r}') + assert catalog._has_python_brace_format('foo {name!a}') + assert catalog._has_python_brace_format('foo {name!r:10}') + assert catalog._has_python_brace_format('foo {name!r:10.2}') + assert catalog._has_python_brace_format('foo {name!r:10.2f}') + assert catalog._has_python_brace_format('foo {name!r:10.2f} {name!r:10.2f}') + assert catalog._has_python_brace_format('foo {name!r:10.2f=}') + def test_translator_comments(self): mess = catalog.Message('foo', user_comments=['Comment About `foo`']) assert mess.user_comments == ['Comment About `foo`'] @@ -342,10 +360,19 @@ def test_message_pluralizable(): def test_message_python_format(): + assert not catalog.Message('foo').python_format + assert not catalog.Message(('foo', 'foo')).python_format assert catalog.Message('foo %(name)s bar').python_format assert catalog.Message(('foo %(name)s', 'foo %(name)s')).python_format +def test_message_python_brace_format(): + assert not catalog.Message('foo').python_brace_format + assert not catalog.Message(('foo', 'foo')).python_brace_format + assert catalog.Message('foo {name} bar').python_brace_format + assert catalog.Message(('foo {name}', 'foo {name}')).python_brace_format + + def test_catalog(): cat = catalog.Catalog(project='Foobar', version='1.0', copyright_holder='Foo Company') From fe8a756bcdf1068e2f4b380884ca358103a075cb Mon Sep 17 00:00:00 2001 From: "Tomas R." Date: Mon, 13 Jan 2025 22:32:23 +0100 Subject: [PATCH 2/3] Optimize checking for format parameters Co-authored-by: Aarni Koskela --- babel/messages/catalog.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/babel/messages/catalog.py b/babel/messages/catalog.py index 83671d8ea..f2ca26a2b 100644 --- a/babel/messages/catalog.py +++ b/babel/messages/catalog.py @@ -71,12 +71,16 @@ def get_close_matches(word, possibilities, n=3, cutoff=0.6): def _has_python_brace_format(string: str) -> bool: + if "{" not in string: + return False fmt = Formatter() try: - parsed = list(fmt.parse(string)) + # `fmt.parse` returns 3-or-4-tuples of the form + # `(literal_text, field_name, format_spec, conversion)`; + # if `field_name` is set, this smells like brace format + return any(t[1] is not None for t in fmt.parse(string)) except ValueError: return False - return any(True for _, field_name, *_ in parsed if field_name is not None) def _parse_datetime_header(value: str) -> datetime.datetime: From 2529cff2fc645d3af23e29367a60b9ad3a289304 Mon Sep 17 00:00:00 2001 From: Tomas Roun Date: Tue, 14 Jan 2025 23:53:00 +0100 Subject: [PATCH 3/3] Fix tests --- babel/messages/catalog.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/babel/messages/catalog.py b/babel/messages/catalog.py index f2ca26a2b..29180eda5 100644 --- a/babel/messages/catalog.py +++ b/babel/messages/catalog.py @@ -78,9 +78,15 @@ def _has_python_brace_format(string: str) -> bool: # `fmt.parse` returns 3-or-4-tuples of the form # `(literal_text, field_name, format_spec, conversion)`; # if `field_name` is set, this smells like brace format - return any(t[1] is not None for t in fmt.parse(string)) + field_name_seen = False + for t in fmt.parse(string): + if t[1] is not None: + field_name_seen = True + # We cannot break here, as we need to consume the whole string + # to ensure that it is a valid format string. except ValueError: return False + return field_name_seen def _parse_datetime_header(value: str) -> datetime.datetime: