Tutorial
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:
Note
If you install rich, traceback rendering will be enabled by default. Refer to the how-to if you would like to disable it.
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.
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
2from polls.models import Question as Poll
3
4
5class Command(BaseCommand):
6 help = "Closes the specified poll for voting"
7
8 def add_arguments(self, parser):
9 parser.add_argument("poll_ids", nargs="+", type=int)
10
11 # Named (optional) arguments
12 parser.add_argument(
13 "--delete",
14 action="store_true",
15 help="Delete poll instead of closing it",
16 )
17
18 def handle(self, *args, **options):
19 for poll_id in options["poll_ids"]:
20 try:
21 poll = Poll.objects.get(pk=poll_id)
22 except Poll.DoesNotExist:
23 raise CommandError(f'Poll "{poll_id}" does not exist')
24
25 poll.opened = False
26 poll.save()
27
28 self.stdout.write(
29 self.style.SUCCESS(f'Successfully closed poll "{poll.id}"')
30 )
31
32 if options["delete"]:
33 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_typer import TyperCommand
4from django.core.management.base import CommandError
5from polls.models import Question as Poll
6
7class Command(TyperCommand):
8 help = "Closes the specified poll for voting"
9
10 def handle(
11 self,
12 poll_ids: t.List[int],
13 delete: bool = False,
14 ):
15 for poll_id in poll_ids:
16 try:
17 poll = Poll.objects.get(pk=poll_id)
18 except Poll.DoesNotExist:
19 raise CommandError(f'Poll "{poll_id}" does not exist')
20
21 poll.opened = False
22 poll.save()
23
24 self.stdout.write(
25 self.style.SUCCESS(f'Successfully closed poll "{poll.id}"')
26 )
27
28 if delete:
29 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:
1import typing as t
2
3from django.core.management.base import CommandError
4from django.utils.translation import gettext_lazy as _
5from typer import Argument, Option
6
7from django_typer import TyperCommand
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 poll_ids: t.Annotated[
17 t.List[int], Argument(help=_("The database IDs of the poll(s) to close."))
18 ],
19 delete: t.Annotated[
20 bool, Option(help=_("Delete poll instead of closing it."))
21 ] = False,
22 ):
23 # ...
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.
Note
On Python <=3.8 you will need to import Annotated from typing_extensions instead of the standard library.
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:
1import typing as t
2
3# ...
4
5def get_poll_from_id(poll: t.Union[str, Poll]) -> Poll:
6 # our parser may be passed a Poll object depending on how
7 # users might call our command from code - so we must check
8 # to be sure we have something to parse at all!
9 if isinstance(poll, Poll):
10 return poll
11 try:
12 return Poll.objects.get(pk=int(poll))
13 except Poll.DoesNotExist:
14 raise CommandError(f'Poll "{poll_id}" does not exist')
15
16
17class Command(TyperCommand):
18
19 def handle(
20 self,
21 polls: t.Annotated[
22 t.List[Poll], # change our type hint to a list of Polls!
23 Argument(
24 parser=get_poll_from_id, # pass our parser to the Argument!
25 help=_("The database IDs of the poll(s) to close."),
26 ),
27 ],
28 delete: t.Annotated[
29 bool,
30 Option(
31 "--delete", # we can also get rid of that unnecessary --no-delete flag
32 help=_("Delete poll instead of closing it."),
33 ),
34 ] = False,
35 ):
36 """
37 Closes the specified poll for voting.
38
39 As mentioned in the last section, helps can also
40 be set in the docstring
41 """
42 for poll in polls:
43 poll.opened = False
44 poll.save()
45 self.stdout.write(
46 self.style.SUCCESS(f'Successfully closed poll "{poll.id}"')
47 )
48 if delete:
49 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 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: 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: Annotated[
23 bool, Option(help=_("Delete poll instead of closing it.")),
24 ] = False,
25 ):
26 for poll in polls:
27 poll.opened = False
28 poll.save()
29 self.stdout.write(
30 self.style.SUCCESS(f'Successfully closed poll "{poll.id}"')
31 )
32 if delete:
33 poll.delete()