diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 49e2651..0aee7c1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -56,6 +56,8 @@ repos: - types-setuptools - numpy - pytest + - click + - typer - repo: https://github.com/codespell-project/codespell rev: "v2.4.1" diff --git a/CHANGELOG.md b/CHANGELOG.md index 341b06b..b4df26c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.4.0] - 2025-09-07 + +### Changed + +- `pyodide-cli` will now use `click` app instead of `typer` app, + thus rich format help panel is no longer displayed for `pyodide-cli`. +- The source of registered commands are now displayed in a slightly different style. + ([#47](https://github.com/pyodide/pyodide-cli/pull/47)) + ## [0.3.0] - 2025-04-05 ### Added diff --git a/pyodide_cli/app.py b/pyodide_cli/app.py index 855a648..b01ba14 100644 --- a/pyodide_cli/app.py +++ b/pyodide_cli/app.py @@ -1,54 +1,130 @@ import sys +from collections import defaultdict from functools import cache from importlib.metadata import Distribution, EntryPoint from importlib.metadata import distribution as importlib_distribution from importlib.metadata import entry_points +from typing import override -import typer # type: ignore[import] -from typer.main import TyperInfo, solve_typer_info_help # type: ignore[import] +import click +import typer +from typer.main import solve_typer_info_help +from typer.models import TyperInfo from . import __version__ -app = typer.Typer( - add_completion=False, - rich_markup_mode="markdown", - pretty_exceptions_show_locals=False, -) +class OriginGroup(click.Group): + """A click Group to support grouped command help message by its origin.""" + + origin_map: defaultdict[str, str] = defaultdict() + + @override + def add_command( + self, cmd: click.Command, name: str | None = None, *, origin: str | None = None + ) -> None: + """ + Register click commands with its origin. + """ + super().add_command(cmd, name) + + # if name is None, click will raise an exception + name = str(name or cmd.name) + + if origin is None: + origin = "" + + self.origin_map[name] = origin + + @override + def format_commands(self, ctx: click.Context, formatter: click.HelpFormatter): + """ + Format commands grouped by origin, similar to rich formatting. + The rest of the behavior is similar to click.Group.format_commands. + """ + names = self.list_commands(ctx) + + # list of (origin, name, cmd) + commands: list[tuple[str, str, click.Command]] = [] + + for name in names: + cmd = self.get_command(ctx, name) + if cmd is None: + continue + if cmd.hidden: + continue + + commands.append((name, self.origin_map.get(name, ""), cmd)) + + # sort by its origin, then by command name + commands.sort(key=lambda elem: (elem[1], elem[0])) + + if len(commands): + limit = formatter.width - 6 - max(len(cmd[0]) for cmd in commands) + + last_source: str = "" + rows: list[tuple[str, str]] = [] + + with formatter.section("Commands"): + pass + + def write_row(): + if rows: + if len(last_source): + source_desc = f" Registered by {last_source}" + else: + source_desc = "" + with formatter.section(f"{source_desc}"): + formatter.write_dl(rows) + rows.clear() -def version_callback(value: bool): - if not value: + for name, source, cmd in commands: + if last_source != source: + write_row() + + help = cmd.get_short_help_str(limit) + rows.append((name, help)) + last_source = source + + write_row() + + +def version_callback( + ctx: click.Context, _param: click.Option, value: bool | None +) -> None: + if not value or ctx.resilient_parsing: return - typer.echo(f"pyodide CLI version: {__version__}") + click.echo(f"pyodide CLI version: {__version__}") eps = entry_points(group="pyodide.cli") # filter out duplicate pkgs pkgs = {_entrypoint_to_pkgname(ep): _entrypoint_to_version(ep) for ep in eps} for pkg, version in pkgs.items(): - typer.echo(f"{pkg} version: {version}") - - raise typer.Exit() - - -@app.callback(no_args_is_help=True) -def callback( - ctx: typer.Context, - version: bool = typer.Option( - None, - "--version", - callback=version_callback, - is_eager=True, - help="Show the version of the Pyodide CLI", - ), -): + click.echo(f"{pkg} version: {version}") + + ctx.exit() + + +@click.group(cls=OriginGroup, invoke_without_command=True) +@click.option( + "--version", + is_flag=True, + is_eager=True, + callback=version_callback, + expose_value=False, + help="Show the version of the Pyodide CLI", +) +@click.pass_context +def cli(ctx: click.Context): """A command line interface for Pyodide. Other CLI subcommands are registered via the plugin system by installing Pyodide ecosystem packages (e.g. pyodide-build, pyodide-pack, auditwheel-emscripten, etc.) """ - pass + if ctx.invoked_subcommand is None: + click.echo(ctx.get_help()) @cache @@ -79,43 +155,52 @@ def register_plugins(): """Register subcommands via the ``pyodide.cli`` entry-point""" eps = entry_points(group="pyodide.cli") plugins = {ep.name: (ep.load(), ep) for ep in eps} + for plugin_name, (module, ep) in plugins.items(): pkgname = _entrypoint_to_pkgname(ep) - origin_text = f"Registered by: {pkgname}" + origin_text = f"Registered by {pkgname}:" if isinstance(module, typer.Typer): typer_info = TyperInfo(module) help_with_origin = _inject_origin( solve_typer_info_help(typer_info), origin_text ) - app.add_typer( - module, - name=plugin_name, - rich_help_panel=origin_text, - help=help_with_origin, + else: + help_with_origin = _inject_origin( + getattr(module, "__doc__", ""), origin_text ) + + if isinstance(module, click.Group): + cmd = module + elif isinstance(module, typer.Typer): + cmd = typer.main.get_command(module) elif callable(module): typer_kwargs = getattr(module, "typer_kwargs", {}) - help_with_origin = _inject_origin(module.__doc__, origin_text) + app = typer.Typer() app.command( plugin_name, - rich_help_panel=origin_text, help=help_with_origin, **typer_kwargs, )(module) + cmd = typer.main.get_command(app) else: raise RuntimeError(f"Invalid plugin: {plugin_name}") + # directly manipulate click Command help message + cmd.help = help_with_origin + + cli.add_command(cmd, name=plugin_name, origin=pkgname) + def main(): register_plugins() - app() + cli() if "sphinx" in sys.modules and __name__ != "__main__": # Create the typer click object to generate docs with sphinx-click register_plugins() - typer_click_object = typer.main.get_command(app) + typer_click_object = cli if __name__ == "__main__": main() diff --git a/pyodide_cli/tests/plugin-test/plugin_test/cli.py b/pyodide_cli/tests/plugin-test/plugin_test/cli.py new file mode 100644 index 0000000..54f8205 --- /dev/null +++ b/pyodide_cli/tests/plugin-test/plugin_test/cli.py @@ -0,0 +1,25 @@ +import click # type: ignore[import] + + +@click.group(invoke_without_command=True) +@click.pass_context +def cli(ctx: click.Context) -> None: + """ + Test help message short desc + + Test help message long desc + """ + pass + if ctx.invoked_subcommand is None: + click.echo(ctx.get_help()) + + +@cli.command() +@click.argument("name", required=True) +def hello(name: str) -> None: + """ + Test help message short desc + + Test help message long desc + """ + click.echo(f"Hello {name}") diff --git a/pyodide_cli/tests/plugin-test/pyproject.toml b/pyodide_cli/tests/plugin-test/pyproject.toml index 722939b..9397859 100644 --- a/pyodide_cli/tests/plugin-test/pyproject.toml +++ b/pyodide_cli/tests/plugin-test/pyproject.toml @@ -10,3 +10,4 @@ authors = [] [project.entry-points."pyodide.cli"] plugin_test_func = "plugin_test.main:main" plugin_test_app = "plugin_test.app:app" +plugin_test_cli = "plugin_test.cli:cli" diff --git a/pyodide_cli/tests/test_cli.py b/pyodide_cli/tests/test_cli.py index bd80485..29c9bd6 100644 --- a/pyodide_cli/tests/test_cli.py +++ b/pyodide_cli/tests/test_cli.py @@ -67,14 +67,16 @@ def func(q: Queue, with_sphinx=False): def test_plugin_origin(plugins): output = check_output(["pyodide", "--help"]).decode("utf-8") - msg = "Registered by: plugin-test" + msg = "Registered by plugin-test:" assert msg in output -@pytest.mark.parametrize("entrypoint", ["plugin_test_app", "plugin_test_func"]) +@pytest.mark.parametrize( + "entrypoint", ["plugin_test_app", "plugin_test_func", "plugin_test_cli"] +) def test_plugin_origin_subcommand(plugins, entrypoint): output = check_output(["pyodide", entrypoint, "--help"]).decode("utf-8") - msg = "Registered by: plugin-test" + msg = "Registered by plugin-test:" assert msg in output