1

I have a click application which is running just fine currently.

We want to introduce a new command called api which will dynamically reach into our python sdk and extract sdk methods that can be invoked to interact with our platform. There are 100s of sdk methods and it doesn't make sense to expose them all via the CLI application explicitly but we want to at least provide "power users" the option to use the sdk via the CLI in a more dynamic way.

I have this working in the general sense via progname api METHOD which would be invoked as progname api users.list. This will call the list method on the users object within our sdk to list our all our users.

I have successfully built a custom shell_complete method for the METHOD argument which dynamically reaches into the sdk to pull out the options available based on the incomplete which has been provided.

Next step is to allow shell completion for a list of dynamic options, which is sourced from the sdk method being invoked. The api command has a set of click managed options and then we need to dynamically grab a set of options from the sdk.

I have decorated the command with the following.

@click.command(
    context_settings=dict(
        ignore_unknown_options=True,
        allow_extra_args=True
    )
)

This allows me to provide a dynamic set of options and then my business logic can parse out what is required via the context object and invoke the correct sdk method with the correct method parameters.

This would present itself like progname api users.create --username <name> --email <email> --phone <phone>. And also as progname api users.delete --user-id <user-id>. As can be seen, both the METHOD and options are dynamic.

So far, this is all working as expected...the final piece is the dynamic options for shell completion.

How can I build a custom shell completion method and associate it with the api command? I would prefer to maintain the existing shell completion for the command (to get the click managed options...things like output format, auth details, etc) and just append my dynamically sourced options?

I am not seeing a shell_complete setting for commands, just parameters.

I am open to monkey patching some section of the click codebase if that ends up being a viable option. Or perhaps there is a "special" click option I can provide that encapsulates all non explicitly provided options?

thomas
  • 2,592
  • 1
  • 30
  • 44

1 Answers1

0

I went with a patch methodology for this. The con being that as click shell completion changes I will need to review this logic. But for now it is fine.

def command_api_patch_shell_complete(cls):
    from click.shell_completion import CompletionItem
    from click.core import ParameterSource
    from click import Context, Option

    # https://stackoverflow.com/questions/43778914/python3-using-super-in-eq-methods-raises-runtimeerror-super-class
    __class__ = cls  # provide closure cell for super()

    def shell_complete(self, ctx: Context, incomplete: str) -> t.List["CompletionItem"]:
        from click.shell_completion import CompletionItem

        results: t.List["CompletionItem"] = []

        if incomplete and not incomplete[0].isalnum():
            method = ctx.params.get('method')
            if method:
                dynamic_params = get_dynamic_method_parameters(method)

                results.extend(
                    CompletionItem(p['flag'], help=p['help'])
                    for p in dynamic_params if p['flag'].startswith(incomplete)
                )

            for param in self.get_params(ctx):
                if (
                    not isinstance(param, Option)
                    or param.hidden
                    or (
                        not param.multiple
                        and ctx.get_parameter_source(param.name)  # type: ignore
                        is ParameterSource.COMMANDLINE
                    )
                ):
                    continue

                results.extend(
                    CompletionItem(name, help=param.help)
                    for name in [*param.opts, *param.secondary_opts]
                    if name.startswith(incomplete)
                )

        results.extend(super().shell_complete(ctx, incomplete))

        return results

    cls.shell_complete = shell_complete

And the patch gets put into place with

command_api_patch_shell_complete(Command)
thomas
  • 2,592
  • 1
  • 30
  • 44