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 thepolls 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.
-
The 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.model 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.model import ModelObjectParser
from django_typer.completers.model 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
7from django_typer.utils import model_parser_completer
8from polls.models import Question as Poll
9
10
11class Command(TyperCommand):
12 help = _("Closes the specified poll for voting.")
13
14 def 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()
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
7from django_typer.utils import model_parser_completer
8from polls.models import Question as Poll
9
10
11app = Typer(help=_("Closes the specified poll for voting."))
12
13
14@app.command()
15def handle(
16 self,
17 polls: t.Annotated[
18 t.List[Poll],
19 Argument(
20 **model_parser_completer(Poll, help_field="question_text"),
21 help=_("The database IDs of the poll(s) to close."),
22 ),
23 ],
24 delete: t.Annotated[
25 bool,
26 Option(help=_("Delete poll instead of closing it.")),
27 ] = False,
28):
29 for poll in polls:
30 poll.opened = False
31 poll.save()
32 self.stdout.write(
33 self.style.SUCCESS(f'Successfully closed poll "{poll.id}"')
34 )
35 if delete:
36 poll.delete()
