From a8f8c5df230b5152fd707c854b776460a3a44d6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B7=AF=E4=BB=8E=E4=BB=8A=E5=A4=9C=E7=99=BD?= <1174273035@qq.com> Date: Mon, 26 Oct 2026 13:30:50 +0800 Subject: [PATCH 1/2] Keep star format arguments distinct from decimal conversions --- babel/messages/checkers.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/babel/messages/checkers.py b/babel/messages/checkers.py index 50db2341d..feeb0e1e6 100644 --- a/babel/messages/checkers.py +++ b/babel/messages/checkers.py @@ -100,13 +100,18 @@ def _parse(string: str) -> list[tuple[str, str]]: continue if name is None: # A star width or precision consumes an integer argument first. - result.extend((None, 'd') for _ in range(format.count('*'))) + result.extend((None, '*') for _ in range(format.count('*'))) result.append((name, str(typechar))) return result def _compatible(a: str, b: str) -> bool: if a == b: return True + # Stars require integers, while decimal conversions also accept floats. + # An integer width/precision can therefore become a decimal conversion, + # but the reverse is not safe for all arguments accepted by the source. + if a == '*' and b in {'d', 'i', 'u'}: + return True for set in _string_format_compatibilities: if a in set and b in set: return True From ea788b0fb164b7e8e02807347b42e4fea859c4c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B7=AF=E4=BB=8E=E4=BB=8A=E5=A4=9C=E7=99=BD?= <1174273035@qq.com> Date: Mon, 26 Oct 2026 13:32:00 +0800 Subject: [PATCH 2/2] Test directional star and decimal argument compatibility --- tests/messages/test_checkers.py | 42 +++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/tests/messages/test_checkers.py b/tests/messages/test_checkers.py index 5b26a4988..05fb3c6dc 100644 --- a/tests/messages/test_checkers.py +++ b/tests/messages/test_checkers.py @@ -411,9 +411,47 @@ def test_python_format_star_arguments_valid(msgid, msgstr, arguments): python_format(None, Message(msgid, msgstr)) -def test_python_format_star_plural_catalog_check(): +@pytest.mark.parametrize('conversion', ['d', 'i', 'u']) +@pytest.mark.parametrize('target', ['%*s', '%.*s']) +def test_python_format_decimal_cannot_become_star(conversion, target): + source = '%' + conversion + '%s' + arguments = (3.5, 'word') + assert isinstance(source % arguments, str) + with pytest.raises(TypeError, match=r'\* wants int'): + target % arguments + with pytest.raises(TranslationError): + python_format(None, Message(source, target)) + + +@pytest.mark.parametrize('conversion', ['d', 'i', 'u']) +@pytest.mark.parametrize('source', ['%*s', '%.*s']) +@pytest.mark.parametrize('width', [-3, 3, True]) +def test_python_format_star_can_become_decimal(conversion, source, width): + target = '%' + conversion + '%s' + arguments = (width, 'word') + assert isinstance(source % arguments, str) + assert isinstance(target % arguments, str) + python_format(None, Message(source, target)) + + +@pytest.mark.parametrize('source', ['%d%.*s', '%*d%s']) +def test_python_format_decimal_cannot_become_second_star(source): + arguments = (3.5, 2, 'word') if source == '%d%.*s' else (3, 2.5, 'word') + target = '%*.*s' + assert isinstance(source % arguments, str) + with pytest.raises(TypeError, match=r'\* wants int'): + target % arguments + with pytest.raises(TranslationError): + python_format(None, Message(source, target)) + + +@pytest.mark.parametrize( + ('source', 'translation'), + [('%*s', '%s'), ('%d%s', '%*s'), ('%d%s', '%.*s')], +) +def test_python_format_star_plural_catalog_check(source, translation): catalog = Catalog(locale='en') - message = catalog.add(('%*s', '%*s'), ('%*s', '%s')) + message = catalog.add((source, source), (source, translation)) errors = list(catalog.check()) assert len(errors) == 1 assert errors[0][0] is message