Skip to content

Commit b9ee931

Browse files
committed
more ref doc work
1 parent eb0e597 commit b9ee931

File tree

4 files changed

+200
-105
lines changed

4 files changed

+200
-105
lines changed

django_typer/__init__.py

Lines changed: 191 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -9,52 +9,53 @@
99
|__/ |___/ |___/|_|
1010
1111
12-
django-typer provides an extension to the base django management command class that
13-
melds the typer/click infrastructure with the django infrastructure. The result is
14-
all the ease of specifying commands, groups and options and arguments using typer and
15-
click in a way that feels like and is interface compatible with django's BaseCommand_
16-
This should enable a smooth transition for existing django commands and an intuitive
17-
feel for implementing new commands.
18-
19-
django-typer also supports shell completion for bash, zsh, fish and powershell and
20-
extends that support to native django management commands as well.
21-
22-
During development of django-typer I've wrestled with a number of encumbrances in the
23-
aging django management command design. I detail them here mostly to keep track of them
24-
for possible future refactors of core Django.
25-
26-
1) BaseCommand::execute() prints results to stdout without attempting to convert them
27-
to strings. This means you've gotta do weird stuff to get a return object out of
28-
call_command()
29-
30-
2) call_command() converts arguments to strings. There is no official way to pass
31-
previously parsed arguments through call_command(). This makes it a bit awkward to
32-
use management commands as callable functions in django code which you should be able
33-
to easily do. django-typer allows you to invoke the command and group functions
34-
directly so you can work around this, but it would be nice if call_command() supported
35-
a general interface that all command libraries could easily implement to.
36-
37-
3) terminal autocompletion is not pluggable. As of this writing (Django<=5)
38-
autocomplete is implemented for bash only and has no mechanism for passing the buck
39-
down to command implementations. The result of this in django-typer is that we wrap
40-
django's autocomplete and pass the buck to it instead of the other way around. This is
41-
fine but it will be awkward if two django command line apps with their own autocomplete
42-
infrastructure are used together. Django should be the central coordinating point for
43-
this. This is the reason for the pluggable --fallback awkwardness in shellcompletion.
44-
45-
4) Too much of the BaseCommand implementation is built assuming argparse. A more
46-
generalized abstraction of this interface is in order. Right now BaseCommand is doing
47-
double duty both as a base class and a protocol.
48-
49-
5) There is an awkwardness to how parse_args flattens all the arguments and options
50-
into a single dictionary. This means that when mapping a library like Typer onto the
51-
BaseCommand interface you cannot allow arguments at different levels
52-
(e.g. in initialize()) or group() functions above the command to have the same names as
53-
the command's options. You can work around this by using a different name for the
54-
option in the command and supplying the desired name in the annotation, but its an odd
55-
quirk imposed by the base class for users to be aware of.
12+
django-typer_ provides an extension class, :class:`~django_typer.TyperCommand`, to the
13+
BaseCommand_ class that melds the Typer_/click_ infrastructure with
14+
the Django_ infrastructure. The result is all the ease of specifying commands, groups
15+
and options and arguments using Typer_ and click_ in a way that feels like and is
16+
interface compatible with Django_'s BaseCommand_ This should enable a smooth transition
17+
for existing Django_ commands and an intuitive feel for implementing new commands.
18+
19+
django-typer_ also supports shell completion for bash_, zsh_, fish_ and powershell_ and
20+
extends that support to native Django_ management commands as well.
5621
"""
5722

23+
# During development of django-typer_ I've wrestled with a number of encumbrances in the
24+
# aging django management command design. I detail them here mostly to keep track of them
25+
# for possible future refactors of core Django.
26+
27+
# 1) BaseCommand::execute() prints results to stdout without attempting to convert them
28+
# to strings. This means you've gotta do weird stuff to get a return object out of
29+
# call_command()
30+
31+
# 2) call_command() converts arguments to strings. There is no official way to pass
32+
# previously parsed arguments through call_command(). This makes it a bit awkward to
33+
# use management commands as callable functions in django code which you should be able
34+
# to easily do. django-typer allows you to invoke the command and group functions
35+
# directly so you can work around this, but it would be nice if call_command() supported
36+
# a general interface that all command libraries could easily implement to.
37+
38+
# 3) terminal autocompletion is not pluggable. As of this writing (Django<=5)
39+
# autocomplete is implemented for bash only and has no mechanism for passing the buck
40+
# down to command implementations. The result of this in django-typer is that we wrap
41+
# django's autocomplete and pass the buck to it instead of the other way around. This is
42+
# fine but it will be awkward if two django command line apps with their own autocomplete
43+
# infrastructure are used together. Django should be the central coordinating point for
44+
# this. This is the reason for the pluggable --fallback awkwardness in shellcompletion.
45+
46+
# 4) Too much of the BaseCommand implementation is built assuming argparse. A more
47+
# generalized abstraction of this interface is in order. Right now BaseCommand is doing
48+
# double duty both as a base class and a protocol.
49+
50+
# 5) There is an awkwardness to how parse_args flattens all the arguments and options
51+
# into a single dictionary. This means that when mapping a library like Typer onto the
52+
# BaseCommand interface you cannot allow arguments at different levels
53+
# (e.g. in initialize()) or group() functions above the command to have the same names as
54+
# the command's options. You can work around this by using a different name for the
55+
# option in the command and supplying the desired name in the annotation, but its an odd
56+
# quirk imposed by the base class for users to be aware of.
57+
58+
5859
import inspect
5960
import sys
6061
import typing as t
@@ -184,11 +185,36 @@ def get_command(
184185
force_color: bool = False,
185186
) -> t.Union[BaseCommand, MethodType]:
186187
"""
187-
Get a django command by its name and instantiate it with the provided options. This
188-
will work for normal django commands as well as django_typer commands. If subcommands
189-
are listed for a typer command, the method that corresponds to the command name will
190-
be returned. This method may then be invoked directly. If no subcommands are listed
191-
the command instance will be returned.
188+
Get a Django_ command by its name and instantiate it with the provided options. This
189+
will work for subclasses of BaseCommand_ as well as for :class:`~django_typer.TyperCommand`
190+
subclasses. If subcommands are listed for a :class:`~django_typer.TyperCommand`, the
191+
method that corresponds to the command name will be returned. This method may then be
192+
invoked directly. If no subcommands are listed the command instance will be returned.
193+
194+
Using ``get_command`` to fetch a command instance and then invoking the instance as
195+
a callable is the preferred way to execute :class:`~django_typer.TyperCommand` commands
196+
from code. The arguments and options passed to the __call__ method of the command should
197+
be fully resolved to their expected parameter types before being passed to the command.
198+
The call_command_ interface also works, but arguments must be unparsed strings
199+
and options may be either strings or resolved parameter types. The following is more
200+
efficient than call_command_.
201+
202+
.. code-block:: python
203+
204+
basic = get_command('basic')
205+
result = basic(
206+
arg1,
207+
arg2,
208+
arg3=0.5,
209+
arg4=1
210+
)
211+
212+
Subcommands may be retrieved by passing the subcommand names as additional arguments:
213+
214+
.. code-block:: python
215+
216+
divide = get_command('hierarchy', 'math', 'divide')
217+
result = divide(10, 2)
192218
193219
:param command_name: the name of the command to get
194220
:param subcommand: the subcommand to get if any
@@ -208,6 +234,7 @@ def get_command(
208234
if subcommand:
209235
method = cmd.get_subcommand(*subcommand).click_command._callback.__wrapped__
210236
return MethodType(method, cmd) # return the bound method
237+
211238
return cmd
212239

213240

@@ -263,11 +290,10 @@ def _get_kwargs(self):
263290

264291
class Context(TyperContext):
265292
"""
266-
An extension of the click.Context class that adds a reference to
267-
the TyperCommand instance so that the Django command can be accessed
268-
from within click/typer callbacks that take a context. This context
269-
also keeps track of parameters that were supplied to the call_command()
270-
interface.
293+
An extension of the `click.Context <https://click.palletsprojects.com/api/#context>`_
294+
class that adds a reference to the :class:`~django_typer.TyperCommand` instance so that
295+
the Django_ command can be accessed from within click_ and Typer_ callbacks that take a
296+
context. This context also keeps track of parameters that were supplied to call_command_.
271297
"""
272298

273299
django_command: "TyperCommand"
@@ -279,8 +305,8 @@ class Context(TyperContext):
279305
class ParamDict(dict):
280306
"""
281307
An extension of dict we use to block updates to parameters that were supplied
282-
when the command was invoked via call_command. This complexity is introduced
283-
by the hybrid parsing and option passing inherent to call_command.
308+
when the command was invoked via call_command_. This complexity is introduced
309+
by the hybrid parsing and option passing inherent to call_command_.
284310
"""
285311

286312
supplied: t.Sequence[str]
@@ -297,7 +323,7 @@ def __setitem__(self, key, value):
297323
def supplied_params(self) -> t.Dict[str, t.Any]:
298324
"""
299325
Get the parameters that were supplied when the command was invoked via
300-
call_command, only the root context has these.
326+
call_command_, only the root context has these.
301327
"""
302328
if self.parent:
303329
return self.parent.supplied_params
@@ -484,8 +510,8 @@ def common_params(self) -> t.Sequence[t.Union[click.Argument, click.Option]]:
484510

485511
class GroupFunction(Typer):
486512
"""
487-
Typer adds additional groups of commands by adding Typer apps to parent
488-
Typer apps. This class extends the Typer app class so that we can add
513+
Typer_ adds additional groups of commands by adding Typer_ apps to parent
514+
Typer_ apps. This class extends the ``typer.Typer`` class so that we can add
489515
the additional information necessary to attach this app to the root app
490516
and other groups specified on the django command.
491517
"""
@@ -730,19 +756,71 @@ def initialize(
730756
**kwargs,
731757
):
732758
"""
733-
A function decorator that creates a typer 'callback'. This decorator wraps
734-
the Typer.callback() functionality. We've renamed it to initialize() because
735-
callback() is to general and not intuitive. Callbacks in Typer are general
736-
functions that can be invoked before a command is invoked and that can accept
737-
their own arguments. When an initialize() function is supplied to a django
738-
TyperCommand the default django options will be added as parameters. You can
739-
specify these parameters (see django_typer.types) as arguments on the wrapped
740-
function if you wish to receive them - otherwise they will be intercepted by
741-
the base class infrastructure and used to their purpose.
759+
A function decorator that creates a Typer_
760+
`callback <https://typer.tiangolo.com/tutorial/commands/callback/>`_. This
761+
decorator wraps the
762+
`Typer.callback() <https://typer.tiangolo.com/tutorial/commands/callback/>`_
763+
functionality. We've renamed it to ``initialize()`` because ``callback()`` is
764+
to general and not intuitive. Callbacks in Typer_ are functions that are invoked
765+
before a command is invoked and that can accept their own arguments. When an
766+
``initialize()`` function is supplied to a django :class:`~django_typer.TyperCommand`
767+
the default Django_ options will be added as parameters. You can specify these
768+
parameters (see :mod:`django_typer.types`) as arguments on the wrapped function
769+
if you wish to receive them - otherwise they will be intercepted by the base class
770+
infrastructure and used to their purpose.
742771
743772
The parameters are passed through to
744773
`Typer.callback() <https://typer.tiangolo.com/tutorial/commands/callback/>`_
745774
775+
For example the below command defines two subcommands that both have a common
776+
initializer that accepts a --precision parameter option:
777+
778+
.. code-block:: python
779+
:linenos:
780+
:caption: management/commands/math.py
781+
782+
import typing as t
783+
from typer import Argument, Option
784+
from django_typer import TyperCommand, initialize, command
785+
786+
787+
class Command(TyperCommand):
788+
789+
precision = 2
790+
791+
@initialize(help="Do some math at the given precision.")
792+
def init(
793+
self,
794+
precision: t.Annotated[
795+
int, Option(help="The number of decimal places to output.")
796+
] = precision,
797+
):
798+
self.precision = precision
799+
800+
@command(help="Multiply the given numbers.")
801+
def multiply(
802+
self,
803+
numbers: t.Annotated[
804+
t.List[float], Argument(help="The numbers to multiply")
805+
],
806+
):
807+
...
808+
809+
@command()
810+
def divide(
811+
self,
812+
numerator: t.Annotated[float, Argument(help="The numerator")],
813+
denominator: t.Annotated[float, Argument(help="The denominator")]
814+
):
815+
...
816+
817+
When we run, the command we should provide the --precision option before the subcommand:
818+
819+
.. code-block:: bash
820+
821+
$ ./manage.py math --precision 5 multiply 2 2.333
822+
4.66600
823+
746824
:param name: the name of the callback (defaults to the name of the decorated
747825
function)
748826
:param cls: the command class to use - (the initialize() function is technically
@@ -818,17 +896,36 @@ def command( # pylint: disable=keyword-arg-before-vararg
818896
):
819897
"""
820898
A function decorator that creates a new command and attaches it to the root
821-
command group. This is a passthrough to Typer.command() and the options are
822-
the same, except we swap the default command class for our wrapper.
899+
command group. This is a passthrough to
900+
`Typer.command() <https://typer.tiangolo.com/tutorial/commands/>`_ and the
901+
options are the same, except we swap the default command class for our wrapper.
823902
824-
The decorated function is the command function. It may also be invoked directly
825-
as a method from an instance of the django command class.
903+
We do not need to decorate handle() functions with this decorator, but if we
904+
want to pass options upstream to typer we can:
905+
906+
.. code-block:: python
907+
908+
@command(epilog="This is the epilog for the command.")
909+
def handle():
910+
...
911+
912+
We can also use the command decorator to define multiple subcommands:
826913
827914
.. code-block:: python
828915
829916
@command()
830917
def command1():
831-
# do stuff here
918+
# execute command1 logic here
919+
920+
@command(name='command2')
921+
def other_command():
922+
# arguments passed to the decorator are passed to typer and control
923+
# various aspects of the command, for instance here we've changed the
924+
# name of the command to 'command2' from 'other_command'
925+
926+
The decorated function is the command function. It may also be invoked directly
927+
as a method from an instance of the :class:`~django_typer.TyperCommand` class,
928+
see :func:`~django_typer.get_command`.
832929
833930
:param name: the name of the command (defaults to the name of the decorated
834931
function)
@@ -900,15 +997,33 @@ def group(
900997
):
901998
"""
902999
A function decorator that creates a new subgroup and attaches it to the root
903-
command group. This is like creating a new Typer app and adding it to a parent
904-
Typer app. The kwargs are passed through to the Typer() constructor.
1000+
command group. This is like creating a new Typer_ app and adding it to a parent
1001+
Typer app. The kwargs are passed through to the Typer() constructor. The group()
1002+
functions work like :func:`~django_typer.initialize` functions for their command
1003+
groups.
9051004
9061005
.. code-block:: python
1006+
:caption: management/commands/example.py
9071007
9081008
@group()
909-
def group1():
1009+
def group1(flag: bool = False):
9101010
# do group init stuff here
9111011
1012+
# to attach a command to the group, use the command() decorator
1013+
# on the group function
1014+
@group1.command()
1015+
def command1():
1016+
# this would be invoked like: ./manage.py example group1 --flag command1
1017+
1018+
# you can also attach subgroups to groups!
1019+
@group1.group()
1020+
def subgroup():
1021+
# do subgroup init stuff here
1022+
1023+
@subgroup.command()
1024+
def subcommand():
1025+
# this would be invoked like: ./manage.py example group1 --flag subgroup subcommand
1026+
9121027
:param name: the name of the group (defaults to the name of the decorated function)
9131028
:param cls: the group class to use
9141029
:param invoke_without_command: whether to invoke the group callback if no command

django_typer/examples/tutorial/step6/closepoll.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from django.utils.translation import gettext_lazy as _
1010
from typer import Argument, Option
1111

12-
from django_typer import TyperCommand, model_parser_completer
12+
from django_typer import TyperCommand, command, model_parser_completer
1313
from django_typer.tests.polls.models import Question as Poll
1414

1515

0 commit comments

Comments
 (0)