Skip to content

Commit 511f10d

Browse files
committed
Implement parsing of command configuration from pyproject.toml
If present, use a "pyproject.toml" file for default configuration of `pybabel` commands.
1 parent baf9431 commit 511f10d

1 file changed

Lines changed: 94 additions & 33 deletions

File tree

babel/messages/frontend.py

Lines changed: 94 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,84 @@
4242
log = logging.getLogger('babel')
4343

4444

45+
def _parse_toml(fileobj, style):
46+
"""Parse the Babel configuration from the given binary file object.
47+
48+
The ``style`` can be either "pyproject.toml" or "standalone", and dictates the
49+
sections/tables in the TOML file that will be used.
50+
"""
51+
try:
52+
import tomllib
53+
except ImportError:
54+
try:
55+
import tomli as tomllib
56+
except ImportError as ie: # pragma: no cover
57+
raise ImportError("tomli or tomllib is required to parse TOML files") from ie
58+
59+
try:
60+
parsed_data = tomllib.load(fileobj)
61+
except tomllib.TOMLDecodeError as e:
62+
raise ConfigurationError(f"{filename}: Error parsing TOML file: {e}") from e
63+
64+
if style == "pyproject.toml":
65+
try:
66+
babel_data = parsed_data["tool"]["babel"]
67+
except (TypeError, KeyError) as e:
68+
raise ConfigurationError(
69+
f"{filename}: No 'tool.babel' section found in file",
70+
) from e
71+
elif style == "standalone":
72+
babel_data = parsed_data
73+
if "babel" in babel_data:
74+
raise ConfigurationError(
75+
f"{filename}: 'babel' should not be present in a stand-alone configuration file",
76+
)
77+
else: # pragma: no cover
78+
raise ValueError(f"Unknown TOML style {style!r}")
79+
80+
return babel_data
81+
82+
83+
def find_pyproject_toml(path: pathlib.Path | None, max_depth=10) -> pathlib.Path | None:
84+
"""Find the closest "pyproject.toml" file to the specified path.
85+
86+
If the path already points to a "pyproject.toml" file, return it.
87+
Otherwise, check if the path's directory contains one and return that, or walk
88+
up the directory hierarchy until one has been found or the maximum search depth
89+
has been exhausted.
90+
"""
91+
if path is None:
92+
path = pathlib.Path(".")
93+
94+
if not path.is_dir() and path.name == "pyproject.toml":
95+
return path
96+
97+
tries = 0
98+
while tries < max_depth:
99+
pyproject_file = path / "pyproject.toml"
100+
if pyproject_file.exists():
101+
return pyproject_file
102+
103+
parent = path.parent
104+
if path == parent:
105+
return None
106+
107+
path = parent
108+
tries += 1
109+
110+
return None
111+
112+
113+
def parse_pyproject_toml_config(path: pathlib.Path, table_name: str) -> dict:
114+
"""Parse the Babel config from a "pyproject.toml" file near the specified path."""
115+
path = find_pyproject_toml(path)
116+
if path and path.exists():
117+
babel_data = _parse_toml(path.open("rb"), style="pyproject.toml")
118+
return babel_data[table_name]
119+
else:
120+
return {}
121+
122+
45123
class BaseError(Exception):
46124
pass
47125

@@ -176,6 +254,7 @@ class CompileCatalog(CommandMixin):
176254
'print statistics about translations'),
177255
] # fmt: skip
178256
boolean_options = ['use-fuzzy', 'statistics']
257+
toml_config_table = "compile_catalog"
179258

180259
def initialize_options(self):
181260
self.domain = 'messages'
@@ -365,6 +444,7 @@ class ExtractMessages(CommandMixin):
365444
option_choices = {
366445
'add-location': ('full', 'file', 'never'),
367446
}
447+
toml_config_table = "extract_messages"
368448

369449
def initialize_options(self):
370450
self.charset = 'utf-8'
@@ -561,7 +641,7 @@ def _get_mappings(self):
561641
if os.path.basename(self.mapping_file) == "pyproject.toml"
562642
else "standalone"
563643
)
564-
method_map, options_map = _parse_mapping_toml(
644+
method_map, options_map = _parse_mappings_from_toml(
565645
fileobj,
566646
filename=self.mapping_file,
567647
style=file_style,
@@ -632,6 +712,7 @@ class InitCatalog(CommandMixin):
632712
'into several lines'),
633713
] # fmt: skip
634714
boolean_options = ['no-wrap']
715+
toml_config_table = "init_catalog"
635716

636717
def initialize_options(self):
637718
self.output_dir = None
@@ -729,6 +810,7 @@ class UpdateCatalog(CommandMixin):
729810
'check',
730811
'ignore-pot-creation-date',
731812
]
813+
toml_config_table = "update_catalog"
732814

733815
def initialize_options(self):
734816
self.domain = 'messages'
@@ -972,6 +1054,7 @@ def run(self, argv=None):
9721054
if cmdname not in self.commands:
9731055
self.parser.error(f'unknown command "{cmdname}"')
9741056

1057+
# TODO add possibility to turn TOML config parsing off, e.g. new option above?
9751058
cmdinst = self._configure_command(cmdname, args[1:])
9761059
return cmdinst.run()
9771060

@@ -1009,14 +1092,16 @@ def _configure_command(self, cmdname, argv):
10091092
assert isinstance(cmdinst, CommandMixin)
10101093
cmdinst.initialize_options()
10111094

1095+
toml_config = parse_pyproject_toml_config(None, cmdinst.toml_config_table)
10121096
parser = optparse.OptionParser(
10131097
usage=self.usage % (cmdname, ''),
10141098
description=self.commands[cmdname],
10151099
)
10161100
as_args: str | None = getattr(cmdclass, "as_args", None)
10171101
for long, short, help in cmdclass.user_options:
10181102
name = long.strip("=")
1019-
default = getattr(cmdinst, name.replace("-", "_"))
1103+
normalized_name = name.replace("-", "_")
1104+
default = toml_config.get(normalized_name) or getattr(cmdinst, normalized_name)
10201105
strs = [f"--{name}"]
10211106
if short:
10221107
strs.append(f"-{short}")
@@ -1030,9 +1115,13 @@ def _configure_command(self, cmdname, argv):
10301115
parser.add_option(*strs, action="append", help=help, choices=choices)
10311116
else:
10321117
parser.add_option(*strs, help=help, default=default, choices=choices)
1118+
1119+
parser.set_defaults(**toml_config)
10331120
options, args = parser.parse_args(argv)
10341121

10351122
if as_args:
1123+
# use the TOML config as fallback if args isn't set
1124+
args = args or toml_config.get(as_args.replace("-", "_"), args)
10361125
setattr(options, as_args.replace('-', '_'), args)
10371126

10381127
for key, value in vars(options).items():
@@ -1194,7 +1283,7 @@ def _parse_config_object(config: dict, *, filename="(unknown)"):
11941283
return method_map, options_map
11951284

11961285

1197-
def _parse_mapping_toml(
1286+
def _parse_mappings_from_toml(
11981287
fileobj: BinaryIO,
11991288
filename: str = "(unknown)",
12001289
style: Literal["standalone", "pyproject.toml"] = "standalone",
@@ -1207,36 +1296,8 @@ def _parse_mapping_toml(
12071296
:param filename: the name of the file being parsed, for error messages
12081297
:param style: whether the file is in the style of a `pyproject.toml` file, i.e. whether to look for `tool.babel`.
12091298
"""
1210-
try:
1211-
import tomllib
1212-
except ImportError:
1213-
try:
1214-
import tomli as tomllib
1215-
except ImportError as ie: # pragma: no cover
1216-
raise ImportError("tomli or tomllib is required to parse TOML files") from ie
1217-
1218-
try:
1219-
parsed_data = tomllib.load(fileobj)
1220-
except tomllib.TOMLDecodeError as e:
1221-
raise ConfigurationError(f"{filename}: Error parsing TOML file: {e}") from e
1222-
1223-
if style == "pyproject.toml":
1224-
try:
1225-
babel_data = parsed_data["tool"]["babel"]
1226-
except (TypeError, KeyError) as e:
1227-
raise ConfigurationError(
1228-
f"{filename}: No 'tool.babel' section found in file",
1229-
) from e
1230-
elif style == "standalone":
1231-
babel_data = parsed_data
1232-
if "babel" in babel_data:
1233-
raise ConfigurationError(
1234-
f"{filename}: 'babel' should not be present in a stand-alone configuration file",
1235-
)
1236-
else: # pragma: no cover
1237-
raise ValueError(f"Unknown TOML style {style!r}")
1238-
1239-
return _parse_config_object(babel_data, filename=filename)
1299+
parsed_data = _parse_toml(fileobj, style)
1300+
return _parse_config_object(parsed_data, filename=filename)
12401301

12411302

12421303
def _parse_spec(s: str) -> tuple[int | None, tuple[int | tuple[int, str], ...]]:

0 commit comments

Comments
 (0)