Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
- **complete_in_thread**: (boolean) if `True`, then completion will run in a separate
thread. If `False` then completion runs in the main thread and causes it to block if slow.
Defaults to `True`.
- Experimental features
- `@with_annotated` now supports `frozenset[T]` collection parameters, alongside the existing
`list[T]`, `set[T]`, and `tuple[T, ...]` collection types.

## 4.0.0 (June 5, 2026)

Expand Down
21 changes: 11 additions & 10 deletions cmd2/annotated.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ def do_paint(
- ``enum.Enum`` subclass -- ``type=converter``, ``choices`` from member values
- ``decimal.Decimal`` -- sets ``type=Decimal``
- ``Literal[...]`` -- ``type=converter`` and ``choices`` from the literal values
- ``list[T]`` / ``set[T]`` / ``tuple[T, ...]`` -- ``nargs='+'`` (or ``'*'`` with a default or ``| None``)
- ``list[T]`` / ``set[T]`` / ``frozenset[T]`` / ``tuple[T, ...]`` -- ``nargs='+'`` (or ``'*'`` with a default or ``| None``)
- ``tuple[T, T]`` (fixed arity, same type) -- ``nargs=N`` with ``type=T``
- ``*args: T`` -- variadic positional (``nargs='*'``); ``T`` is each value's type, not the
collected tuple. ``Annotated[T, Argument(...)]`` metadata is honored
Expand Down Expand Up @@ -588,11 +588,11 @@ def _resolve_element(tp: Any) -> _TypeResult:


def _make_collection_resolver(collection_type: type) -> Callable[..., _TypeResult]:
"""Create a resolver for single-arg collections (list[T], set[T])."""
"""Create a resolver for single-arg collections (list[T], set[T], frozenset[T])."""

def _resolve(_tp: Any, args: tuple[Any, ...], **_ctx: Any) -> _TypeResult:
if len(args) == 0:
# Bare list/set without type args -- treat as list[str]/set[str].
# Bare list/set/frozenset without type args -- treat as list[str]/set[str]/frozenset[str].
return _TypeResult(is_collection=True, container_factory=collection_type)
if len(args) != 1:
raise TypeError(
Expand Down Expand Up @@ -679,6 +679,7 @@ def _resolve_enum(tp: Any, _args: tuple[Any, ...], **_ctx: Any) -> _TypeResult:
float: _make_simple_resolver(float),
int: _make_simple_resolver(int),
Literal: _resolve_literal,
frozenset: _make_collection_resolver(frozenset),
Comment thread
tleonhardt marked this conversation as resolved.
list: _make_collection_resolver(list),
set: _make_collection_resolver(set),
tuple: _resolve_tuple,
Expand Down Expand Up @@ -721,7 +722,7 @@ def _resolve_base_type(tp: Any, *, is_positional: bool = False) -> _TypeResult:
f"Unsupported parameter type {_type_name(tp)!r} for @with_annotated: there is no converter "
f"for it, so command-line values would silently arrive as plain strings. Supported scalar types "
f"are str, int, float, bool, decimal.Decimal, pathlib.Path, enum.Enum subclasses, and Literal[...]; "
f"use one of these (optionally in list/set/tuple) or a subclass of one."
f"use one of these (optionally in list/set/frozenset/tuple) or a subclass of one."
)


Expand Down Expand Up @@ -911,7 +912,7 @@ def omittable(self) -> bool:
def _is_list(self) -> bool:
"""Whether the declared type is ``list``/``list[T]`` -- the shape the list actions need.

Distinct from :attr:`is_collection` (also true for ``set``/``tuple``): ``append``/``extend``/
Distinct from :attr:`is_collection` (also true for ``set``/``frozenset``/``tuple``): ``append``/``extend``/
``append_const`` accumulate specifically into a ``list``.
"""
return get_origin(self.inner_type) is list or self.inner_type is list
Expand All @@ -932,14 +933,14 @@ def _var_positional_element_display(self) -> str:

@property
def _var_positional_element_is_collection(self) -> bool:
"""Whether the ``*args`` element is itself a collection (``list``/``set``/``tuple``).
"""Whether the ``*args`` element is itself a collection (``list``/``set``/``frozenset``/``tuple``).

Mirrors the collection entries in :data:`_TYPE_TABLE`; a collection element means ``*args``
would collect a tuple of collections, which the constraint table rejects.
"""
element = self._var_positional_element
origin = get_origin(element)
return (origin if origin is not None else element) in (list, set, tuple)
return (origin if origin is not None else element) in (list, set, frozenset, tuple)

# -- the user's metadata overrides, derived read-only from ``metadata`` (consulted by the
# choices/action/nargs/required tables, the action phase, and the constraints) --
Expand Down Expand Up @@ -1287,7 +1288,7 @@ def add_to(self, target: _ArgumentTarget) -> None:
_NARGS_RULES: list[_Rule[_ArgparseArgument, _NargsValue | None]] = [
(lambda a: a._meta_nargs is not None, lambda a: a._meta_nargs), # an explicit Argument(nargs=) wins
(lambda a: a.fixed_arity is not None, lambda a: a.fixed_arity), # tuple[T, T] pins nargs to its arity
(lambda a: a.is_collection and a.omittable, _const("*")), # list/set/tuple[T, ...] that may be empty
(lambda a: a.is_collection and a.omittable, _const("*")), # list/set/frozenset/tuple[T, ...] that may be empty
(lambda a: a.is_collection, _const("+")), # collection requiring >= 1 value
(lambda a: a.is_positional and a.omittable, _const("?")), # an optional scalar positional
(_always, _const(None)), # required scalar / any option scalar
Expand Down Expand Up @@ -1571,12 +1572,12 @@ def _const_mismatches_type(a: _ArgparseArgument) -> bool:
lambda a: TypeError(
f"nargs={a._meta_nargs!r} produces a list of values, but the annotation "
f"'{_type_name(a.inner_type)}' is not a collection type. "
f"Use list[T], tuple[T, ...], or set[T] (optionally with | None) to match."
f"Use list[T], tuple[T, ...], set[T], or frozenset[T] (optionally with | None) to match."
),
),
(
# An explicit '?' / (0, 1) on a collection yields a single value (or None), which the
# collection-casting action cannot wrap into the declared list/set/tuple.
# collection-casting action cannot wrap into the declared list/set/frozenset/tuple.
lambda a: a.is_collection and a._meta_nargs_yields_optional_single,
lambda a: TypeError(
f"parameter '{a.name}' in {a.func_qualname} sets nargs={a._meta_nargs!r} on the collection "
Expand Down
29 changes: 15 additions & 14 deletions docs/features/annotated.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,26 +77,27 @@ them as keyword arguments.

The decorator converts Python type annotations into `add_argument()` calls:

| Type annotation | Generated argparse setting |
| -------------------------------------- | ---------------------------------------------------------- |
| `str` | default (no `type=` needed) |
| `int`, `float` | `type=int` or `type=float` |
| `bool` with a default | boolean optional flag via `BooleanOptionalAction` |
| positional `bool` | parsed from `true/false`, `yes/no`, `on/off`, `1/0` |
| `Path` | `type=Path` |
| `Enum` subclass | `type=converter`, `choices` from member values |
| `decimal.Decimal` | `type=decimal.Decimal` |
| `Literal[...]` | `type=literal-converter`, `choices` from values |
| `list[T]` / `set[T]` / `tuple[T, ...]` | `nargs='+'` (or `'*'` if it has a default or is `\| None`) |
| `tuple[T, T]` | fixed `nargs=N` with `type=T` |
| `T \| None` (no default) | positional with `nargs='?'` (accepts 0-or-1 tokens) |
| `T \| None = None` | `--flag` option with `default=None` |
| Type annotation | Generated argparse setting |
| ------------------------------------------------------- | ---------------------------------------------------------- |
| `str` | default (no `type=` needed) |
| `int`, `float` | `type=int` or `type=float` |
| `bool` with a default | boolean optional flag via `BooleanOptionalAction` |
| positional `bool` | parsed from `true/false`, `yes/no`, `on/off`, `1/0` |
| `Path` | `type=Path` |
| `Enum` subclass | `type=converter`, `choices` from member values |
| `decimal.Decimal` | `type=decimal.Decimal` |
| `Literal[...]` | `type=literal-converter`, `choices` from values |
| `list[T]` / `set[T]` / `frozenset[T]` / `tuple[T, ...]` | `nargs='+'` (or `'*'` if it has a default or is `\| None`) |
| `tuple[T, T]` | fixed `nargs=N` with `type=T` |
| `T \| None` (no default) | positional with `nargs='?'` (accepts 0-or-1 tokens) |
| `T \| None = None` | `--flag` option with `default=None` |

When collection types are used with `@with_annotated`, parsed values are passed to the command
function as:

- `list[T]` as `list`
- `set[T]` as `set`
- `frozenset[T]` as `frozenset`
- `tuple[T, ...]` as `tuple`

Unsupported patterns raise `TypeError`, including:
Expand Down
63 changes: 34 additions & 29 deletions tests/test_annotated.py
Original file line number Diff line number Diff line change
Expand Up @@ -325,13 +325,21 @@ class TestBuildParser:
),
pytest.param(_make_func(list[int], name="nums"), {"option_strings": [], "nargs": "+", "type": int}, id="list_int"),
pytest.param(_make_func(set[int], name="nums"), {"option_strings": [], "nargs": "+", "type": int}, id="set_int"),
pytest.param(
_make_func(frozenset[int], name="nums"),
{"option_strings": [], "nargs": "+", "type": int},
id="frozenset_int",
),
pytest.param(
_make_func(tuple[int, int, int], name="triple"),
{"option_strings": [], "nargs": 3, "type": int},
id="tuple_fixed_triple",
),
pytest.param(_make_func(list[str], name="files"), {"option_strings": [], "nargs": "+"}, id="list_positional"),
pytest.param(_make_func(set[str], name="tags"), {"option_strings": [], "nargs": "+"}, id="set_positional"),
pytest.param(
_make_func(frozenset[str], name="tags"), {"option_strings": [], "nargs": "+"}, id="frozenset_positional"
),
pytest.param(
_make_func(tuple[int, ...], name="values"),
{"option_strings": [], "nargs": "+", "type": int},
Expand All @@ -341,6 +349,7 @@ class TestBuildParser:
_make_func(tuple[int, int], name="pair"), {"option_strings": [], "nargs": 2, "type": int}, id="tuple_fixed"
),
pytest.param(_make_func(list, name="items"), {"option_strings": [], "nargs": "+"}, id="bare_list"),
pytest.param(_make_func(frozenset, name="items"), {"option_strings": [], "nargs": "+"}, id="bare_frozenset"),
pytest.param(_make_func(tuple, name="items"), {"option_strings": [], "nargs": "+"}, id="bare_tuple"),
pytest.param(
_make_func(Annotated[int | None, Argument()], name="val"),
Expand Down Expand Up @@ -1469,6 +1478,8 @@ def test_optional_fixed_arity_positional_raises(self, annotation, resolve_kwargs
pytest.param(list[set[int]], id="list_of_set"),
pytest.param(set[list[str]], id="set_of_list"),
pytest.param(tuple[list[int], ...], id="tuple_of_list"),
pytest.param(frozenset[list[int]], id="frozenset_of_list"),
pytest.param(list[frozenset[int]], id="list_of_frozenset"),
],
)
def test_nested_collection_raises(self, annotation) -> None:
Expand All @@ -1478,7 +1489,6 @@ def test_nested_collection_raises(self, annotation) -> None:
@pytest.mark.parametrize(
"annotation",
[
pytest.param(frozenset[str], id="frozenset"),
pytest.param(dict[str, int], id="dict"),
],
)
Expand Down Expand Up @@ -1737,34 +1747,29 @@ def test_non_list_passthrough(self) -> None:
class TestCollectionRuntimeCast:
"""End-to-end verify ``parse_args`` returns the declared container type, not a plain list."""

def test_set_int_returns_set(self) -> None:
parser = build_parser_from_function(_make_func(set[int], name="nums"))
ns = parser.parse_args(["1", "2", "2", "3"])
assert isinstance(ns.nums, set)
assert ns.nums == {1, 2, 3}

def test_tuple_ellipsis_returns_tuple(self) -> None:
parser = build_parser_from_function(_make_func(tuple[int, ...], name="values"))
ns = parser.parse_args(["1", "2", "3"])
assert isinstance(ns.values, tuple)
assert ns.values == (1, 2, 3)

def test_tuple_fixed_returns_tuple(self) -> None:
parser = build_parser_from_function(_make_func(tuple[int, int], name="pair"))
ns = parser.parse_args(["5", "10"])
assert isinstance(ns.pair, tuple)
assert ns.pair == (5, 10)

def test_list_bool_returns_list_of_bools(self) -> None:
parser = build_parser_from_function(_make_func(list[bool], name="flags"))
ns = parser.parse_args(["true", "no", "on"])
assert ns.flags == [True, False, True]

def test_tuple_paths_returns_tuple_of_paths(self) -> None:
parser = build_parser_from_function(_make_func(tuple[Path, Path], name="src_dst"))
ns = parser.parse_args(["/tmp/a", "/tmp/b"])
assert isinstance(ns.src_dst, tuple)
assert ns.src_dst == (Path("/tmp/a"), Path("/tmp/b"))
@pytest.mark.parametrize(
("annotation", "name", "args", "container", "expected"),
[
pytest.param(frozenset[int], "nums", ["1", "2", "2", "3"], frozenset, frozenset({1, 2, 3}), id="frozenset_int"),
pytest.param(set[int], "nums", ["1", "2", "2", "3"], set, {1, 2, 3}, id="set_int"),
pytest.param(tuple[int, ...], "values", ["1", "2", "3"], tuple, (1, 2, 3), id="tuple_ellipsis"),
pytest.param(tuple[int, int], "pair", ["5", "10"], tuple, (5, 10), id="tuple_fixed"),
pytest.param(list[bool], "flags", ["true", "no", "on"], list, [True, False, True], id="list_bool"),
pytest.param(
tuple[Path, Path],
"src_dst",
["/tmp/a", "/tmp/b"],
tuple,
(Path("/tmp/a"), Path("/tmp/b")),
id="tuple_paths",
),
],
)
def test_returns_declared_container(self, annotation, name, args, container, expected) -> None:
parser = build_parser_from_function(_make_func(annotation, name=name))
value = getattr(parser.parse_args(args), name)
assert isinstance(value, container)
assert value == expected

def test_append_action_collects_values(self) -> None:
parser = build_parser_from_function(_make_func(Annotated[list[str], Option("--tag", action="append")], name="tag"))
Expand Down
Loading