Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ repos:
- types-setuptools
- numpy
- pytest
- click
- typer

- repo: https://github.com/codespell-project/codespell
rev: "v2.4.1"
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
159 changes: 122 additions & 37 deletions pyodide_cli/app.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()
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"
8 changes: 5 additions & 3 deletions pyodide_cli/tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
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"
msg = "Registered by plugin-test:"

assert msg in output