Skip to content

Commit 0fdfe9b

Browse files
committed
fix #129, progress towards #128
1 parent a160ca6 commit 0fdfe9b

File tree

9 files changed

+312
-13
lines changed

9 files changed

+312
-13
lines changed

ARCHITECTURE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Architecture
22

3-
The principal design challenge of [django-typer](https://pypi.python.org/pypi/django-typer) is to manage the [Typer](https://typer.tiangolo.com/) app trees associated with each Django management command class and to keep these trees separate when classes are inherited and allow them to be edited directly when commands are extended through the plugin pattern. There are also incurred complexities with adding default django options where appropriate and supporting command callbacks as methods or static functions. Supporting dynamic command/group access through attributes on command instances also requires careful usage of advanced Python features.
3+
The principal design challenge of [django-typer](https://pypi.python.org/pypi/django-typer) is to manage the [Typer](https://typer.tiangolo.com/) app trees associated with each Django management command class and to keep these trees separate when classes are inherited and allow them to be edited directly when commands are extended through the plugin pattern. There are also incurred complexities with adding default django options where appropriate and supporting command callbacks as methods or static functions. Supporting dynamic command/group access through attributes on command instances also requires careful usage of esoteric Python features.
44

55
The [Typer](https://typer.tiangolo.com/) app tree defines the layers of groups and commands that define the CLI. Each [TyperCommand](https://django-typer.readthedocs.io/en/latest/reference.html#django_typer.TyperCommand) maintains its own app tree defined by a root [Typer](https://django-typer.readthedocs.io/en/latest/reference.html#django_typer.management.Typer) node. When other classes inherit from a base command class, that app tree is copied and the new class can modify it without affecting the base class's tree. We extend [Typer](https://typer.tiangolo.com/)'s Typer type with our own [Typer](https://django-typer.readthedocs.io/en/latest/reference.html#django_typer.management.Typer) class that adds additional bookkeeping and attribute resolution features we need.
66

django_typer/management/__init__.py

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -612,6 +612,18 @@ def grp(self):
612612
...
613613
"""
614614

615+
def list_commands(self, ctx: click.Context) -> t.List[str]:
616+
"""
617+
Do our best to list commands in definition order.
618+
"""
619+
cmds = list(self.commands.keys())
620+
ordered = []
621+
for defined in getattr(self.django_command, "_defined_order", []):
622+
if defined in cmds:
623+
ordered.append(defined)
624+
cmds.remove(defined)
625+
return ordered + cmds
626+
615627

616628
# staticmethod objects are not picklable which causes problems with deepcopy
617629
# hence the following mishegoss
@@ -1663,9 +1675,9 @@ def _add_common_initializer(
16631675
"""
16641676
if cmd.is_compound_command and not cmd.typer_app.registered_callback:
16651677
cmd.typer_app.callback(
1666-
cls=type(
1678+
cls=type( # pyright: ignore[reportArgumentType]
16671679
"_Initializer",
1668-
(DTGroup,),
1680+
(cmd.typer_app.info.cls or DTGroup,),
16691681
{
16701682
"django_command": cmd,
16711683
"_callback_is_method": False,
@@ -1966,10 +1978,16 @@ def get_ctor(attr: t.Any) -> t.Optional[t.Callable[..., t.Any]]:
19661978
to_remove = []
19671979
to_register = []
19681980
local_handle = attrs.pop("handle", None)
1981+
defined_order = []
19691982
for cmd_cls, cls_attrs in [
19701983
*[(base, vars(base)) for base in command_bases()],
19711984
(None, attrs),
19721985
]:
1986+
defined_order += [
1987+
cmd
1988+
for cmd in getattr(cmd_cls, "_defined_order", [])
1989+
if cmd not in defined_order
1990+
]
19731991
for name, attr in list(cls_attrs.items()):
19741992
if name == "_handle":
19751993
continue
@@ -1980,8 +1998,12 @@ def get_ctor(attr: t.Any) -> t.Optional[t.Callable[..., t.Any]]:
19801998
assert name
19811999
to_remove.append(name)
19822000
_defined_groups[name] = attr
2001+
if cmd_cls is None and name not in defined_order:
2002+
defined_order.append(name)
19832003
elif register := get_ctor(attr):
19842004
to_register.append(register)
2005+
if cmd_cls is None and name not in defined_order:
2006+
defined_order.append(name)
19852007

19862008
handle = getattr(cmd_cls, "_handle", handle)
19872009

@@ -2002,6 +2024,7 @@ def get_ctor(attr: t.Any) -> t.Optional[t.Callable[..., t.Any]]:
20022024

20032025
if handle:
20042026
ctor = get_ctor(handle)
2027+
defined_order.insert(0, typer_app.info.name)
20052028
if ctor:
20062029
to_register.append(
20072030
lambda cmd_cls: ctor(
@@ -2019,6 +2042,7 @@ def get_ctor(attr: t.Any) -> t.Optional[t.Callable[..., t.Any]]:
20192042
)
20202043

20212044
attrs = {
2045+
"_defined_order": defined_order,
20222046
**attrs,
20232047
"_handle": handle,
20242048
"_to_register": to_register,
@@ -2034,20 +2058,20 @@ def get_ctor(attr: t.Any) -> t.Optional[t.Callable[..., t.Any]]:
20342058

20352059
return super().__new__(mcs, cls_name, bases, attrs)
20362060

2037-
def __init__(cls, cls_name, bases, attrs, **kwargs):
2061+
def __init__(self, cls_name, bases, attrs, **kwargs):
20382062
"""
20392063
This method is called after a new class is created.
20402064
"""
2041-
cls = t.cast(t.Type["TyperCommand"], cls)
2042-
if getattr(cls, "typer_app", None):
2043-
cls.typer_app.django_command = cls
2044-
cls.typer_app.info.name = (
2045-
cls.typer_app.info.name or cls.__module__.rsplit(".", maxsplit=1)[-1]
2065+
self = t.cast(t.Type["TyperCommand"], self)
2066+
if getattr(self, "typer_app", None):
2067+
self.typer_app.django_command = self
2068+
self.typer_app.info.name = (
2069+
self.typer_app.info.name or self.__module__.rsplit(".", maxsplit=1)[-1]
20462070
)
2047-
for cmd in getattr(cls, "_to_register", []):
2048-
cmd(cls)
2071+
for cmd in getattr(self, "_to_register", []):
2072+
cmd(self)
20492073

2050-
_add_common_initializer(cls)
2074+
_add_common_initializer(self)
20512075

20522076
super().__init__(cls_name, bases, attrs, **kwargs)
20532077

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from django_typer.management import TyperCommand, DTGroup, command, group
2+
from click import Context
3+
4+
5+
class ReverseAlphaCommands(DTGroup):
6+
def list_commands(self, ctx: Context) -> list[str]:
7+
return list(sorted(self.commands.keys(), reverse=True))
8+
9+
10+
class Command(TyperCommand, cls=ReverseAlphaCommands):
11+
@command()
12+
def a(self):
13+
print("a")
14+
15+
@command()
16+
def b(self):
17+
print("b")
18+
19+
@command()
20+
def c(self):
21+
print("c")
22+
23+
@group(cls=ReverseAlphaCommands)
24+
def d(self):
25+
print("d")
26+
27+
@d.command()
28+
def e(self):
29+
print("e")
30+
31+
@d.command()
32+
def f(self):
33+
print("f")
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
from django_typer.management import Typer, DTGroup
2+
from click import Context
3+
4+
5+
class ReverseAlphaCommands(DTGroup):
6+
def list_commands(self, ctx: Context) -> list[str]:
7+
return list(sorted(self.commands.keys(), reverse=True))
8+
9+
10+
app = Typer(cls=ReverseAlphaCommands)
11+
12+
d_app = Typer(cls=ReverseAlphaCommands)
13+
app.add_typer(d_app)
14+
15+
16+
@app.command()
17+
def a():
18+
print("a")
19+
20+
21+
@app.command()
22+
def b():
23+
print("b")
24+
25+
26+
@app.command()
27+
def c():
28+
print("c")
29+
30+
31+
@d_app.callback(cls=ReverseAlphaCommands)
32+
def d():
33+
print("d")
34+
35+
36+
@d_app.command()
37+
def e():
38+
print("e")
39+
40+
41+
@d_app.command()
42+
def f():
43+
print("f")
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from django_typer.management import TyperCommand, command, group
2+
3+
4+
class Command(TyperCommand):
5+
"""
6+
Test that helps list commands in definition order.
7+
(This is different than click where the order is alphabetical by default)
8+
"""
9+
10+
@command()
11+
def b(self):
12+
print("b")
13+
14+
@command()
15+
def a(self):
16+
print("a")
17+
18+
@group()
19+
def d(self):
20+
print("d")
21+
22+
@command()
23+
def c(self):
24+
print("c")
25+
26+
@d.command()
27+
def g(self):
28+
print("g")
29+
30+
def handle(self):
31+
print("handle")
32+
33+
@d.command()
34+
def e(self):
35+
print("e")
36+
37+
@d.command()
38+
def f(self):
39+
print("f")
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from django_typer.management import TyperCommand, command, group
2+
from tests.apps.test_app.management.commands.order import Command as OrderCommand
3+
4+
5+
class Command(OrderCommand):
6+
"""
7+
Test that helps list commands in definition order.
8+
(This is different than click where the order is alphabetical by default)
9+
"""
10+
11+
@group()
12+
def bb(self):
13+
print("bb")
14+
15+
@command()
16+
def aa(self):
17+
print("aa")
18+
19+
@OrderCommand.d.group()
20+
def x(self):
21+
print("x")
22+
23+
@command()
24+
def b(self):
25+
print("b")
26+
27+
@OrderCommand.d.command()
28+
def i(self):
29+
print("i")
30+
31+
@OrderCommand.d.command()
32+
def h(self):
33+
print("h")
34+
35+
@command(help="Override handle")
36+
def handle(self):
37+
print("handle")
38+
39+
@x.command()
40+
def z(self):
41+
print("z")
42+
43+
@x.command()
44+
def y(self):
45+
print("y")

tests/test_basics.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,3 +125,99 @@ def test_get_command_make_callable(self):
125125
self.assertEqual(
126126
get_command("base")(*args, **kwargs), f"base({args}, {kwargs})"
127127
)
128+
129+
def test_cmd_help_order(self):
130+
buffer = StringIO()
131+
cmd = get_command("order", TyperCommand, stdout=buffer, no_color=True)
132+
133+
cmd.print_help("./manage.py", "order")
134+
hlp = buffer.getvalue()
135+
136+
self.assertTrue(
137+
"""
138+
╭─ Commands ───────────────────────────────────────────────────────────────────╮
139+
│ order │
140+
│ b │
141+
│ a │
142+
│ d │
143+
│ c │
144+
╰──────────────────────────────────────────────────────────────────────────────╯
145+
""".strip()
146+
in hlp
147+
)
148+
149+
buffer.seek(0)
150+
buffer.truncate()
151+
152+
cmd.print_help("./manage.py", "order", "d")
153+
hlp = buffer.getvalue()
154+
155+
self.assertTrue(
156+
"""
157+
╭─ Commands ───────────────────────────────────────────────────────────────────╮
158+
│ g │
159+
│ e │
160+
│ f │
161+
╰──────────────────────────────────────────────────────────────────────────────╯
162+
""".strip()
163+
in hlp
164+
)
165+
166+
cmd2 = get_command("order2", TyperCommand, stdout=buffer, no_color=True)
167+
168+
buffer.seek(0)
169+
buffer.truncate()
170+
171+
cmd2.print_help("./manage.py", "order2")
172+
hlp = buffer.getvalue()
173+
174+
self.assertTrue(
175+
"""
176+
╭─ Commands ───────────────────────────────────────────────────────────────────╮
177+
│ order2 Override handle │
178+
│ b │
179+
│ a │
180+
│ d │
181+
│ c │
182+
│ bb │
183+
│ aa │
184+
╰──────────────────────────────────────────────────────────────────────────────╯
185+
""".strip()
186+
in hlp
187+
)
188+
189+
buffer.seek(0)
190+
buffer.truncate()
191+
192+
cmd2.print_help("./manage.py", "order2", "d")
193+
hlp = buffer.getvalue()
194+
195+
self.assertTrue(
196+
"""
197+
╭─ Commands ───────────────────────────────────────────────────────────────────╮
198+
│ g │
199+
│ e │
200+
│ f │
201+
│ i │
202+
│ h │
203+
│ x │
204+
╰──────────────────────────────────────────────────────────────────────────────╯
205+
""".strip()
206+
in hlp
207+
)
208+
209+
buffer.seek(0)
210+
buffer.truncate()
211+
212+
cmd2.print_help("./manage.py", "order2", "d", "x")
213+
hlp = buffer.getvalue()
214+
215+
self.assertTrue(
216+
"""
217+
╭─ Commands ───────────────────────────────────────────────────────────────────╮
218+
│ z │
219+
│ y │
220+
╰──────────────────────────────────────────────────────────────────────────────╯
221+
""".strip()
222+
in hlp
223+
)

0 commit comments

Comments
 (0)