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
« prev ^ index » next coverage.py v6.5.0, created at 2023-02-09 12:09 +0100
1from __future__ import annotations
3import inspect
4from typing import Any
5from typing import Type
7import click
8import typer
9from pydantic import BaseModel
11from ..models import CommandSummary
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)
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 ""
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 = []
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)
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)))
66 return sorted(cmds, key=lambda x: x.name)
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]
73 if not app.registered_callback:
74 return options
76 callback = app.registered_callback.callback
78 if not callback:
79 return options
80 if not hasattr(callback, "__defaults__") or not callback.__defaults__:
81 return options
83 for option in callback.__defaults__:
84 options.append(option)
85 return options
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.
95 Examples
96 -------
97 ```python
98 class MyModel(BaseModel):
99 my_field: str = Field(..., description="Description of my_field")
101 @app.command(name="my-command")
102 @inject_help(MyModel)
103 def my_command(my_field: str = typer.Option(...)):
104 ...
106 # `my-app my-command --help`
107 # my_field's help text will be "Description of my_field"
108 ```
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.
117 e.g. `@inject_help(MyModel, my_field="Additional help text that is appended to the field's description.")`
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 """
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
154 return decorator
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?
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.
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.
186 NOTE: needs to be specified BEFORE @app.command() in order to work!
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).
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`
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 ...
203 # OK
204 @app.command()
205 @inject_resource_options()
206 def my_command(query: str, sort: str):
207 ...
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 ...
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 ...
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):
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 ```
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.
245 Example:
246 @inject_resource_options(use_defaults=True)
247 my_func(page_size: int = typer.Option(20)) -> None: ...
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!
254 @inject_resource_options(use_default=True)
255 my_func(page_size: int = 20) -> None: ... # will fail (for now)
257 Returns
258 -------
259 Any
260 The decorated function
261 """
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!
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
276 # Support using plain @inject_resource_options or @inject_resource_options()
277 if callable(f):
278 return decorator(f)
279 else:
280 return decorator
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)
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
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)
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
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)
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
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)
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
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)
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
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)
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
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 # )
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 # )
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
405 new_params[name] = to_replace.replace(default=value)
406 new_sig = sig.replace(parameters=list(new_params.values()))
407 func.__signature__ = new_sig
409 return func