Skip to content

Commit 58b6f76

Browse files
committed
reference docs done, add a chaining test
1 parent a31b496 commit 58b6f76

File tree

4 files changed

+238
-65
lines changed

4 files changed

+238
-65
lines changed

django_typer/__init__.py

Lines changed: 179 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@
1818
1919
django-typer_ also supports shell completion for bash_, zsh_, fish_ and powershell_ and
2020
extends that support to native Django_ management commands as well.
21+
22+
23+
The goal of django-typer_ is to provide full typer style functionality while maintaining
24+
compatibility with the Django management command system. This means that the BaseCommand
25+
interface is preserved and the Typer_ interface is added on top of it. This means that
26+
this code base is more robust to changes in the Django management command system - because
27+
most of the base class functionality is preserved but many Typer_ and click_ internals are
28+
used directly to achieve this. We rely on robust CI to catch breaking changes upstream.
2129
"""
2230

2331
# During development of django-typer_ I've wrestled with a number of encumbrances in the
@@ -586,13 +594,15 @@ def command( # type: ignore
586594
587595
.. code-block:: python
588596
589-
@group()
590-
def group1():
591-
pass
597+
class Command(TyperCommand):
592598
593-
@group1.command()
594-
def command1():
595-
# do stuff here
599+
@group()
600+
def group1(self):
601+
pass
602+
603+
@group1.command()
604+
def command1(self):
605+
# do stuff here
596606
597607
.. note::
598608
@@ -664,17 +674,19 @@ def group(
664674
665675
.. code-block:: python
666676
667-
@group()
668-
def group1():
669-
pass
677+
class Command(TyperCommand):
670678
671-
@group1.group()
672-
def subgroup():
673-
# do common group init stuff here
679+
@group()
680+
def group1(self):
681+
pass
682+
683+
@group1.group()
684+
def subgroup(self):
685+
# do common group init stuff here
674686
675-
@subgroup.command(help=_('My command does good stuff!'))
676-
def subcommand():
677-
# do command stuff here
687+
@subgroup.command(help=_('My command does good stuff!'))
688+
def subcommand(self):
689+
# do command stuff here
678690
679691
680692
:param name: the name of the group
@@ -901,23 +913,27 @@ def command( # pylint: disable=keyword-arg-before-vararg
901913
902914
.. code-block:: python
903915
904-
@command(epilog="This is the epilog for the command.")
905-
def handle():
906-
...
916+
class Command(TyperCommand):
917+
918+
@command(epilog="This is the epilog for the command.")
919+
def handle(self):
920+
...
907921
908922
We can also use the command decorator to define multiple subcommands:
909923
910924
.. code-block:: python
911925
912-
@command()
913-
def command1():
914-
# execute command1 logic here
926+
class Command(TyperCommand):
927+
928+
@command()
929+
def command1(self):
930+
# execute command1 logic here
915931
916-
@command(name='command2')
917-
def other_command():
918-
# arguments passed to the decorator are passed to typer and control
919-
# various aspects of the command, for instance here we've changed the
920-
# name of the command to 'command2' from 'other_command'
932+
@command(name='command2')
933+
def other_command(self):
934+
# arguments passed to the decorator are passed to typer and control
935+
# various aspects of the command, for instance here we've changed the
936+
# name of the command to 'command2' from 'other_command'
921937
922938
The decorated function is the command function. It may also be invoked directly
923939
as a method from an instance of the :class:`~django_typer.TyperCommand` class,
@@ -1001,24 +1017,35 @@ def group(
10011017
.. code-block:: python
10021018
:caption: management/commands/example.py
10031019
1004-
@group()
1005-
def group1(flag: bool = False):
1006-
# do group init stuff here
1020+
from django_typer import TyperCommand, group
1021+
1022+
class Command(TyperCommand):
1023+
1024+
@group()
1025+
def group1(self, flag: bool = False):
1026+
# do group init stuff here
1027+
1028+
# to attach a command to the group, use the command() decorator
1029+
# on the group function
1030+
@group1.command()
1031+
def command1(self):
1032+
...
1033+
1034+
# you can also attach subgroups to groups!
1035+
@group1.group()
1036+
def subgroup(self):
1037+
# do subgroup init stuff here
1038+
1039+
@subgroup.command()
1040+
def subcommand(self):
1041+
...
10071042
1008-
# to attach a command to the group, use the command() decorator
1009-
# on the group function
1010-
@group1.command()
1011-
def command1():
1012-
# this would be invoked like: ./manage.py example group1 --flag command1
1043+
These groups and subcommands can be invoked from the command line like so:
10131044
1014-
# you can also attach subgroups to groups!
1015-
@group1.group()
1016-
def subgroup():
1017-
# do subgroup init stuff here
1045+
.. code-block:: bash
10181046
1019-
@subgroup.command()
1020-
def subcommand():
1021-
# this would be invoked like: ./manage.py example group1 --flag subgroup subcommand
1047+
$ ./manage.py example group1 --flag command1
1048+
$ ./manage.py example group1 --flag subgroup subcommand
10221049
10231050
:param name: the name of the group (defaults to the name of the decorated function)
10241051
:param cls: the group class to use
@@ -1070,7 +1097,7 @@ def create_app(func: CommandFunctionType):
10701097
return create_app
10711098

10721099

1073-
class _TyperCommandMeta(type):
1100+
class TyperCommandMeta(type):
10741101
"""
10751102
The metaclass used to build the TyperCommand class. This metaclass is responsible
10761103
for building Typer app using the arguments supplied to the TyperCommand constructor.
@@ -1514,41 +1541,128 @@ class write method.
15141541
return super().write(msg=msg, style_func=style_func, ending=ending)
15151542

15161543

1517-
class TyperCommand(BaseCommand, metaclass=_TyperCommandMeta):
1544+
class TyperCommand(BaseCommand, metaclass=TyperCommandMeta):
15181545
"""
1519-
A BaseCommand extension class that uses the Typer library to parse
1520-
arguments and options. This class adapts BaseCommand using a light touch
1521-
that relies on most of the original BaseCommand implementation to handle
1546+
An extension of BaseCommand_ that uses the Typer_ library to parse
1547+
arguments and options. This class adapts BaseCommand_ using a light touch
1548+
that relies on most of the original BaseCommand_ implementation to handle
15221549
default arguments and behaviors.
15231550
1524-
The goal of django-typer is to provide full typer style functionality
1525-
while maintaining compatibility with the Django management command system.
1526-
This means that the BaseCommand interface is preserved and the Typer
1527-
interface is added on top of it. This means that this code base is more
1528-
robust to changes in the Django management command system - because most
1529-
of the base class functionality is preserved but many typer and click
1530-
internals are used directly to achieve this. We rely on robust CI to
1531-
catch breaking changes upstream.
1532-
1533-
TyperCommands are built in stages. The metaclass is responsible for finding
1534-
all the commands and callbacks and building the Typer app. This happens at
1535-
class definition time (i.e. on module load). When the TyperCommand is instantiated
1536-
the command tree is built thats used for subcommand resolution in django-typer's
1537-
get_command method and for help output.
1538-
1539-
All of the documented BaseCommand_ functionality works as expected. call_command()
1551+
All of the documented BaseCommand_ functionality works as expected. call_command_
15401552
also works as expected. TyperCommands however add a few extra features:
15411553
1542-
- Simple TyperCommands implemented only using handle() can be invoked directly
1543-
as a function.
1544-
- Subcommands can be fetched and invoked directly as functions using get_command()
1554+
- We define arguments_ and options_ using concise and optionally annotated type hints.
1555+
- Simple TyperCommands implemented only using handle() can be called directly
1556+
by invoking the command as a callable.
1557+
- We can define arbitrarily complex subcommand group hierarchies using the
1558+
:func:`~django_typer.group` and :func:`~django_typer.command` decorators.
1559+
- Commands and subcommands can be fetched and invoked directly as functions using
1560+
:func:`~django_typer.get_command`
1561+
- We can define common initialization logic for groups of commands using
1562+
:func:`~django_typer.initialize`
1563+
- TyperCommands may safely return non-string values from handle()
1564+
1565+
Defining a typer command is a lot like defining a BaseCommand_ except that we do not
1566+
have an add_arguments() method. Instead we define the parameters using type hints
1567+
directly on handle():
1568+
1569+
.. code-block:: python
1570+
1571+
import typing as t
1572+
from django_typer import TyperCommand
1573+
1574+
class Command(TyperCommand):
1575+
1576+
def handle(
1577+
self,
1578+
arg: str,
1579+
option: t.Optional[str] = None
1580+
):
1581+
# do command logic here
1582+
1583+
TyperCommands can be extremely simple like above, or we can create really complex
1584+
command group hierarchies with subcommands and subgroups (see :func:`~django_typer.group`
1585+
and :func:`~django_typer.command`).
1586+
1587+
Typer_ apps can be configured with a number of parameters to control behavior such as
1588+
exception behavior, help output, help markup interpretation, result processing and
1589+
execution flow. These parameters can be passed to typer as keyword arguments in your
1590+
Command class inheritance:
1591+
1592+
.. code-block:: python
1593+
:caption: management/commands/chain.py
1594+
:linenos:
1595+
1596+
import typing as t
1597+
from django_typer import TyperCommand, command
1598+
1599+
1600+
class Command(TyperCommand, rich_markup_mode='markdown', chain=True):
1601+
1602+
suppressed_base_arguments = [
1603+
'--verbosity', '--traceback', '--no-color', '--force-color',
1604+
'--skip_checks', '--settings', '--pythonpath', '--version'
1605+
]
1606+
1607+
@command()
1608+
def command1(self, option: t.Optional[str] = None):
1609+
\"""This is a *markdown* help string\"""
1610+
print('command1')
1611+
return option
1612+
1613+
@command()
1614+
def command2(self, option: t.Optional[str] = None):
1615+
\"""This is a *markdown* help string\"""
1616+
print('command2')
1617+
return option
1618+
1619+
1620+
We're doing a number of things here:
1621+
1622+
- Using the :func:`~django_typer.command` decorator to define multiple subcommands.
1623+
- Using the `suppressed_base_arguments attribute
1624+
<https://docs.djangoproject.com/en/stable/howto/custom-management-commands/#django.core.management.BaseCommand.suppressed_base_arguments>`_
1625+
to suppress the default options Django adds to the command interface.
1626+
- Using the `rich_markup_mode parameter
1627+
<https://typer.tiangolo.com/tutorial/commands/help/#rich-markdown-and-markup>`_ to enable
1628+
markdown rendering in help output.
1629+
- Using the chain parameter to enable command chaining.
1630+
1631+
1632+
We can see that our help renders like so:
1633+
1634+
.. typer:: django_typer.tests.test_app.management.commands.chain.Command:typer_app
1635+
:prog: ./manage.py chain
1636+
:width: 80
1637+
:convert-png: latex
1638+
1639+
1640+
And we can see the chain behavior by calling our command(s) like so:
1641+
1642+
.. code-block:: bash
1643+
1644+
$ ./manage.py chain command1 --option one command2 --option two
1645+
command1
1646+
command2
1647+
['one', 'two']
1648+
1649+
See :class:`~django_typer.TyperCommandMeta` for the list of accepted parameters. Also
1650+
refer to the Typer_ docs for more information on the behaviors expected for
1651+
those parameters - they are passed through to the Typer class constructor. Not all
1652+
parameters may make sense in the context of a django command.
15451653
15461654
:param stdout: the stdout stream to use
15471655
:param stderr: the stderr stream to use
15481656
:param no_color: whether to disable color output
15491657
:param force_color: whether to force color output even if the stream is not a tty
15501658
"""
15511659

1660+
# TyperCommands are built in stages. The metaclass is responsible for finding
1661+
# all the commands and callbacks and building the Typer_ app. This happens at
1662+
# class definition time (i.e. on module load). When the TyperCommand is instantiated
1663+
# the command tree is built thats used for subcommand resolution in django-typer's
1664+
# get_command method and for help output.
1665+
15521666
style: ColorStyle
15531667
stdout: BaseOutputWrapper
15541668
stderr: BaseOutputWrapper
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import typing as t
2+
3+
from django_typer import TyperCommand, command
4+
5+
6+
class Command(TyperCommand, rich_markup_mode="markdown", chain=True):
7+
8+
suppressed_base_arguments = [
9+
"--verbosity",
10+
"--traceback",
11+
"--no-color",
12+
"--force-color",
13+
"--skip_checks",
14+
"--settings",
15+
"--pythonpath",
16+
"--version",
17+
]
18+
19+
@command()
20+
def command1(self, option: t.Optional[str] = None):
21+
"""This is a *markdown* help string"""
22+
print(f"command1")
23+
return option
24+
25+
@command()
26+
def command2(self, option: t.Optional[str] = None):
27+
"""This is a *markdown* help string"""
28+
print(f"command2")
29+
return option

django_typer/tests/tests.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1681,6 +1681,34 @@ def test_get_current_command_returns_none():
16811681
assert get_current_command() is None
16821682

16831683

1684+
class TestChaining(TestCase):
1685+
1686+
def test_command_chaining(self):
1687+
from django_typer import OutputWrapper
1688+
1689+
result = run_command(
1690+
"chain", "command1", "--option=one", "command2", "--option=two"
1691+
)[0]
1692+
self.assertEqual(result, "command1\ncommand2\n['one', 'two']\n")
1693+
1694+
result = run_command(
1695+
"chain", "command2", "--option=two", "command1", "--option=one"
1696+
)[0]
1697+
self.assertEqual(result, "command2\ncommand1\n['two', 'one']\n")
1698+
1699+
stdout = StringIO()
1700+
with contextlib.redirect_stdout(stdout):
1701+
result = call_command(
1702+
"chain", "command2", "--option=two", "command1", "--option=one"
1703+
)
1704+
self.assertEqual(stdout.getvalue(), "command2\ncommand1\n['two', 'one']\n")
1705+
self.assertEqual(result, ["two", "one"])
1706+
1707+
chain = get_command("chain")
1708+
self.assertEqual(chain.command1(option="one"), "one")
1709+
self.assertEqual(chain.command2(option="two"), "two")
1710+
1711+
16841712
SHELLS = [
16851713
(None, False),
16861714
("zsh", True),

doc/source/reference.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ django_typer
1818

1919
.. autoclass:: django_typer.TyperCommand
2020

21+
.. autoclass:: django_typer.TyperCommandMeta
22+
2123
.. autoclass:: django_typer.GroupFunction
2224
:members: group, command
2325

0 commit comments

Comments
 (0)