From e2e037c7510cf6a32ef6e1517c9a367790460101 Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Wed, 10 Jun 2026 20:36:10 +0100 Subject: [PATCH 1/2] 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). --- cmd2/annotated.py | 7 +++-- docs/features/annotated.md | 29 +++++++++--------- tests/test_annotated.py | 63 ++++++++++++++++++++------------------ 3 files changed, 53 insertions(+), 46 deletions(-) diff --git a/cmd2/annotated.py b/cmd2/annotated.py index 268a71f09..c79c1e904 100644 --- a/cmd2/annotated.py +++ b/cmd2/annotated.py @@ -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 @@ -663,6 +663,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), list: _make_collection_resolver(list), set: _make_collection_resolver(set), tuple: _resolve_tuple, @@ -916,14 +917,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) -- diff --git a/docs/features/annotated.md b/docs/features/annotated.md index 1b04286d1..c215fbc4d 100644 --- a/docs/features/annotated.md +++ b/docs/features/annotated.md @@ -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: diff --git a/tests/test_annotated.py b/tests/test_annotated.py index 449d0e1ef..3a5aa7922 100644 --- a/tests/test_annotated.py +++ b/tests/test_annotated.py @@ -275,6 +275,11 @@ 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}, @@ -282,6 +287,9 @@ class TestBuildParser: ), 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}, @@ -291,6 +299,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"), @@ -1362,6 +1371,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: @@ -1371,7 +1382,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"), ], ) @@ -1630,34 +1640,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")) From 9ea292f65ad1ec028d27647af3b620cc4f2d6d15 Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Thu, 11 Jun 2026 10:57:29 +0100 Subject: [PATCH 2/2] 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. --- CHANGELOG.md | 3 +++ cmd2/annotated.py | 14 +++++++------- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b67cf797e..406bf16ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/cmd2/annotated.py b/cmd2/annotated.py index 08ee3f775..6b4a2efe5 100644 --- a/cmd2/annotated.py +++ b/cmd2/annotated.py @@ -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( @@ -722,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." ) @@ -912,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 @@ -1288,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 @@ -1572,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 "