Skip to content

Commit 84663f3

Browse files
committed
feat(argument): add support for Literal types in Argument
- Automatically set choices from Literal type unless explicitly provided - Update tests to cover new functionality - Improve error handling for duplicate command names - Refactor command inheritance to properly handle __commands__ attribute
1 parent 1a09520 commit 84663f3

4 files changed

Lines changed: 49 additions & 3 deletions

File tree

src/ptcmd/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ def do_hello(
3737
from .command import Command, auto_argument
3838
from .info import set_info
3939
from .core import BaseCmd, Cmd
40-
from .version import __version__
40+
from .version import __version__ # noqa: F401
4141

4242
__all__ = [
4343
"Arg",

src/ptcmd/argument.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,9 @@ def __class_getitem__(cls, args: Any) -> Annotated:
118118
This enables the Argument class to be used in type annotations to define
119119
command-line arguments in a declarative way.
120120
121+
Additionally, if the type is a `Literal`, the choices will be automatically
122+
set to the values in the Literal, unless the `choices` keyword is explicitly provided.
123+
121124
:param args: Either:
122125
- A single type (e.g. `Argument[str]`)
123126
- A tuple of (type, *names, Argument) (e.g. `Argument[str, "-f", "--file"]`)
@@ -147,6 +150,14 @@ def __class_getitem__(cls, args: Any) -> Annotated:
147150
else:
148151
kwargs = {}
149152

153+
# Automatically set choices from Literal type
154+
origin = get_origin(tp)
155+
if origin is Literal:
156+
literal_choices = get_args(tp)
157+
# Only set choices if not explicitly provided
158+
if "choices" not in kwargs:
159+
kwargs["choices"] = literal_choices
160+
150161
if not all(isinstance(arg, str) for arg in args): # pragma: no cover
151162
raise TypeError("argument name must be str")
152163
return Annotated[tp, cls(*args, **kwargs)] # type: ignore

src/ptcmd/core.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,10 @@ def __init__(
180180
self.cmdqueue = []
181181
self.lastcmd = ""
182182
self.command_info = {}
183-
self.command_info = {info.name: info for info in map(self._build_command_info, self.__commands__)}
183+
for info in map(self._build_command_info, self.__commands__):
184+
if info.name in self.command_info:
185+
raise ValueError(f"Duplicate command name: {info.name}")
186+
self.command_info[info.name] = info
184187

185188
def cmdloop(self, intro: Optional[Any] = None) -> None:
186189
"""Start the command loop for synchronous execution.
@@ -478,7 +481,10 @@ def __init_subclass__(cls, **kwds: Any) -> None:
478481
)
479482
cls.__commands__ = set()
480483
else:
481-
cls.__commands__ = cls.__commands__.copy()
484+
cls.__commands__ = set()
485+
for base in cls.__bases__:
486+
if issubclass(base, BaseCmd):
487+
cls.__commands__.update(base.__commands__)
482488
for name in dir(cls):
483489
if not name.startswith(cls.COMMAND_FUNC_PREFIX):
484490
continue

tests/test_argument.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import argparse
22
from pathlib import Path
3+
from typing import Literal
34

45
import pytest
56
from typing_extensions import Annotated
@@ -75,3 +76,31 @@ def test_func(*args: str, verbose: bool) -> dict:
7576

7677
result = invoke_from_argv(test_func, ["--verbose", "arg1", "arg2"], unannotated_mode="autoconvert")
7778
assert result == {"args": ("arg1", "arg2"), "verbose": True}
79+
80+
81+
def test_literal_support() -> None:
82+
"""Test automatic choices extraction from Literal types."""
83+
# Test with Literal type alone
84+
arg = Arg[Literal["a", "b", "c"]]
85+
arg_ins = get_argument(arg)
86+
assert arg_ins is not None
87+
assert arg_ins.kwargs.get("choices") == ("a", "b", "c")
88+
89+
# Test with Literal and flags
90+
arg = Arg[Literal[0, 1, 2], "-v", "--verbose"]
91+
arg_ins = get_argument(arg)
92+
assert arg_ins is not None
93+
assert arg_ins.args == ("-v", "--verbose")
94+
assert arg_ins.kwargs.get("choices") == (0, 1, 2)
95+
96+
# Test with explicit choices that override Literal
97+
arg = Arg[Literal["x", "y", "z"], "--choice", {"choices": ["x", "y"]}]
98+
arg_ins = get_argument(arg)
99+
assert arg_ins is not None
100+
assert arg_ins.kwargs.get("choices") == ["x", "y"]
101+
102+
# Test with non-Literal type
103+
arg = Arg[str, "--name"]
104+
arg_ins = get_argument(arg)
105+
assert arg_ins is not None
106+
assert "choices" not in arg_ins.kwargs

0 commit comments

Comments
 (0)