Skip to content

Commit fb826d1

Browse files
committed
all tests working - pre-inheritance mechanism refactor
1 parent 437aa7b commit fb826d1

File tree

9 files changed

+485
-83
lines changed

9 files changed

+485
-83
lines changed

django_typer/__init__.py

Lines changed: 196 additions & 70 deletions
Large diffs are not rendered by default.
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import typing as t
2+
from django.core.management.base import CommandError
3+
import typer.core
4+
from django_typer import TyperCommand, completers, get_command, Typer, CommandGroup
5+
import importlib
6+
import sys
7+
8+
if sys.version_info < (3, 9):
9+
from typing_extensions import Annotated
10+
else:
11+
from typing import Annotated
12+
13+
import typer
14+
from pathlib import Path
15+
16+
import graphviz
17+
18+
19+
from enum import StrEnum
20+
21+
22+
class Format(StrEnum):
23+
png = "png"
24+
svg = "svg"
25+
pdf = "pdf"
26+
dot = "dot"
27+
28+
29+
class Command(TyperCommand):
30+
"""
31+
Graph the Typer app tree associated with the given command.
32+
"""
33+
34+
cmd: TyperCommand
35+
cmd_name: str
36+
dot = graphviz.Digraph()
37+
level: int = -1
38+
39+
def handle(
40+
self,
41+
command: Annotated[
42+
str,
43+
typer.Argument(
44+
help="An import path to the command to graph, or simply the name of the command.",
45+
shell_complete=completers.complete_import_path,
46+
),
47+
],
48+
output: Annotated[
49+
Path,
50+
typer.Option(
51+
"-o",
52+
"--output",
53+
help="The path to save the graph to.",
54+
shell_complete=completers.complete_path,
55+
),
56+
] = Path("{command}_app_tree"),
57+
format: Annotated[
58+
Format,
59+
typer.Option(
60+
"-f",
61+
"--format",
62+
help="The format to save the graph in.",
63+
shell_complete=completers.these_strings(list(Format)),
64+
),
65+
] = Format.png,
66+
instantiate: Annotated[
67+
bool,
68+
typer.Option(help="Instantiate the command before graphing the app tree."),
69+
] = True,
70+
):
71+
self.cmd_name = command.split(".")[-1]
72+
if "." in command:
73+
self.cmd = getattr(importlib.import_module(command), "Command")
74+
if instantiate:
75+
self.cmd = self.cmd()
76+
elif instantiate:
77+
self.cmd = get_command(command, TyperCommand)
78+
else:
79+
raise CommandError("Cannot instantiate a command that is not imported.")
80+
81+
output = Path(output.parent) / Path(output.name.format(command=self.cmd_name))
82+
83+
self.visit_app(self.cmd.typer_app)
84+
self.dot.render(output, format=format, view=True)
85+
86+
def get_node_name(self, obj: t.Union[typer.models.CommandInfo, CommandGroup]):
87+
assert obj.callback
88+
name = (
89+
obj.callback.__name__
90+
if isinstance(obj, typer.models.CommandInfo)
91+
else str(obj.name)
92+
)
93+
cli_name = (
94+
obj.name or name
95+
if isinstance(obj, typer.models.CommandInfo)
96+
else obj.info.name or name
97+
)
98+
if name != cli_name:
99+
name += f"({cli_name})"
100+
return f"[{hex(id(obj))[-4:]}] {name}"
101+
102+
def visit_app(self, app: Typer):
103+
"""
104+
Walk the typer app tree.
105+
"""
106+
self.level += 1
107+
parent_node = self.get_node_name(app) if self.level else self.cmd_name
108+
109+
self.dot.node(str(id(app)), parent_node)
110+
for cmd in app.registered_commands:
111+
node_name = self.get_node_name(cmd)
112+
self.dot.node(str(id(cmd)), node_name)
113+
self.dot.edge(str(id(app)), str(id(cmd)))
114+
for grp in app.registered_groups:
115+
assert grp.typer_instance
116+
node_name = self.get_node_name(t.cast(CommandGroup, grp.typer_instance))
117+
self.dot.edge(str(id(app)), str(id(grp.typer_instance)))
118+
self.visit_app(t.cast(Typer, grp.typer_instance))

django_typer/tests/apps/test_app/management/commands/native_groups.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
from django_typer import Typer, TyperCommand
1+
from django_typer import Typer, TyperCommand, CommandGroup
22
from django_typer.types import Verbosity
3+
import typing as t
34

45
Command: TyperCommand
56

@@ -23,6 +24,9 @@ def main(name: str):
2324

2425
grp2 = Typer()
2526

27+
# # this does not work
28+
# app.add_typer(t.cast(CommandGroup, grp2))
29+
2630

2731
@grp2.callback(name="grp1")
2832
def init_grp1(flag: bool = False):
@@ -35,6 +39,7 @@ def cmd2(fraction: float):
3539
return {"verbosity": _verbosity, "flag": _flag, "fraction": fraction}
3640

3741

42+
# this works
3843
app.add_typer(grp2)
3944

4045

django_typer/tests/apps/test_app/management/extensions/grp_overload.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,13 @@ def cmd2():
1212
def cmd2(self):
1313
assert self.__class__ is GrpOverload
1414
return f"g1:l2:cmd2()"
15+
16+
17+
@GrpOverload.group(invoke_without_command=True)
18+
def g2(self):
19+
return "g2()"
20+
21+
22+
@GrpOverload.g2.group()
23+
def l2():
24+
return "g2:l2()"

django_typer/tests/test_native.py

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -238,12 +238,7 @@ def test_native_groups_helps(self):
238238
native_groups.print_help("./manage.py", *(cmd_pth.split()))
239239
sim = similarity(expected_help, stdout.getvalue())
240240
print(f"print_help({cmd_pth}) --help similiarity: {sim}")
241-
try:
242-
self.assertGreater(sim, 0.99)
243-
except AssertionError:
244-
import ipdb
245-
246-
ipdb.set_trace()
241+
self.assertGreater(sim, 0.99)
247242

248243
parts = cmd_pth.split()
249244
sim = similarity(

django_typer/tests/test_overloads.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -218,9 +218,19 @@ def test_grp_overload_direct(self):
218218

219219
grp_overload = get_command("grp_overload", GrpOverload)
220220

221+
print("----------")
221222
self.assertEqual(grp_overload.g1.l2("a"), "g1:l2(a)")
223+
print("----------")
222224
self.assertEqual(grp_overload.g0.l2(1), "g0:l2(1)")
225+
print("----------")
223226
self.assertEqual(grp_overload.g0.l2.cmd(), "g0:l2:cmd()")
227+
print("----------")
224228
self.assertEqual(grp_overload.g1.l2.cmd(), "g1:l2:cmd()")
225-
# self.assertEqual(grp_overload.g0.l2.cmd2(), "g0:l2:cmd2()")
226-
# self.assertEqual(grp_overload.g1.l2.cmd2(), "g1:l2:cmd2()")
229+
print("----------")
230+
231+
self.assertEqual(grp_overload.g0.l2.cmd_obj(), grp_overload)
232+
self.assertEqual(grp_overload.g1.l2.cmd_obj(), grp_overload)
233+
234+
self.assertTrue(hasattr(grp_overload.g0.l2, "cmd2"))
235+
self.assertEqual(grp_overload.g0.l2.cmd2(), "g0:l2:cmd2()")
236+
self.assertEqual(grp_overload.g1.l2.cmd2(), "g1:l2:cmd2()")

doc/source/howto.rst

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -322,12 +322,29 @@ Say we have this command, called ``mycommand``:
322322
assert mycommand(10) == 10
323323
324324
325-
The rule of them is this:
325+
The rule of thumb is this:
326326

327327
- Use call_command_ if your options and arguments need parsing.
328328
- Use :func:`~django_typer.get_command` and invoke the command functions directly if your
329329
options and arguments are already of the correct type.
330330

331+
If the second argument is a type, static type checking will assume the return value of get_command
332+
to be of that type:
333+
334+
.. code-block:: python
335+
336+
from django_typer import get_command
337+
from myapp.management.commands.math import Command as Math
338+
339+
math = get_command("math", Math)
340+
math.add(10, 5) # type checkers will resolve add parameters correctly
341+
342+
You may also fetch a subcommand function directly by passing its path:
343+
344+
.. code-block:: python
345+
346+
get_command("math", "add")(10, 5)
347+
331348
.. tip::
332349

333350
Also refer to the :func:`~django_typer.get_command` docs and :ref:`here <multi_with_defaults>`
@@ -655,8 +672,8 @@ The precedence order, for a simple command is as follows:
655672
The rule for how helps are resolved when inheriting from other commands is that higher precedence
656673
helps in base classes will be chosen over lower priority helps in deriving classes. However, if
657674
you would like to use a docstring as the help in a derived class instead of the high priority
658-
help in a base class you can set the equivalent priority help in the deriving class to the empty
659-
string:
675+
help in a base class you can set the equivalent priority help in the deriving class to a falsy
676+
value:
660677

661678
.. code-block:: python
662679

doc/source/reference.rst

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ utils
6161
-----
6262

6363
.. automodule:: django_typer.utils
64-
:members: traceback_config, get_current_command, register_command_extensions
64+
:members: traceback_config, get_current_command, register_command_extensions, is_method
6565

6666
.. _shellcompletion:
6767

@@ -70,3 +70,123 @@ shellcompletion
7070

7171
.. automodule:: django_typer.management.commands.shellcompletion
7272
:members:
73+
74+
75+
Metaclass Logic: Tending the Typer Tree
76+
---------------------------------------
77+
78+
The principal design challenge of django-typer_ is to manage the Typer_ app trees associated with
79+
each Django management command class and to keep these trees separate when classes are inherited
80+
and allow them to be edited directly when commands are extended through the composition pattern.
81+
82+
The Typer_ app tree defines the layers of groups and commands that define the CLI. Each
83+
:class:`~django_typer.TyperCommand` maintains its own app tree, and when other classes inherit from
84+
a base command class, that app tree is copied and the new class can modify it without affecting the
85+
base class.
86+
87+
django-typer_ must support all of the following:
88+
89+
* Inherited classes can extend and override groups and commands defined on the base class without
90+
affecting the base class so that the base class may still be imported and used directly as it
91+
was originally designed.
92+
* Extensions defined using the composition pattern must be able to modify the app trees of the
93+
commands they extend directly.
94+
* The group/command tree on instantiated commands must be walkable using attributes from the
95+
command instance itself to support subgroup name overloads.
96+
97+
During all of this, the correct self must be passed if the function accepts it, but all of the
98+
registered functions are not registered as methods because they enter the Typer_ app tree as
99+
regular functions. This means another thing django-typer_ must do is decide if a function is a
100+
method and if so, bind it to the correct class and pass the correct self instance. The method
101+
test is :func:`~django_typer.utils.is_method` and simply checks to see if the function accepts
102+
a first positional argument named `self`.
103+
104+
django-typer_ uses metaclasses to build the typer app tree when :class:`~django_typer.TyperCommand`
105+
classes are instantiated. The logic flow proceeds this way:
106+
107+
- Class definition is read and @initialize, @group, @command decorators label and store typer
108+
config and registration logic onto the function objects for processing later once the root
109+
Typer_ app is created.
110+
- Metaclass __new__ creates the root Typer_ app for the class and caches the implementation of
111+
handle if it exists.
112+
- Metaclass __init__ walks the class tree and copies the Typer_ app tree from the parent class
113+
to the child class.
114+
115+
116+
117+
.. code-block:: python
118+
119+
class Command(TyperCommand):
120+
121+
# command() runs before the Typer_ app is created, therefore we
122+
# have to cache it and run it later during class creation
123+
@command()
124+
def cmd1(self):
125+
pass
126+
127+
@group()
128+
def grp1(self):
129+
pass
130+
131+
@grp1.group(self):
132+
def grp2(self):
133+
pass
134+
135+
136+
.. code-block:: python
137+
138+
class Command(UpstreamCommand):
139+
140+
# This must *not* alter the grp1 app on the base
141+
# app tree but instead create a new one on this
142+
# commands app tree when it is created
143+
@UpstreamCommand.grp1.command()
144+
def cmd3(self):
145+
pass
146+
147+
# this gets interesting though, because these should be
148+
# equivalent:
149+
@UpstreamCommand.grp2.command()
150+
def cmd4(self):
151+
pass
152+
153+
@UpstreamCommand.grp1.grp2.command()
154+
def cmd4(self):
155+
pass
156+
157+
158+
.. code-block:: python
159+
160+
# extensions called at module scope should modify the app tree of the
161+
# command directly
162+
@UpstreamCommand.grp1.command()
163+
def cmd4(self):
164+
pass
165+
166+
167+
.. code-block:: python
168+
169+
app = Typer()
170+
171+
# similar to extensions these calls should modify the app tree directly
172+
# the Command class exists after the first Typer() call and app is a reference
173+
# directly to Command.typer_app
174+
@app.callback()
175+
def init():
176+
pass
177+
178+
179+
@app.command()
180+
def main():
181+
pass
182+
183+
grp2 = Typer()
184+
app.add_typer(grp2)
185+
186+
@grp2.callback(name="grp1")
187+
def init_grp1():
188+
pass
189+
190+
@grp2.command()
191+
def cmd2():
192+
pass

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ django-stubs = "^4.2.7"
8888
pexpect = "^4.9.0"
8989
pyright = "^1.1.357"
9090
ruff = "^0.4.1"
91+
graphviz = "^0.20.3"
9192

9293
[tool.poetry.extras]
9394
rich = ["rich"]

0 commit comments

Comments
 (0)