Skip to content

Commit 4fbda9a

Browse files
committed
New plugin API for adding CLI options. Let plugins add corresponding TOML conf
1 parent 9ed01b7 commit 4fbda9a

File tree

5 files changed

+139
-1
lines changed

5 files changed

+139
-1
lines changed

src/mdformat/_cli.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ def run(cli_args: Sequence[str]) -> int: # noqa: C901
4040
cli_opts = {
4141
k: v for k, v in vars(arg_parser.parse_args(cli_args)).items() if v is not None
4242
}
43+
cli_core_opts, cli_plugin_opts = separate_core_and_plugin_opts(cli_opts)
44+
4345
if not cli_opts["paths"]:
4446
print_paragraphs(["No files have been passed in. Doing nothing."])
4547
return 0
@@ -57,7 +59,13 @@ def run(cli_args: Sequence[str]) -> int: # noqa: C901
5759
except InvalidConfError as e:
5860
print_error(str(e))
5961
return 1
60-
opts: Mapping = {**DEFAULT_OPTS, **toml_opts, **cli_opts}
62+
63+
opts: Mapping = {**DEFAULT_OPTS, **toml_opts, **cli_core_opts}
64+
for plugin_id, plugin_opts in cli_plugin_opts.items():
65+
if plugin_id in opts["plugin"]:
66+
opts["plugin"][plugin_id] |= plugin_opts
67+
else:
68+
opts["plugin"][plugin_id] = plugin_opts
6169

6270
if sys.version_info >= (3, 13): # pragma: >=3.13 cover
6371
if is_excluded(path, opts["exclude"], toml_path, "exclude" in cli_opts):
@@ -182,10 +190,38 @@ def make_arg_parser(
182190
)
183191
for plugin in parser_extensions.values():
184192
if hasattr(plugin, "add_cli_options"):
193+
# TODO: deprecate in favor of add_cli_argument_group
185194
plugin.add_cli_options(parser)
195+
for plugin_id, plugin in parser_extensions.items():
196+
if hasattr(plugin, "add_cli_argument_group"):
197+
group = parser.add_argument_group(title=f"{plugin_id} plugin")
198+
plugin.add_cli_argument_group(group)
199+
for action in group._group_actions:
200+
action.dest = f"plugin.{plugin_id}.{action.dest}"
186201
return parser
187202

188203

204+
def separate_core_and_plugin_opts(opts: Mapping) -> tuple[dict, dict]:
205+
"""Move dotted keys like 'plugin.gfm.some_key' to a separate mapping.
206+
207+
Return a tuple of two mappings. First is for core CLI options, the
208+
second for plugin options. E.g. 'plugin.gfm.some_key' belongs to the
209+
second mapping under {"gfm": {"some_key": <value>}}.
210+
"""
211+
cli_core_opts = {}
212+
cli_plugin_opts: dict = {}
213+
for k, v in opts.items():
214+
if k.startswith("plugin."):
215+
_, plugin_id, plugin_conf_key = k.split(".", maxsplit=2)
216+
if plugin_id in cli_plugin_opts:
217+
cli_plugin_opts[plugin_id][plugin_conf_key] = v
218+
else:
219+
cli_plugin_opts[plugin_id] = {plugin_conf_key: v}
220+
else:
221+
cli_core_opts[k] = v
222+
return cli_core_opts, cli_plugin_opts
223+
224+
189225
class InvalidPath(Exception):
190226
"""Exception raised when a path does not exist."""
191227

src/mdformat/_conf.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"number": False,
1212
"end_of_line": "lf",
1313
"exclude": [],
14+
"plugin": {},
1415
}
1516

1617

@@ -65,6 +66,12 @@ def _validate_values(opts: Mapping, conf_path: Path) -> None: # noqa: C901
6566
for pattern in opts["exclude"]:
6667
if not isinstance(pattern, str):
6768
raise InvalidConfError(f"Invalid 'exclude' value in {conf_path}")
69+
if "plugin" in opts:
70+
if not isinstance(opts["plugin"], dict):
71+
raise InvalidConfError(f"Invalid 'plugin' value in {conf_path}")
72+
for plugin_conf in opts["plugin"].values():
73+
if not isinstance(plugin_conf, dict):
74+
raise InvalidConfError(f"Invalid 'plugin' value in {conf_path}")
6875

6976

7077
def _validate_keys(opts: Mapping, conf_path: Path) -> None:

src/mdformat/plugins.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,27 @@ class ParserExtensionInterface(Protocol):
4040
# (optional)
4141
POSTPROCESSORS: Mapping[str, Postprocess]
4242

43+
# TODO: deprecate in favor of add_cli_argument_group
4344
@staticmethod
4445
def add_cli_options(parser: argparse.ArgumentParser) -> None:
4546
"""Add options to the mdformat CLI, to be stored in
4647
mdit.options["mdformat"] (optional)"""
4748

49+
@staticmethod
50+
def add_cli_argument_group(group: argparse._ArgumentGroup) -> None:
51+
"""Add an argument group to mdformat CLI and add arguments to it.
52+
53+
Call `group.add_argument()` to add CLI arguments (signature is
54+
the same as argparse.ArgumentParser.add_argument). Values will be
55+
stored in a mapping under mdit.options["mdformat"]["plugin"][<plugin_id>]
56+
where <plugin_id> equals entry point name of the plugin.
57+
58+
The mapping will be merged with values read from TOML config file
59+
section [plugin.<plugin_id>].
60+
61+
(optional)
62+
"""
63+
4864
@staticmethod
4965
def update_mdit(mdit: MarkdownIt) -> None:
5066
"""Update the parser, e.g. by adding a plugin: `mdit.use(myplugin)`"""

tests/test_config_file.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ def test_invalid_toml(tmp_path, capsys):
6868
("number", "number = 0"),
6969
("exclude", "exclude = '**'"),
7070
("exclude", "exclude = ['1',3]"),
71+
("plugin", "plugin = []"),
72+
("plugin", "plugin.gfm = {}\nplugin.myst = 1"),
7173
],
7274
)
7375
def test_invalid_conf_value(bad_conf, conf_key, tmp_path, capsys):

tests/test_plugins.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,83 @@ def test_cli_options(monkeypatch, tmp_path):
222222
assert opts["mdformat"]["arg_name"] == 4
223223

224224

225+
class ExamplePluginWithGroupedCli:
226+
"""A plugin that adds CLI options."""
227+
228+
@staticmethod
229+
def update_mdit(mdit: MarkdownIt):
230+
mdit.enable("table")
231+
232+
@staticmethod
233+
def add_cli_argument_group(group: argparse._ArgumentGroup) -> None:
234+
group.add_argument("--o1", type=str)
235+
group.add_argument("--o2", type=str, default="a")
236+
group.add_argument("--o3", dest="arg_name", type=int)
237+
group.add_argument("--override-toml")
238+
239+
240+
def test_cli_options_group(monkeypatch, tmp_path):
241+
"""Test that CLI arguments added by plugins are correctly added to the
242+
options dict.
243+
244+
Use add_cli_argument_group plugin API.
245+
"""
246+
monkeypatch.setitem(PARSER_EXTENSIONS, "table", ExamplePluginWithGroupedCli)
247+
file_path = tmp_path / "test_markdown.md"
248+
conf_path = tmp_path / ".mdformat.toml"
249+
file_path.touch()
250+
conf_path.write_text(
251+
"""\
252+
[plugin.table]
253+
override_toml = 'failed'
254+
toml_only = true
255+
"""
256+
)
257+
258+
with patch.object(MDRenderer, "render", return_value="") as mock_render:
259+
assert (
260+
run(
261+
(
262+
str(file_path),
263+
"--o1",
264+
"other",
265+
"--o3",
266+
"4",
267+
"--override-toml",
268+
"success",
269+
)
270+
)
271+
== 0
272+
)
273+
274+
(call_,) = mock_render.call_args_list
275+
posargs = call_[0]
276+
# Options is the second positional arg of MDRender.render
277+
opts = posargs[1]
278+
assert opts["mdformat"]["plugin"]["table"]["o1"] == "other"
279+
assert opts["mdformat"]["plugin"]["table"]["o2"] == "a"
280+
assert opts["mdformat"]["plugin"]["table"]["arg_name"] == 4
281+
assert opts["mdformat"]["plugin"]["table"]["override_toml"] == "success"
282+
assert opts["mdformat"]["plugin"]["table"]["toml_only"] is True
283+
284+
285+
def test_cli_options_group__no_toml(monkeypatch, tmp_path):
286+
"""Test add_cli_argument_group plugin API with configuration only from
287+
CLI."""
288+
monkeypatch.setitem(PARSER_EXTENSIONS, "table", ExamplePluginWithGroupedCli)
289+
file_path = tmp_path / "test_markdown.md"
290+
file_path.touch()
291+
292+
with patch.object(MDRenderer, "render", return_value="") as mock_render:
293+
assert run((str(file_path), "--o1", "other")) == 0
294+
295+
(call_,) = mock_render.call_args_list
296+
posargs = call_[0]
297+
# Options is the second positional arg of MDRender.render
298+
opts = posargs[1]
299+
assert opts["mdformat"]["plugin"]["table"]["o1"] == "other"
300+
301+
225302
class ExampleASTChangingPlugin:
226303
"""A plugin that makes AST breaking formatting changes."""
227304

0 commit comments

Comments
 (0)