Wednesday 9th December, 2009
At the moment, writing a management command for Django is an unenjoyable process. Django is supposed to encourage DRY, simple code, yet the boilerplate required for a management command sticks out like a sore thumb in an otherwise elegant and reusable app.
Here’s how we go about creating a management command at the moment.
Our app starts off like this:
myapp/
|-- __init__.py
|-- admin.py
|-- models.py
`-- views.py
We then need to add a boilerplate filesystem structure:
myapp/
|-- management/
| |-- commands/
| | `-- __init__.py
| `-- __init__.py
|-- __init__.py
|-- admin.py
|-- models.py
`-- views.py
Those __init__.py files are empty; they tell Python that a directory is a
package, allowing Django to do stuff like import myapp.management.commands. Of
course, we don’t actually have any commands yet.
Let’s start off simple. ./manage.py hello should print the text Hello,
World! to the console. First, we create a myapp.management.commands.hello
module:
myapp/
|-- management/
| |-- commands/
| | |-- __init__.py
| | `-- hello.py
| `-- __init__.py
|-- __init__.py
|-- admin.py
|-- models.py
`-- views.py
Now we need to write the command. Open up hello.py in your favourite editor
and add the following text:
from django.core.management.base import NoArgsCommand
class Command(NoArgsCommand):
help = "Print a cliche to the console."
def handle_noargs(self, **options):
print "Hello, World!"
Now, from the project where myapp is installed, we can run ./manage.py hello
and the text will be printed to the screen.
But we want to write a command which accepts arguments, right?
# file: myapp/management/commands/echo.py
# example: ./manage.py echo some words here
# => some words here
from optparse import make_option
from django.core.management.base import BaseCommand
class Command(BaseCommand):
help = "Echo all positional arguments."
option_list = BaseCommand.option_list + [
make_option('-n', dest='no_newline', action='store_true',
default=False, help="Don't print a newline afterwards.")
]
def handle(self, *args, **options):
if options.get('no_newline', False):
print ' '.join(args), # the comma is significant.
else:
print ' '.join(args)
Everything.
The management/commands/ layout is repeated within every application, with
no apparent benefits over a simple top-level commands.py file in the app
directory.
One module per command is just unwieldy, and rather pointless.
You have to use a different superclass depending on whether or not you want arguments, or what type of arguments you want to consume (app labels, model names, et cetera).
You have to override different methods depending on the superclass.
option_list = BaseCommand.option_list + [...] is horrible.
The first step to solving the whole issue is to fix command-line option parsing.
Django uses the stdlib’s optparse, which is OK for smaller projects, but is
getting a little long in the tooth. The far-superior argparse offers a much
cleaner all-round experience, as well as the ability to process positional
arguments, variadic arguments and sub-parsers.
Let’s write a speculative example of how we would like to write management
commands. In the file myapp/commands.py:
from django.core.management.commands import *
@command
def hello(args):
"""Print a cliche to the console."""
print "Hello, World!"
@command
@argument('-n', '--no-newline', action='store_true',
help="Don't print a newline afterwards.")
@argument('words', nargs='*')
def echo(args):
"""Echo all positional arguments."""
if args.no_newline:
print ' '.join(args.words),
else:
print ' '.join(args.words)
There are a few things to note about this example:
The easy case is easy.
There’s no need to specify a help attribute, because that can (and should)
be gleamed from the docstring itself.
Commands are functions. This is Python, not Java.
There’s no boilerplate.
The echo() command’s words argument shows how argparse can handle
variadic positional arguments with ease.
Decorators are used throughout, but this need not be the case. A decorator-less example:
def echo(args):
"""Echo all positional arguments."""
if args.no_newline:
print ' '.join(args.words),
else:
print ' '.join(args.words)
echo = Command(echo)
echo.add_argument('-n', '--no-newline', action='store_true',
help="Don't print a newline afterwards.")
echo.add_argument('words', nargs='*')
The Command class would wrap the function and provide the add_argument()
method.
This might seem like a difficult feat to pull off. Luckily, argparse handles a
lot of it for us. It supports the notion of sub-parsers; these are essentially
branches in the parser that allow a multi-tiered structure. The top-level
command has an ArgumentParser instance, which has a set of options, followed
by a ‘subparsers’ branch point. To this branch point, multiple ArgumentParser
instances are attached. When argparse starts processing arguments from the
command line and it hits this branch point, it decides what subparser to use and
then gives all the unparsed arguments to it.
The creation of sub-parsers and their registration on the top-level parser is
all handled by the @command decorator and Command class. The @argument
decorator and Command.add_argument() method will be basic wrappers around the
sub-parser’s add_argument() method. The decorators will also have to employ a
little behind-the-scenes shuffling to make sure that arguments are added in the
right order, since decorators are applied in reverse order.
It actually takes fewer lines of Python than lines of English to explain sub-parsers. For an example of the concept in practice, take a look at the CLI code for my project Markdoc.
In Django, the manage command would go through the INSTALLED_APPS list,
attempting to import a commands submodule from each app. Nothing else would be
necessary, since @command/Command would automatically register each command
upon import.
The current system allows you to write commands that take the names of apps and models as arguments; it deals with resolving the names to the modules/classes in question, and knows what to do if a non-existent app/model is specified.
Such cases warrant special attention when you’re using optparse, because the
library can only handle the --keyword value type of argument. However,
argparse’s support for positional arguments and custom types mitigates this
problem. If custom app_label and model_name types were implemented by
Django, code could look like this:
from django.core.management.commands import *
@command
@argument('app', type=app_label)
def models(args):
"""List the models and table names for the specified app."""
from django.db.models import Model
print "Name\tDB Table"
print '-' * 20
for name, model in vars(args.app.models).items():
if isinstance(model, type) and issubclass(model, Model):
print model.__name__ + '\t' + model._meta.db_table
@command
@argument('model', type=model_name)
def fields(args):
"""List the fields for the specified model."""
for field in args.model._meta.fields:
print field.name
Implementing a custom type just involves writing a function that takes a string and either returns an object or raises an exception. Furthermore, commands could take multiple apps/models as arguments (keyword or positional).
I think all of this could be quite quickly and easily implemented as a third-party reusable app, so I’m going to do it. In future, I’d like it to be included in Django proper, but that would have to wait until at least v1.3.
I’m going to go ahead and get started right now; I’d appreciate any comments, suggestions or criticism.