Skip to content

Commit

Permalink
Improve plugin list in --help (#470)
Browse files Browse the repository at this point in the history
  • Loading branch information
hukkin authored Nov 4, 2024
1 parent c15c5d5 commit 8c573e4
Show file tree
Hide file tree
Showing 7 changed files with 131 additions and 84 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:

- name: Installation (deps and package)
run: |
pip install . pre-commit mypy==1.11.2 -r tests/requirements.txt
pip install . pre-commit mypy==1.13.0 -r tests/requirements.txt
- name: run linters
run: |
Expand Down
3 changes: 3 additions & 0 deletions docs/users/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ Note that there is currently no guarantee for a stable Markdown formatting style
- 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 list at the end of `--help` output:
List languages supported by codeformatter plugin distributions,
and parser extensions added by parser extension distributions.
- Changed
- Style: No longer escape square bracket enclosures.
- Style: No longer escape less than sign followed by space character.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ description = "run mypy"
basepython = ["python3.11"]
deps = [
"-r tests/requirements.txt",
"mypy==1.11.2",
"mypy==1.13.0",
]
commands = [
["mypy", { replace = "posargs", default = ["src/", "tests/"], extend = true }],
Expand Down
90 changes: 39 additions & 51 deletions src/mdformat/_cli.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
from __future__ import annotations

import argparse
from collections.abc import Callable, Generator, Iterable, Mapping, Sequence
from collections.abc import Generator, Iterable, Mapping, Sequence
import contextlib
import inspect
import itertools
import logging
import os.path
from pathlib import Path
Expand All @@ -13,7 +12,6 @@
import textwrap

import mdformat
from mdformat._compat import importlib_metadata
from mdformat._conf import DEFAULT_OPTS, InvalidConfError, read_toml_opts
from mdformat._util import detect_newline_type, is_md_equal
import mdformat.plugins
Expand All @@ -37,7 +35,11 @@ def run(cli_args: Sequence[str]) -> int: # noqa: C901
for plugin in enabled_parserplugins.values()
)

arg_parser = make_arg_parser(enabled_parserplugins, enabled_codeformatters)
arg_parser = make_arg_parser(
mdformat.plugins._PARSER_EXTENSION_DISTS,
mdformat.plugins._CODEFORMATTER_DISTS,
enabled_parserplugins,
)
cli_opts = {
k: v for k, v in vars(arg_parser.parse_args(cli_args)).items() if v is not None
}
Expand Down Expand Up @@ -150,23 +152,26 @@ def validate_wrap_arg(value: str) -> str | int:


def make_arg_parser(
parser_extension_dists: Mapping[str, tuple[str, list[str]]],
codeformatter_dists: Mapping[str, tuple[str, list[str]]],
parser_extensions: Mapping[str, mdformat.plugins.ParserExtensionInterface],
codeformatters: Mapping[str, Callable[[str, str], str]],
) -> argparse.ArgumentParser:
plugin_versions_str = get_plugin_versions_str(parser_extensions, codeformatters)
epilog = get_plugin_info_str(parser_extension_dists, codeformatter_dists)
parser = argparse.ArgumentParser(
description="CommonMark compliant Markdown formatter",
epilog=(
f"Installed plugins: {plugin_versions_str}" if plugin_versions_str else None
),
epilog=(epilog if epilog else None),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("paths", nargs="*", help="files to format")
parser.add_argument(
"--check", action="store_true", help="do not apply changes to files"
)
version_str = f"mdformat {mdformat.__version__}"
if plugin_versions_str:
version_str += f" ({plugin_versions_str})"
plugin_version_str = get_plugin_version_str(
{**parser_extension_dists, **codeformatter_dists}
)
if plugin_version_str:
version_str += f" ({plugin_version_str})"
parser.add_argument("--version", action="version", version=version_str)
parser.add_argument(
"--number",
Expand Down Expand Up @@ -360,47 +365,30 @@ def get_package_name(obj: object) -> str | None:
return module.__name__.split(".", maxsplit=1)[0] if module else None


def get_plugin_versions(
parser_extensions: Mapping[str, mdformat.plugins.ParserExtensionInterface],
codeformatters: Mapping[str, Callable[[str, str], str]],
) -> list[tuple[str, str]]:
"""Return a list of (plugin_distro, plugin_version) tuples.
If many plugins come from the same distribution package, only return
the version of that distribution once. If we have no reliable way to
one-to-one map a plugin to a distribution package, use the top level
module name and set version to "unknown". If we don't even know the
top level module name, return the tuple ("unknown", "unknown").
"""
problematic_versions = []
# Use a dict for successful version lookups so that if more than one plugin
# originates from the same distribution, it only shows up once in a version
# string.
success_versions = {}
import_package_to_distro = importlib_metadata.packages_distributions()
for iface in itertools.chain(parser_extensions.values(), codeformatters.values()):
import_package = get_package_name(iface)
if import_package is None:
problematic_versions.append(("unknown", "unknown"))
continue
distro_list = import_package_to_distro.get(import_package)
if (
not distro_list # No distribution package found
or len(distro_list) > 1 # Don't make any guesses with namespace packages
):
problematic_versions.append((import_package, "unknown"))
continue
distro_name = distro_list[0]
success_versions[distro_name] = importlib_metadata.version(distro_name)
return [(k, v) for k, v in success_versions.items()] + problematic_versions


def get_plugin_versions_str(
parser_extensions: Mapping[str, mdformat.plugins.ParserExtensionInterface],
codeformatters: Mapping[str, Callable[[str, str], str]],
def get_plugin_info_str(
parser_extension_dists: Mapping[str, tuple[str, list[str]]],
codeformatter_dists: Mapping[str, tuple[str, list[str]]],
) -> str:
plugin_versions = get_plugin_versions(parser_extensions, codeformatters)
return ", ".join(f"{name}: {version}" for name, version in plugin_versions)
info = ""
if codeformatter_dists:
info += "installed codeformatters:"
for dist, dist_info in codeformatter_dists.items():
langs = ", ".join(dist_info[1])
info += f"\n {dist}: {langs}"
if parser_extension_dists:
if info:
info += "\n\n"
info += "installed extensions:"
for dist, dist_info in parser_extension_dists.items():
extensions = ", ".join(dist_info[1])
info += f"\n {dist}: {extensions}"
return info


def get_plugin_version_str(dist_map: Mapping[str, tuple[str, list[str]]]) -> str:
return ", ".join(
f"{dist_name} {dist_info[0]}" for dist_name, dist_info in dist_map.items()
)


def get_source_file_and_line(obj: object) -> tuple[str, int]:
Expand Down
47 changes: 30 additions & 17 deletions src/mdformat/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,38 @@

import argparse
from collections.abc import Callable, Mapping
from typing import Protocol
from typing import Any, Protocol

from markdown_it import MarkdownIt

from mdformat._compat import importlib_metadata
from mdformat.renderer.typing import Postprocess, Render


def _load_codeformatters() -> dict[str, Callable[[str, str], str]]:
codeformatter_entrypoints = importlib_metadata.entry_points(
group="mdformat.codeformatter"
)
return {ep.name: ep.load() for ep in codeformatter_entrypoints}


CODEFORMATTERS: Mapping[str, Callable[[str, str], str]] = _load_codeformatters()
def _load_entrypoints(
eps: importlib_metadata.EntryPoints,
) -> tuple[dict[str, Any], dict[str, tuple[str, list[str]]]]:
loaded_ifaces: dict[str, Any] = {}
dist_versions: dict[str, tuple[str, list[str]]] = {}
for ep in eps:
assert ep.dist, (
"EntryPoint.dist should never be None "
"when coming from Distribution.entry_points"
)
loaded_ifaces[ep.name] = ep.load()
dist_name = ep.dist.name
if dist_name in dist_versions:
dist_versions[dist_name][1].append(ep.name)
else:
dist_versions[dist_name] = (ep.dist.version, [ep.name])
return loaded_ifaces, dist_versions


CODEFORMATTERS: Mapping[str, Callable[[str, str], str]]
_CODEFORMATTER_DISTS: Mapping[str, tuple[str, list[str]]]
CODEFORMATTERS, _CODEFORMATTER_DISTS = _load_entrypoints(
importlib_metadata.entry_points(group="mdformat.codeformatter")
)


class ParserExtensionInterface(Protocol):
Expand Down Expand Up @@ -68,11 +84,8 @@ def update_mdit(mdit: MarkdownIt) -> None:
"""Update the parser, e.g. by adding a plugin: `mdit.use(myplugin)`"""


def _load_parser_extensions() -> dict[str, ParserExtensionInterface]:
parser_extension_entrypoints = importlib_metadata.entry_points(
group="mdformat.parser_extension"
)
return {ep.name: ep.load() for ep in parser_extension_entrypoints}


PARSER_EXTENSIONS: Mapping[str, ParserExtensionInterface] = _load_parser_extensions()
PARSER_EXTENSIONS: Mapping[str, ParserExtensionInterface]
_PARSER_EXTENSION_DISTS: Mapping[str, tuple[str, list[str]]]
PARSER_EXTENSIONS, _PARSER_EXTENSION_DISTS = _load_entrypoints(
importlib_metadata.entry_points(group="mdformat.parser_extension")
)
24 changes: 14 additions & 10 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import pytest

import mdformat
from mdformat._cli import get_package_name, get_plugin_versions, run, wrap_paragraphs
from mdformat._cli import get_package_name, get_plugin_info_str, run, wrap_paragraphs
from mdformat.plugins import CODEFORMATTERS

UNFORMATTED_MARKDOWN = "\n\n# A header\n\n"
Expand Down Expand Up @@ -350,16 +350,20 @@ def test_get_package_name():
assert get_package_name(mdformat) == "mdformat"


def test_get_plugin_versions():
# Pretend that "pytest" and "unittest.mock.patch" are plugins
versions = get_plugin_versions({"p1": pytest}, {"f1": patch}) # type: ignore[dict-item] # noqa: E501
assert versions[0][0] == "pytest"
assert versions[0][1] != "unknown"
assert versions[1] == ("unittest", "unknown")
def test_get_plugin_info_str():
info = get_plugin_info_str(
{"mdformat-tables": ("0.1.0", ["tables"])},
{"mdformat-black": ("12.1.0", ["python"])},
)
assert (
info
== """\
installed codeformatters:
mdformat-black: python
with patch("mdformat._cli.inspect.getmodule", return_value=None):
versions = get_plugin_versions({"p1": pytest}, {}) # type: ignore[dict-item]
assert versions[0] == ("unknown", "unknown")
installed extensions:
mdformat-tables: tables"""
)


def test_no_timestamp_modify(tmp_path):
Expand Down
47 changes: 43 additions & 4 deletions tests/test_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@

import mdformat
from mdformat._cli import run
from mdformat.plugins import CODEFORMATTERS, PARSER_EXTENSIONS
from mdformat._compat import importlib_metadata
from mdformat.plugins import (
_PARSER_EXTENSION_DISTS,
CODEFORMATTERS,
PARSER_EXTENSIONS,
_load_entrypoints,
)
from mdformat.renderer import MDRenderer, RenderContext, RenderTreeNode


Expand Down Expand Up @@ -389,13 +395,15 @@ def test_plugin_conflict(monkeypatch, tmp_path, capsys):


def test_plugin_versions_in_cli_help(monkeypatch, capsys):
monkeypatch.setitem(PARSER_EXTENSIONS, "table", ExampleTablePlugin)
monkeypatch.setitem(
_PARSER_EXTENSION_DISTS, "table-dist", ("v3.2.1", ["table-ext"])
)
with pytest.raises(SystemExit) as exc_info:
run(["--help"])
assert exc_info.value.code == 0
captured = capsys.readouterr()
assert "Installed plugins:" in captured.out
assert "tests: unknown" in captured.out
assert "installed extensions:" in captured.out
assert "table-dist: table-ext" in captured.out


class PrefixPostprocessPlugin:
Expand Down Expand Up @@ -457,3 +465,34 @@ def test_postprocess_plugins(monkeypatch):
Prefixed!Example paragraph.Suffixed!
"""
)


def test_load_entrypoints(tmp_path, monkeypatch):
"""Test the function that loads plugins to constants."""
# Create a minimal .dist-info to create EntryPoints out of
dist_info_path = tmp_path / "mdformat_gfm-0.3.6.dist-info"
dist_info_path.mkdir()
entry_points_path = dist_info_path / "entry_points.txt"
metadata_path = dist_info_path / "METADATA"
# The modules here will get loaded so use ones we know will always exist
# (even though they aren't actual extensions).
entry_points_path.write_text(
"""\
[mdformat.parser_extension]
ext1=mdformat.plugins
ext2=mdformat.plugins
"""
)
metadata_path.write_text(
"""\
Metadata-Version: 2.1
Name: mdformat-gfm
Version: 0.3.6
"""
)
distro = importlib_metadata.PathDistribution(dist_info_path)
entrypoints = distro.entry_points

loaded_eps, dist_infos = _load_entrypoints(entrypoints)
assert loaded_eps == {"ext1": mdformat.plugins, "ext2": mdformat.plugins}
assert dist_infos == {"mdformat-gfm": ("0.3.6", ["ext1", "ext2"])}

0 comments on commit 8c573e4

Please sign in to comment.