A recent project involved adding a CLI to an existing project. Unusually this project
has its natural entry points living as member functions on Classes, including a lot of
them that are inherited from one of a couple of base classes. Notably these were regular
methods, not classmethods, so you have to have an instance on hand to run them on. Luckily
these instances were easy to create, and the __init__ methods require no arguments.
I decided to use the Click module both because it matched some of my requirements, and it was the library used in other projects in the codebase I am working in.
So the requirements:
command/subcommand and arguments, which are mapped to
Python’s Classes and methods on themclick)Oct. 23, 2019 update: I found that there were problems with multiple click.arguments, and
fixed that.
There were a number of hurdles that I did not see coming along the way:
Click is designed to be wrapped around functions, and does not have a natural way of
instantiating an object to then call the methods on.@click.command() is the natural way of decorating entry points, and works on methods
(minus problem #1), but when you inherit it is points at the method referencing only the
inherited class making it hard to divine the final class at runtime.@click.command decorator without parens it creates a function/closure
style wrapper that is hard to see though.My solution:
metaclass which uses __new__ to modify sublasses as those classes are done being
parsed. Specifically it looks for click.Command objects on the new subclass and wrapps
them with a class with a __call__ method that instantiates the correct class before
calling the callback on that command.metaclass also collects all of these click.Command objects in a click.Group
object attached to the class as click_group. This allows the __main__ to group all of
these into another click.Group which is then run to parse arguments and run the entry
point.class.Command instances the metaclass has to see if they have already
has their callback wrapped in my instantiaor. This happens on subclasses that inherit
their methods from a class that has already been wrapped. In these cases a copy of the
whole command needs to be made, and the class to be instantiated swapped in.@click.command decorator was used, and since
those can not really be inspected without silly measures (looking at source code), the code
instead posts a warning that hopefully will prompt developers to change their code.All of this work is confined to 3 classes: the Instantiator, the Metaclass, and a technically unnecessary class to inherit from the Metaclass to make other subclasses easy (this can be used to put other inherited methods on as well).
import click
import copy
import inspect
import warnings
class ClickInstantiator:
klass = None
command = None
def __init__(self, command, klass):
self.command = command
self.klass = klass
def __call__(self, *args, **kwargs):
return self.command(self.klass(), *args, **kwargs)
class ClickCommandMetaclass(type):
def __new__(mcs, name, bases, dct):
klass = super().__new__(mcs, name, bases, dct)
# create and populate the click.Group for this Class
klass.click_group = click.Group(name=klass.__name__.lower())
# warn about @click.command decorators missing the parens
for name, command in inspect.getmembers(klass, inspect.isfunction):
if repr(command).startswith('<function command.'):
warnings.warn(
'%s.%s is wrapped with click.command without parens, please add them' % (klass.__name__, name))
for name, command in inspect.getmembers(klass, lambda x: isinstance(x, click.Command)):
if name == 'click_group':
continue
def find_final_command(target):
"""Find the last call command at the end of a stack of click.Command instances"""
while isinstance(target.callback, click.Command):
target = target.callback
return target
command_target = find_final_command(command)
if not isinstance(command_target.callback, ClickInstantiator):
# the top class to implement this
command_target.callback = ClickInstantiator(command_target.callback, klass)
else:
# this is a subclass function, copy it and replace the klass
setattr(klass, name, copy.deepcopy(command))
command = getattr(klass, name)
find_final_command(getattr(klass, name)).callback.klass = klass
# now add it to the group
klass.click_group.add_command(command, name)
return klass
class ClickCommandBase(metaclass=ClickCommandMetaclass):
pass
# == Example code
class Alpha(ClickCommandBase):
@click.command()
def red(self) -> None:
"""Lets see if this works"""
print('This works on %s!' % self.__class__.__name__)
class Beta(ClickCommandBase):
@click.command()
def red(self) -> None:
"""Do something blue"""
print('Beta works as well!')
class Gamma(Alpha):
pass
if __name__ == '__main__':
cli = click.Group()
for group in (x.click_group for x in {Alpha, Beta, Gamma} if hasattr(x, 'click_group')):
cli.add_command(group, name=group.name)
cli()