metaflow/cli_components/utils.py (82 lines of code) (raw):

import importlib from metaflow._vendor import click from metaflow.extension_support.plugins import get_plugin class LazyPluginCommandCollection(click.CommandCollection): # lazy_source should only point to things that are resolved as CLI plugins. def __init__(self, *args, lazy_sources=None, **kwargs): super().__init__(*args, **kwargs) # lazy_sources is a list of strings in the form # "{plugin_name}" -> "{module-name}.{command-object-name}" self.lazy_sources = lazy_sources or {} self._lazy_loaded = {} def invoke(self, ctx): # NOTE: This is copied from MultiCommand.invoke. The change is that we # behave like chain in the sense that we evaluate the subcommand *after* # invoking the base command but we don't chain the commands like self.chain # would otherwise indicate. # The goal of this is to make sure that the first command is properly executed # *first* prior to loading the other subcommands. It's more a lazy_subcommand_load # than a chain. # Look for CHANGE HERE in this code to see where the changes are made. # If click is updated, this may also need to be updated. This version is for # click 7.1.2. def _process_result(value): if self.result_callback is not None: value = ctx.invoke(self.result_callback, value, **ctx.params) return value if not ctx.protected_args: # If we are invoked without command the chain flag controls # how this happens. If we are not in chain mode, the return # value here is the return value of the command. # If however we are in chain mode, the return value is the # return value of the result processor invoked with an empty # list (which means that no subcommand actually was executed). if self.invoke_without_command: # CHANGE HERE: We behave like self.chain = False here # if not self.chain: return click.Command.invoke(self, ctx) # with ctx: # click.Command.invoke(self, ctx) # return _process_result([]) ctx.fail("Missing command.") # Fetch args back out args = ctx.protected_args + ctx.args ctx.args = [] ctx.protected_args = [] # CHANGE HERE: Add saved_args so we have access to it in the command to be # able to infer what we are calling next ctx.saved_args = args # If we're not in chain mode, we only allow the invocation of a # single command but we also inform the current context about the # name of the command to invoke. # CHANGE HERE: We change this block to do the invoke *before* the resolve_command # Make sure the context is entered so we do not clean up # resources until the result processor has worked. with ctx: ctx.invoked_subcommand = "*" if args else None click.Command.invoke(self, ctx) cmd_name, cmd, args = self.resolve_command(ctx, args) sub_ctx = cmd.make_context(cmd_name, args, parent=ctx) with sub_ctx: return _process_result(sub_ctx.command.invoke(sub_ctx)) # CHANGE HERE: Removed all the part of chain mode. def list_commands(self, ctx): base = super().list_commands(ctx) for source_name, source in self.lazy_sources.items(): subgroup = self._lazy_load(source_name, source) base.extend(subgroup.list_commands(ctx)) return base def get_command(self, ctx, cmd_name): base_cmd = super().get_command(ctx, cmd_name) if base_cmd is not None: return base_cmd for source_name, source in self.lazy_sources.items(): subgroup = self._lazy_load(source_name, source) cmd = subgroup.get_command(ctx, cmd_name) if cmd is not None: return cmd return None def _lazy_load(self, source_name, source_path): if source_name in self._lazy_loaded: return self._lazy_loaded[source_name] cmd_object = get_plugin("cli", source_path, source_name) if not isinstance(cmd_object, click.Group): raise ValueError( f"Lazy loading of {source_name} failed by returning " "a non-group object" ) self._lazy_loaded[source_name] = cmd_object return cmd_object class LazyGroup(click.Group): def __init__(self, *args, lazy_subcommands=None, **kwargs): super().__init__(*args, **kwargs) # lazy_subcommands is a list of strings in the form # "{command} -> "{module-name}.{command-object-name}" self.lazy_subcommands = lazy_subcommands or {} self._lazy_loaded = {} def list_commands(self, ctx): base = super().list_commands(ctx) lazy = sorted(self.lazy_subcommands.keys()) return base + lazy def get_command(self, ctx, cmd_name): if cmd_name in self.lazy_subcommands: return self._lazy_load(cmd_name) return super().get_command(ctx, cmd_name) def _lazy_load(self, cmd_name): if cmd_name in self._lazy_loaded: return self._lazy_loaded[cmd_name] import_path = self.lazy_subcommands[cmd_name] modname, cmd = import_path.rsplit(".", 1) # do the import mod = importlib.import_module(modname) # get the Command object from that module cmd_object = getattr(mod, cmd) # check the result to make debugging easier. note that wrapped BaseCommand # can be functions if not isinstance(cmd_object, click.BaseCommand): raise ValueError( f"Lazy loading of {import_path} failed by returning " f"a non-command object {type(cmd_object)}" ) self._lazy_loaded[cmd_name] = cmd_object return cmd_object