|
| 1 | +import typing as t |
| 2 | +import inspect |
| 3 | +from britive.britive import Britive |
| 4 | +import pkg_resources |
| 5 | + |
| 6 | + |
| 7 | +def get_dynamic_method_parameters(method): |
| 8 | + try: |
| 9 | + # create an instance of the Britive class, so we can inspect it |
| 10 | + # this doesn't need to actually connect to any tenant, and we couldn't even if we |
| 11 | + # wanted to since when performing shell completion we have no tenant/token |
| 12 | + # context in order to properly establish a connection. |
| 13 | + b = Britive(token='ignore', tenant='britive.com', query_features=False) |
| 14 | + |
| 15 | + # parse the method, so we can determine where in the "hierarchy" we are |
| 16 | + # and what commands/subcommands the user should be presented with |
| 17 | + for part in method.split('.'): |
| 18 | + b = getattr(b, part) |
| 19 | + |
| 20 | + params = {} |
| 21 | + spec = inspect.getfullargspec(b) |
| 22 | + |
| 23 | + # reformat parameters into a more consumable dict while holds all the required details |
| 24 | + helper = spec[6] |
| 25 | + helper.pop('return', None) |
| 26 | + for param, param_type in helper.items(): |
| 27 | + params[param] = { |
| 28 | + 'type': str(param_type).split("'")[1] |
| 29 | + } |
| 30 | + |
| 31 | + defaults = list(spec[3]) |
| 32 | + names = list(spec[0]) |
| 33 | + |
| 34 | + if len(defaults) > 0: |
| 35 | + for i in range(1, len(defaults) + 1): |
| 36 | + name = names[-1 * i] |
| 37 | + default = defaults[-1 * i] |
| 38 | + params[name]['default'] = default |
| 39 | + |
| 40 | + try: # we don't REALLY need the doc string so if there are errors just eat them and move on |
| 41 | + doc_lines = inspect.getdoc(b) |
| 42 | + doc_lines = doc_lines.replace(':returns:', 'RETURNSPLIT') |
| 43 | + doc_lines = doc_lines.replace(':return:', 'RETURNSPLIT') |
| 44 | + doc_lines = doc_lines.split('RETURNSPLIT')[0].split(':param ')[1:] |
| 45 | + |
| 46 | + for line in doc_lines: |
| 47 | + helper = line.split(':') |
| 48 | + name = helper[0].strip() |
| 49 | + help_text = ''.join(helper[1].strip().splitlines()).replace(' ', ' ') |
| 50 | + params[name]['help'] = help_text |
| 51 | + except: |
| 52 | + pass |
| 53 | + |
| 54 | + param_list = [] |
| 55 | + |
| 56 | + for name, values in params.items(): |
| 57 | + help_text = values.get('help') or '' |
| 58 | + |
| 59 | + if 'default' in values.keys(): # cannot do a .get('default') as the default value could be False/None/etc. |
| 60 | + preamble = f'[optional: default = {values["default"]}]' |
| 61 | + if help_text == '': |
| 62 | + help_text = preamble |
| 63 | + else: |
| 64 | + help_text = f'{preamble} - {help_text}' |
| 65 | + |
| 66 | + param = { |
| 67 | + 'flag': f'--{name.replace("_", "-")}', |
| 68 | + 'help': help_text |
| 69 | + } |
| 70 | + |
| 71 | + param_list.append(param) |
| 72 | + |
| 73 | + return param_list |
| 74 | + except Exception as e: |
| 75 | + return [] |
| 76 | + |
| 77 | + |
| 78 | +def command_api_patch_shell_complete(cls): |
| 79 | + # click < 8.0.0 does shell completion different... |
| 80 | + # not all the classes/decorators are available, so we cannot |
| 81 | + # create custom shell completions like we can with click > 8.0.0 |
| 82 | + major, minor, patch = pkg_resources.get_distribution('click').version.split('.')[0:3] |
| 83 | + |
| 84 | + # we cannot patch the shell_complete method because it does not exist (click 7.x doesn't have it) |
| 85 | + # future proofing this as well in case click 9.x changes things up a lot |
| 86 | + if int(major) != 8: |
| 87 | + return |
| 88 | + |
| 89 | + # we could potentially patch but there could be changes to shell_complete method which are not |
| 90 | + # accounted for in this patch - we will have to manually review any changes and ensure they are |
| 91 | + # backwards compatible. |
| 92 | + if int(minor) != 1: |
| 93 | + return |
| 94 | + |
| 95 | + from click.shell_completion import CompletionItem |
| 96 | + from click.core import ParameterSource |
| 97 | + from click import Context, Option |
| 98 | + |
| 99 | + # https://stackoverflow.com/questions/43778914/python3-using-super-in-eq-methods-raises-runtimeerror-super-class |
| 100 | + __class__ = cls # provide closure cell for super() |
| 101 | + |
| 102 | + def shell_complete(self, ctx: Context, incomplete: str) -> t.List["CompletionItem"]: |
| 103 | + from click.shell_completion import CompletionItem |
| 104 | + |
| 105 | + results: t.List["CompletionItem"] = [] |
| 106 | + |
| 107 | + if incomplete and not incomplete[0].isalnum(): |
| 108 | + method = ctx.params.get('method') |
| 109 | + if method: |
| 110 | + dynamic_params = get_dynamic_method_parameters(method) |
| 111 | + |
| 112 | + results.extend( |
| 113 | + CompletionItem(p['flag'], help=p['help']) |
| 114 | + for p in dynamic_params if p['flag'].startswith(incomplete) |
| 115 | + ) |
| 116 | + |
| 117 | + for param in self.get_params(ctx): |
| 118 | + if ( |
| 119 | + not isinstance(param, Option) |
| 120 | + or param.hidden |
| 121 | + or ( |
| 122 | + not param.multiple |
| 123 | + and ctx.get_parameter_source(param.name) # type: ignore |
| 124 | + is ParameterSource.COMMANDLINE |
| 125 | + ) |
| 126 | + ): |
| 127 | + continue |
| 128 | + |
| 129 | + results.extend( |
| 130 | + CompletionItem(name, help=param.help) |
| 131 | + for name in [*param.opts, *param.secondary_opts] |
| 132 | + if name.startswith(incomplete) |
| 133 | + ) |
| 134 | + |
| 135 | + results.extend(super().shell_complete(ctx, incomplete)) |
| 136 | + |
| 137 | + return results |
| 138 | + |
| 139 | + cls.shell_complete = shell_complete |
0 commit comments