Writing a Datasette CLI plugin that mostly duplicates an existing command
My new datasette-gunicorn plugin adds a new command to Datasette - datasette gunicorn
- which mostly replicates the existing datasette serve
command but with a few differences.
I learned some useful tricks for modifying and extending existing Click commands building this plugin.
Here’s the relevant section of code, with some extra comments (the full code is here):
import clickfrom datasette import hookimpl
# These options do not work with 'datasette gunicorn':invalid_options = { "get", "root", "open_browser", "uds", "reload", "pdb", "ssl_keyfile", "ssl_certfile",}
def serve_with_gunicorn(**kwargs): # Avoid a circular import error running the tests: from datasette import cli
workers = kwargs.pop("workers") port = kwargs["port"] host = kwargs["host"] # Need to add back default kwargs for everything in invalid_options: kwargs.update({invalid_option: None for invalid_option in invalid_options}) kwargs["return_instance"] = True ds = cli.serve.callback(**kwargs) # ds is now a configured Datasette instance asgi = StandaloneApplication( app=ds.app(), options={ "bind": "{}:{}".format(host, port), "workers": workers, }, ) asgi.run()
@hookimpldef register_commands(cli): # Get a reference to the existing "datasette serve" command serve_command = cli.commands["serve"] # Create a new list of params, excluding any in invalid_options params = [ param for param in serve_command.params if param.name not in invalid_options ] # This is the longer way of constructing a new Click option, as an alternative # to using the @click.option() decorator params.append( click.Option( ["-w", "--workers"], type=int, default=1, help="Number of Gunicorn workers", # Causes [default: 1] to show in the option help show_default=True, ) ) gunicorn_command = click.Command( name="gunicorn", params=params, callback=serve_with_gunicorn, short_help="Serve Datasette using Gunicorn", help="Start a Gunicorn server running to serve Datasette", ) # cli is the Click command group passed to this plugin hook by # Datasette - this is how we add the "datasette gunicorn" command: cli.add_command(gunicorn_command, name="gunicorn")
Here’s the documentation for the register_commands() plugin hook. It is passed cli
which is a Click command group.
cli.add_command(...)
can then be used to register additional commands - in this case the datasette gunicorn
one.
I want that command to take almost the same options as the existing datasette serve
command - which is defined here in the Datasette codebase.
So… I start by creating a copy of those options. But there are a few options which don’t make sense for my new command (see this issue). So I filtered those out with a list comprehension:
params = [ param for param in serve_command.params if param.name not in invalid_options]
I did need one extra option: a -w/--workers
integer specifying the number of workers that should be started by Gunicorn.
Here’s the relevant Click documentation. I defined it like this:
params.append( click.Option( ["-w", "--workers"], type=int, default=1, help="Number of Gunicorn workers", # Causes [default: 1] to show in the option help show_default=True, ))
I defined the new gunicorn
command like this:
gunicorn_command = click.Command( name="gunicorn", params=params, callback=serve_with_gunicorn, short_help="Serve Datasette using Gunicorn", help="Start a Gunicorn server running to serve Datasette",)cli.add_command(gunicorn_command, name="gunicorn")
The short_help
is shown in the list of commands displayed by datasette --help
.
The help
is shown at the top of the list of options when you run datasette gunicorn --help
.
The most important argument here is callback=
- this is the function which will be executed when the user types datasette gunicorn ...
.
Here’s a partial implementation of that function:
def serve_with_gunicorn(**kwargs): # Avoid a circular import error running the tests: from datasette import cli
workers = kwargs.pop("workers") port = kwargs["port"] host = kwargs["host"] # Need to add back default kwargs for everything in invalid_options: kwargs.update({invalid_option: None for invalid_option in invalid_options}) kwargs["return_instance"] = True ds = cli.serve.callback(**kwargs) # ds is now a configured Datasette instance asgi = StandaloneApplication( app=ds.app(), options={ "bind": "{}:{}".format(host, port), "workers": workers, }, ) asgi.run()
The **kwargs
passed to that function are the options and argumenst that have been extracted from the command line by Click.
In this case, I know I’m going to be calling the existing serve
function from Datasette. cli.serve
is the Click decorated version, but cli.serve.callback()
is the original function I defined in my own Datasette source code (linked above).
That function in Datasette takes a list of keyword arguments, which I need to pass through.
The kwargs
passed to serve_with_gunicorn()
are not quite right - remember, I removed some options earlier, and I also added a workers
option that serve()
doesn’t know how to handle.
So I pop workers
off the dictionary, and I add "name": None
keys for the invalid_options
that I previously filtered out.
One last trick: my serve()
function here in Datasette has an extra return_instance
keyword argument, which can be used to shortcut that function and return the configured Datasette instance instead of starting the server.
I originally built this to help with unit tests, but this is also exactly what I need for this particular plugin! I set that to true to get back a configured Datasette object instance, which I can then use to serve the application using Gunicorn.