Skip to content

Commit d26a669

Browse files
akxAA-Turnertomasr8
authored
Initial support for reading mapping configuration as TOML (#1108)
* Rename parse_mapping to parse_mapping_cfg and remove duplicated test * Add initial support for TOML mapping configuration (prefer tomllib to tomli) --------- Co-authored-by: Adam Turner <9087854+AA-Turner@users.noreply.github.com> Co-authored-by: Tomas R <tomas.roun8@gmail.com>
1 parent 34ed517 commit d26a669

15 files changed

+274
-66
lines changed

babel/messages/frontend.py

+121-47
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,11 @@
1919
import shutil
2020
import sys
2121
import tempfile
22+
import warnings
2223
from collections import OrderedDict
2324
from configparser import RawConfigParser
2425
from io import StringIO
25-
from typing import Iterable
26+
from typing import BinaryIO, Iterable, Literal
2627

2728
from babel import Locale, localedata
2829
from babel import __version__ as VERSION
@@ -53,6 +54,12 @@ class SetupError(BaseError):
5354
pass
5455

5556

57+
class ConfigurationError(BaseError):
58+
"""
59+
Raised for errors in configuration files.
60+
"""
61+
62+
5663
def listify_value(arg, split=None):
5764
"""
5865
Make a list out of an argument.
@@ -534,16 +541,29 @@ def _get_mappings(self):
534541
mappings = []
535542

536543
if self.mapping_file:
537-
with open(self.mapping_file) as fileobj:
538-
method_map, options_map = parse_mapping(fileobj)
544+
if self.mapping_file.endswith(".toml"):
545+
with open(self.mapping_file, "rb") as fileobj:
546+
file_style = (
547+
"pyproject.toml"
548+
if os.path.basename(self.mapping_file) == "pyproject.toml"
549+
else "standalone"
550+
)
551+
method_map, options_map = _parse_mapping_toml(
552+
fileobj,
553+
filename=self.mapping_file,
554+
style=file_style,
555+
)
556+
else:
557+
with open(self.mapping_file) as fileobj:
558+
method_map, options_map = parse_mapping_cfg(fileobj, filename=self.mapping_file)
539559
for path in self.input_paths:
540560
mappings.append((path, method_map, options_map))
541561

542562
elif getattr(self.distribution, 'message_extractors', None):
543563
message_extractors = self.distribution.message_extractors
544564
for path, mapping in message_extractors.items():
545565
if isinstance(mapping, str):
546-
method_map, options_map = parse_mapping(StringIO(mapping))
566+
method_map, options_map = parse_mapping_cfg(StringIO(mapping))
547567
else:
548568
method_map, options_map = [], {}
549569
for pattern, method, options in mapping:
@@ -980,53 +1000,19 @@ def main():
9801000

9811001

9821002
def parse_mapping(fileobj, filename=None):
983-
"""Parse an extraction method mapping from a file-like object.
1003+
warnings.warn(
1004+
"parse_mapping is deprecated, use parse_mapping_cfg instead",
1005+
DeprecationWarning,
1006+
stacklevel=2,
1007+
)
1008+
return parse_mapping_cfg(fileobj, filename)
9841009

985-
>>> buf = StringIO('''
986-
... [extractors]
987-
... custom = mypackage.module:myfunc
988-
...
989-
... # Python source files
990-
... [python: **.py]
991-
...
992-
... # Genshi templates
993-
... [genshi: **/templates/**.html]
994-
... include_attrs =
995-
... [genshi: **/templates/**.txt]
996-
... template_class = genshi.template:TextTemplate
997-
... encoding = latin-1
998-
...
999-
... # Some custom extractor
1000-
... [custom: **/custom/*.*]
1001-
... ''')
1002-
1003-
>>> method_map, options_map = parse_mapping(buf)
1004-
>>> len(method_map)
1005-
4
1006-
1007-
>>> method_map[0]
1008-
('**.py', 'python')
1009-
>>> options_map['**.py']
1010-
{}
1011-
>>> method_map[1]
1012-
('**/templates/**.html', 'genshi')
1013-
>>> options_map['**/templates/**.html']['include_attrs']
1014-
''
1015-
>>> method_map[2]
1016-
('**/templates/**.txt', 'genshi')
1017-
>>> options_map['**/templates/**.txt']['template_class']
1018-
'genshi.template:TextTemplate'
1019-
>>> options_map['**/templates/**.txt']['encoding']
1020-
'latin-1'
1021-
1022-
>>> method_map[3]
1023-
('**/custom/*.*', 'mypackage.module:myfunc')
1024-
>>> options_map['**/custom/*.*']
1025-
{}
1010+
1011+
def parse_mapping_cfg(fileobj, filename=None):
1012+
"""Parse an extraction method mapping from a file-like object.
10261013
10271014
:param fileobj: a readable file-like object containing the configuration
10281015
text to parse
1029-
:see: `extract_from_directory`
10301016
"""
10311017
extractors = {}
10321018
method_map = []
@@ -1053,6 +1039,94 @@ def parse_mapping(fileobj, filename=None):
10531039
return method_map, options_map
10541040

10551041

1042+
def _parse_config_object(config: dict, *, filename="(unknown)"):
1043+
extractors = {}
1044+
method_map = []
1045+
options_map = {}
1046+
1047+
extractors_read = config.get("extractors", {})
1048+
if not isinstance(extractors_read, dict):
1049+
raise ConfigurationError(f"{filename}: extractors: Expected a dictionary, got {type(extractors_read)!r}")
1050+
for method, callable_spec in extractors_read.items():
1051+
if not isinstance(method, str):
1052+
# Impossible via TOML, but could happen with a custom object.
1053+
raise ConfigurationError(f"{filename}: extractors: Extraction method must be a string, got {method!r}")
1054+
if not isinstance(callable_spec, str):
1055+
raise ConfigurationError(f"{filename}: extractors: Callable specification must be a string, got {callable_spec!r}")
1056+
extractors[method] = callable_spec
1057+
1058+
if "mapping" in config:
1059+
raise ConfigurationError(f"{filename}: 'mapping' is not a valid key, did you mean 'mappings'?")
1060+
1061+
mappings_read = config.get("mappings", [])
1062+
if not isinstance(mappings_read, list):
1063+
raise ConfigurationError(f"{filename}: mappings: Expected a list, got {type(mappings_read)!r}")
1064+
for idx, entry in enumerate(mappings_read):
1065+
if not isinstance(entry, dict):
1066+
raise ConfigurationError(f"{filename}: mappings[{idx}]: Expected a dictionary, got {type(entry)!r}")
1067+
entry = entry.copy()
1068+
1069+
method = entry.pop("method", None)
1070+
if not isinstance(method, str):
1071+
raise ConfigurationError(f"{filename}: mappings[{idx}]: 'method' must be a string, got {method!r}")
1072+
method = extractors.get(method, method) # Map the extractor name to the callable now
1073+
1074+
pattern = entry.pop("pattern", None)
1075+
if not isinstance(pattern, (list, str)):
1076+
raise ConfigurationError(f"{filename}: mappings[{idx}]: 'pattern' must be a list or a string, got {pattern!r}")
1077+
if not isinstance(pattern, list):
1078+
pattern = [pattern]
1079+
1080+
for pat in pattern:
1081+
if not isinstance(pat, str):
1082+
raise ConfigurationError(f"{filename}: mappings[{idx}]: 'pattern' elements must be strings, got {pat!r}")
1083+
method_map.append((pat, method))
1084+
options_map[pat] = entry
1085+
1086+
return method_map, options_map
1087+
1088+
1089+
def _parse_mapping_toml(
1090+
fileobj: BinaryIO,
1091+
filename: str = "(unknown)",
1092+
style: Literal["standalone", "pyproject.toml"] = "standalone",
1093+
):
1094+
"""Parse an extraction method mapping from a binary file-like object.
1095+
1096+
.. warning: As of this version of Babel, this is a private API subject to changes.
1097+
1098+
:param fileobj: a readable binary file-like object containing the configuration TOML to parse
1099+
:param filename: the name of the file being parsed, for error messages
1100+
:param style: whether the file is in the style of a `pyproject.toml` file, i.e. whether to look for `tool.babel`.
1101+
"""
1102+
try:
1103+
import tomllib
1104+
except ImportError:
1105+
try:
1106+
import tomli as tomllib
1107+
except ImportError as ie: # pragma: no cover
1108+
raise ImportError("tomli or tomllib is required to parse TOML files") from ie
1109+
1110+
try:
1111+
parsed_data = tomllib.load(fileobj)
1112+
except tomllib.TOMLDecodeError as e:
1113+
raise ConfigurationError(f"{filename}: Error parsing TOML file: {e}") from e
1114+
1115+
if style == "pyproject.toml":
1116+
try:
1117+
babel_data = parsed_data["tool"]["babel"]
1118+
except (TypeError, KeyError) as e:
1119+
raise ConfigurationError(f"{filename}: No 'tool.babel' section found in file") from e
1120+
elif style == "standalone":
1121+
babel_data = parsed_data
1122+
if "babel" in babel_data:
1123+
raise ConfigurationError(f"{filename}: 'babel' should not be present in a stand-alone configuration file")
1124+
else: # pragma: no cover
1125+
raise ValueError(f"Unknown TOML style {style!r}")
1126+
1127+
return _parse_config_object(babel_data, filename=filename)
1128+
1129+
10561130
def _parse_spec(s: str) -> tuple[int | None, tuple[int | tuple[int, str], ...]]:
10571131
inds = []
10581132
number = None

tests/messages/test_frontend.py

+82-19
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,14 @@
1111
# history and logs, available at http://babel.edgewall.org/log/.
1212
import logging
1313
import os
14+
import re
1415
import shlex
1516
import shutil
1617
import sys
1718
import time
1819
import unittest
1920
from datetime import datetime, timedelta
21+
from functools import partial
2022
from io import BytesIO, StringIO
2123
from typing import List
2224

@@ -1388,25 +1390,86 @@ def test_update_init_missing(self):
13881390
assert len(catalog) == 4 # Catalog was updated
13891391

13901392

1391-
def test_parse_mapping():
1392-
buf = StringIO(
1393-
'[extractors]\n'
1394-
'custom = mypackage.module:myfunc\n'
1395-
'\n'
1396-
'# Python source files\n'
1397-
'[python: **.py]\n'
1398-
'\n'
1399-
'# Genshi templates\n'
1400-
'[genshi: **/templates/**.html]\n'
1401-
'include_attrs =\n'
1402-
'[genshi: **/templates/**.txt]\n'
1403-
'template_class = genshi.template:TextTemplate\n'
1404-
'encoding = latin-1\n'
1405-
'\n'
1406-
'# Some custom extractor\n'
1407-
'[custom: **/custom/*.*]\n')
1408-
1409-
method_map, options_map = frontend.parse_mapping(buf)
1393+
mapping_cfg = """
1394+
[extractors]
1395+
custom = mypackage.module:myfunc
1396+
1397+
# Python source files
1398+
[python: **.py]
1399+
1400+
# Genshi templates
1401+
[genshi: **/templates/**.html]
1402+
include_attrs =
1403+
1404+
[genshi: **/templates/**.txt]
1405+
template_class = genshi.template:TextTemplate
1406+
encoding = latin-1
1407+
1408+
# Some custom extractor
1409+
[custom: **/custom/*.*]
1410+
"""
1411+
1412+
mapping_toml = """
1413+
[extractors]
1414+
custom = "mypackage.module:myfunc"
1415+
1416+
# Python source files
1417+
[[mappings]]
1418+
method = "python"
1419+
pattern = "**.py"
1420+
1421+
# Genshi templates
1422+
[[mappings]]
1423+
method = "genshi"
1424+
pattern = "**/templates/**.html"
1425+
include_attrs = ""
1426+
1427+
[[mappings]]
1428+
method = "genshi"
1429+
pattern = "**/templates/**.txt"
1430+
template_class = "genshi.template:TextTemplate"
1431+
encoding = "latin-1"
1432+
1433+
# Some custom extractor
1434+
[[mappings]]
1435+
method = "custom"
1436+
pattern = "**/custom/*.*"
1437+
"""
1438+
1439+
1440+
@pytest.mark.parametrize(
1441+
("data", "parser", "preprocess", "is_toml"),
1442+
[
1443+
(
1444+
mapping_cfg,
1445+
frontend.parse_mapping_cfg,
1446+
None,
1447+
False,
1448+
),
1449+
(
1450+
mapping_toml,
1451+
frontend._parse_mapping_toml,
1452+
None,
1453+
True,
1454+
),
1455+
(
1456+
mapping_toml,
1457+
partial(frontend._parse_mapping_toml, style="pyproject.toml"),
1458+
lambda s: re.sub(r"^(\[+)", r"\1tool.babel.", s, flags=re.MULTILINE),
1459+
True,
1460+
),
1461+
],
1462+
ids=("cfg", "toml", "pyproject-toml"),
1463+
)
1464+
def test_parse_mapping(data: str, parser, preprocess, is_toml):
1465+
if preprocess:
1466+
data = preprocess(data)
1467+
if is_toml:
1468+
buf = BytesIO(data.encode())
1469+
else:
1470+
buf = StringIO(data)
1471+
1472+
method_map, options_map = parser(buf)
14101473
assert len(method_map) == 4
14111474

14121475
assert method_map[0] == ('**.py', 'python')

tests/messages/test_toml_config.py

+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import pathlib
2+
from io import BytesIO
3+
4+
import pytest
5+
6+
from babel.messages import frontend
7+
8+
toml_test_cases_path = pathlib.Path(__file__).parent / "toml-test-cases"
9+
assert toml_test_cases_path.is_dir(), "toml-test-cases directory not found"
10+
11+
12+
def test_toml_mapping_multiple_patterns():
13+
"""
14+
Test that patterns may be specified as a list in TOML,
15+
and are expanded to multiple entries in the method map.
16+
"""
17+
method_map, options_map = frontend._parse_mapping_toml(BytesIO(b"""
18+
[[mappings]]
19+
method = "python"
20+
pattern = ["xyz/**.py", "foo/**.py"]
21+
"""))
22+
assert len(method_map) == 2
23+
assert method_map[0] == ('xyz/**.py', 'python')
24+
assert method_map[1] == ('foo/**.py', 'python')
25+
26+
27+
@pytest.mark.parametrize("test_case", toml_test_cases_path.glob("bad.*.toml"), ids=lambda p: p.name)
28+
def test_bad_toml_test_case(test_case: pathlib.Path):
29+
"""
30+
Test that bad TOML files raise a ValueError.
31+
"""
32+
with pytest.raises(frontend.ConfigurationError):
33+
with test_case.open("rb") as f:
34+
frontend._parse_mapping_toml(
35+
f,
36+
filename=test_case.name,
37+
style="pyproject.toml" if "pyproject" in test_case.name else "standalone",
38+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[extractors]
2+
custom = { module = "mypackage.module", func = "myfunc" }
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
[[extractors]]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[mapping]
2+
method = "jinja2"
3+
pattern = "**.html"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
mappings = [8]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
mappings = "python"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[[mappings]]
2+
pattern = ["xyz/**.py", "foo/**.py"]

0 commit comments

Comments
 (0)