diff --git a/Lib/test/test_tools/i18n_data/messages.pot b/Lib/test/test_tools/i18n_data/messages.pot index e8167acfc0742b..24f98ee1a83ea6 100644 --- a/Lib/test/test_tools/i18n_data/messages.pot +++ b/Lib/test/test_tools/i18n_data/messages.pot @@ -33,65 +33,69 @@ msgid "" " multiline!\n" msgstr "" -#: messages.py:46 messages.py:89 messages.py:90 messages.py:93 messages.py:94 -#: messages.py:99 messages.py:100 messages.py:101 +#: messages.py:32 +msgid "f-strings!" +msgstr "" + +#: messages.py:50 messages.py:93 messages.py:94 messages.py:97 messages.py:98 +#: messages.py:103 messages.py:104 messages.py:105 msgid "foo" msgid_plural "foos" msgstr[0] "" msgstr[1] "" -#: messages.py:47 +#: messages.py:51 msgid "something" msgstr "" -#: messages.py:50 +#: messages.py:54 msgid "Hello, {}!" msgstr "" -#: messages.py:54 +#: messages.py:58 msgid "1" msgstr "" -#: messages.py:54 +#: messages.py:58 msgid "2" msgstr "" -#: messages.py:55 messages.py:56 +#: messages.py:59 messages.py:60 msgid "A" msgstr "" -#: messages.py:55 messages.py:56 +#: messages.py:59 messages.py:60 msgid "B" msgstr "" -#: messages.py:57 +#: messages.py:61 msgid "set" msgstr "" -#: messages.py:62 messages.py:63 +#: messages.py:66 messages.py:67 msgid "nested string" msgstr "" -#: messages.py:68 +#: messages.py:72 msgid "baz" msgstr "" -#: messages.py:71 messages.py:75 +#: messages.py:75 messages.py:79 msgid "default value" msgstr "" -#: messages.py:91 messages.py:92 messages.py:95 messages.py:96 +#: messages.py:95 messages.py:96 messages.py:99 messages.py:100 msgctxt "context" msgid "foo" msgid_plural "foos" msgstr[0] "" msgstr[1] "" -#: messages.py:102 +#: messages.py:106 msgid "domain foo" msgstr "" -#: messages.py:118 messages.py:119 +#: messages.py:122 messages.py:123 msgid "world" msgid_plural "worlds" msgstr[0] "" diff --git a/Lib/test/test_tools/i18n_data/messages.py b/Lib/test/test_tools/i18n_data/messages.py index 9457bcb8611020..aa569757bfa217 100644 --- a/Lib/test/test_tools/i18n_data/messages.py +++ b/Lib/test/test_tools/i18n_data/messages.py @@ -28,6 +28,9 @@ multiline! """) +# F-strings without formatted values are allowed +_(f"f-strings!") + # Invalid arguments _() _(None) @@ -38,6 +41,7 @@ _("string"[3]) _("string"[:3]) _({"string": "foo"}) +_(f"Hello, {world}!") # pygettext does not allow keyword arguments, but both xgettext and pybabel do _(x="kwargs are not allowed!") diff --git a/Lib/test/test_tools/test_i18n.py b/Lib/test/test_tools/test_i18n.py index f5aba31ed42c10..332c1bbae14d7d 100644 --- a/Lib/test/test_tools/test_i18n.py +++ b/Lib/test/test_tools/test_i18n.py @@ -230,10 +230,6 @@ def test_msgid_bytes(self): msgids = self.extract_docstrings_from_str('_(b"""doc""")') self.assertFalse([msgid for msgid in msgids if 'doc' in msgid]) - def test_msgid_fstring(self): - msgids = self.extract_docstrings_from_str('_(f"""doc""")') - self.assertFalse([msgid for msgid in msgids if 'doc' in msgid]) - def test_funcdocstring_annotated_args(self): """ Test docstrings for functions with annotated args """ msgids = self.extract_docstrings_from_str(dedent('''\ @@ -349,13 +345,6 @@ def test_calls_in_fstring_with_keyword_args(self): self.assertNotIn('bar', msgids) self.assertNotIn('baz', msgids) - def test_calls_in_fstring_with_partially_wrong_expression(self): - msgids = self.extract_docstrings_from_str(dedent('''\ - f"{_(f'foo') + _('bar')}" - ''')) - self.assertNotIn('foo', msgids) - self.assertIn('bar', msgids) - def test_function_and_class_names(self): """Test that function and class names are not mistakenly extracted.""" msgids = self.extract_from_str(dedent('''\ diff --git a/Misc/NEWS.d/next/Tools-Demos/2025-02-15-14-37-39.gh-issue-130154.YJB4lJ.rst b/Misc/NEWS.d/next/Tools-Demos/2025-02-15-14-37-39.gh-issue-130154.YJB4lJ.rst new file mode 100644 index 00000000000000..f2fda7b07c6552 --- /dev/null +++ b/Misc/NEWS.d/next/Tools-Demos/2025-02-15-14-37-39.gh-issue-130154.YJB4lJ.rst @@ -0,0 +1,2 @@ +Support extracting constant f-strings (f-strings not containing formatted +values) in :program:`pygettext.py`. diff --git a/Tools/i18n/pygettext.py b/Tools/i18n/pygettext.py index 4177d46048f9b9..7bed72e389baec 100755 --- a/Tools/i18n/pygettext.py +++ b/Tools/i18n/pygettext.py @@ -364,12 +364,13 @@ def _extract_message(self, node): msg_data = {} for position, arg_type in spec.items(): arg = node.args[position] - if not self._is_string_const(arg): + value = self._parse_string_const(arg) + if value is None: 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 + msg_data[arg_type] = value lineno = node.lineno self._add_message(lineno, **msg_data) @@ -413,8 +414,26 @@ def _get_func_name(self, node): case _: return None - def _is_string_const(self, node): - return isinstance(node, ast.Constant) and isinstance(node.value, str) + def _parse_string_const(self, node): + value = self._parse_literal_string(node) + if value is not None: + return value + return self._parse_literal_fstring(node) + + def _parse_literal_fstring(self, node): + if not isinstance(node, ast.JoinedStr): + return None + parts = [] + for value in node.values: + part = self._parse_literal_string(value) + if part is None: + return None + parts.append(part) + return ''.join(parts) + + def _parse_literal_string(self, node): + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return node.value def write_pot_file(messages, options, fp): timestamp = time.strftime('%Y-%m-%d %H:%M%z')