Skip to content

Commit 437aa7b

Browse files
committed
partial work towards 70
1 parent e6809f1 commit 437aa7b

File tree

10 files changed

+408
-31
lines changed

10 files changed

+408
-31
lines changed

django_typer/__init__.py

Lines changed: 140 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,10 @@
7171
# command class they are attached to. The length of this file is largely a result of
7272
# wrapping Typer.command, Typer.callback and Typer.add_typer in many different contexts
7373
# to achieve the nice interface we would like and also because we list out each parameter
74-
# for developer experience reasons, its only ~600 actual statements
74+
# for developer experience
75+
# The other complexity comes from the fact that we enter pull in methods into the typer
76+
# infrastructure before classes are created so we have to do some booking to make sure
77+
# methods are bound to the right objects when called
7578

7679
import inspect
7780
import sys
@@ -80,6 +83,7 @@
8083
from importlib import import_module
8184
from pathlib import Path
8285
from types import MethodType, SimpleNamespace
86+
import threading
8387

8488
import click
8589
from click.shell_completion import CompletionItem
@@ -655,8 +659,10 @@ def grp(self):
655659
"""
656660

657661

658-
def _staticmethod(func: t.Callable[..., t.Any]) -> staticmethod:
659-
static_wrapper = staticmethod(func)
662+
def _staticmethod(func: t.Callable[..., t.Any]) -> t.Callable[..., t.Any]:
663+
static_wrapper = func
664+
if not type(func).__name__ == "staticmethod":
665+
static_wrapper = staticmethod(func)
660666
cached = getattr(func, _CACHE_KEY, None)
661667
if cached:
662668
setattr(static_wrapper, _CACHE_KEY, cached)
@@ -848,6 +854,23 @@ def django_command(self) -> t.Optional[t.Type["TyperCommand"]]:
848854
def django_command(self, cmd: t.Optional[t.Type["TyperCommand"]]):
849855
self._django_command = cmd
850856

857+
def __deepcopy__(self, memo):
858+
"""
859+
A one level deep shallow-ish deepcopy that makes sure we have our own new lists
860+
of commands and groups, which is all we care about.
861+
"""
862+
if id(self) in memo:
863+
return memo[id(self)]
864+
new_obj = self.__class__.__new__(self.__class__)
865+
memo[id(self)] = new_obj
866+
for k, v in self.__dict__.items():
867+
if callable(v) and type(v).__name__ == "staticmethod":
868+
if hasattr(self.__class__, k):
869+
setattr(new_obj, k, getattr(self.__class__, k))
870+
else:
871+
setattr(new_obj, k, copy(v))
872+
return new_obj
873+
851874
def __init__(
852875
self,
853876
*,
@@ -881,6 +904,8 @@ def __init__(
881904
):
882905
self.parent = parent
883906
self._django_command = django_command
907+
if callback and not is_method(callback):
908+
callback = staticmethod(callback)
884909
super().__init__(
885910
name=name,
886911
cls=cls,
@@ -962,11 +987,15 @@ def callback( # type: ignore
962987
def make_initializer(
963988
func: typer.models.CommandFunctionType,
964989
) -> typer.models.CommandFunctionType:
990+
fn = t.cast(
991+
typer.models.CommandFunctionType,
992+
func if is_method(func) else _staticmethod(func),
993+
)
965994
if self.__class__ is Typer:
966995
# only cache at the top level - this enables subclassing of
967996
# commands defined through the typer style interface.
968997
_cache_initializer(
969-
func,
998+
fn,
970999
common_init=self.parent is None,
9711000
name=name,
9721001
help=help,
@@ -987,12 +1016,8 @@ def make_initializer(
9871016
**kwargs,
9881017
)
9891018
if self.django_command and not hasattr(self.django_command, func.__name__):
990-
setattr(
991-
self.django_command,
992-
func.__name__,
993-
func if is_method(func) else _staticmethod(func),
994-
)
995-
return register_initializer(func)
1019+
setattr(self.django_command, func.__name__, fn)
1020+
return register_initializer(fn)
9961021

9971022
return make_initializer
9981023

@@ -1037,11 +1062,15 @@ def command( # type: ignore
10371062
def make_command(
10381063
func: typer.models.CommandFunctionType,
10391064
) -> typer.models.CommandFunctionType:
1065+
fn = t.cast(
1066+
typer.models.CommandFunctionType,
1067+
func if is_method(func) else _staticmethod(func),
1068+
)
10401069
if self.__class__ is Typer:
10411070
# only cache at the top level - this enables subclassing of
10421071
# commands defined through the typer style interface.
10431072
_cache_command(
1044-
func,
1073+
fn,
10451074
name=name,
10461075
help=help,
10471076
cls=cls,
@@ -1057,12 +1086,8 @@ def make_command(
10571086
**kwargs,
10581087
)
10591088
if self.django_command and not hasattr(self.django_command, func.__name__):
1060-
setattr(
1061-
self.django_command,
1062-
func.__name__,
1063-
func if is_method(func) else _staticmethod(func),
1064-
)
1065-
return register_command(func)
1089+
setattr(self.django_command, func.__name__, fn)
1090+
return register_command(fn)
10661091

10671092
return make_command
10681093

@@ -1093,6 +1118,8 @@ def add_typer( # type: ignore
10931118
) -> None:
10941119
typer_instance.parent = self
10951120
typer_instance.django_command = self.django_command
1121+
if callback and not is_method(callback):
1122+
callback = _staticmethod(callback)
10961123
return super().add_typer(
10971124
typer_instance=typer_instance,
10981125
name=name,
@@ -1116,6 +1143,48 @@ def add_typer( # type: ignore
11161143
)
11171144

11181145

1146+
class _ChainBoundMethod:
1147+
"""
1148+
A descriptor utility class that allows us to call sub groups and commands
1149+
like this:
1150+
1151+
.. code-block:: python
1152+
Command().grp1.grp2()
1153+
Command().grp1.grp2.cmd()
1154+
1155+
If grp1 and grp2 return themselves you can chain calls like this:
1156+
1157+
.. code-block:: python
1158+
Command().grp1().grp2().cmd()
1159+
"""
1160+
parent: t.Optional["Typer"] = None
1161+
1162+
is_method: t.Optional[bool] = None
1163+
1164+
_callback: t.Callable[..., t.Any]
1165+
_local = threading.local()
1166+
1167+
def __init__(self, callback: t.Callable[..., t.Any]):
1168+
self._callback = callback
1169+
1170+
@property
1171+
def cmd_obj(self) -> t.Optional["TyperCommand"]:
1172+
"""
1173+
If this command group was ultimately accessed from a TyperCommand instance,
1174+
get that instance. For instance Command().lvl1.lvl2.cmd() will pass self
1175+
to cmd()
1176+
"""
1177+
assert self.parent is None or isinstance(self.parent, Typer)
1178+
obj = self._local.object or (
1179+
self.parent.cmd_obj if isinstance(self.parent, _ChainBoundMethod) else None
1180+
)
1181+
return (
1182+
obj
1183+
if isinstance(obj, TyperCommand)
1184+
else None
1185+
)
1186+
1187+
11191188
class CommandGroup(t.Generic[P, R], Typer, metaclass=type):
11201189
"""
11211190
Typer_ adds additional groups of commands by adding Typer_ apps to parent
@@ -1128,14 +1197,19 @@ class CommandGroup(t.Generic[P, R], Typer, metaclass=type):
11281197
_initializer: t.Optional[t.Callable[P, R]] = None
11291198
_bindings: t.Dict[t.Type["TyperCommand"], "CommandGroup[P, R]"]
11301199
_owner: t.Any = None
1131-
1132-
is_method: t.Optional[bool] = None
1200+
_local = threading.local()
11331201

11341202
@Typer.django_command.setter # type: ignore[attr-defined]
11351203
def django_command(self, cmd: t.Optional[t.Type["TyperCommand"]]):
11361204
self._django_command = cmd
11371205
self.initializer = self.initializer # trigger class binding
11381206

1207+
@property
1208+
def name(self) -> t.Optional[str]:
1209+
if self.initializer:
1210+
return self.initializer.__name__
1211+
return None
1212+
11391213
@property
11401214
def initializer(self) -> t.Optional[t.Callable[P, R]]:
11411215
return self._initializer
@@ -1161,7 +1235,6 @@ def initializer(self, initializer: t.Optional[t.Callable[P, R]]):
11611235
setattr(
11621236
cmd_cls,
11631237
initializer.__name__,
1164-
# initializer if self.is_method else staticmethod(initializer),
11651238
self,
11661239
)
11671240
for cmd in getattr(self, "registered_commands", []):
@@ -1185,7 +1258,7 @@ def bound(self) -> bool:
11851258
return bool(len(self._bindings))
11861259

11871260
@property
1188-
def owner(self):
1261+
def owner(self) -> t.Optional[t.Type["TyperCommand"]]:
11891262
"""
11901263
If this function or its root ancestor was accessed as a member of a TyperCommand class,
11911264
return that class, if it was accessed in any other way - return None.
@@ -1206,6 +1279,8 @@ def owner(self):
12061279

12071280
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R:
12081281
assert self.initializer
1282+
if self.is_method:
1283+
return MethodType(self.initializer, self.owner)(*args, **kwargs)
12091284
return self.initializer(*args, **kwargs)
12101285

12111286
@t.overload # pragma: no cover
@@ -1232,12 +1307,18 @@ def __get__(self, obj, owner=None):
12321307
on the class and subclasses.
12331308
"""
12341309
self._owner = owner or None
1235-
if obj is None or not isinstance(obj, TyperCommand) or not self.initializer:
1310+
self._local.object = obj
1311+
if obj is None or not isinstance(obj, (TyperCommand, Typer)) or not self.initializer:
12361312
return self
12371313
if self.is_method:
12381314
return MethodType(self.initializer, obj)
12391315
return self.initializer
12401316

1317+
def __deepcopy__(self, memo):
1318+
new_obj = super().__deepcopy__(memo)
1319+
new_obj.initializer = self.initializer
1320+
return new_obj
1321+
12411322
def __init__(
12421323
self,
12431324
*,
@@ -1316,6 +1397,9 @@ def attach(
13161397
# it. This is done to avoid polluting the base command which should still be expected
13171398
# to be instantiable and behave normally regardless of the app stack. This is not an
13181399
# issue when using the typer-style interface because no inheritance is involved.
1400+
# we also take this opportunity to add direct functions of groups and commands to parent
1401+
# command groups as attributes. This allows fully namespaced references to be used when
1402+
# calling command trees. Should be rarely used, but a nice basically zero cost feature.
13191403
self.django_command = self.django_command or django_command
13201404
if not parent:
13211405
parent = django_command.typer_app
@@ -1325,6 +1409,18 @@ def attach(
13251409
self._bindings[django_command] = cpy
13261410
for grp in self.groups:
13271411
grp.attach(django_command=django_command, parent=cpy)
1412+
initializer = getattr(cpy, "initializer", None)
1413+
if grp.name and initializer and not hasattr(initializer, grp.name):
1414+
setattr(initializer, grp.name, grp)
1415+
for cmd in getattr(self, "registered_commands", []):
1416+
if not hasattr(cpy, cmd.callback.__name__):
1417+
setattr(
1418+
cpy,
1419+
cmd.callback.__name__,
1420+
cmd.callback
1421+
if is_method(cmd.callback)
1422+
else _staticmethod(cmd.callback),
1423+
)
13281424
return self
13291425

13301426
def callback( # type: ignore
@@ -1470,9 +1566,12 @@ def command1(self):
14701566

14711567
def make_command(f: t.Callable[P2, R2]) -> t.Callable[P2, R2]:
14721568
owner = kwargs.pop("_owner", None)
1569+
cmd = f if is_method(f) else _staticmethod(f)
14731570
if owner:
14741571
# attach this function to the adapted Command class
1475-
setattr(owner, f.__name__, f if is_method(f) else _staticmethod(f))
1572+
setattr(owner, f.__name__, cmd)
1573+
if not hasattr(self, f.__name__):
1574+
setattr(self, f.__name__, cmd)
14761575
return super( # pylint: disable=super-with-arguments
14771576
CommandGroup, self
14781577
).command(
@@ -1615,12 +1714,15 @@ def create_app(func: t.Callable[P2, R2]) -> CommandGroup[P2, R2]:
16151714
**kwargs,
16161715
)
16171716
)
1717+
new_grp = self.groups[-1]
16181718
if owner:
1619-
new_grp = self.groups[-1]
16201719
cpy = deepcopy(new_grp)
16211720
new_grp._bindings[owner] = cpy
16221721
self.add_typer(cpy)
16231722
setattr(owner, func.__name__, new_grp)
1723+
assert new_grp.name
1724+
if not hasattr(self, new_grp.name):
1725+
setattr(self, new_grp.name, new_grp)
16241726
return self.groups[-1]
16251727

16261728
return create_app
@@ -1741,6 +1843,8 @@ def divide(
17411843
"""
17421844

17431845
def make_initializer(func: t.Callable[P2, R2]) -> t.Callable[P2, R2]:
1846+
if not is_method(func):
1847+
func = staticmethod(func)
17441848
_cache_initializer(
17451849
func,
17461850
common_init=True,
@@ -1844,6 +1948,8 @@ def other_command(self):
18441948
"""
18451949

18461950
def make_command(func: t.Callable[P, R]) -> t.Callable[P, R]:
1951+
if not is_method(func):
1952+
func = staticmethod(func)
18471953
_cache_command(
18481954
func,
18491955
name=name,
@@ -1952,6 +2058,8 @@ def subcommand(self):
19522058
"""
19532059

19542060
def create_app(func: t.Callable[P, R]) -> CommandGroup[P, R]:
2061+
if not is_method(func):
2062+
func = staticmethod(func)
19552063
grp: CommandGroup = CommandGroup( # pyright: ignore[reportAssignmentType]
19562064
name=name,
19572065
cls=cls,
@@ -2744,6 +2852,9 @@ def init(self, ...):
27442852
)
27452853

27462854
def make_initializer(func: t.Callable[P, R]) -> t.Callable[P, R]:
2855+
func = t.cast(
2856+
t.Callable[P, R], func if is_method(func) else _staticmethod(func)
2857+
)
27472858
cmd.typer_app.callback(
27482859
name=name,
27492860
cls=type("_Initializer", (cls,), {"django_command": cmd}),
@@ -2764,9 +2875,7 @@ def make_initializer(func: t.Callable[P, R]) -> t.Callable[P, R]:
27642875
rich_help_panel=rich_help_panel,
27652876
**kwargs,
27662877
)(func)
2767-
setattr(
2768-
cmd, func.__name__, func if is_method(func) else _staticmethod(func)
2769-
)
2878+
setattr(cmd, func.__name__, func)
27702879
return func
27712880

27722881
return make_initializer
@@ -2845,6 +2954,9 @@ def new_command(self, ...):
28452954
)
28462955

28472956
def make_command(func: t.Callable[P, R]) -> t.Callable[P, R]:
2957+
func = t.cast(
2958+
t.Callable[P, R], func if is_method(func) else _staticmethod(func)
2959+
)
28482960
cmd.typer_app.command(
28492961
name=name,
28502962
cls=type("_Command", (cls,), {"django_command": cmd}),
@@ -2861,9 +2973,7 @@ def make_command(func: t.Callable[P, R]) -> t.Callable[P, R]:
28612973
rich_help_panel=rich_help_panel,
28622974
**kwargs,
28632975
)(func)
2864-
setattr(
2865-
cmd, func.__name__, func if is_method(func) else _staticmethod(func)
2866-
)
2976+
setattr(cmd, func.__name__, func)
28672977
return func
28682978

28692979
return make_command

0 commit comments

Comments
 (0)