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

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.""" 

11 

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 

28 

29 

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) 

37 

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) 

48 

49 

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 

62 

63 

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. 

69 

70 For the definition of param ids see check_exclusive_params(). 

71 

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) 

79 

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 

97 

98 

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 

120 

121 

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 

134 

135 

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. 

142 

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 ) 

157 

158 

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. 

165 

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 ) 

188 

189 

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. 

196 

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 ) 

209 

210 

211class ApioOption(click.Option): 

212 """Custom class for apio click options. Currently it adds handling 

213 of deprecated options. 

214 """ 

215 

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("-")] 

219 

220 # Pass the rest to the base class. 

221 super().__init__(*args, **kwargs) 

222 

223 

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.""" 

228 

229 title: str 

230 commands: List[click.Command] 

231 

232 

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.""" 

238 

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 

244 

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") 

249 

250 

251class ApioGroup(click.Group): 

252 """A customized click.Group class that allows apio customized help 

253 format.""" 

254 

255 def __init__(self, *args, **kwargs): 

256 

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) 

261 

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 

266 

267 # -- Pass the rest of the arg to init the base class. 

268 super().__init__(*args, **kwargs) 

269 

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) 

275 

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) 

283 

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) 

291 

292 # -- Call the grandparent method which formats the options without 

293 # -- the subcommands. 

294 click.Command.format_options(self, ctx, formatter) 

295 

296 # -- Format the subcommands, grouped by the apio defined subgroups 

297 # -- in self._subgroups. 

298 formatter.write("\n") 

299 

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 ] 

306 

307 # -- Find the length of the longest name. 

308 max_name_len = max(len(name) for name in cmd_names) 

309 

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") 

324 

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'. 

331 

332 Returns the Command or Group (a subclass of Command) of the matching 

333 sub command or None if not match. 

334 """ 

335 

336 assert isinstance(ctx, ApioCmdContext) 

337 

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 

343 

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 

358 

359 

360class ApioCommand(click.Command): 

361 """A customized click.Command class that allows apio customized help 

362 format and proper handling of command shortcuts.""" 

363 

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) 

371 

372 

373class ApioCmdContext(click.Context): 

374 """A custom click.Context class.""" 

375 

376 def __init__(self, *args, **kwargs): 

377 super().__init__(*args, **kwargs) 

378 

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 

383 

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() 

388 

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() 

393 

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 ""