Coverage for apio/utils/cmd_util.py: 92%
138 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-23 03:53 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-23 03:53 +0000
1# -*- coding: utf-8 -*-
2# -- This file is part of the Apio project
3# -- (C) 2016-2018 FPGAwars
4# -- Author Jesús Arroyo
5# -- License GPLv2
6# -- Derived from:
7# ---- Platformio project
8# ---- (C) 2014-2016 Ivan Kravets <me@ikravets.com>
9# ---- License Apache v2
10"""Utility functionality for apio click commands."""
12from dataclasses import dataclass
13import click
14from click.formatting import HelpFormatter
15from apio.common import apio_console
16from apio.managers.profile import Profile
17from apio.common.apio_styles import CMD_NAME
18from apio.common.apio_console import (
19 # ConsoleCapture,
20 cout,
21 cstyle,
22 docs_text_to_str,
23 fatal_error,
24)
25from apio.utils import util
28def fatal_usage_error(cmd_ctx: click.Context, msg: str) -> None:
29 """Prints a an error message and command help hint, and exists the program
30 with an error status.
31 cmd_ctx: The context that was passed to the command.
32 msg: A single line short error message.
33 """
34 assert isinstance(cmd_ctx, ApioCmdContext)
36 # Mimicking the usage error message from click/exceptions.py.
37 # E.g. "Try 'apio packages -h' for help."
38 cout(cmd_ctx.get_usage())
39 cout(
40 f"Try '{cmd_ctx.command_path} {cmd_ctx.help_option_names[0]}' "
41 "for help."
42 )
43 cout("")
44 fatal_error(f"{msg}")
47def _get_all_params_definitions(
48 cmd_ctx: click.Context,
49) -> dict[str, click.Option | click.Argument]:
50 """Return a mapping from param id to param obj, for all options and
51 arguments that are defined for the command."""
52 result: dict[str, click.Option | click.Argument] = {}
53 for param_obj in cmd_ctx.command.get_params(cmd_ctx):
54 assert isinstance(param_obj, (click.Option, click.Argument)), type(
55 param_obj
56 )
57 assert param_obj.name is not None
58 result[param_obj.name] = param_obj
59 return result
62def _params_ids_to_aliases(
63 cmd_ctx: click.Context, params_ids: list[str]
64) -> list[str]:
65 """Maps param ids to their respective user facing canonical aliases.
66 The order of the params is in the input list is preserved.
68 For the definition of param ids see check_exclusive_params().
70 The canonical alias of an option is it's longest alias,
71 for example "--dir" for the option ["-d", "--dir"]. The canonical
72 alias of an argument is the argument name as shown in the command's help,
73 e.g. "PACKAGES" for the argument packages.
74 """
75 # Param id -> param obj.
76 params_dict = _get_all_params_definitions(cmd_ctx)
78 # Map the param ids to their canonical aliases.
79 result = []
80 for param_id in params_ids:
81 param_obj: click.Option | click.Argument = params_dict[param_id]
82 assert isinstance(param_obj, (click.Option, click.Argument)), type(
83 param_obj
84 )
85 if isinstance(param_obj, click.Option):
86 # For options we pick their longest alias
87 param_alias = max(param_obj.opts, key=len)
88 else:
89 # For arguments we pick its user facing name, e.g. "PACKAGES"
90 # for argument packages.
91 param_alias = param_obj.human_readable_name
92 assert param_obj is not None, param_id
93 result.append(param_alias)
94 return result
97def _is_param_specified(cmd_ctx, param_id) -> bool:
98 """Determine if the param with given id was specified in the
99 command line."""
100 # Mapping: param id -> param obj.
101 params_dict = _get_all_params_definitions(cmd_ctx)
102 # If this fails, look for spelling error in the param name string in
103 # the apio command cli function.
104 assert param_id in params_dict, f"Unknown command param_id [{param_id}]."
105 # Get the official status.
106 param_src = cmd_ctx.get_parameter_source(param_id)
107 is_specified = param_src == click.core.ParameterSource.COMMANDLINE
108 # A special case for repeating arguments. Click considers the
109 # empty tuple value to come with the command line but we consider
110 # it to come from the default.
111 is_arg = isinstance(params_dict[param_id], click.Argument)
112 if is_specified and is_arg:
113 arg_value = cmd_ctx.params[param_id]
114 if arg_value == tuple():
115 is_specified = False
116 # All done
117 return is_specified
120def _specified_params(
121 cmd_ctx: click.Context, param_ids: list[str]
122) -> list[str]:
123 """Returns the subset of param ids that were used in the command line.
124 The original order of the list is preserved.
125 For definition of params and param ids see check_exclusive_params().
126 """
127 result = []
128 for param_id in param_ids:
129 if _is_param_specified(cmd_ctx, param_id):
130 result.append(param_id)
131 return result
134def check_at_most_one_param(
135 cmd_ctx: click.Context, param_ids: list[str]
136) -> None:
137 """Checks that at most one of given params were specified in
138 the command line. If more than one param was specified, exits the
139 program with a message and error status.
141 Param ids are names click options and arguments variables that are passed
142 to a command.
143 """
144 # The the subset of ids of params that where used in the command.
145 specified_param_ids = _specified_params(cmd_ctx, param_ids)
146 # If more 2 or more print an error and exit.
147 if len(specified_param_ids) >= 2:
148 canonical_aliases = _params_ids_to_aliases(
149 cmd_ctx, specified_param_ids
150 )
151 aliases_str = util.list_plurality(canonical_aliases, "and")
152 fatal_usage_error(
153 cmd_ctx, f"{aliases_str} cannot be combined together."
154 )
157def check_exactly_one_param(
158 cmd_ctx: click.Context, param_ids: list[str]
159) -> None:
160 """Checks that at exactly one of given params is specified in
161 the command line. If more or less than one params is specified, exits the
162 program with a message and error status.
164 Param ids are names click options and arguments variables that are passed
165 to a command.
166 """
167 # The the subset of ids of params that where used in the command.
168 specified_param_ids = _specified_params(cmd_ctx, param_ids)
169 # If exactly one than we are good.
170 if len(specified_param_ids) == 1:
171 return
172 if len(specified_param_ids) < 1:
173 # -- User specified Less flags than required.
174 canonical_aliases = _params_ids_to_aliases(cmd_ctx, param_ids)
175 aliases_str = util.list_plurality(canonical_aliases, "or")
176 fatal_usage_error(cmd_ctx, f"specify one of {aliases_str}.")
177 else:
178 # -- User specified more flags than allowed.
179 canonical_aliases = _params_ids_to_aliases(
180 cmd_ctx, specified_param_ids
181 )
182 aliases_str = util.list_plurality(canonical_aliases, "and")
183 fatal_usage_error(
184 cmd_ctx, f"{aliases_str} cannot be combined together."
185 )
188def check_at_least_one_param(
189 cmd_ctx: click.Context, param_ids: list[str]
190) -> None:
191 """Checks that at least one of given params is specified in
192 the command line. If none of the params is specified, exits the
193 program with a message and error status.
195 Param ids are names click options and arguments variables that are passed
196 to a command.
197 """
198 # The the subset of ids of params that where used in the command.
199 specified_param_ids = _specified_params(cmd_ctx, param_ids)
200 # If more 2 or more print an error and exit.
201 if len(specified_param_ids) < 1:
202 canonical_aliases = _params_ids_to_aliases(cmd_ctx, param_ids)
203 aliases_str = util.list_plurality(canonical_aliases, "or")
204 fatal_usage_error(
205 cmd_ctx, f"at least one of {aliases_str} must be specified."
206 )
209class ApioOption(click.Option):
210 """Custom class for apio click options. Currently it adds handling
211 of deprecated options.
212 """
214 def __init__(self, *args, **kwargs):
215 # Cache a list of option's aliases. E.g. ["-t", "--top-model"].
216 self.aliases = [k for k in args[0] if k.startswith("-")]
218 # Pass the rest to the base class.
219 super().__init__(*args, **kwargs)
222@dataclass(frozen=True)
223class ApioSubgroup:
224 """A class to represent a named group of subcommands. An apio command
225 of type group, contains two or more subcommand in one or more subgroups."""
227 title: str
228 commands: list[click.Command]
231def _format_apio_rich_text_help_text(
232 rich_text: str, formatter: HelpFormatter
233) -> None:
234 """Format command's or group's help rich text into a given
235 click formatter."""
237 # -- Style the metadata text.
238 styled_text = docs_text_to_str(rich_text.rstrip("\n"), end="")
240 # -- Raw write to the output, with indent.
241 lines = styled_text.split("\n")
242 for line in lines:
243 formatter.write((" " + line).rstrip(" ") + "\n")
246class ApioGroup(click.Group):
247 """A customized click.Group class that allows apio customized help
248 format."""
250 def __init__(self, *args, **kwargs) -> None:
252 # -- Consume the 'subgroups' arg.
253 self.subgroups: list[ApioSubgroup] = kwargs.pop("subgroups")
254 assert isinstance(self.subgroups, list)
255 assert isinstance(self.subgroups[0], ApioSubgroup)
257 # -- Override the static variable of the Command class to point
258 # -- to our custom ApioCmdContext. This causes the command to use
259 # -- contexts of type ApioCmdContext instead of click.Context.
260 click.Command.context_class = ApioCmdContext
262 # -- Pass the rest of the arg to init the base class.
263 super().__init__(*args, **kwargs)
265 # -- Register the commands of the subgroups as subcommands of this
266 # -- group.
267 for subgroup in self.subgroups:
268 for cmd in subgroup.commands:
269 self.add_command(cmd=cmd, name=cmd.name)
271 # @override
272 def format_help_text(
273 self, ctx: click.Context, formatter: HelpFormatter
274 ) -> None:
275 """Overrides the parent method that formats the command's help text."""
276 assert isinstance(ctx, ApioCmdContext)
277 _format_apio_rich_text_help_text(str(self.help), formatter)
279 # @override
280 def format_options(
281 self, ctx: click.Context, formatter: HelpFormatter
282 ) -> None:
283 """Overrides the parent method which formats the options and sub
284 commands."""
285 assert isinstance(ctx, ApioCmdContext)
287 # -- Call the grandparent method which formats the options without
288 # -- the subcommands.
289 click.Command.format_options(self, ctx, formatter)
291 # -- Format the subcommands, grouped by the apio defined subgroups
292 # -- in self._subgroups.
293 formatter.write("\n")
295 # -- Get a flat list of all subcommand names.
296 cmd_names = [
297 cmd.name
298 for subgroup in self.subgroups
299 for cmd in subgroup.commands
300 ]
302 # -- Find the length of the longest name.
303 max_name_len = max(len(str(name)) for name in cmd_names)
305 # -- Generate the subcommands short help, grouped by subgroup.
306 for subgroup in self.subgroups:
307 assert isinstance(subgroup, ApioSubgroup), subgroup
308 formatter.write(f"{subgroup.title}:\n")
309 # -- Print the commands that are in this subgroup.
310 for cmd in subgroup.commands:
311 # -- We pad for field width and then apply color.
312 styled_name = cstyle(
313 f"{cmd.name:{max_name_len}}", style=CMD_NAME
314 )
315 formatter.write(
316 f" {ctx.command_path} {styled_name} {cmd.short_help}\n"
317 )
318 formatter.write("\n")
320 # @override
321 def get_command(self, ctx, cmd_name) -> click.Command | None:
322 """Overrides the method that matches a token in the command line to
323 a sub-command. This alternative implementation allows to specify also
324 a prefix of the command name, as long as it matches exactly one
325 sub command. For example 'pref' or 'p' for 'preferences'.
327 Returns the Command or Group (a subclass of Command) of the matching
328 sub command or None if not match.
329 """
331 assert isinstance(ctx, ApioCmdContext)
333 # -- First priority is for exact match. For this we use the click
334 # -- default implementation from the parent class.
335 cmd: click.Command | None = click.Group.get_command(
336 self, ctx, cmd_name
337 )
338 if cmd is not None:
339 return cmd
341 # -- Here when there was no exact match, we will try partial matches.
342 sub_cmds = self.list_commands(ctx)
343 matches = [x for x in sub_cmds if x.startswith(cmd_name)]
344 # -- Handle no matches.
345 if not matches:
346 return None
347 # -- Handle multiple matches.
348 if len(matches) > 1:
349 ctx.fail(f"Command prefix '{cmd_name}' is ambagious: {matches}.")
350 # cout(f"Command '{cmd_name}' is ambagious: {matches}", style=INFO)
351 return None
352 # -- Here when exact match. We are good.
353 cmd = click.Group.get_command(self, ctx, matches[0])
354 return cmd
357class ApioCommand(click.Command):
358 """A customized click.Command class that allows apio customized help
359 format and proper handling of command shortcuts."""
361 # @override
362 def format_help_text(
363 self, ctx: click.Context, formatter: HelpFormatter
364 ) -> None:
365 """Overrides the parent method that formats the command's help text."""
366 assert isinstance(ctx, ApioCmdContext), type(ctx)
367 _format_apio_rich_text_help_text(str(self.help), formatter)
370class ApioCmdContext(click.Context):
371 """A custom click.Context class."""
373 def __init__(self, *args, **kwargs):
374 super().__init__(*args, **kwargs)
376 # -- Replace the potentially partial command name the user specified
377 # -- with the full command name. This will cause usage messages to
378 # -- include the full command names.
379 self.info_name = self.command.name
381 # -- If this the top command context, apply user color preferences
382 # -- to the apio console.
383 if self.parent is None:
384 Profile.apply_color_preferences()
386 # -- Synchronize the click color output setting to the apio console
387 # -- setting. The self.color flag affects output of help and
388 # -- usage text by click.
389 self.color = apio_console.is_terminal()
391 # @override
392 def get_help(self) -> str:
393 # IMPORTANT:
394 # This implementation behaves differently than the parent method
395 # it overrides.
396 #
397 # Instead of returning the help text, we print it using the rich
398 # library and exit and just return an empty string. This avoids
399 # the default printing using the click library which strips some
400 # colors on windows.
401 #
402 # The empty string we return is printed by click as an black line
403 # which adds a nice separation line. Otherwise we would pass None.
404 cout(self.command.get_help(self))
405 return ""