Tutorial: Building Commands¶
Using the TyperCommand
class is very similar to using the
BaseCommand class. The main difference is that we use Typer’s decorators, classes and type
annotations to define the command’s command line interface instead of argparse as
BaseCommand expects.
Upstream Libraries¶
django-typer merges the Django BaseCommand interface with the Typer interface and Typer itself is built on top of click. This means when using django-typer you will encounter interfaces and concepts from all three of these upstream libraries:
-
Django has a good tutorial for understanding how commands are organized and built in Django. If you are unfamiliar with using BaseCommand please first work through the polls Tutorial in the Django documentation.
-
This tutorial can be completed without working through the Typer tutorials, but familiarizing yourself with Typer will make this easier and will also be helpful when you want to define CLIs outside of Django! We use the Typer interface to define Arguments and Options so please refer to the Typer documentation for any questions about how to define these.
-
Click interfaces and concepts are relatively hidden by Typer, but occasionally you may need to refer to the click documentation when you want to implement more complex behaviors like passing context parameters. It is not necessary to familiarize yourself with click to use django-typer, but you should know that it exists and is the engine behind much of this functionality.
Install django-typer¶
Install the latest release off PyPI :
pip install "django-typer[rich]"
rich is a powerful library for rich text and beautiful formatting in the terminal. It is not required, but highly recommended for the best experience:
Add
django_typer
to yourINSTALLED_APPS
setting:INSTALLED_APPS = [ ... 'django_typer', ]
Note
Adding django_typer to INSTALLED_APPS is not strictly necessary if you do not wish to use shell tab completions or configure rich traceback rendering. Refer to the how-to if you would like to disable it.
Convert the closepoll command to a TyperCommand
¶
Recall our closepoll command from the polls Tutorial in the Django documentation looks like this:
1from django.core.management.base import BaseCommand, CommandError
2
3from polls.models import Question as Poll
4
5
6class Command(BaseCommand):
7 help = "Closes the specified poll for voting"
8
9 def add_arguments(self, parser):
10 parser.add_argument("poll_ids", nargs="+", type=int)
11
12 # Named (optional) arguments
13 parser.add_argument(
14 "--delete",
15 action="store_true",
16 help="Delete poll instead of closing it",
17 )
18
19 def handle(self, *args, **options):
20 for poll_id in options["poll_ids"]:
21 try:
22 poll = Poll.objects.get(pk=poll_id)
23 except Poll.DoesNotExist:
24 raise CommandError('Poll "%s" does not exist' % poll_id)
25
26 poll.opened = False
27 poll.save()
28
29 self.stdout.write(
30 self.style.SUCCESS('Successfully closed poll "%s"' % poll_id)
31 )
32
33 if options["delete"]:
34 poll.delete()
Inherit from TyperCommand
¶
We first need to change the inheritance to TyperCommand
and then
move the argument and option definitions from add_arguments into the method signature of handle. A
minimal conversion may look like this:
1import typing as t
2
3from django.core.management.base import CommandError
4
5from django_typer.management import TyperCommand
6from polls.models import Question as Poll
7
8
9class Command(TyperCommand):
10 help = "Closes the specified poll for voting"
11
12 def handle(
13 self,
14 poll_ids: t.List[int],
15 delete: bool = False,
16 ):
17 for poll_id in poll_ids:
18 try:
19 poll = Poll.objects.get(pk=poll_id)
20 except Poll.DoesNotExist:
21 raise CommandError(f'Poll "{poll_id}" does not exist')
22
23 poll.opened = False
24 poll.save()
25
26 self.stdout.write(
27 self.style.SUCCESS(f'Successfully closed poll "{poll.id}"')
28 )
29
30 if delete:
31 poll.delete()
1import typing as t
2
3from django.core.management.base import CommandError
4
5from django_typer.management import Typer
6from polls.models import Question as Poll
7
8app = Typer(help="Closes the specified poll for voting")
9
10
11@app.command()
12def handle(
13 self,
14 poll_ids: t.List[int],
15 delete: bool = False,
16):
17 for poll_id in poll_ids:
18 try:
19 poll = Poll.objects.get(pk=poll_id)
20 except Poll.DoesNotExist:
21 raise CommandError(f'Poll "{poll_id}" does not exist')
22
23 poll.opened = False
24 poll.save()
25
26 self.stdout.write(
27 self.style.SUCCESS(f'Successfully closed poll "{poll.id}"')
28 )
29
30 if delete:
31 poll.delete()
You’ll note that we’ve removed add_arguments entirely and specified the arguments and options as parameters to the handle method. django-typer will interpret the parameters on the handle() method as the command line interface for the command. If we have rich installed the help for our new closepoll command will look like this:
Note
TyperCommand
adds the standard set of default options to the command
line interface, with the exception of verbosity.
Add Helps with Type annotations¶
Typer allows us to use Annotated types to add additional controls to how the command line interface behaves. The most common use case for this is to add help text to the command line interface. We will annotate our parameter type hints with one of two Typer parameter types, either Argument or Option. Arguments are positional parameters and Options are named parameters (i.e. –delete). In our polls example, the poll_ids are arguments and the delete flag is an option. Here is what that would look like:
class Command(TyperCommand):
help = "Closes the specified poll for voting"
def handle(
self,
poll_ids: t.Annotated[
t.List[int],
Argument(help=_("The database IDs of the poll(s) to close.")),
],
delete: t.Annotated[
bool, Option(help=_("Delete poll instead of closing it."))
] = False,
):
@app.command()
def handle(
self,
poll_ids: t.Annotated[
t.List[int],
Argument(help=_("The database IDs of the poll(s) to close.")),
],
delete: t.Annotated[
bool, Option(help=_("Delete poll instead of closing it."))
] = False,
):
See that our help text now shows up in the command line interface. Also note, that lazy translations work for the help strings. Typer also allows us to specify our help text in the docstrings of the command function or class, in this case either Command or handle() - but docstrings are not available to the translation system. If translation is not necessary and your help text is extensive or contains markup the docstring may be the more appropriate place to put it.
Defining custom and reusable parameter types¶
We may have other commands that need to operate on Poll objects from given poll ids. We could duplicate our for loop that loads Poll objects from ids, but that wouldn’t be very DRY. Instead, Typer allows us to define custom parsers for arbitrary parameter types. Lets see what that would look like if we used the Poll class as our type hint:
def get_poll_from_id(poll: t.Union[str, Poll]) -> Poll:
if isinstance(poll, Poll):
return poll
try:
return Poll.objects.get(pk=int(poll))
except Poll.DoesNotExist:
raise CommandError(f'Poll "{poll}" does not exist')
class Command(TyperCommand, rich_markup_mode="markdown"):
def handle(
self,
polls: t.Annotated[
t.List[Poll],
Argument(
parser=get_poll_from_id,
help=_("The database IDs of the poll(s) to close."),
),
],
delete: t.Annotated[
bool,
Option(
# we can also get rid of that unnecessary --no-delete flag
"--delete",
help=_("Delete poll instead of closing it."),
),
] = False,
):
"""
:sparkles: As mentioned in the last section, helps can also be set in
the docstring and rendered using either
[rich](https://rich.readthedocs.io/en/stable/markup.html)
or [markdown](https://www.markdownguide.org/) :sparkles:
"""
for poll in polls:
poll.opened = False
poll.save()
self.stdout.write(
self.style.SUCCESS(f'Successfully closed poll "{poll.id}"')
)
if delete:
poll.delete()
def get_poll_from_id(poll: t.Union[str, Poll]) -> Poll:
if isinstance(poll, Poll):
return poll
try:
return Poll.objects.get(pk=int(poll))
except Poll.DoesNotExist:
raise CommandError(f'Poll "{poll}" does not exist')
app = Typer(rich_markup_mode="markdown")
@app.command()
def handle(
self,
polls: t.Annotated[
t.List[Poll],
Argument(
parser=get_poll_from_id,
help=_("The database IDs of the poll(s) to close."),
),
],
delete: t.Annotated[
bool,
Option(
"--delete", # we can also get rid of that unnecessary --no-delete flag
help=_("Delete poll instead of closing it."),
),
] = False,
):
"""
:sparkles: As mentioned in the last section, helps can also be set in
the docstring and rendered using either
[rich](https://rich.readthedocs.io/en/stable/markup.html)
or [markdown](https://www.markdownguide.org/) :sparkles:
"""
for poll in polls:
poll.opened = False
poll.save()
self.stdout.write(
self.style.SUCCESS(f'Successfully closed poll "{poll.id}"')
)
if delete:
poll.delete()
django-typer offers some built-in parsers
that can be used for
common Django types. For example, the ModelObjectParser
can
be used to fetch a model object from a given field. By default it will use the primary key,
so we could rewrite the relevant lines above like so:
from django_typer.parsers import ModelObjectParser
# ...
t.Annotated[
t.List[Poll],
Argument(
parser=ModelObjectParser(Poll),
help=_("The database IDs of the poll(s) to close.")
)
]
# ...
Add shell tab-completion suggestions for polls¶
It’s very annoying to have to know the database primary key of the poll to close it. django-typer makes it
easy to add tab completion suggestions! You can always
implement your own completer functions,
but as with, parsers
, there are some out-of-the-box completers
that
make this easy. Let’s see what the relevant updates to our closepoll command would look like:
from django_typer.parsers import ModelObjectParser
from django_typer.completers import ModelObjectCompleter
# ...
t.Annotated[
t.List[Poll],
Argument(
parser=ModelObjectParser(Poll),
shell_complete=ModelObjectCompleter(Poll, help_field='question_text'),
help=_("The database IDs of the poll(s) to close.")
)
]
# ...
Note
For tab-completions to work you will need to install the shell completion scripts for your shell.
Putting it all together¶
When we’re using a ModelObjectParser
and
ModelObjectCompleter
we can use the
model_parser_completer()
convenience function to reduce the amount
of boiler plate. Let’s put everything together and see what our full-featured refactored
closepoll command looks like:
1import typing as t
2
3from django.utils.translation import gettext_lazy as _
4from typer import Argument, Option
5
6from django_typer.management import TyperCommand, model_parser_completer
7from polls.models import Question as Poll
8
9
10class Command(TyperCommand):
11 help = _("Closes the specified poll for voting.")
12
13 def handle(
14 self,
15 polls: t.Annotated[
16 t.List[Poll],
17 Argument(
18 **model_parser_completer(Poll, help_field="question_text"),
19 help=_("The database IDs of the poll(s) to close."),
20 ),
21 ],
22 delete: t.Annotated[
23 bool,
24 Option(help=_("Delete poll instead of closing it.")),
25 ] = False,
26 ):
27 for poll in polls:
28 poll.opened = False
29 poll.save()
30 self.stdout.write(
31 self.style.SUCCESS(f'Successfully closed poll "{poll.id}"')
32 )
33 if delete:
34 poll.delete()
1import typing as t
2
3from django.utils.translation import gettext_lazy as _
4from typer import Argument, Option
5
6from django_typer.management import Typer, model_parser_completer
7from polls.models import Question as Poll
8
9
10app = Typer(help=_("Closes the specified poll for voting."))
11
12
13@app.command()
14def handle(
15 self,
16 polls: t.Annotated[
17 t.List[Poll],
18 Argument(
19 **model_parser_completer(Poll, help_field="question_text"),
20 help=_("The database IDs of the poll(s) to close."),
21 ),
22 ],
23 delete: t.Annotated[
24 bool,
25 Option(help=_("Delete poll instead of closing it.")),
26 ] = False,
27):
28 for poll in polls:
29 poll.opened = False
30 poll.save()
31 self.stdout.write(
32 self.style.SUCCESS(f'Successfully closed poll "{poll.id}"')
33 )
34 if delete:
35 poll.delete()