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

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 

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 

26 

27 

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) 

35 

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

45 

46 

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 

60 

61 

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. 

67 

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

69 

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) 

77 

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 

95 

96 

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 

118 

119 

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 

132 

133 

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. 

140 

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 ) 

155 

156 

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. 

163 

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 ) 

186 

187 

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. 

194 

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 ) 

207 

208 

209class ApioOption(click.Option): 

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

211 of deprecated options. 

212 """ 

213 

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

217 

218 # Pass the rest to the base class. 

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

220 

221 

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

226 

227 title: str 

228 commands: list[click.Command] 

229 

230 

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

236 

237 # -- Style the metadata text. 

238 styled_text = docs_text_to_str(rich_text.rstrip("\n"), end="") 

239 

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

244 

245 

246class ApioGroup(click.Group): 

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

248 format.""" 

249 

250 def __init__(self, *args, **kwargs) -> None: 

251 

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) 

256 

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 

261 

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

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

264 

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) 

270 

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) 

278 

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) 

286 

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

288 # -- the subcommands. 

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

290 

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

292 # -- in self._subgroups. 

293 formatter.write("\n") 

294 

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 ] 

301 

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

303 max_name_len = max(len(str(name)) for name in cmd_names) 

304 

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

319 

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

326 

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

328 sub command or None if not match. 

329 """ 

330 

331 assert isinstance(ctx, ApioCmdContext) 

332 

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 

340 

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 

355 

356 

357class ApioCommand(click.Command): 

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

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

360 

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) 

368 

369 

370class ApioCmdContext(click.Context): 

371 """A custom click.Context class.""" 

372 

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

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

375 

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 

380 

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

385 

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

390 

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