Skip to content

Commit 8ca00a5

Browse files
author
Brian Kohan
committed
convert depth first attribute resolution to breadth first, fix shellcompletion tets
1 parent 23ec7cb commit 8ca00a5

File tree

3 files changed

+78
-49
lines changed

3 files changed

+78
-49
lines changed

django_typer/__init__.py

Lines changed: 69 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@
8282
import inspect
8383
import sys
8484
import typing as t
85+
from collections import deque
8586
from copy import copy, deepcopy
8687
from functools import cached_property
8788
from importlib import import_module
@@ -902,16 +903,12 @@ def __get__(self, obj, _=None) -> "Typer[P, R]":
902903
def __getattr__(self, name: str) -> t.Any:
903904
for cmd in self.registered_commands:
904905
assert cmd.callback
905-
if name in (cmd.callback.__name__, cmd.name):
906+
if name in _names(cmd):
906907
return cmd
907908
for grp in self.registered_groups:
908909
cmd_grp = t.cast(Typer, grp.typer_instance)
909910
assert cmd_grp
910-
if name in (
911-
cmd_grp.name,
912-
grp.name,
913-
getattr(cmd_grp.info.callback, "__name__", None),
914-
):
911+
if name in _names(cmd_grp):
915912
return cmd_grp
916913
raise AttributeError(
917914
"{cls} object has no attribute {name}".format(
@@ -1753,37 +1750,76 @@ def _resolve_help(dj_cmd: "TyperCommand"):
17531750
dj_cmd.typer_app.info.help = hlp
17541751

17551752

1756-
def depth_first_match(
1757-
app: typer.Typer, name: str
1758-
) -> t.Optional[t.Union[typer.models.CommandInfo, Typer]]:
1753+
def _names(tc: t.Union[typer.models.CommandInfo, Typer]) -> t.List[str]:
17591754
"""
1760-
Perform a depth first search for a command or group by name.
1755+
For a command or group, get a list of attribute name and its CLI name.
17611756
1762-
TODO - should be breadth first
1757+
This annoyingly lives in difference places depending on how the command
1758+
or group was defined. This logic is sensitive to typer internals.
1759+
"""
1760+
names = []
1761+
if isinstance(tc, typer.models.CommandInfo):
1762+
assert tc.callback
1763+
names.append(tc.callback.__name__)
1764+
if tc.name and tc.name != tc.callback.__name__:
1765+
names.append(tc.name)
1766+
else:
1767+
if tc.name:
1768+
names.append(tc.name)
1769+
if tc.info.name and tc.info.name != tc.name:
1770+
names.append(tc.info.name)
1771+
cb_name = getattr(tc.info.callback, "__name__", None)
1772+
if cb_name and cb_name not in names:
1773+
names.append(cb_name)
1774+
return names
1775+
1776+
1777+
def _bfs_match(
1778+
app: Typer, name: str
1779+
) -> t.Optional[t.Union[typer.models.CommandInfo, Typer]]:
1780+
"""
1781+
Perform a breadth first search for a command or group by name.
17631782
17641783
:param app: The Typer app to search.
17651784
:param name: The name of the command or group to search for.
17661785
:return: The command or group if found, otherwise None.
17671786
"""
1768-
for cmd in reversed(app.registered_commands):
1769-
if name in [cmd.name, *([cmd.callback.__name__] if cmd.callback else [])]:
1770-
return cmd
1771-
for grp in reversed(app.registered_groups):
1772-
assert grp.typer_instance
1773-
grp_app = t.cast(Typer, grp.typer_instance)
1774-
# some weirdness, grp_app.info not always == grp
1775-
# todo __deepcopy__ problem?
1776-
# assert grp_app.info is grp
1777-
if name in [
1778-
grp.name,
1779-
grp_app.name,
1780-
getattr(grp_app.info.callback, "__name__", None),
1781-
]:
1782-
return grp_app
1783-
for grp in reversed(app.registered_groups):
1784-
assert grp.typer_instance
1785-
grp_app = t.cast(Typer, grp.typer_instance)
1786-
found = depth_first_match(grp_app, name)
1787+
1788+
def find_at_level(
1789+
lvl: Typer,
1790+
) -> t.Optional[t.Union[typer.models.CommandInfo, Typer]]:
1791+
for cmd in reversed(lvl.registered_commands):
1792+
if name in _names(cmd):
1793+
return cmd
1794+
if name in _names(lvl):
1795+
return lvl
1796+
return None
1797+
1798+
# fast exit out if at top level (most searches - avoid building BFS)
1799+
if found := find_at_level(app):
1800+
return found
1801+
1802+
visited = set()
1803+
bfs_order: t.List[Typer] = []
1804+
queue = deque([app])
1805+
1806+
while queue:
1807+
grp = queue.popleft()
1808+
if grp not in visited:
1809+
visited.add(grp)
1810+
bfs_order.append(grp)
1811+
# if names conflict, only pick the first the others have been
1812+
# overridden - avoids walking down stale branches
1813+
seen = []
1814+
for child_grp in reversed(grp.registered_groups):
1815+
child_app = t.cast(Typer, child_grp.typer_instance)
1816+
assert child_app
1817+
if child_app not in visited and child_app.name not in seen:
1818+
seen.extend(_names(child_app))
1819+
queue.append(child_app)
1820+
1821+
for grp in bfs_order[1:]:
1822+
found = find_at_level(grp)
17871823
if found:
17881824
return found
17891825
return None
@@ -2057,15 +2093,15 @@ def __init__(cls, cls_name, bases, attrs, **kwargs):
20572093

20582094
def __getattr__(cls, name: str) -> t.Any:
20592095
"""
2060-
Fall back depth first search of the typer app tree to resolve attribute accesses of the type:
2096+
Fall back breadth first search of the typer app tree to resolve attribute accesses of the type:
20612097
Command.sub_grp or Command.sub_cmd
20622098
"""
20632099
if name != "typer_app":
20642100
if called_from_command_definition():
20652101
if name in cls._defined_groups:
20662102
return cls._defined_groups[name]
20672103
elif cls.typer_app:
2068-
found = depth_first_match(cls.typer_app, name)
2104+
found = _bfs_match(cls.typer_app, name)
20692105
if found:
20702106
return found
20712107
raise AttributeError(
@@ -2957,7 +2993,7 @@ def __getattr__(self, name: str) -> t.Any:
29572993
)
29582994
if init and init and name == init.__name__:
29592995
return BoundProxy(self, init)
2960-
found = depth_first_match(self.typer_app, name)
2996+
found = _bfs_match(self.typer_app, name)
29612997
if found:
29622998
return BoundProxy(self, found)
29632999
raise AttributeError(

django_typer/tests/shellcompletion/__init__.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import time
1010
import typing as t
1111
from pathlib import Path
12-
12+
import re
1313
from shellingham import detect_shell
1414

1515
from django_typer import get_command
@@ -47,6 +47,11 @@ def read_all_from_fd_with_timeout(fd, timeout):
4747
return bytes(all_data).decode()
4848

4949

50+
def scrub(output: str) -> str:
51+
"""Scrub control code characters and ansi escape sequences for terminal colors from output"""
52+
return re.sub(r"[\x00-\x1F\x7F]|\x1B\[[0-?]*[ -/]*[@-~]", "", output)
53+
54+
5055
class _DefaultCompleteTestCase:
5156
shell = None
5257
manage_script = "manage.py"
@@ -162,7 +167,7 @@ def read(fd):
162167
process.terminate()
163168
process.wait()
164169
# remove bell character which can show up in some terminals where we hit tab
165-
return output.replace("\x07", "")
170+
return scrub(output)
166171

167172
def run_app_completion(self):
168173
completions = self.get_completions(self.launch_script, "completion", " ")

django_typer/tests/test_plugin_pattern.py

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1074,20 +1074,8 @@ def test_group_adapter_precedence(self):
10741074
"adapted2", "--verbosity", "2", "grp2", "sub-grp2", "4", "subsub-grp2"
10751075
)
10761076

1077-
# you might expect that whn sub-grp2 is overridden we'd remove all child functions that
1078-
# were added to the command class. We don't because the bookeeping is not necessarily
1079-
# worth it and also this provides some flexibility for apps to call functions directly
1080-
# on command objects they adapt with confidence
1081-
# self.assertEqual(
1082-
# adapted2.__class__.subsub_grp2(),
1083-
# "adapter2::adapted2()::grp2()::sub_grp2()::subsub_grp2()",
1084-
# )
1085-
self.assertTrue(hasattr(adapted2.__class__, "subsub_grp2"))
1086-
self.assertEqual(
1087-
adapted2.subsub_grp2(),
1088-
"adapter2::adapted2()::grp2()::sub_grp2()::subsub_grp2()",
1089-
)
1090-
# self.assertFalse(hasattr(adapted2, 'subsub_grp2'))
1077+
self.assertFalse(hasattr(adapted2.__class__, "subsub_grp2"))
1078+
self.assertFalse(hasattr(adapted2, "subsub_grp2"))
10911079

10921080
self.assertEqual(
10931081
call_command("adapted2", "--verbosity", "2", "grp3"),

0 commit comments

Comments
 (0)