|
| 1 | +import importlib.util |
| 2 | +import sys |
| 3 | +import sysconfig |
| 4 | +from io import BytesIO, StringIO |
| 5 | +from types import ModuleType, SimpleNamespace |
| 6 | +from unittest.mock import patch |
| 7 | + |
| 8 | +import pytest |
| 9 | + |
| 10 | +from babel.messages import pofile |
| 11 | + |
| 12 | + |
| 13 | +@pytest.fixture |
| 14 | +def pofile_for_runtime(): |
| 15 | + def load(implementation, gil_disabled, gil_enabled): |
| 16 | + runtime_sys = ModuleType('sys') |
| 17 | + runtime_sys.__dict__.update(vars(sys)) |
| 18 | + runtime_sys.implementation = SimpleNamespace(**vars(sys.implementation)) |
| 19 | + runtime_sys.implementation.name = implementation |
| 20 | + runtime_sys._is_gil_enabled = lambda: gil_enabled |
| 21 | + runtime_config = ModuleType('sysconfig') |
| 22 | + runtime_config.__dict__.update(vars(sysconfig)) |
| 23 | + |
| 24 | + def get_config_var(name): |
| 25 | + if name == 'Py_GIL_DISABLED': |
| 26 | + return gil_disabled |
| 27 | + return sysconfig.get_config_var(name) |
| 28 | + |
| 29 | + runtime_config.get_config_var = get_config_var |
| 30 | + interned = [] |
| 31 | + |
| 32 | + def intern(value): |
| 33 | + interned.append(value) |
| 34 | + return sys.intern(value) |
| 35 | + |
| 36 | + runtime_sys.intern = intern |
| 37 | + spec = importlib.util.spec_from_file_location( |
| 38 | + 'babel.messages._pofile_runtime_test', |
| 39 | + pofile.__file__, |
| 40 | + ) |
| 41 | + module = importlib.util.module_from_spec(spec) |
| 42 | + with patch.dict(sys.modules, {'sys': runtime_sys, 'sysconfig': runtime_config}): |
| 43 | + spec.loader.exec_module(module) |
| 44 | + interned.clear() |
| 45 | + return module, interned |
| 46 | + |
| 47 | + return load |
| 48 | + |
| 49 | + |
| 50 | +@pytest.fixture |
| 51 | +def conventional_pofile(pofile_for_runtime): |
| 52 | + module, _ = pofile_for_runtime('cpython', 0, True) |
| 53 | + return module |
| 54 | + |
| 55 | + |
| 56 | +@pytest.mark.parametrize( |
| 57 | + ('implementation', 'gil_disabled', 'gil_enabled'), |
| 58 | + [ |
| 59 | + ('cpython', 1, True), |
| 60 | + ('cpython', 1, False), |
| 61 | + ('pypy', None, True), |
| 62 | + ('pypy', 0, True), |
| 63 | + ('graalpy', None, True), |
| 64 | + ], |
| 65 | +) |
| 66 | +@pytest.mark.parametrize('plural', [False, True]) |
| 67 | +def test_read_po_avoids_interning_on_other_builds( |
| 68 | + pofile_for_runtime, |
| 69 | + implementation, |
| 70 | + gil_disabled, |
| 71 | + gil_enabled, |
| 72 | + plural, |
| 73 | +): |
| 74 | + # A free-threaded build can enable its GIL without changing string lifetime. |
| 75 | + module, interned = pofile_for_runtime(implementation, gil_disabled, gil_enabled) |
| 76 | + po = '''\ |
| 77 | +# Translator comment |
| 78 | +#. Extracted comment |
| 79 | +#: source.py:3 |
| 80 | +#, fuzzy |
| 81 | +msgctxt "menu" |
| 82 | +msgid "A Unicode source ID: café, Ω, 日本語." |
| 83 | +''' |
| 84 | + if plural: |
| 85 | + po += '''\ |
| 86 | +msgid_plural "Several Unicode source IDs: café, Ω, 日本語." |
| 87 | +msgstr[0] "Une traduction" |
| 88 | +msgstr[1] "Des traductions" |
| 89 | +''' |
| 90 | + else: |
| 91 | + po += 'msgstr "Une traduction"\n' |
| 92 | + catalog = module.read_po(BytesIO(po.encode('utf-8')), locale='fr') |
| 93 | + message = catalog.get('A Unicode source ID: café, Ω, 日本語.', context='menu') |
| 94 | + if plural: |
| 95 | + assert message.id == ( |
| 96 | + 'A Unicode source ID: café, Ω, 日本語.', |
| 97 | + 'Several Unicode source IDs: café, Ω, 日本語.', |
| 98 | + ) |
| 99 | + # French has three plural forms; the omitted translation stays empty. |
| 100 | + assert message.string == ('Une traduction', 'Des traductions', '') |
| 101 | + else: |
| 102 | + assert message.id == 'A Unicode source ID: café, Ω, 日本語.' |
| 103 | + assert message.string == 'Une traduction' |
| 104 | + assert message.context == 'menu' |
| 105 | + assert message.flags == {'fuzzy'} |
| 106 | + assert message.locations == [('source.py', 3)] |
| 107 | + assert message.user_comments == ['Translator comment'] |
| 108 | + assert message.auto_comments == ['Extracted comment'] |
| 109 | + assert interned == [] |
| 110 | + |
| 111 | + |
| 112 | +@pytest.mark.parametrize('gil_disabled', [None, 0]) |
| 113 | +@pytest.mark.parametrize('plural', [False, True]) |
| 114 | +def test_read_po_interns_ids_on_conventional_cpython( |
| 115 | + pofile_for_runtime, |
| 116 | + gil_disabled, |
| 117 | + plural, |
| 118 | +): |
| 119 | + module, interned = pofile_for_runtime('cpython', gil_disabled, True) |
| 120 | + po = 'msgid "A shared Unicode source ID: café, Ω, 日本語."\n' |
| 121 | + expected = ['A shared Unicode source ID: café, Ω, 日本語.'] |
| 122 | + if plural: |
| 123 | + po += '''\ |
| 124 | +msgid_plural "Several shared Unicode source IDs: café, Ω, 日本語." |
| 125 | +msgstr[0] "Une traduction" |
| 126 | +msgstr[1] "Des traductions" |
| 127 | +''' |
| 128 | + expected.append('Several shared Unicode source IDs: café, Ω, 日本語.') |
| 129 | + else: |
| 130 | + po += 'msgstr "Une traduction"\n' |
| 131 | + first = module.read_po(StringIO(po), locale='fr') |
| 132 | + second = module.read_po(BytesIO(po.encode('utf-8')), locale='fr') |
| 133 | + assert interned == expected * 2 |
| 134 | + first_id = first['A shared Unicode source ID: café, Ω, 日本語.'].id |
| 135 | + second_id = second['A shared Unicode source ID: café, Ω, 日本語.'].id |
| 136 | + if plural: |
| 137 | + assert first_id == second_id == tuple(expected) |
| 138 | + assert first_id[0] is second_id[0] |
| 139 | + assert first_id[1] is second_id[1] |
| 140 | + else: |
| 141 | + assert first_id == second_id == expected[0] |
| 142 | + assert first_id is second_id |
| 143 | + |
| 144 | + |
| 145 | +def test_read_po_does_not_intern_discarded_obsolete_ids(pofile_for_runtime): |
| 146 | + module, interned = pofile_for_runtime('cpython', 0, True) |
| 147 | + catalog = module.read_po( |
| 148 | + StringIO('''\ |
| 149 | +#~ msgctxt "menu" |
| 150 | +#~ msgid "An obsolete Unicode source ID: café, Ω, 日本語." |
| 151 | +#~ msgid_plural "Several obsolete Unicode source IDs: café, Ω, 日本語." |
| 152 | +#~ msgstr[0] "Une traduction" |
| 153 | +#~ msgstr[1] "Des traductions" |
| 154 | +'''), |
| 155 | + locale='fr', |
| 156 | + ignore_obsolete=True, |
| 157 | + ) |
| 158 | + assert not catalog |
| 159 | + assert not catalog.obsolete |
| 160 | + assert interned == [] |
| 161 | + |
| 162 | + |
| 163 | +def test_read_po_shares_source_ids_across_catalogs(conventional_pofile): |
| 164 | + first = conventional_pofile.read_po( |
| 165 | + StringIO('''\ |
| 166 | +msgid "A source message shared by several independently loaded translation catalogs." |
| 167 | +msgstr "First translation" |
| 168 | +'''), |
| 169 | + ) |
| 170 | + second = conventional_pofile.read_po( |
| 171 | + StringIO('''\ |
| 172 | +msgid "A source message shared by several independently loaded translation catalogs." |
| 173 | +msgstr "Second translation" |
| 174 | +'''), |
| 175 | + ) |
| 176 | + identifier = ( |
| 177 | + "A source message shared by several independently loaded translation catalogs." |
| 178 | + ) |
| 179 | + assert first[identifier].id == second[identifier].id == identifier |
| 180 | + assert first[identifier].id is second[identifier].id |
| 181 | + assert first[identifier].string == "First translation" |
| 182 | + assert second[identifier].string == "Second translation" |
| 183 | + |
| 184 | + |
| 185 | +def test_read_po_shares_each_plural_source_id(conventional_pofile): |
| 186 | + po = '''\ |
| 187 | +msgid "One source message shared by independently loaded translation catalogs." |
| 188 | +msgid_plural "Several source messages shared by independently loaded translation catalogs." |
| 189 | +msgstr[0] "One translated message" |
| 190 | +msgstr[1] "Several translated messages" |
| 191 | +''' |
| 192 | + first = conventional_pofile.read_po(StringIO(po)) |
| 193 | + second = conventional_pofile.read_po(StringIO(po)) |
| 194 | + identifier = "One source message shared by independently loaded translation catalogs." |
| 195 | + expected = ( |
| 196 | + identifier, |
| 197 | + "Several source messages shared by independently loaded translation catalogs.", |
| 198 | + ) |
| 199 | + assert first[identifier].id == second[identifier].id == expected |
| 200 | + assert first[identifier].id[0] is second[identifier].id[0] |
| 201 | + assert first[identifier].id[1] is second[identifier].id[1] |
| 202 | + assert first[identifier].string == ( |
| 203 | + "One translated message", |
| 204 | + "Several translated messages", |
| 205 | + ) |
| 206 | + |
| 207 | + |
| 208 | +def test_read_po_shares_denormalized_unicode_ids(conventional_pofile): |
| 209 | + first = conventional_pofile.read_po( |
| 210 | + StringIO('''\ |
| 211 | +msgid "Unicode source: 日本語, Ω, café and an escaped \\"quote\\"." |
| 212 | +msgstr "first" |
| 213 | +'''), |
| 214 | + ) |
| 215 | + second = conventional_pofile.read_po( |
| 216 | + StringIO('''\ |
| 217 | +msgid "" |
| 218 | +"Unicode source: 日本語, Ω, " |
| 219 | +"café and an escaped \\"quote\\"." |
| 220 | +msgstr "second" |
| 221 | +'''), |
| 222 | + ) |
| 223 | + identifier = 'Unicode source: 日本語, Ω, café and an escaped "quote".' |
| 224 | + assert first[identifier].id == second[identifier].id == identifier |
| 225 | + assert first[identifier].id is second[identifier].id |
| 226 | + |
| 227 | + |
| 228 | +def test_read_po_keeps_messages_and_metadata_independent(): |
| 229 | + po = '''\ |
| 230 | +# Translator comment |
| 231 | +#. Extracted comment |
| 232 | +#: source.py:3 |
| 233 | +#, fuzzy |
| 234 | +msgid "An immutable source ID must not cause mutable message data to be shared." |
| 235 | +msgstr "translation" |
| 236 | +''' |
| 237 | + first = pofile.read_po(StringIO(po)) |
| 238 | + second = pofile.read_po(StringIO(po)) |
| 239 | + identifier = "An immutable source ID must not cause mutable message data to be shared." |
| 240 | + message = first[identifier] |
| 241 | + message.string = "changed" |
| 242 | + message.flags.add("custom") |
| 243 | + message.locations.append(("other.py", 9)) |
| 244 | + message.user_comments.append("additional translator comment") |
| 245 | + message.auto_comments.append("additional extracted comment") |
| 246 | + assert message is not second[identifier] |
| 247 | + assert second[identifier].string == "translation" |
| 248 | + assert second[identifier].flags == {"fuzzy"} |
| 249 | + assert second[identifier].locations == [("source.py", 3)] |
| 250 | + assert second[identifier].user_comments == ["Translator comment"] |
| 251 | + assert second[identifier].auto_comments == ["Extracted comment"] |
| 252 | + |
| 253 | + |
| 254 | +def test_read_po_preserves_distinct_contexts(): |
| 255 | + catalog = pofile.read_po( |
| 256 | + StringIO('''\ |
| 257 | +msgid "A source ID occurring in distinct contexts." |
| 258 | +msgstr "without context" |
| 259 | +
|
| 260 | +msgctxt "" |
| 261 | +msgid "A source ID occurring in distinct contexts." |
| 262 | +msgstr "empty context" |
| 263 | +
|
| 264 | +msgctxt "menu" |
| 265 | +msgid "A source ID occurring in distinct contexts." |
| 266 | +msgstr "menu context" |
| 267 | +'''), |
| 268 | + ) |
| 269 | + identifier = "A source ID occurring in distinct contexts." |
| 270 | + assert len(catalog) == 3 |
| 271 | + for context, translation in ( |
| 272 | + (None, "without context"), |
| 273 | + ("", "empty context"), |
| 274 | + ("menu", "menu context"), |
| 275 | + ): |
| 276 | + message = catalog.get(identifier, context=context) |
| 277 | + assert message.id == identifier |
| 278 | + assert message.context == context |
| 279 | + assert message.string == translation |
| 280 | + |
| 281 | + |
| 282 | +def test_read_po_preserves_obsolete_and_duplicate_messages(): |
| 283 | + po = '''\ |
| 284 | +#: first.py:1 |
| 285 | +msgid "An active source ID repeated with a second reference." |
| 286 | +msgstr "first translation" |
| 287 | +
|
| 288 | +#: second.py:2 |
| 289 | +msgid "An active source ID repeated with a second reference." |
| 290 | +msgstr "second translation" |
| 291 | +
|
| 292 | +#~ msgid "An obsolete source ID retained separately from active messages." |
| 293 | +#~ msgstr "obsolete translation" |
| 294 | +''' |
| 295 | + catalog = pofile.read_po(StringIO(po)) |
| 296 | + ignored = pofile.read_po(StringIO(po), ignore_obsolete=True) |
| 297 | + identifier = "An active source ID repeated with a second reference." |
| 298 | + assert len(catalog) == len(ignored) == 1 |
| 299 | + assert catalog[identifier].string == "first translation" |
| 300 | + assert catalog[identifier].locations == [("first.py", 1), ("second.py", 2)] |
| 301 | + obsolete_id = "An obsolete source ID retained separately from active messages." |
| 302 | + assert catalog.obsolete[obsolete_id].string == "obsolete translation" |
| 303 | + assert not ignored.obsolete |
0 commit comments