Skip to content

Commit cc0bf6c

Browse files
committed
feat(parser): bind parser to command instance and improve subparser handling
1 parent 84663f3 commit cc0bf6c

4 files changed

Lines changed: 109 additions & 53 deletions

File tree

src/ptcmd/command.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66

77
import sys
88
from argparse import ArgumentParser, Namespace, _SubParsersAction
9-
from copy import copy
109
from functools import partial, update_wrapper
1110
from types import MethodType
1211
from typing import (
@@ -30,7 +29,7 @@
3029

3130
from .argument import build_parser, invoke_from_ns
3231
from .completer import ArgparseCompleter
33-
from .info import CommandInfo, CompleterGetterFunc
32+
from .info import CommandInfo, CompleterGetterFunc, bind_parser
3433

3534
if TYPE_CHECKING:
3635
from .core import BaseCmd
@@ -262,7 +261,7 @@ def _ensure_subparsers(self) -> _SubParsersAction:
262261
for action in self.parser._actions:
263262
if isinstance(action, _SubParsersAction):
264263
return action
265-
return self.parser.add_subparsers(metavar='SUBCOMMAND')
264+
return self.parser.add_subparsers(metavar='SUBCOMMAND', required=True)
266265

267266
@overload
268267
def __get__(self, instance: None, owner: Optional[type]) -> Self: ...
@@ -305,10 +304,7 @@ def __cmd_info__(self, cmd: "BaseCmd") -> CommandInfo:
305304
else:
306305
assert self.__func__.__name__.startswith(cmd.COMMAND_FUNC_PREFIX), f"{self.__func__} is not a command function"
307306
cmd_name = self.__func__.__name__[len(cmd.COMMAND_FUNC_PREFIX) :]
308-
parser = self.parser
309-
if parser.prog != cmd_name:
310-
parser = copy(parser)
311-
parser.prog = cmd_name
307+
parser = bind_parser(self.parser, cmd_name, cmd)
312308
if self._completer_getter is not None:
313309
completer = self._completer_getter(cmd)
314310
else:

src/ptcmd/core.py

Lines changed: 20 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
import pydoc
33
import shlex
44
import sys
5-
from argparse import Action, ArgumentParser
5+
import warnings
6+
from abc import ABCMeta
7+
from argparse import _StoreAction
68
from asyncio import iscoroutine
79
from collections import defaultdict
810
from subprocess import run
@@ -13,7 +15,6 @@
1315
Coroutine,
1416
Dict,
1517
List,
16-
Literal,
1718
Optional,
1819
Sequence,
1920
Set,
@@ -23,7 +24,6 @@
2324
Union,
2425
cast,
2526
)
26-
import warnings
2727

2828
from prompt_toolkit.completion import Completer, NestedCompleter
2929
from prompt_toolkit.formatted_text import ANSI, is_formatted_text
@@ -42,8 +42,8 @@
4242

4343
from .argument import Arg
4444
from .command import auto_argument
45-
from .completer import ArgparseCompleter, MultiPrefixCompleter
46-
from .info import CommandInfo, CommandLike, build_cmd_info, set_info
45+
from .completer import MultiPrefixCompleter
46+
from .info import CommandInfo, CommandLike, build_cmd_info, set_info, get_cmd_ins
4747
from .theme import DEFAULT as THEME
4848

4949

@@ -64,7 +64,7 @@ async def _ensure_coroutine(coro: Union[Coroutine[Any, Any, _T], _T]) -> _T:
6464
return coro
6565

6666

67-
class BaseCmd(object):
67+
class BaseCmd(object, metaclass=ABCMeta):
6868
"""Base class for command line interfaces in ptcmd.
6969
7070
This class provides the core functionality for building interactive command-line
@@ -492,36 +492,17 @@ def __init_subclass__(cls, **kwds: Any) -> None:
492492

493493

494494

495-
class _TopicAction(Action):
496-
def __init__(
497-
self,
498-
option_strings: Sequence[str],
499-
dest: str,
500-
nargs: Optional[Literal[1]] = None,
501-
default: None = None,
502-
type: None = None,
503-
choices: None = None,
504-
required: bool = False,
505-
help: Optional[str] = None,
506-
metavar: Union[str, Tuple[str], None] = None,
507-
cmd: Optional["Cmd"] = None,
508-
) -> None:
509-
self.option_strings = option_strings
510-
self.dest = dest
511-
self.nargs = nargs
512-
self.const = None
513-
self.default = default
514-
self.type = type
515-
self.required = required
516-
self.help = help
517-
self.metavar = metavar
518-
self.cmd = cmd
519-
495+
class _TopicAction(_StoreAction):
520496
@property
521497
def choices(self) -> Optional[Sequence[str]]:
522-
if self.cmd is None: # pragma: no cover
498+
cmd = get_cmd_ins(self)
499+
if cmd is None: # pragma: no cover
523500
return
524-
return self.cmd.get_visible_commands()
501+
return cmd.get_visible_commands()
502+
503+
@choices.setter
504+
def choices(self, _: Any) -> None:
505+
pass
525506

526507

527508
class Cmd(BaseCmd):
@@ -604,7 +585,12 @@ def __init__(
604585
self.nohelp = nohelp
605586

606587
@auto_argument
607-
def do_help(self, topic: str = "", *, verbose: Arg[bool, "-v", "--verbose"] = False) -> None: # noqa: F821,B002
588+
def do_help(
589+
self,
590+
topic: Arg[Optional[str], {"action": _TopicAction, "help": "Command or topic for help"}] = None, # noqa: F821,F722,B002
591+
*,
592+
verbose: Arg[bool, "-v", "--verbose", {"help": "Show more detailed help"}] = False # noqa: F821,F722,B002
593+
) -> None:
608594
"""List available commands or provide detailed help for a specific command.
609595
610596
:param topic: Command or topic for which to get help, defaults to ""
@@ -622,13 +608,6 @@ def do_help(self, topic: str = "", *, verbose: Arg[bool, "-v", "--verbose"] = Fa
622608
return self.perror(f"Unknown command: {topic}")
623609
return self.poutput(self._format_help_text(self.command_info[topic], verbose))
624610

625-
@do_help.completer_getter
626-
def _help_completer(self) -> Completer:
627-
parser = ArgumentParser("help", description=self.do_help.__doc__)
628-
parser.add_argument("topic", nargs="?", help="Command or topic for help", action=_TopicAction, cmd=self)
629-
parser.add_argument("-v", "--verbose", action="store_true", help="Show more detailed help")
630-
return ArgparseCompleter(parser)
631-
632611
def _help_menu(self, verbose: bool = False) -> None:
633612
"""Display the help menu showing available commands and help topics.
634613

src/ptcmd/info.py

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
and retrieving command metadata.
55
"""
66

7-
from argparse import ArgumentParser
7+
import copy
8+
from argparse import Action, ArgumentParser, _SubParsersAction
89
from types import MethodType
910
from typing import TYPE_CHECKING, Any, Callable, List, NamedTuple, Optional, Protocol, Union
1011

@@ -29,6 +30,10 @@
2930
CMD_ATTR_DISABLED = "disabled"
3031
CMD_ATTR_HELP_CATEGORY = "help_category"
3132
CMD_ATTR_SHUTCUT = "shortcut"
33+
PARSER_ATTR_CMD = "cmd_ins"
34+
PARSER_ATTR_NAME = "cmd_name"
35+
ACTION_ATTR_CMD = "cmd_ins"
36+
ACTION_ATTR_NAME = "cmd_name"
3237

3338

3439
class CommandInfo(NamedTuple):
@@ -111,3 +116,83 @@ def inner(func: CommandFunc) -> CommandFunc:
111116
return func
112117

113118
return inner
119+
120+
121+
def bind_parser(parser: ArgumentParser, cmd_name: str, cmd_ins: "BaseCmd") -> ArgumentParser:
122+
"""
123+
Binds an ArgumentParser to a command function.
124+
125+
Creates a copy of the parser, sets its prog to the full command path,
126+
and binds the command name and command instance to the parser and all
127+
its actions. Handles subparsers recursively. Raises ValueError if
128+
the parser is already bound.
129+
130+
:param parser: The ArgumentParser to bind
131+
:type parser: ArgumentParser
132+
:param cmd_name: The name of the command
133+
:type cmd_name: str
134+
:param cmd_ins: The instance of the command
135+
:type cmd_ins: BaseCmd
136+
:return: The bound ArgumentParser
137+
:rtype: ArgumentParser
138+
:raises ValueError: If parser is already bound
139+
"""
140+
# Check if parser is already bound
141+
if hasattr(parser, PARSER_ATTR_CMD):
142+
raise ValueError("Parser is already bound to a command")
143+
144+
# Create a shallow copy of the parser
145+
new_parser = copy.copy(parser)
146+
147+
# Build full command path for this parser
148+
if ' ' in new_parser.prog:
149+
cmds = new_parser.prog.split(' ')
150+
cmds[0] = cmd_name
151+
new_parser.prog = ' '.join(cmds)
152+
else:
153+
new_parser.prog = cmd_name
154+
155+
# Bind command metadata to parser
156+
try:
157+
setattr(new_parser, PARSER_ATTR_CMD, cmd_ins)
158+
setattr(new_parser, PARSER_ATTR_NAME, cmd_name)
159+
except AttributeError:
160+
pass
161+
162+
# Process all actions in the parser
163+
new_actions = []
164+
for action in new_parser._actions:
165+
action = copy.copy(action)
166+
# Bind command metadata to action
167+
try:
168+
setattr(action, ACTION_ATTR_CMD, cmd_ins)
169+
setattr(action, ACTION_ATTR_NAME, cmd_name)
170+
except AttributeError:
171+
pass
172+
173+
# Handle subparsers recursively
174+
if isinstance(action, _SubParsersAction):
175+
for n, subparser in action.choices.items():
176+
# Recursively bind subparsers
177+
action.choices[n] = bind_parser(subparser, cmd_name, cmd_ins)
178+
new_actions.append(action)
179+
180+
new_parser._actions = new_actions
181+
return new_parser
182+
183+
184+
def get_cmd_ins(obj: Union["BaseCmd", ArgumentParser, Action]) -> Optional["BaseCmd"]:
185+
"""Get the BaseCmd instance from an object.
186+
187+
:param obj: The object to get the BaseCmd instance from
188+
:type obj: Union[BaseCmd, ArgumentParser, Action]
189+
:return: The BaseCmd instance
190+
:rtype: Optional[BaseCmd]
191+
"""
192+
from .core import BaseCmd
193+
if isinstance(obj, BaseCmd):
194+
return obj
195+
elif isinstance(obj, ArgumentParser):
196+
return getattr(obj, PARSER_ATTR_CMD, None)
197+
elif isinstance(obj, Action):
198+
return getattr(obj, ACTION_ATTR_CMD, None)

tests/test_command.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -264,10 +264,6 @@ def do_main_sub(self, arg: Optional[str] = None) -> None:
264264

265265
cmd = TestCmd()
266266

267-
# Test main command
268-
await cmd.onecmd("main --arg test")
269-
assert cmd.output == "Main command: test"
270-
271267
# Test subcommand
272268
await cmd.onecmd("main sub subarg")
273269
assert cmd.output == "Subcommand: subarg"

0 commit comments

Comments
 (0)