Skip to content

Commit 99173cc

Browse files
feat: annotated argpare adding support for frozenset (#1691)
* feat(annotated): support frozenset[T] collections frozenset[T] joins list/set/tuple as a supported collection annotation: it registers the same single-arg collection resolver, coerces the parsed values into a frozenset, and rejects nested collections like the others. Adds a do_tags example and parametrizes the container runtime-cast test (which now also covers frozenset). * docs(annotated): list frozenset in collection messages and changelog Address review feedback on #1691: frozenset[T] was functionally supported but the error messages and docstrings enumerating the supported collection types still only mentioned list/set/tuple. Update those strings so exceptions and docs accurately list frozenset, and add a CHANGELOG entry for the new collection type. --------- Co-authored-by: Todd Leonhardt <todd.leonhardt@gmail.com>
1 parent 6c6d844 commit 99173cc

4 files changed

Lines changed: 63 additions & 53 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
- **complete_in_thread**: (boolean) if `True`, then completion will run in a separate
66
thread. If `False` then completion runs in the main thread and causes it to block if slow.
77
Defaults to `True`.
8+
- Experimental features
9+
- `@with_annotated` now supports `frozenset[T]` collection parameters, alongside the existing
10+
`list[T]`, `set[T]`, and `tuple[T, ...]` collection types.
811

912
## 4.0.0 (June 5, 2026)
1013

cmd2/annotated.py

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ def do_paint(
5656
- ``enum.Enum`` subclass -- ``type=converter``, ``choices`` from member values
5757
- ``decimal.Decimal`` -- sets ``type=Decimal``
5858
- ``Literal[...]`` -- ``type=converter`` and ``choices`` from the literal values
59-
- ``list[T]`` / ``set[T]`` / ``tuple[T, ...]`` -- ``nargs='+'`` (or ``'*'`` with a default or ``| None``)
59+
- ``list[T]`` / ``set[T]`` / ``frozenset[T]`` / ``tuple[T, ...]`` -- ``nargs='+'`` (or ``'*'`` with a default or ``| None``)
6060
- ``tuple[T, T]`` (fixed arity, same type) -- ``nargs=N`` with ``type=T``
6161
- ``*args: T`` -- variadic positional (``nargs='*'``); ``T`` is each value's type, not the
6262
collected tuple. ``Annotated[T, Argument(...)]`` metadata is honored
@@ -588,11 +588,11 @@ def _resolve_element(tp: Any) -> _TypeResult:
588588

589589

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

593593
def _resolve(_tp: Any, args: tuple[Any, ...], **_ctx: Any) -> _TypeResult:
594594
if len(args) == 0:
595-
# Bare list/set without type args -- treat as list[str]/set[str].
595+
# Bare list/set/frozenset without type args -- treat as list[str]/set[str]/frozenset[str].
596596
return _TypeResult(is_collection=True, container_factory=collection_type)
597597
if len(args) != 1:
598598
raise TypeError(
@@ -679,6 +679,7 @@ def _resolve_enum(tp: Any, _args: tuple[Any, ...], **_ctx: Any) -> _TypeResult:
679679
float: _make_simple_resolver(float),
680680
int: _make_simple_resolver(int),
681681
Literal: _resolve_literal,
682+
frozenset: _make_collection_resolver(frozenset),
682683
list: _make_collection_resolver(list),
683684
set: _make_collection_resolver(set),
684685
tuple: _resolve_tuple,
@@ -721,7 +722,7 @@ def _resolve_base_type(tp: Any, *, is_positional: bool = False) -> _TypeResult:
721722
f"Unsupported parameter type {_type_name(tp)!r} for @with_annotated: there is no converter "
722723
f"for it, so command-line values would silently arrive as plain strings. Supported scalar types "
723724
f"are str, int, float, bool, decimal.Decimal, pathlib.Path, enum.Enum subclasses, and Literal[...]; "
724-
f"use one of these (optionally in list/set/tuple) or a subclass of one."
725+
f"use one of these (optionally in list/set/frozenset/tuple) or a subclass of one."
725726
)
726727

727728

@@ -911,7 +912,7 @@ def omittable(self) -> bool:
911912
def _is_list(self) -> bool:
912913
"""Whether the declared type is ``list``/``list[T]`` -- the shape the list actions need.
913914
914-
Distinct from :attr:`is_collection` (also true for ``set``/``tuple``): ``append``/``extend``/
915+
Distinct from :attr:`is_collection` (also true for ``set``/``frozenset``/``tuple``): ``append``/``extend``/
915916
``append_const`` accumulate specifically into a ``list``.
916917
"""
917918
return get_origin(self.inner_type) is list or self.inner_type is list
@@ -932,14 +933,14 @@ def _var_positional_element_display(self) -> str:
932933

933934
@property
934935
def _var_positional_element_is_collection(self) -> bool:
935-
"""Whether the ``*args`` element is itself a collection (``list``/``set``/``tuple``).
936+
"""Whether the ``*args`` element is itself a collection (``list``/``set``/``frozenset``/``tuple``).
936937
937938
Mirrors the collection entries in :data:`_TYPE_TABLE`; a collection element means ``*args``
938939
would collect a tuple of collections, which the constraint table rejects.
939940
"""
940941
element = self._var_positional_element
941942
origin = get_origin(element)
942-
return (origin if origin is not None else element) in (list, set, tuple)
943+
return (origin if origin is not None else element) in (list, set, frozenset, tuple)
943944

944945
# -- the user's metadata overrides, derived read-only from ``metadata`` (consulted by the
945946
# choices/action/nargs/required tables, the action phase, and the constraints) --
@@ -1287,7 +1288,7 @@ def add_to(self, target: _ArgumentTarget) -> None:
12871288
_NARGS_RULES: list[_Rule[_ArgparseArgument, _NargsValue | None]] = [
12881289
(lambda a: a._meta_nargs is not None, lambda a: a._meta_nargs), # an explicit Argument(nargs=) wins
12891290
(lambda a: a.fixed_arity is not None, lambda a: a.fixed_arity), # tuple[T, T] pins nargs to its arity
1290-
(lambda a: a.is_collection and a.omittable, _const("*")), # list/set/tuple[T, ...] that may be empty
1291+
(lambda a: a.is_collection and a.omittable, _const("*")), # list/set/frozenset/tuple[T, ...] that may be empty
12911292
(lambda a: a.is_collection, _const("+")), # collection requiring >= 1 value
12921293
(lambda a: a.is_positional and a.omittable, _const("?")), # an optional scalar positional
12931294
(_always, _const(None)), # required scalar / any option scalar
@@ -1571,12 +1572,12 @@ def _const_mismatches_type(a: _ArgparseArgument) -> bool:
15711572
lambda a: TypeError(
15721573
f"nargs={a._meta_nargs!r} produces a list of values, but the annotation "
15731574
f"'{_type_name(a.inner_type)}' is not a collection type. "
1574-
f"Use list[T], tuple[T, ...], or set[T] (optionally with | None) to match."
1575+
f"Use list[T], tuple[T, ...], set[T], or frozenset[T] (optionally with | None) to match."
15751576
),
15761577
),
15771578
(
15781579
# An explicit '?' / (0, 1) on a collection yields a single value (or None), which the
1579-
# collection-casting action cannot wrap into the declared list/set/tuple.
1580+
# collection-casting action cannot wrap into the declared list/set/frozenset/tuple.
15801581
lambda a: a.is_collection and a._meta_nargs_yields_optional_single,
15811582
lambda a: TypeError(
15821583
f"parameter '{a.name}' in {a.func_qualname} sets nargs={a._meta_nargs!r} on the collection "

docs/features/annotated.md

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -77,26 +77,27 @@ them as keyword arguments.
7777

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

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

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

9898
- `list[T]` as `list`
9999
- `set[T]` as `set`
100+
- `frozenset[T]` as `frozenset`
100101
- `tuple[T, ...]` as `tuple`
101102

102103
Unsupported patterns raise `TypeError`, including:

tests/test_annotated.py

Lines changed: 34 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -325,13 +325,21 @@ class TestBuildParser:
325325
),
326326
pytest.param(_make_func(list[int], name="nums"), {"option_strings": [], "nargs": "+", "type": int}, id="list_int"),
327327
pytest.param(_make_func(set[int], name="nums"), {"option_strings": [], "nargs": "+", "type": int}, id="set_int"),
328+
pytest.param(
329+
_make_func(frozenset[int], name="nums"),
330+
{"option_strings": [], "nargs": "+", "type": int},
331+
id="frozenset_int",
332+
),
328333
pytest.param(
329334
_make_func(tuple[int, int, int], name="triple"),
330335
{"option_strings": [], "nargs": 3, "type": int},
331336
id="tuple_fixed_triple",
332337
),
333338
pytest.param(_make_func(list[str], name="files"), {"option_strings": [], "nargs": "+"}, id="list_positional"),
334339
pytest.param(_make_func(set[str], name="tags"), {"option_strings": [], "nargs": "+"}, id="set_positional"),
340+
pytest.param(
341+
_make_func(frozenset[str], name="tags"), {"option_strings": [], "nargs": "+"}, id="frozenset_positional"
342+
),
335343
pytest.param(
336344
_make_func(tuple[int, ...], name="values"),
337345
{"option_strings": [], "nargs": "+", "type": int},
@@ -341,6 +349,7 @@ class TestBuildParser:
341349
_make_func(tuple[int, int], name="pair"), {"option_strings": [], "nargs": 2, "type": int}, id="tuple_fixed"
342350
),
343351
pytest.param(_make_func(list, name="items"), {"option_strings": [], "nargs": "+"}, id="bare_list"),
352+
pytest.param(_make_func(frozenset, name="items"), {"option_strings": [], "nargs": "+"}, id="bare_frozenset"),
344353
pytest.param(_make_func(tuple, name="items"), {"option_strings": [], "nargs": "+"}, id="bare_tuple"),
345354
pytest.param(
346355
_make_func(Annotated[int | None, Argument()], name="val"),
@@ -1469,6 +1478,8 @@ def test_optional_fixed_arity_positional_raises(self, annotation, resolve_kwargs
14691478
pytest.param(list[set[int]], id="list_of_set"),
14701479
pytest.param(set[list[str]], id="set_of_list"),
14711480
pytest.param(tuple[list[int], ...], id="tuple_of_list"),
1481+
pytest.param(frozenset[list[int]], id="frozenset_of_list"),
1482+
pytest.param(list[frozenset[int]], id="list_of_frozenset"),
14721483
],
14731484
)
14741485
def test_nested_collection_raises(self, annotation) -> None:
@@ -1478,7 +1489,6 @@ def test_nested_collection_raises(self, annotation) -> None:
14781489
@pytest.mark.parametrize(
14791490
"annotation",
14801491
[
1481-
pytest.param(frozenset[str], id="frozenset"),
14821492
pytest.param(dict[str, int], id="dict"),
14831493
],
14841494
)
@@ -1737,34 +1747,29 @@ def test_non_list_passthrough(self) -> None:
17371747
class TestCollectionRuntimeCast:
17381748
"""End-to-end verify ``parse_args`` returns the declared container type, not a plain list."""
17391749

1740-
def test_set_int_returns_set(self) -> None:
1741-
parser = build_parser_from_function(_make_func(set[int], name="nums"))
1742-
ns = parser.parse_args(["1", "2", "2", "3"])
1743-
assert isinstance(ns.nums, set)
1744-
assert ns.nums == {1, 2, 3}
1745-
1746-
def test_tuple_ellipsis_returns_tuple(self) -> None:
1747-
parser = build_parser_from_function(_make_func(tuple[int, ...], name="values"))
1748-
ns = parser.parse_args(["1", "2", "3"])
1749-
assert isinstance(ns.values, tuple)
1750-
assert ns.values == (1, 2, 3)
1751-
1752-
def test_tuple_fixed_returns_tuple(self) -> None:
1753-
parser = build_parser_from_function(_make_func(tuple[int, int], name="pair"))
1754-
ns = parser.parse_args(["5", "10"])
1755-
assert isinstance(ns.pair, tuple)
1756-
assert ns.pair == (5, 10)
1757-
1758-
def test_list_bool_returns_list_of_bools(self) -> None:
1759-
parser = build_parser_from_function(_make_func(list[bool], name="flags"))
1760-
ns = parser.parse_args(["true", "no", "on"])
1761-
assert ns.flags == [True, False, True]
1762-
1763-
def test_tuple_paths_returns_tuple_of_paths(self) -> None:
1764-
parser = build_parser_from_function(_make_func(tuple[Path, Path], name="src_dst"))
1765-
ns = parser.parse_args(["/tmp/a", "/tmp/b"])
1766-
assert isinstance(ns.src_dst, tuple)
1767-
assert ns.src_dst == (Path("/tmp/a"), Path("/tmp/b"))
1750+
@pytest.mark.parametrize(
1751+
("annotation", "name", "args", "container", "expected"),
1752+
[
1753+
pytest.param(frozenset[int], "nums", ["1", "2", "2", "3"], frozenset, frozenset({1, 2, 3}), id="frozenset_int"),
1754+
pytest.param(set[int], "nums", ["1", "2", "2", "3"], set, {1, 2, 3}, id="set_int"),
1755+
pytest.param(tuple[int, ...], "values", ["1", "2", "3"], tuple, (1, 2, 3), id="tuple_ellipsis"),
1756+
pytest.param(tuple[int, int], "pair", ["5", "10"], tuple, (5, 10), id="tuple_fixed"),
1757+
pytest.param(list[bool], "flags", ["true", "no", "on"], list, [True, False, True], id="list_bool"),
1758+
pytest.param(
1759+
tuple[Path, Path],
1760+
"src_dst",
1761+
["/tmp/a", "/tmp/b"],
1762+
tuple,
1763+
(Path("/tmp/a"), Path("/tmp/b")),
1764+
id="tuple_paths",
1765+
),
1766+
],
1767+
)
1768+
def test_returns_declared_container(self, annotation, name, args, container, expected) -> None:
1769+
parser = build_parser_from_function(_make_func(annotation, name=name))
1770+
value = getattr(parser.parse_args(args), name)
1771+
assert isinstance(value, container)
1772+
assert value == expected
17681773

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

0 commit comments

Comments
 (0)