Skip to content

Commit

Permalink
add: New plugin API for adding CLI options and corresponding TOML con…
Browse files Browse the repository at this point in the history
…f. Deprecate `ParserExtensionInterface.add_cli_options` (#461)
  • Loading branch information
hukkin authored Oct 25, 2024
1 parent ddf77ab commit 0b8fa44
Show file tree
Hide file tree
Showing 6 changed files with 180 additions and 3 deletions.
11 changes: 11 additions & 0 deletions docs/users/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,17 @@
This log documents all Python API or CLI breaking backwards incompatible changes.
Note that there is currently no guarantee for a stable Markdown formatting style across versions.

## 0.7.19

- Deprecated
- Plugin interface: `mdformat.plugins.ParserExtensionInterface.add_cli_options`.
The replacing interface is `mdformat.plugins.ParserExtensionInterface.add_cli_argument_group`.
- Added
- Plugin interface: `mdformat.plugins.ParserExtensionInterface.add_cli_argument_group`.
With this plugins can now read CLI arguments merged with values from `.mdformat.toml`.
- Improved
- Plugin interface: A trailing newline is added to fenced code blocks if a plugin fails to add it.

## 0.7.18

- Added
Expand Down
64 changes: 63 additions & 1 deletion src/mdformat/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ def run(cli_args: Sequence[str]) -> int: # noqa: C901
cli_opts = {
k: v for k, v in vars(arg_parser.parse_args(cli_args)).items() if v is not None
}
cli_core_opts, cli_plugin_opts = separate_core_and_plugin_opts(cli_opts)

if not cli_opts["paths"]:
print_paragraphs(["No files have been passed in. Doing nothing."])
return 0
Expand All @@ -58,7 +60,13 @@ def run(cli_args: Sequence[str]) -> int: # noqa: C901
except InvalidConfError as e:
print_error(str(e))
return 1
opts: Mapping = {**DEFAULT_OPTS, **toml_opts, **cli_opts}

opts: Mapping = {**DEFAULT_OPTS, **toml_opts, **cli_core_opts}
for plugin_id, plugin_opts in cli_plugin_opts.items():
if plugin_id in opts["plugin"]:
opts["plugin"][plugin_id] |= plugin_opts
else:
opts["plugin"][plugin_id] = plugin_opts

if sys.version_info >= (3, 13): # pragma: >=3.13 cover
if is_excluded(path, opts["exclude"], toml_path, "exclude" in cli_opts):
Expand Down Expand Up @@ -187,10 +195,48 @@ def make_arg_parser(
)
for plugin in parser_extensions.values():
if hasattr(plugin, "add_cli_options"):
import warnings

plugin_file, plugin_line = get_source_file_and_line(plugin)
warnings.warn_explicit(
"`mdformat.plugins.ParserExtensionInterface.add_cli_options`"
" is deprecated."
" Please use `add_cli_argument_group`.",
DeprecationWarning,
filename=plugin_file,
lineno=plugin_line,
)
plugin.add_cli_options(parser)
for plugin_id, plugin in parser_extensions.items():
if hasattr(plugin, "add_cli_argument_group"):
group = parser.add_argument_group(title=f"{plugin_id} plugin")
plugin.add_cli_argument_group(group)
for action in group._group_actions:
action.dest = f"plugin.{plugin_id}.{action.dest}"
return parser


def separate_core_and_plugin_opts(opts: Mapping) -> tuple[dict, dict]:
"""Move dotted keys like 'plugin.gfm.some_key' to a separate mapping.
Return a tuple of two mappings. First is for core CLI options, the
second for plugin options. E.g. 'plugin.gfm.some_key' belongs to the
second mapping under {"gfm": {"some_key": <value>}}.
"""
cli_core_opts = {}
cli_plugin_opts: dict = {}
for k, v in opts.items():
if k.startswith("plugin."):
_, plugin_id, plugin_conf_key = k.split(".", maxsplit=2)
if plugin_id in cli_plugin_opts:
cli_plugin_opts[plugin_id][plugin_conf_key] = v
else:
cli_plugin_opts[plugin_id] = {plugin_conf_key: v}
else:
cli_core_opts[k] = v
return cli_core_opts, cli_plugin_opts


class InvalidPath(Exception):
"""Exception raised when a path does not exist."""

Expand Down Expand Up @@ -355,3 +401,19 @@ def get_plugin_versions_str(
) -> str:
plugin_versions = get_plugin_versions(parser_extensions, codeformatters)
return ", ".join(f"{name}: {version}" for name, version in plugin_versions)


def get_source_file_and_line(obj: object) -> tuple[str, int]:
import inspect

try:
filename = inspect.getsourcefile(obj) # type: ignore[arg-type]
if filename is None: # pragma: no cover
filename = "not found"
except TypeError: # pragma: no cover
filename = "built-in object"
try:
_, lineno = inspect.getsourcelines(obj) # type: ignore[arg-type]
except (OSError, TypeError): # pragma: no cover
lineno = 0
return filename, lineno
7 changes: 7 additions & 0 deletions src/mdformat/_conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"number": False,
"end_of_line": "lf",
"exclude": [],
"plugin": {},
}


Expand Down Expand Up @@ -65,6 +66,12 @@ def _validate_values(opts: Mapping, conf_path: Path) -> None: # noqa: C901
for pattern in opts["exclude"]:
if not isinstance(pattern, str):
raise InvalidConfError(f"Invalid 'exclude' value in {conf_path}")
if "plugin" in opts:
if not isinstance(opts["plugin"], dict):
raise InvalidConfError(f"Invalid 'plugin' value in {conf_path}")
for plugin_conf in opts["plugin"].values():
if not isinstance(plugin_conf, dict):
raise InvalidConfError(f"Invalid 'plugin' value in {conf_path}")


def _validate_keys(opts: Mapping, conf_path: Path) -> None:
Expand Down
22 changes: 20 additions & 2 deletions src/mdformat/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,26 @@ class ParserExtensionInterface(Protocol):

@staticmethod
def add_cli_options(parser: argparse.ArgumentParser) -> None:
"""Add options to the mdformat CLI, to be stored in
mdit.options["mdformat"] (optional)"""
"""DEPRECATED - use `add_cli_argument_group` instead.
Add options to the mdformat CLI, to be stored in
mdit.options["mdformat"] (optional)
"""

@staticmethod
def add_cli_argument_group(group: argparse._ArgumentGroup) -> None:
"""Add an argument group to mdformat CLI and add arguments to it.
Call `group.add_argument()` to add CLI arguments (signature is
the same as argparse.ArgumentParser.add_argument). Values will be
stored in a mapping under mdit.options["mdformat"]["plugin"][<plugin_id>]
where <plugin_id> equals entry point name of the plugin.
The mapping will be merged with values read from TOML config file
section [plugin.<plugin_id>].
(optional)
"""

@staticmethod
def update_mdit(mdit: MarkdownIt) -> None:
Expand Down
2 changes: 2 additions & 0 deletions tests/test_config_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ def test_invalid_toml(tmp_path, capsys):
("number", "number = 0"),
("exclude", "exclude = '**'"),
("exclude", "exclude = ['1',3]"),
("plugin", "plugin = []"),
("plugin", "plugin.gfm = {}\nplugin.myst = 1"),
],
)
def test_invalid_conf_value(bad_conf, conf_key, tmp_path, capsys):
Expand Down
77 changes: 77 additions & 0 deletions tests/test_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,83 @@ def test_cli_options(monkeypatch, tmp_path):
assert opts["mdformat"]["arg_name"] == 4


class ExamplePluginWithGroupedCli:
"""A plugin that adds CLI options."""

@staticmethod
def update_mdit(mdit: MarkdownIt):
mdit.enable("table")

@staticmethod
def add_cli_argument_group(group: argparse._ArgumentGroup) -> None:
group.add_argument("--o1", type=str)
group.add_argument("--o2", type=str, default="a")
group.add_argument("--o3", dest="arg_name", type=int)
group.add_argument("--override-toml")


def test_cli_options_group(monkeypatch, tmp_path):
"""Test that CLI arguments added by plugins are correctly added to the
options dict.
Use add_cli_argument_group plugin API.
"""
monkeypatch.setitem(PARSER_EXTENSIONS, "table", ExamplePluginWithGroupedCli)
file_path = tmp_path / "test_markdown.md"
conf_path = tmp_path / ".mdformat.toml"
file_path.touch()
conf_path.write_text(
"""\
[plugin.table]
override_toml = 'failed'
toml_only = true
"""
)

with patch.object(MDRenderer, "render", return_value="") as mock_render:
assert (
run(
(
str(file_path),
"--o1",
"other",
"--o3",
"4",
"--override-toml",
"success",
)
)
== 0
)

(call_,) = mock_render.call_args_list
posargs = call_[0]
# Options is the second positional arg of MDRender.render
opts = posargs[1]
assert opts["mdformat"]["plugin"]["table"]["o1"] == "other"
assert opts["mdformat"]["plugin"]["table"]["o2"] == "a"
assert opts["mdformat"]["plugin"]["table"]["arg_name"] == 4
assert opts["mdformat"]["plugin"]["table"]["override_toml"] == "success"
assert opts["mdformat"]["plugin"]["table"]["toml_only"] is True


def test_cli_options_group__no_toml(monkeypatch, tmp_path):
"""Test add_cli_argument_group plugin API with configuration only from
CLI."""
monkeypatch.setitem(PARSER_EXTENSIONS, "table", ExamplePluginWithGroupedCli)
file_path = tmp_path / "test_markdown.md"
file_path.touch()

with patch.object(MDRenderer, "render", return_value="") as mock_render:
assert run((str(file_path), "--o1", "other")) == 0

(call_,) = mock_render.call_args_list
posargs = call_[0]
# Options is the second positional arg of MDRender.render
opts = posargs[1]
assert opts["mdformat"]["plugin"]["table"]["o1"] == "other"


class ExampleASTChangingPlugin:
"""A plugin that makes AST breaking formatting changes."""

Expand Down

0 comments on commit 0b8fa44

Please sign in to comment.