Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 130 additions & 41 deletions pyodide_cli/app.py
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):
"""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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's remove this prefix. I don't think we need to care about i18n, and it looks a bit verbose to me.

image

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you prefer removing Commands in this case? If that's the case, I would like to preserve it only when the subcommand was added without origin.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

when the subcommand was added without origin

When can this happen?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought the commands provided by pyodide-cli would be, please correct me if I am wrong or it is an unintended scenario.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pyodide-cli is a meta package and will not provide any subcommands.

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
Expand Down Expand Up @@ -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}"
Expand All @@ -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,
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍


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()
25 changes: 25 additions & 0 deletions pyodide_cli/tests/plugin-test/plugin_test/cli.py
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}")
1 change: 1 addition & 0 deletions pyodide_cli/tests/plugin-test/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
6 changes: 4 additions & 2 deletions pyodide_cli/tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this message changed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The command section message is currently displayed as Commands Registered by <source>:, instead of Commands Registered by: <source> If the original message suits better, I will revert the change.

Copy link
Member

Choose a reason for hiding this comment

The 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?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 test_plugin_origin is checking (it is checked in test_plugin_origin_subcommand instead). Would you like to update it as well?

Copy link
Member

Choose a reason for hiding this comment

The 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"
Expand Down
Loading