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

_images/django_typer_upstream.svg

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:

  • BaseCommand

    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.

  • Typer

    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

    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

  1. 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.

  2. Add django_typer to your INSTALLED_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:

manage.py closepoll Usage: manage.py closepoll [OPTIONS] POLL_IDS... Closes the specified poll for voting ╭─ Arguments ──────────────────────────────────────────────────────────────────╮ *poll_idsPOLL_IDS...[default: None][required] ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Options ────────────────────────────────────────────────────────────────────╮ --delete--no-delete[default: no-delete] --helpShow this message and exit. ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Django ─────────────────────────────────────────────────────────────────────╮ --versionShow program's version number and exit. --settingsTEXTThe Python path to a settings module, e.g.        "myproject.settings.main". If this isn't          provided, the DJANGO_SETTINGS_MODULE environment  variable will be used.                            --pythonpathPATHA directory to add to the Python path, e.g.       "/home/djangoprojects/myproject".                 [default: None]                                   --tracebackRaise on CommandError exceptions --no-colorDon't colorize the command output. --force-colorForce colorization of the command output. --skip-checksSkip system checks. ╰──────────────────────────────────────────────────────────────────────────────╯

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.

manage.py closepoll Usage: manage.py closepoll [OPTIONS] POLL_IDS... Closes the specified poll for voting ╭─ Arguments ──────────────────────────────────────────────────────────────────╮ *poll_idsPOLL_IDS...The database IDs of the poll(s) to close. [default: None]                           [required]                                ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Options ────────────────────────────────────────────────────────────────────╮ --delete--no-deleteDelete poll instead of closing it. [default: no-delete]               --helpShow this message and exit. ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Django ─────────────────────────────────────────────────────────────────────╮ --versionShow program's version number and exit. --settingsTEXTThe Python path to a settings module, e.g.        "myproject.settings.main". If this isn't          provided, the DJANGO_SETTINGS_MODULE environment  variable will be used.                            --pythonpathPATHA directory to add to the Python path, e.g.       "/home/djangoprojects/myproject".                 [default: None]                                   --tracebackRaise on CommandError exceptions --no-colorDon't colorize the command output. --force-colorForce colorization of the command output. --skip-checksSkip system checks. ╰──────────────────────────────────────────────────────────────────────────────╯

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()
manage.py closepoll Usage: manage.py closepoll [OPTIONS] POLLS... Closes the specified poll for voting. As mentioned in the last section, helps can also be set in the docstring ╭─ Arguments ──────────────────────────────────────────────────────────────────╮ *pollsPOLLS...The database IDs of the poll(s) to close. [default: None]                           [required]                                ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Options ────────────────────────────────────────────────────────────────────╮ --deleteDelete poll instead of closing it. --helpShow this message and exit. ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Django ─────────────────────────────────────────────────────────────────────╮ --versionShow program's version number and exit. --settingsTEXTThe Python path to a settings module, e.g.        "myproject.settings.main". If this isn't          provided, the DJANGO_SETTINGS_MODULE environment  variable will be used.                            --pythonpathPATHA directory to add to the Python path, e.g.       "/home/djangoprojects/myproject".                 [default: None]                                   --tracebackRaise on CommandError exceptions --no-colorDon't colorize the command output. --force-colorForce colorization of the command output. --skip-checksSkip system checks. ╰──────────────────────────────────────────────────────────────────────────────╯

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()
_images/closepoll_example.gif