Coverage for harbor_cli/utils/commands.py: 81%

133 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2023-02-09 12:09 +0100

1from __future__ import annotations 

2 

3import inspect 

4from typing import Any 

5from typing import Type 

6 

7import click 

8import typer 

9from pydantic import BaseModel 

10 

11from ..models import CommandSummary 

12 

13 

14def get_parent_ctx( 

15 ctx: typer.Context | click.core.Context, 

16) -> typer.Context | click.core.Context: 

17 """Get the top-level parent context of a context.""" 

18 if ctx.parent is None: 

19 return ctx 

20 return get_parent_ctx(ctx.parent) 

21 

22 

23def get_command_help(command: typer.models.CommandInfo) -> str: 

24 """Get the help text of a command.""" 

25 if command.help: 

26 return command.help 

27 if command.callback and command.callback.__doc__: 

28 return command.callback.__doc__.strip().splitlines()[0] 

29 return "" 

30 

31 

32# TODO: apparently we can just refactor this to use typer.Context.to_info_dict()? 

33# We have to construct a new context to get the help text though 

34def get_app_commands( 

35 app: typer.Typer, cmds: list[CommandSummary] | None = None, current: str = "" 

36) -> list[CommandSummary]: 

37 if cmds is None: 

38 cmds = [] 

39 

40 # If we have subcommands, we need to go deeper. 

41 for group in app.registered_groups: 

42 if not group.typer_instance: 42 ↛ 43line 42 didn't jump to line 43, because the condition on line 42 was never true

43 continue 

44 t = group.typer_instance 

45 if current == "": 

46 # Prioritize direct name of typer instance over group name (?) 

47 new_current = t.info.name or group.name 

48 if isinstance(new_current, typer.models.DefaultPlaceholder): 48 ↛ 49line 48 didn't jump to line 49, because the condition on line 48 was never true

49 new_current = new_current.value 

50 new_current = new_current or "" # guarantee not None 

51 else: 

52 new_current = f"{current} {t.info.name or ''}" 

53 get_app_commands(t, cmds, current=new_current) 

54 

55 # When we have commands, we don't need to go deeper and are done. 

56 # We can now construct the CommandSummary objects. 

57 for command in app.registered_commands: 

58 if not command.name: 

59 continue 

60 if current: 

61 name = f"{current} {command.name}" 

62 else: 

63 name = command.name 

64 cmds.append(CommandSummary(name=name, help=get_command_help(command))) 

65 

66 return sorted(cmds, key=lambda x: x.name) 

67 

68 

69def get_app_callback_options(app: typer.Typer) -> list[typer.models.OptionInfo]: 

70 """Get the options of the main callback of a Typer app.""" 

71 options = [] # type: list[typer.models.OptionInfo] 

72 

73 if not app.registered_callback: 

74 return options 

75 

76 callback = app.registered_callback.callback 

77 

78 if not callback: 

79 return options 

80 if not hasattr(callback, "__defaults__") or not callback.__defaults__: 

81 return options 

82 

83 for option in callback.__defaults__: 

84 options.append(option) 

85 return options 

86 

87 

88def inject_help( 

89 model: Type[BaseModel], strict: bool = False, **field_additions: str 

90) -> Any: 

91 """ 

92 Injects a Pydantic model's field descriptions into the help attributes 

93 of Typer.Option() function parameters whose names match the field names. 

94 

95 Examples 

96 ------- 

97 ```python 

98 class MyModel(BaseModel): 

99 my_field: str = Field(..., description="Description of my_field") 

100 

101 @app.command(name="my-command") 

102 @inject_help(MyModel) 

103 def my_command(my_field: str = typer.Option(...)): 

104 ... 

105 

106 # `my-app my-command --help` 

107 # my_field's help text will be "Description of my_field" 

108 ``` 

109 

110 NOTE 

111 ---- 

112 Does not modify the help text of options with existing help text! 

113 Use the `**field_additions` parameter to add additional help text to a field 

114 in addition to the field's description. This text is appended to the 

115 help text, separated by a space. 

116 

117 e.g. `@inject_help(MyModel, my_field="Additional help text that is appended to the field's description.")` 

118 

119 Parameters 

120 ---------- 

121 model : Type[BaseModel] 

122 The pydantic model to use for help injection. 

123 strict : bool 

124 If True, fail if a field in the model does not have a matching typer 

125 option, by default False 

126 **field_additions 

127 Additional help text to add to the help attribute of a field. 

128 The parameter name should be the name of the field, and the value 

129 should be the additional help text to add. This is useful when 

130 the field's description is not sufficient, and you want to add 

131 additional help text to supplement the existing description. 

132 """ 

133 

134 def decorator(func: Any) -> Any: 

135 sig = inspect.signature(func) 

136 for field_name, field in model.__fields__.items(): 

137 # only overwrite help if not already set 

138 param = sig.parameters.get(field_name, None) 

139 if not param: 

140 if strict: 140 ↛ 141line 140 didn't jump to line 141, because the condition on line 140 was never true

141 raise ValueError( 

142 f"Field {field_name!r} not found in function signature of {func.__qualname__!r}." 

143 ) 

144 continue 

145 if not hasattr(param, "default") or not hasattr(param.default, "help"): 

146 continue 

147 if not param.default.help: 

148 addition = field_additions.get(field_name, "") 

149 if addition: 149 ↛ 150line 149 didn't jump to line 150, because the condition on line 149 was never true

150 addition = f" {addition}" # add leading space 

151 param.default.help = f"{field.field_info.description or ''}{addition}" 

152 return func 

153 

154 return decorator 

155 

156 

157# NOTE: This injection seems too complicated...? Could maybe just create default 

158# typer.Option() instances for each field in the model and use them as defaults? 

159 

160# '--sort' and '-query' are two parameters that are used in many commands 

161# in order to not have to write out the same code over and over again, 

162# we can use these decorators to inject the parameters (and their accompanying help text) 

163# into a function, given that the function has a parameter with the same name, 

164# (e.g. 'query', 'sort', etc.) 

165# 

166# NOTE: we COULD technically inject the parameter even if the function doesn't 

167# already have it, but that is too magical, and does not play well with 

168# static analysis tools like mypy. 

169# 

170# Fundamentally, we don't want to change the function signature, only set the 

171# default value of the parameter to a typer.Option() instance. 

172# This lets Typer pick it up and use it to display help text and create the 

173# correct commandline option (--query, --sort, etc.) 

174# 

175# Unlike most decorators, the function is not wrapped, but rather its 

176# signature is modified in-place, and then the function is returned. 

177 

178 

179def inject_resource_options( 

180 f: Any = None, *, strict: bool = False, use_defaults: bool = True 

181) -> Any: 

182 """Decorator that calls inject_query, inject_sort, inject_page_size, 

183 inject_page and inject_limit to inject typer.Option() defaults 

184 for common options used when querying multiple resources. 

185 

186 NOTE: needs to be specified BEFORE @app.command() in order to work! 

187 

188 Not strict by default, so that it can be used on functions that only 

189 have a subset of the parameters (e.g. only query and sort). 

190 

191 The decorated function should always declare the parameters in the following order 

192 if the parameters don't have defaults: 

193 `query`, `sort`, `page`, `page_size`, `limit` 

194 

195 Examples 

196 ------- 

197 ```python 

198 @app.command() 

199 @inject_resource_options() 

200 def my_command(query: str, sort: str, page: int, page_size: int, limit: Optional[int]): 

201 ... 

202 

203 # OK 

204 @app.command() 

205 @inject_resource_options() 

206 def my_command(query: str, sort: str): 

207 ... 

208 

209 # NOT OK (missing all required parameters) 

210 @app.command() 

211 @inject_resource_options(strict=True) 

212 def my_command(query: str, sort: str): 

213 ... 

214 

215 # OK (inherits defaults) 

216 @app.command() 

217 @inject_resource_options() 

218 def my_command(query: str, sort: str, page: int = typer.Option(1)): 

219 ... 

220 

221 # NOT OK (syntax error [non-default param after param with default]) 

222 # Use ellipsis to specify unset defaults 

223 @app.command() 

224 @inject_resource_options() 

225 def my_command(query: str = typer.Option("tag=latest"), sort: str, page: int): 

226 

227 # OK (inherit default query, but override others) 

228 # Use ellipsis to specify unset defaults 

229 @app.command() 

230 @inject_resource_options() 

231 def my_command(query: str = typer.Option("my-query"), sort: str = ..., page: int = ...): 

232 ``` 

233 

234 Parameters 

235 ---------- 

236 f : Any, optional 

237 The function to decorate, by default None 

238 strict : bool, optional 

239 If True, fail if function is missing any of the injected parameters, by default False 

240 E.g. all of `query`, `sort`, `page`, `page_size`, `limit` must be present 

241 use_defaults : bool, optional 

242 If True, use the default value specified by a parameter's typer.Option() field 

243 as the default value for the parameter, by default True. 

244 

245 Example: 

246 @inject_resource_options(use_defaults=True) 

247 my_func(page_size: int = typer.Option(20)) -> None: ... 

248 

249 If use_defaults is True, the default value of page_size will be 20, 

250 instead of 10, which is the value inject_page_size() would use by default. 

251 NOTE: Only accepts defaults specified via typer.Option() and 

252 typer.Argument() instances! 

253 

254 @inject_resource_options(use_default=True) 

255 my_func(page_size: int = 20) -> None: ... # will fail (for now) 

256 

257 Returns 

258 ------- 

259 Any 

260 The decorated function 

261 """ 

262 

263 # TODO: add check that the function signature is in the correct order 

264 # so we don't raise a cryptic error message later on! 

265 

266 def decorator(func: Any) -> Any: 

267 # Inject in reverse order, because parameters with defaults 

268 # can't be followed by parameters without defaults 

269 func = inject_limit(func, strict=strict, use_default=use_defaults) 

270 func = inject_page_size(func, strict=strict, use_default=use_defaults) 

271 func = inject_page(func, strict=strict, use_default=use_defaults) 

272 func = inject_sort(func, strict=strict, use_default=use_defaults) 

273 func = inject_query(func, strict=strict, use_default=use_defaults) 

274 return func 

275 

276 # Support using plain @inject_resource_options or @inject_resource_options() 

277 if callable(f): 

278 return decorator(f) 

279 else: 

280 return decorator 

281 

282 

283def inject_query( 

284 f: Any = None, *, strict: bool = False, use_default: bool = True 

285) -> Any: 

286 def decorator(func: Any) -> Any: 

287 option = typer.Option( 

288 None, "--query", help="Query parameters to filter the results." 

289 ) 

290 return _patch_param(func, "query", option, strict, use_default) 

291 

292 # Support using plain @inject_query or @inject_query() 

293 if callable(f): 293 ↛ 296line 293 didn't jump to line 296, because the condition on line 293 was never false

294 return decorator(f) 

295 else: 

296 return decorator 

297 

298 

299def inject_sort( 

300 f: Any = None, *, strict: bool = False, use_default: bool = True 

301) -> Any: 

302 def decorator(func: Any) -> Any: 

303 option = typer.Option( 

304 None, 

305 "--sort", 

306 help="Sorting order of the results. Example: [green]'name,-id'[/] to sort by name ascending and id descending.", 

307 ) 

308 return _patch_param(func, "sort", option, strict, use_default) 

309 

310 # Support using plain @inject_sort or @inject_sort() 

311 if callable(f): 311 ↛ 314line 311 didn't jump to line 314, because the condition on line 311 was never false

312 return decorator(f) 

313 else: 

314 return decorator 

315 

316 

317def inject_page_size( 

318 f: Any = None, *, strict: bool = False, use_default: bool = True 

319) -> Any: 

320 def decorator(func: Any) -> Any: 

321 option = typer.Option( 

322 10, 

323 "--page-size", 

324 help="(Advanced) Number of results to fetch per API call.", 

325 ) 

326 return _patch_param(func, "page_size", option, strict, use_default) 

327 

328 # Support using plain @inject_page_size or @inject_page_size() 

329 if callable(f): 329 ↛ 332line 329 didn't jump to line 332, because the condition on line 329 was never false

330 return decorator(f) 

331 else: 

332 return decorator 

333 

334 

335def inject_page( 

336 f: Any = None, *, strict: bool = False, use_default: bool = True 

337) -> Any: 

338 def decorator(func: Any) -> Any: 

339 option = typer.Option( 

340 1, "--page", help="(Advanced) Page to begin fetching from." 

341 ) 

342 return _patch_param(func, "page", option, strict, use_default) 

343 

344 # Support using plain @inject_page or @inject_page() 

345 if callable(f): 345 ↛ 348line 345 didn't jump to line 348, because the condition on line 345 was never false

346 return decorator(f) 

347 else: 

348 return decorator 

349 

350 

351def inject_limit( 

352 f: Any = None, *, strict: bool = False, use_default: bool = False 

353) -> Any: 

354 def decorator(func: Any) -> Any: 

355 option = typer.Option( 

356 None, 

357 "--limit", 

358 help="Maximum number of results to fetch.", 

359 ) 

360 return _patch_param(func, "limit", option, strict, use_default) 

361 

362 # Support using plain @inject_page or @inject_page() 

363 if callable(f): 363 ↛ 366line 363 didn't jump to line 366, because the condition on line 363 was never false

364 return decorator(f) 

365 else: 

366 return decorator 

367 

368 

369def _patch_param( 

370 func: Any, 

371 name: str, 

372 value: typer.models.OptionInfo, 

373 strict: bool, 

374 use_default: bool, 

375) -> Any: 

376 """Patches a function's parameter with the given name to have the given default value.""" 

377 sig = inspect.signature(func) 

378 new_params = sig.parameters.copy() # this copied object is mutable 

379 to_replace = new_params.get(name) 

380 

381 if not to_replace: 

382 if strict: 

383 raise ValueError( 

384 f"Field {name!r} not found in function signature of {func.__qualname__!r}." 

385 ) 

386 return func 

387 

388 # if not to_replace.annotation: 

389 # raise ValueError( 

390 # f"Parameter {name!r} in function {func.__qualname__!r} must have a type annotation." 

391 # ) 

392 

393 # if to_replace.annotation not in ["Optional[str]", "str | None", "None | str"]: 

394 # raise ValueError( 

395 # f"Parameter {name!r} in function {func.__qualname__!r} must be of type 'Optional[str]' or 'str | None'." 

396 # ) 

397 

398 # Use defaults from the injected parameter if they exist 

399 if use_default: 

400 if hasattr(to_replace.default, "default"): 

401 value.default = to_replace.default.default 

402 if hasattr(to_replace.default, "help") and to_replace.default.help: 

403 value.help = to_replace.default.help 

404 

405 new_params[name] = to_replace.replace(default=value) 

406 new_sig = sig.replace(parameters=list(new_params.values())) 

407 func.__signature__ = new_sig 

408 

409 return func