-
Notifications
You must be signed in to change notification settings - Fork 9
Replace Typer app with click cli #47
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 7 commits
78d00c8
fa21139
374a4aa
1299c92
1a5a0eb
5a4ec40
cff02d3
ffbd85e
2190053
4777c04
9f76ca0
7ed7be9
fad12a7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,54 +1,128 @@ | ||
| import sys | ||
| from collections import defaultdict | ||
| from functools import cache | ||
| from gettext import gettext | ||
| 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 click # type: ignore[import] | ||
| import typer # type: ignore[import] | ||
| from typer.main import TyperInfo, solve_typer_info_help # type: ignore[import] | ||
|
|
||
| from . import __version__ | ||
|
|
||
| app = typer.Typer( | ||
| add_completion=False, | ||
| rich_markup_mode="markdown", | ||
| pretty_exceptions_show_locals=False, | ||
| ) | ||
|
|
||
| class OriginGroup(click.Group): | ||
ryanking13 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| """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 = [] | ||
|
|
||
| def write_row(): | ||
| if rows: | ||
| if len(last_source): | ||
| source_desc = f" Registered by {last_source}" | ||
| else: | ||
| source_desc = "" | ||
| # NOTE: gettext is used in click! Any i18n support? | ||
| with formatter.section(f"{gettext('Commands')}{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,6 +153,7 @@ 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}" | ||
|
|
@@ -88,34 +163,48 @@ def register_plugins(): | |
| 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 callable(module): | ||
| typer_kwargs = getattr(module, "typer_kwargs", None) | ||
| # construct Typer app and preserve typer_kwargs as of now | ||
| if typer_kwargs is not None: | ||
| app = typer.Typer() | ||
| app.command( | ||
| plugin_name, | ||
| help=help_with_origin, | ||
| **typer_kwargs, | ||
| )(module) | ||
| cmd = typer.main.get_command(app) | ||
| else: | ||
| # we need a new command with an updated help message | ||
| # set module (whether it is click, typer, or any other callable) as callback | ||
| cmd = click.Command( | ||
| plugin_name, | ||
| callback=module, | ||
| help=help_with_origin, | ||
| ) | ||
|
||
|
|
||
| cli.add_command( | ||
| cmd, | ||
| origin=pkgname, | ||
| ) | ||
| elif callable(module): | ||
| typer_kwargs = getattr(module, "typer_kwargs", {}) | ||
| help_with_origin = _inject_origin(module.__doc__, origin_text) | ||
| app.command( | ||
| plugin_name, | ||
| rich_help_panel=origin_text, | ||
| help=help_with_origin, | ||
| **typer_kwargs, | ||
| )(module) | ||
| else: | ||
| raise RuntimeError(f"Invalid plugin: {plugin_name}") | ||
|
|
||
|
|
||
| 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() | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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}") |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -67,12 +67,14 @@ 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" | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why is this message changed?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The command section message is currently displayed as
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I see. I think this one is better. Thanks. Could you also update here too for consistency?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The referenced message is appended to the long help message of each subcommands, which is slightly different from what
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, I think it is good to be consistent. |
||
| 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" | ||
|
|
||

Uh oh!
There was an error while loading. Please reload this page.