Skip to content

Commit 118d6e3

Browse files
authored
Add unit test (#2)
- Update Cmd class to extend BaseCmd with additional functionality - Update tests to cover new functionality - Update project status to Production/Stable in pyproject.toml - Test command collection after prefix change and with conflicting prefixes
1 parent ef99e32 commit 118d6e3

9 files changed

Lines changed: 984 additions & 51 deletions

File tree

.github/workflows/test_cov.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ jobs:
2727
python -m pip install --upgrade pip
2828
pip install setuptools wheel build
2929
- name: Run tests with coverage
30-
run: make develop test_with_coverage
30+
run: make develop coverage_result
3131
- name: Upload coverage data
3232
uses: actions/upload-artifact@v4
3333
with:

Makefile

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: run install install_all refresh uninstall develop build_dist test coverage clean
1+
.PHONY: run install install_all refresh uninstall develop build_dist test coverage coverage_result clean
22

33
MODULE := ptcmd
44
MODULE_PATH := src/${MODULE}
@@ -28,11 +28,10 @@ lint:
2828
test:
2929
pytest
3030

31-
test_with_coverage:
32-
coverage run --source ${MODULE_PATH} --parallel-mode -m pytest --ignore=tests/online/
33-
34-
coverage:
31+
coverage_result:
3532
coverage run --source ${MODULE_PATH} --parallel-mode -m pytest
33+
34+
coverage: coverage_result
3635
coverage combine
3736
coverage html -i
3837

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ requires-python = ">=3.8"
2424
keywords = ["cmd", "interactive", "prompt", "Python"]
2525
dynamic = ["version", "urls"]
2626
classifiers = [
27-
"Development Status :: 4 - Beta",
27+
"Development Status :: 5 - Production/Stable",
2828
"Environment :: Console",
2929
"License :: OSI Approved :: Apache Software License",
3030
"Programming Language :: Python :: 3",

src/ptcmd/argument.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,20 @@ class Argument:
1111
This class provides a declarative way to define argparse arguments, either directly
1212
or through type annotations using `Annotated` (aliased as `Arg` for convenience).
1313
14-
Usage:
14+
It supports all standard argparse argument types and actions, and can be used in
15+
type annotations to define command-line arguments in a declarative way.
16+
17+
Example usage:
1518
1619
```py
20+
# Using the Arg alias
1721
version: Arg[
1822
str,
1923
"-v", "--version",
2024
{"action": "version", "version": "0.1.0"}
2125
]
2226
23-
# Or using Annotated
27+
# Using Annotated with Argument directly
2428
version: Annotated[
2529
str,
2630
Argument(
@@ -214,7 +218,6 @@ def _is_valid_argparse_type(type_obj: Any) -> bool:
214218
:return: True if the type object is valid, False otherwise
215219
:rtype: bool
216220
"""
217-
# 检查是否可调用(函数、类等)
218221
if isinstance(type_obj, (str, FileType)):
219222
return True
220223
elif not callable(type_obj):

src/ptcmd/command.py

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44
with automatic argument parsing and completion.
55
"""
66

7-
from copy import copy
87
import sys
98
from argparse import ArgumentParser, Namespace, _SubParsersAction
9+
from copy import copy
1010
from functools import partial, update_wrapper
1111
from inspect import Parameter, signature
1212
from types import MethodType
@@ -26,8 +26,8 @@
2626
overload,
2727
)
2828

29-
from typing_extensions import ParamSpec
3029
from rich_argparse import RichHelpFormatter
30+
from typing_extensions import ParamSpec, Concatenate, Self
3131

3232
from .argument import build_parser
3333
from .completer import ArgparseCompleter
@@ -38,7 +38,8 @@
3838

3939

4040
_P = ParamSpec("_P")
41-
_P_Subcmd = ParamSpec("_P_Subcmd")
41+
_P_Method = ParamSpec("_P_Method")
42+
_P_Subcmd = ParamSpec("_P_Subcmd")
4243
_T = TypeVar("_T")
4344
_T_Subcmd = TypeVar("_T_Subcmd")
4445

@@ -51,9 +52,10 @@ class Command(Generic[_P, _T]):
5152
- Command metadata (name, hidden status, disabled status)
5253
- Argument completion support
5354
- Method binding for instance commands
55+
- Subcommand management
5456
55-
The Command class is typically created through the @command decorator rather
56-
than being instantiated directly.
57+
The Command class is typically created through the @auto_argument decorator
58+
rather than being instantiated directly.
5759
"""
5860

5961
def __init__(
@@ -134,6 +136,26 @@ def add_subcommand(
134136
add_help: bool = True,
135137
**kwds: Any,
136138
) -> Union[Callable[[Callable[_P_Subcmd, _T_Subcmd]], "Command[_P_Subcmd, _T_Subcmd]"], "Command[_P_Subcmd, _T_Subcmd]"]:
139+
"""Add a subcommand to this command.
140+
141+
This method can be used as a decorator or directly with a function.
142+
It creates a nested command structure where the current command acts as a parent.
143+
144+
:param name: Name of the subcommand
145+
:type name: str
146+
:param func: The function to wrap as a subcommand (if provided directly)
147+
:type func: Optional[Callable[_P_Subcmd, _T_Subcmd]]
148+
:param help: Help text for the subcommand
149+
:type help: Optional[str]
150+
:param aliases: Aliases for the subcommand
151+
:type aliases: Sequence[str]
152+
:param add_help: Whether to add help for the subcommand
153+
:type add极_help: bool
154+
:param kwds: Additional keyword arguments for the Command constructor
155+
:type kwds: Any
156+
:return: Either a decorator function or a Command instance
157+
:rtype: Union[Callable[[Callable[_P_Subcmd, _T_Subcmd]], Command[_P_Subcmd, _T_Subcmd]], Command[_P_Subcmd, _T_Subcmd]]
158+
"""
137159
subparser_action = self._ensure_subparsers()
138160
def inner(inner: Callable[_P_Subcmd, _T_Subcmd]) -> "Command[_P_Subcmd, _T_Subcmd]":
139161
return cast(Type[Command], self.__class__)(
@@ -158,11 +180,20 @@ def inner(inner: Callable[_P_Subcmd, _T_Subcmd]) -> "Command[_P_Subcmd, _T_Subcm
158180
return inner(func)
159181

160182
def completer_getter(self, func: CompleterGetterFunc) -> CompleterGetterFunc:
183+
"""Decorator to set a custom completer getter function for this command.
184+
185+
The completer getter function should accept a BaseCmd instance and return a Completer.
186+
187+
:param func: The completer getter function
188+
:type func: CompleterGetterFunc
189+
:return: The same function (for decorator chaining)
190+
:rtype: CompleterGetterFunc
191+
"""
161192
self._completer_getter = func
162193
return func
163194

164195
def invoke_from_argv(self, cmd: "BaseCmd", argv: List[str], *, parser: Optional[ArgumentParser] = None) -> Any:
165-
"""Invoke the command with parsed arguments.
196+
"""Invoke the command with parsed arguments from a list of argv strings.
166197
167198
This method parses command-line arguments and invokes the command function.
168199
It handles redirecting stdin/stdout during argument parsing.
@@ -171,6 +202,8 @@ def invoke_from_argv(self, cmd: "BaseCmd", argv: List[str], *, parser: Optional[
171202
:type cmd: "BaseCmd"
172203
:param argv: List of argument strings to parse
173204
:type argv: List[str]
205+
:param parser: Optional ArgumentParser to use (default: self.parser)
206+
:type parser: Optional[ArgumentParser]
174207
:return: The result of the wrapped function
175208
:rtype: Any
176209
"""
@@ -190,7 +223,19 @@ def invoke_from_argv(self, cmd: "BaseCmd", argv: List[str], *, parser: Optional[
190223
sys.stdout = old_stdout
191224
sys.stderr = old_stderr
192225
return self.invoke_from_ns(cmd, ns)
226+
193227
def invoke_from_ns(self, cmd: "BaseCmd", ns: Namespace) -> Any:
228+
"""Invoke the command from a parsed namespace object.
229+
230+
This method handles nested command invocation by traversing the command chain.
231+
232+
:param cmd: The BaseCmd instance this command belongs to
233+
:type cmd: "BaseCmd"
234+
:param ns: The parsed argument namespace
235+
:type ns: Namespace
236+
:return: The result of the wrapped function
237+
:rtype: Any
238+
"""
194239
cmd_ins = getattr(ns, "__cmd_ins__", self)
195240
cmd_chain = [cmd_ins]
196241
while cmd_ins._parent is not None and cmd_ins is not self:
@@ -248,14 +293,14 @@ def _ensure_subparsers(self) -> _SubParsersAction:
248293
return self.parser.add_subparsers(metavar='SUBCOMMAND')
249294

250295
@overload
251-
def __get__(self, instance: None, owner: Optional[type]) -> "Command[_P, _T]":
252-
...
296+
def __get__(self, instance: None, owner: Optional[type]) -> Self: ...
253297

254298
@overload
255-
def __get__(self, instance: object, owner: Optional[type]) -> Callable[_P, _T]:
256-
...
299+
def __get__(
300+
self: "Command[Concatenate[Any, _P_Method], _T]", instance: object, owner: Optional[type]
301+
) -> Callable[_P_Method, _T]: ...
257302

258-
def __get__(self, instance: Optional[object], owner: Optional[type]) -> Union["Command[_P, _T]", Callable[_P, _T]]:
303+
def __get__(self, instance: Optional[object], owner: Optional[type]) -> Callable[..., _T]:
259304
"""Descriptor protocol implementation for method binding.
260305
261306
This allows Command instances to behave like methods when accessed

0 commit comments

Comments
 (0)