Skip to content

Commit e81bb02

Browse files
Replace Typer app with click cli (#47)
* Replace Typer with click Typer app is now a click cli as Typer rich panel is not supported in click, pytest fails as package source is not printed * Show origin of subcommands in click as click does not support `rich_help_panel` by default, grouping by origin was reimplemented by overriding some of the functions of click.Group * Support typer_kwargs compatibility even though our mission is to drop Typer, we need to defer breaking changes * Construct a new click command regardless of module we need to update help message anyway, whether the module is either click command, typer app, or any other format * Add click cli test with clarification introduce click cli object with custom commands in pytest note that `test_plugin_origin` checks OriginGroup help while `test_plugin_origin_subcommand` checks imported module's help * Refine name handling in OriginGroup let click checks if name is None and raise * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Update mypy depedency of click and typer * Fix typer import * Fix subcommand append when module is a callable function Directly passing module as a click callback yields a weird behavior when the module is a function which accepts yper arguments and options In this case, their default values are neither checked nor assigned. To properly handle, construct typer app and append as a command. Actually this is identical to the original implementation except registering origins! Additionally, we have to reassign the help message of the original command. I am not sure if direct manipulation of attribute of click is a good idea... * Remove 'Command' prefix for every origin Print 'Commands' section only once (if command exists) with removal of i18n support of 'Commands'. * Fix consistency of register message now 'Registered by {pkgname}:' is used instead. * Update CHANGELOG for 0.4.0 --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 6d9f97d commit e81bb02

File tree

6 files changed

+164
-40
lines changed

6 files changed

+164
-40
lines changed

.pre-commit-config.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ repos:
5656
- types-setuptools
5757
- numpy
5858
- pytest
59+
- click
60+
- typer
5961

6062
- repo: https://github.com/codespell-project/codespell
6163
rev: "v2.4.1"

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file.
44
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## [0.4.0] - 2025-09-07
8+
9+
### Changed
10+
11+
- `pyodide-cli` will now use `click` app instead of `typer` app,
12+
thus rich format help panel is no longer displayed for `pyodide-cli`.
13+
- The source of registered commands are now displayed in a slightly different style.
14+
([#47](https://github.com/pyodide/pyodide-cli/pull/47))
15+
716
## [0.3.0] - 2025-04-05
817

918
### Added

pyodide_cli/app.py

Lines changed: 122 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,130 @@
11
import sys
2+
from collections import defaultdict
23
from functools import cache
34
from importlib.metadata import Distribution, EntryPoint
45
from importlib.metadata import distribution as importlib_distribution
56
from importlib.metadata import entry_points
7+
from typing import override
68

7-
import typer # type: ignore[import]
8-
from typer.main import TyperInfo, solve_typer_info_help # type: ignore[import]
9+
import click
10+
import typer
11+
from typer.main import solve_typer_info_help
12+
from typer.models import TyperInfo
913

1014
from . import __version__
1115

12-
app = typer.Typer(
13-
add_completion=False,
14-
rich_markup_mode="markdown",
15-
pretty_exceptions_show_locals=False,
16-
)
1716

17+
class OriginGroup(click.Group):
18+
"""A click Group to support grouped command help message by its origin."""
19+
20+
origin_map: defaultdict[str, str] = defaultdict()
21+
22+
@override
23+
def add_command(
24+
self, cmd: click.Command, name: str | None = None, *, origin: str | None = None
25+
) -> None:
26+
"""
27+
Register click commands with its origin.
28+
"""
29+
super().add_command(cmd, name)
30+
31+
# if name is None, click will raise an exception
32+
name = str(name or cmd.name)
33+
34+
if origin is None:
35+
origin = ""
36+
37+
self.origin_map[name] = origin
38+
39+
@override
40+
def format_commands(self, ctx: click.Context, formatter: click.HelpFormatter):
41+
"""
42+
Format commands grouped by origin, similar to rich formatting.
43+
The rest of the behavior is similar to click.Group.format_commands.
44+
"""
45+
names = self.list_commands(ctx)
46+
47+
# list of (origin, name, cmd)
48+
commands: list[tuple[str, str, click.Command]] = []
49+
50+
for name in names:
51+
cmd = self.get_command(ctx, name)
52+
if cmd is None:
53+
continue
54+
if cmd.hidden:
55+
continue
56+
57+
commands.append((name, self.origin_map.get(name, ""), cmd))
58+
59+
# sort by its origin, then by command name
60+
commands.sort(key=lambda elem: (elem[1], elem[0]))
61+
62+
if len(commands):
63+
limit = formatter.width - 6 - max(len(cmd[0]) for cmd in commands)
64+
65+
last_source: str = ""
66+
rows: list[tuple[str, str]] = []
67+
68+
with formatter.section("Commands"):
69+
pass
70+
71+
def write_row():
72+
if rows:
73+
if len(last_source):
74+
source_desc = f" Registered by {last_source}"
75+
else:
76+
source_desc = ""
77+
with formatter.section(f"{source_desc}"):
78+
formatter.write_dl(rows)
79+
rows.clear()
1880

19-
def version_callback(value: bool):
20-
if not value:
81+
for name, source, cmd in commands:
82+
if last_source != source:
83+
write_row()
84+
85+
help = cmd.get_short_help_str(limit)
86+
rows.append((name, help))
87+
last_source = source
88+
89+
write_row()
90+
91+
92+
def version_callback(
93+
ctx: click.Context, _param: click.Option, value: bool | None
94+
) -> None:
95+
if not value or ctx.resilient_parsing:
2196
return
2297

23-
typer.echo(f"pyodide CLI version: {__version__}")
98+
click.echo(f"pyodide CLI version: {__version__}")
2499

25100
eps = entry_points(group="pyodide.cli")
26101
# filter out duplicate pkgs
27102
pkgs = {_entrypoint_to_pkgname(ep): _entrypoint_to_version(ep) for ep in eps}
28103
for pkg, version in pkgs.items():
29-
typer.echo(f"{pkg} version: {version}")
30-
31-
raise typer.Exit()
32-
33-
34-
@app.callback(no_args_is_help=True)
35-
def callback(
36-
ctx: typer.Context,
37-
version: bool = typer.Option(
38-
None,
39-
"--version",
40-
callback=version_callback,
41-
is_eager=True,
42-
help="Show the version of the Pyodide CLI",
43-
),
44-
):
104+
click.echo(f"{pkg} version: {version}")
105+
106+
ctx.exit()
107+
108+
109+
@click.group(cls=OriginGroup, invoke_without_command=True)
110+
@click.option(
111+
"--version",
112+
is_flag=True,
113+
is_eager=True,
114+
callback=version_callback,
115+
expose_value=False,
116+
help="Show the version of the Pyodide CLI",
117+
)
118+
@click.pass_context
119+
def cli(ctx: click.Context):
45120
"""A command line interface for Pyodide.
46121
47122
Other CLI subcommands are registered via the plugin system by installing
48123
Pyodide ecosystem packages (e.g. pyodide-build, pyodide-pack,
49124
auditwheel-emscripten, etc.)
50125
"""
51-
pass
126+
if ctx.invoked_subcommand is None:
127+
click.echo(ctx.get_help())
52128

53129

54130
@cache
@@ -79,43 +155,52 @@ def register_plugins():
79155
"""Register subcommands via the ``pyodide.cli`` entry-point"""
80156
eps = entry_points(group="pyodide.cli")
81157
plugins = {ep.name: (ep.load(), ep) for ep in eps}
158+
82159
for plugin_name, (module, ep) in plugins.items():
83160
pkgname = _entrypoint_to_pkgname(ep)
84-
origin_text = f"Registered by: {pkgname}"
161+
origin_text = f"Registered by {pkgname}:"
85162

86163
if isinstance(module, typer.Typer):
87164
typer_info = TyperInfo(module)
88165
help_with_origin = _inject_origin(
89166
solve_typer_info_help(typer_info), origin_text
90167
)
91-
app.add_typer(
92-
module,
93-
name=plugin_name,
94-
rich_help_panel=origin_text,
95-
help=help_with_origin,
168+
else:
169+
help_with_origin = _inject_origin(
170+
getattr(module, "__doc__", ""), origin_text
96171
)
172+
173+
if isinstance(module, click.Group):
174+
cmd = module
175+
elif isinstance(module, typer.Typer):
176+
cmd = typer.main.get_command(module)
97177
elif callable(module):
98178
typer_kwargs = getattr(module, "typer_kwargs", {})
99-
help_with_origin = _inject_origin(module.__doc__, origin_text)
179+
app = typer.Typer()
100180
app.command(
101181
plugin_name,
102-
rich_help_panel=origin_text,
103182
help=help_with_origin,
104183
**typer_kwargs,
105184
)(module)
185+
cmd = typer.main.get_command(app)
106186
else:
107187
raise RuntimeError(f"Invalid plugin: {plugin_name}")
108188

189+
# directly manipulate click Command help message
190+
cmd.help = help_with_origin
191+
192+
cli.add_command(cmd, name=plugin_name, origin=pkgname)
193+
109194

110195
def main():
111196
register_plugins()
112-
app()
197+
cli()
113198

114199

115200
if "sphinx" in sys.modules and __name__ != "__main__":
116201
# Create the typer click object to generate docs with sphinx-click
117202
register_plugins()
118-
typer_click_object = typer.main.get_command(app)
203+
typer_click_object = cli
119204

120205
if __name__ == "__main__":
121206
main()
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import click # type: ignore[import]
2+
3+
4+
@click.group(invoke_without_command=True)
5+
@click.pass_context
6+
def cli(ctx: click.Context) -> None:
7+
"""
8+
Test help message short desc
9+
10+
Test help message long desc
11+
"""
12+
pass
13+
if ctx.invoked_subcommand is None:
14+
click.echo(ctx.get_help())
15+
16+
17+
@cli.command()
18+
@click.argument("name", required=True)
19+
def hello(name: str) -> None:
20+
"""
21+
Test help message short desc
22+
23+
Test help message long desc
24+
"""
25+
click.echo(f"Hello {name}")

pyodide_cli/tests/plugin-test/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ authors = []
1010
[project.entry-points."pyodide.cli"]
1111
plugin_test_func = "plugin_test.main:main"
1212
plugin_test_app = "plugin_test.app:app"
13+
plugin_test_cli = "plugin_test.cli:cli"

pyodide_cli/tests/test_cli.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,14 +67,16 @@ def func(q: Queue, with_sphinx=False):
6767

6868
def test_plugin_origin(plugins):
6969
output = check_output(["pyodide", "--help"]).decode("utf-8")
70-
msg = "Registered by: plugin-test"
70+
msg = "Registered by plugin-test:"
7171

7272
assert msg in output
7373

7474

75-
@pytest.mark.parametrize("entrypoint", ["plugin_test_app", "plugin_test_func"])
75+
@pytest.mark.parametrize(
76+
"entrypoint", ["plugin_test_app", "plugin_test_func", "plugin_test_cli"]
77+
)
7678
def test_plugin_origin_subcommand(plugins, entrypoint):
7779
output = check_output(["pyodide", entrypoint, "--help"]).decode("utf-8")
78-
msg = "Registered by: plugin-test"
80+
msg = "Registered by plugin-test:"
7981

8082
assert msg in output

0 commit comments

Comments
 (0)