Skip to content

Commit fadc53f

Browse files
committed
fix #140
1 parent 8170d22 commit fadc53f

18 files changed

+748
-71
lines changed

django_typer/management/__init__.py

+93-6
Original file line numberDiff line numberDiff line change
@@ -688,6 +688,10 @@ class Finalizer(t.Generic[P, R]):
688688
finalizer: t.Callable[P, R]
689689
is_method: bool
690690

691+
@property
692+
def name(self):
693+
return self.finalizer.__name__
694+
691695
def __init__(self, finalizer: t.Callable[P, R]):
692696
self.finalizer = finalizer
693697
self.is_method = bool(is_method(finalizer))
@@ -1361,6 +1365,21 @@ def create_app(func: t.Callable[P2, R2]) -> Typer[P2, R2]:
13611365

13621366
return create_app
13631367

1368+
def finalize(self):
1369+
"""
1370+
Add a finalizer callback to collect the results of all commands run as part of this
1371+
command group. This is analogous to the ``result_callback`` on ``Typer.add_typer`` but
1372+
works seamlessly for methods. See :ref:`howto_finalizers` for more information.
1373+
"""
1374+
1375+
def create_finalizer(func: t.Callable[P2, R2]) -> t.Callable[P2, R2]:
1376+
func = _strip_static(func)
1377+
# TODO not this easy: setattr(self, func.__name__, func)
1378+
self.info.result_callback = Finalizer(func)
1379+
return func
1380+
1381+
return create_finalizer
1382+
13641383

13651384
class BoundProxy(t.Generic[P, R]):
13661385
"""
@@ -1369,7 +1388,7 @@ class BoundProxy(t.Generic[P, R]):
13691388
"""
13701389

13711390
command: "TyperCommand"
1372-
proxied: TyperFunction
1391+
proxied: t.Union[TyperFunction, Finalizer]
13731392

13741393
def __init__(self, command: "TyperCommand", proxied: TyperFunction):
13751394
self.command = command
@@ -1379,6 +1398,8 @@ def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R:
13791398
if isinstance(self.proxied, Typer) and not self.proxied.parent:
13801399
# if we're calling a top level Typer app we need invoke Typer's call
13811400
return self.proxied(*args, **kwargs)
1401+
elif isinstance(self.proxied, Finalizer):
1402+
return self.proxied(*args, _command=self.command, **kwargs)
13821403
return _get_direct_function(self.command, self.proxied)(*args, **kwargs)
13831404

13841405
def __getattr__(self, name: str) -> t.Any:
@@ -1393,6 +1414,10 @@ def __getattr__(self, name: str) -> t.Any:
13931414
if isinstance(attr, (Typer, typer.models.CommandInfo)):
13941415
return BoundProxy(self.command, attr)
13951416
return attr
1417+
elif isinstance(self.proxied, Typer) and isinstance(
1418+
self.proxied.info.result_callback, Finalizer
1419+
):
1420+
return BoundProxy(self.command, self.proxied.info.result_callback)
13961421

13971422
raise AttributeError(
13981423
"{cls} object has no attribute {name}".format(
@@ -1548,7 +1573,36 @@ def make_initializer(func: t.Callable[P2, R2]) -> t.Callable[P2, R2]:
15481573

15491574
def finalize() -> t.Callable[[t.Callable[P2, R2]], t.Callable[P2, R2]]:
15501575
"""
1551-
TODO
1576+
Attach a callback function that collects and finalizes results returned
1577+
from any invoked subcommands. This is a wrapper around typer's result_callback that
1578+
extends it to work as a method and for non-compound commands.
1579+
1580+
.. code-block:: python
1581+
1582+
class Command(TyperCommand, chain=True):
1583+
1584+
@finalize()
1585+
def collect(self, results: t.List[str]):
1586+
return ", ".join(results)
1587+
1588+
@command()
1589+
def cmd1(self):
1590+
return "result1"
1591+
1592+
@command()
1593+
def cmd2(self):
1594+
return "result2"
1595+
1596+
.. code-block:: bash
1597+
1598+
$ ./manage.py command cmd1 cmd2
1599+
result1, result2
1600+
1601+
The callback must at minimum accept a single argument that is either a list of
1602+
results or a singular result if the command is not compound. The callback may
1603+
optionally accept named arguments that correspond to parameters at the given
1604+
command group scope. These parameters will also be available on the current
1605+
context.
15521606
"""
15531607

15541608
def make_finalizer(func: t.Callable[P2, R2]) -> t.Callable[P2, R2]:
@@ -1839,7 +1893,7 @@ def _names(tc: t.Union[typer.models.CommandInfo, Typer]) -> t.List[str]:
18391893
"""
18401894
For a command or group, get a list of attribute name and its CLI name.
18411895
1842-
This annoyingly lives in difference places depending on how the command
1896+
This annoyingly lives in different places depending on how the command
18431897
or group was defined. This logic is sensitive to typer internals.
18441898
"""
18451899
names = []
@@ -1858,7 +1912,7 @@ def _names(tc: t.Union[typer.models.CommandInfo, Typer]) -> t.List[str]:
18581912

18591913
def _bfs_match(
18601914
app: Typer, name: str
1861-
) -> t.Optional[t.Union[typer.models.CommandInfo, Typer]]:
1915+
) -> t.Optional[t.Union[typer.models.CommandInfo, Typer, Finalizer]]:
18621916
"""
18631917
Perform a breadth first search for a command or group by name.
18641918
@@ -1869,12 +1923,18 @@ def _bfs_match(
18691923

18701924
def find_at_level(
18711925
lvl: Typer,
1872-
) -> t.Optional[t.Union[typer.models.CommandInfo, Typer]]:
1926+
) -> t.Optional[t.Union[typer.models.CommandInfo, Typer, Finalizer]]:
18731927
for cmd in reversed(lvl.registered_commands):
18741928
if name in _names(cmd):
18751929
return cmd
18761930
if name in _names(lvl):
18771931
return lvl
1932+
1933+
if (
1934+
isinstance(lvl.info.result_callback, Finalizer)
1935+
and lvl.info.result_callback.name == name
1936+
):
1937+
return lvl.info.result_callback
18781938
return None
18791939

18801940
# fast exit out if at top level (most searches - avoid building BFS)
@@ -2191,7 +2251,7 @@ def __init__(self, cls_name, bases, attrs, **kwargs):
21912251
def __getattr__(cls, name: str) -> t.Any:
21922252
"""
21932253
Fall back breadth first search of the typer app tree to resolve attribute accesses of the type:
2194-
Command.sub_grp or Command.sub_cmd
2254+
Command.sub_grp or Command.sub_cmd or Command.finalizer
21952255
"""
21962256
if name != "typer_app":
21972257
if called_from_command_definition():
@@ -2740,6 +2800,33 @@ def make_initializer(func: t.Callable[P, R]) -> t.Callable[P, R]:
27402800

27412801
callback = initialize
27422802

2803+
@classmethod
2804+
def finalize(cls) -> t.Callable[[t.Callable[P2, R2]], t.Callable[P2, R2]]:
2805+
"""
2806+
Override the finalizer for this command class after it has been defined.
2807+
2808+
.. note::
2809+
See :ref:`plugins` for details on when you might want to use this extension
2810+
pattern.
2811+
2812+
.. code-block:: python
2813+
2814+
from your_app.management.commands.your_command import Command as YourCommand
2815+
2816+
@YourCommand.finalize()
2817+
def collect(self, ...):
2818+
# implement your command result collection logic here
2819+
"""
2820+
if called_from_command_definition():
2821+
return finalize()
2822+
2823+
def make_finalizer(func: t.Callable[P2, R2]) -> t.Callable[P2, R2]:
2824+
setattr(cls, func.__name__, func)
2825+
cls.typer_app.info.result_callback = Finalizer(_strip_static(func))
2826+
return func
2827+
2828+
return make_finalizer
2829+
27432830
@classmethod
27442831
def command(
27452832
cmd, # pyright: ignore[reportSelfClsParameterName]

django_typer/patch.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ def apply() -> None:
5252
# are not attached to ttys. Upstream Django should change the init
5353
# call to a just_fix_windows_console - we undo this and redo the right
5454
# thing here.
55-
import colorama
55+
import colorama # pyright: ignore[reportMissingModuleSource]
5656

5757
colorama.deinit()
5858
colorama.just_fix_windows_console()

doc/source/howto.rst

+10
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,16 @@ decorator. This is like defining a group at the command root and is an extension
262262
command.init(False)
263263
assert not command.subcommand2()
264264
265+
266+
.. _howto_finalizer:
267+
268+
Collect and Finalize Results
269+
----------------------------
270+
271+
.. TODO::
272+
273+
This section is not yet implemented.
274+
265275
Call Commands from Code
266276
-----------------------
267277

tests/apps/test_app/management/commands/finalize_multi_named_param.py

+27-11
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,36 @@
33

44

55
class Command(TyperCommand, chain=True):
6+
suppressed_base_arguments = {
7+
"verbosity",
8+
"skip_checks",
9+
"version",
10+
"settings",
11+
"pythonpath",
12+
}
13+
614
@finalize()
7-
def final(self, result, **kwargs):
15+
def final(self, result, force_color=None, no_color=None, traceback=None):
816
assert isinstance(self, Command)
9-
click_params = getattr(get_current_context(silent=True), "params", {})
10-
assert len(kwargs) == len(click_params)
11-
for key, value in kwargs.items():
12-
assert key in click_params
13-
assert click_params[key] == value
14-
return "finalized: {}".format(result)
17+
try:
18+
click_params = getattr(get_current_context(silent=False), "params", {})
19+
assert "force_color" in click_params
20+
assert click_params["force_color"] == force_color
21+
assert "no_color" in click_params
22+
assert click_params["no_color"] == no_color
23+
assert "traceback" in click_params
24+
assert click_params["traceback"] == traceback
25+
except Exception:
26+
pass
27+
return "finalized: {} | {}".format(
28+
result,
29+
{"force_color": force_color, "no_color": no_color, "traceback": traceback},
30+
)
1531

1632
@command()
17-
def cmd1(self):
18-
return "cmd1"
33+
def cmd1(self, arg1: int = 1):
34+
return "cmd1 {}".format(arg1)
1935

2036
@command()
21-
def cmd2(self):
22-
return "cmd2"
37+
def cmd2(self, arg2: int):
38+
return "cmd2 {}".format(arg2)

tests/apps/test_app/management/commands/finalize_multi_no_params.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ def final(self, result):
99
return "finalized: {}".format(result)
1010

1111
@command()
12-
def cmd1(self):
13-
return "cmd1"
12+
def cmd1(self, arg1: int = 1):
13+
return "cmd1 {}".format(arg1)
1414

1515
@command()
16-
def cmd2(self):
17-
return "cmd2"
16+
def cmd2(self, arg2: int):
17+
return "cmd2 {}".format(arg2)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from .finalize_simple import Command as FinalizeSimple
2+
3+
4+
class Command(FinalizeSimple):
5+
@FinalizeSimple.finalize()
6+
def collect(self, result, **_):
7+
assert isinstance(self, Command)
8+
assert result == "handle"
9+
return "collect: {}".format(result)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from .finalize_simple import Command as FinalizeSimple
2+
3+
4+
class Command(FinalizeSimple):
5+
pass
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from django_typer.management import TyperCommand, finalize
2+
3+
4+
class Command(TyperCommand):
5+
@finalize()
6+
@staticmethod
7+
def final(result, traceback: bool = False):
8+
assert result == "handle"
9+
return f"finalized: {result} | traceback={traceback}"
10+
11+
def handle(self):
12+
return "handle"

0 commit comments

Comments
 (0)