|
1 | 1 | import sys |
| 2 | +from collections import defaultdict |
2 | 3 | from functools import cache |
3 | 4 | from importlib.metadata import Distribution, EntryPoint |
4 | 5 | from importlib.metadata import distribution as importlib_distribution |
5 | 6 | from importlib.metadata import entry_points |
| 7 | +from typing import override |
6 | 8 |
|
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 |
9 | 13 |
|
10 | 14 | from . import __version__ |
11 | 15 |
|
12 | | -app = typer.Typer( |
13 | | - add_completion=False, |
14 | | - rich_markup_mode="markdown", |
15 | | - pretty_exceptions_show_locals=False, |
16 | | -) |
17 | 16 |
|
| 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() |
18 | 80 |
|
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: |
21 | 96 | return |
22 | 97 |
|
23 | | - typer.echo(f"pyodide CLI version: {__version__}") |
| 98 | + click.echo(f"pyodide CLI version: {__version__}") |
24 | 99 |
|
25 | 100 | eps = entry_points(group="pyodide.cli") |
26 | 101 | # filter out duplicate pkgs |
27 | 102 | pkgs = {_entrypoint_to_pkgname(ep): _entrypoint_to_version(ep) for ep in eps} |
28 | 103 | 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): |
45 | 120 | """A command line interface for Pyodide. |
46 | 121 |
|
47 | 122 | Other CLI subcommands are registered via the plugin system by installing |
48 | 123 | Pyodide ecosystem packages (e.g. pyodide-build, pyodide-pack, |
49 | 124 | auditwheel-emscripten, etc.) |
50 | 125 | """ |
51 | | - pass |
| 126 | + if ctx.invoked_subcommand is None: |
| 127 | + click.echo(ctx.get_help()) |
52 | 128 |
|
53 | 129 |
|
54 | 130 | @cache |
@@ -79,43 +155,52 @@ def register_plugins(): |
79 | 155 | """Register subcommands via the ``pyodide.cli`` entry-point""" |
80 | 156 | eps = entry_points(group="pyodide.cli") |
81 | 157 | plugins = {ep.name: (ep.load(), ep) for ep in eps} |
| 158 | + |
82 | 159 | for plugin_name, (module, ep) in plugins.items(): |
83 | 160 | pkgname = _entrypoint_to_pkgname(ep) |
84 | | - origin_text = f"Registered by: {pkgname}" |
| 161 | + origin_text = f"Registered by {pkgname}:" |
85 | 162 |
|
86 | 163 | if isinstance(module, typer.Typer): |
87 | 164 | typer_info = TyperInfo(module) |
88 | 165 | help_with_origin = _inject_origin( |
89 | 166 | solve_typer_info_help(typer_info), origin_text |
90 | 167 | ) |
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 |
96 | 171 | ) |
| 172 | + |
| 173 | + if isinstance(module, click.Group): |
| 174 | + cmd = module |
| 175 | + elif isinstance(module, typer.Typer): |
| 176 | + cmd = typer.main.get_command(module) |
97 | 177 | elif callable(module): |
98 | 178 | typer_kwargs = getattr(module, "typer_kwargs", {}) |
99 | | - help_with_origin = _inject_origin(module.__doc__, origin_text) |
| 179 | + app = typer.Typer() |
100 | 180 | app.command( |
101 | 181 | plugin_name, |
102 | | - rich_help_panel=origin_text, |
103 | 182 | help=help_with_origin, |
104 | 183 | **typer_kwargs, |
105 | 184 | )(module) |
| 185 | + cmd = typer.main.get_command(app) |
106 | 186 | else: |
107 | 187 | raise RuntimeError(f"Invalid plugin: {plugin_name}") |
108 | 188 |
|
| 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 | + |
109 | 194 |
|
110 | 195 | def main(): |
111 | 196 | register_plugins() |
112 | | - app() |
| 197 | + cli() |
113 | 198 |
|
114 | 199 |
|
115 | 200 | if "sphinx" in sys.modules and __name__ != "__main__": |
116 | 201 | # Create the typer click object to generate docs with sphinx-click |
117 | 202 | register_plugins() |
118 | | - typer_click_object = typer.main.get_command(app) |
| 203 | + typer_click_object = cli |
119 | 204 |
|
120 | 205 | if __name__ == "__main__": |
121 | 206 | main() |
0 commit comments