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