9
9
|__/ |___/ |___/|_|
10
10
11
11
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.
56
21
"""
57
22
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
+
58
59
import inspect
59
60
import sys
60
61
import typing as t
@@ -184,11 +185,36 @@ def get_command(
184
185
force_color : bool = False ,
185
186
) -> t .Union [BaseCommand , MethodType ]:
186
187
"""
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)
192
218
193
219
:param command_name: the name of the command to get
194
220
:param subcommand: the subcommand to get if any
@@ -208,6 +234,7 @@ def get_command(
208
234
if subcommand :
209
235
method = cmd .get_subcommand (* subcommand ).click_command ._callback .__wrapped__
210
236
return MethodType (method , cmd ) # return the bound method
237
+
211
238
return cmd
212
239
213
240
@@ -263,11 +290,10 @@ def _get_kwargs(self):
263
290
264
291
class Context (TyperContext ):
265
292
"""
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_.
271
297
"""
272
298
273
299
django_command : "TyperCommand"
@@ -279,8 +305,8 @@ class Context(TyperContext):
279
305
class ParamDict (dict ):
280
306
"""
281
307
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_ .
284
310
"""
285
311
286
312
supplied : t .Sequence [str ]
@@ -297,7 +323,7 @@ def __setitem__(self, key, value):
297
323
def supplied_params (self ) -> t .Dict [str , t .Any ]:
298
324
"""
299
325
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.
301
327
"""
302
328
if self .parent :
303
329
return self .parent .supplied_params
@@ -484,8 +510,8 @@ def common_params(self) -> t.Sequence[t.Union[click.Argument, click.Option]]:
484
510
485
511
class GroupFunction (Typer ):
486
512
"""
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
489
515
the additional information necessary to attach this app to the root app
490
516
and other groups specified on the django command.
491
517
"""
@@ -730,19 +756,71 @@ def initialize(
730
756
** kwargs ,
731
757
):
732
758
"""
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.
742
771
743
772
The parameters are passed through to
744
773
`Typer.callback() <https://typer.tiangolo.com/tutorial/commands/callback/>`_
745
774
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
+
746
824
:param name: the name of the callback (defaults to the name of the decorated
747
825
function)
748
826
:param cls: the command class to use - (the initialize() function is technically
@@ -818,17 +896,36 @@ def command( # pylint: disable=keyword-arg-before-vararg
818
896
):
819
897
"""
820
898
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.
823
902
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:
826
913
827
914
.. code-block:: python
828
915
829
916
@command()
830
917
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`.
832
929
833
930
:param name: the name of the command (defaults to the name of the decorated
834
931
function)
@@ -900,15 +997,33 @@ def group(
900
997
):
901
998
"""
902
999
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.
905
1004
906
1005
.. code-block:: python
1006
+ :caption: management/commands/example.py
907
1007
908
1008
@group()
909
- def group1():
1009
+ def group1(flag: bool = False ):
910
1010
# do group init stuff here
911
1011
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
+
912
1027
:param name: the name of the group (defaults to the name of the decorated function)
913
1028
:param cls: the group class to use
914
1029
:param invoke_without_command: whether to invoke the group callback if no command
0 commit comments