diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index fc589c9..1c31df3 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -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: | diff --git a/docs/users/changelog.md b/docs/users/changelog.md index 10ed08f..49794ba 100644 --- a/docs/users/changelog.md +++ b/docs/users/changelog.md @@ -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. diff --git a/pyproject.toml b/pyproject.toml index efd9064..00274a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 }], diff --git a/src/mdformat/_cli.py b/src/mdformat/_cli.py index 563cab1..cc1b42b 100644 --- a/src/mdformat/_cli.py +++ b/src/mdformat/_cli.py @@ -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 @@ -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 @@ -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 } @@ -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", @@ -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]: diff --git a/src/mdformat/plugins.py b/src/mdformat/plugins.py index 7349f65..373b7bd 100644 --- a/src/mdformat/plugins.py +++ b/src/mdformat/plugins.py @@ -2,7 +2,7 @@ import argparse from collections.abc import Callable, Mapping -from typing import Protocol +from typing import Any, Protocol from markdown_it import MarkdownIt @@ -10,14 +10,30 @@ 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): @@ -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") +) diff --git a/tests/test_cli.py b/tests/test_cli.py index 4df9140..6c4870c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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" @@ -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): diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 1eb06a6..455a61b 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -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 @@ -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: @@ -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"])}