@@ -41,9 +41,12 @@ def do_paint(
4141- ``int``, ``float`` -- sets ``type=`` for argparse
4242- ``bool`` with default ``False`` -- ``--flag`` with ``store_true``
4343- ``bool`` with default ``True`` -- ``--no-flag`` with ``store_false``
44+ - positional ``bool`` -- parsed from ``true/false``, ``yes/no``, ``on/off``, ``1/0``
4445- ``pathlib.Path`` -- sets ``type=Path``
4546- ``enum.Enum`` subclass -- ``type=converter``, ``choices`` from member values
46- - ``list[T]`` -- ``nargs='+'`` (or ``'*'`` if has a default)
47+ - ``decimal.Decimal`` -- sets ``type=Decimal``
48+ - ``Literal[...]`` -- sets ``type=converter`` and ``choices`` from literal values
49+ - ``Collection[T]`` / ``list[T]`` / ``set[T]`` / ``tuple[T, ...]`` -- ``nargs='+'`` (or ``'*'`` if has a default)
4750- ``T | None`` -- unwrapped to ``T``, treated as optional
4851
4952Note: ``Path`` and ``Enum`` types also get automatic tab completion via
@@ -52,14 +55,19 @@ def do_paint(
5255"""
5356
5457import argparse
58+ import decimal
5559import enum
5660import inspect
5761import pathlib
5862import types
59- from collections .abc import Callable
63+ from collections .abc import (
64+ Callable ,
65+ Collection ,
66+ )
6067from typing import (
6168 Annotated ,
6269 Any ,
70+ Literal ,
6371 Union ,
6472 get_args ,
6573 get_origin ,
@@ -144,6 +152,63 @@ def __init__(
144152
145153_NoneType = type (None )
146154
155+ _BOOL_TRUE_VALUES = {'1' , 'true' , 't' , 'yes' , 'y' , 'on' }
156+ _BOOL_FALSE_VALUES = {'0' , 'false' , 'f' , 'no' , 'n' , 'off' }
157+
158+
159+ def _parse_bool (value : str ) -> bool :
160+ """Parse a string into a boolean value for argparse type conversion."""
161+ lowered = value .strip ().lower ()
162+ if lowered in _BOOL_TRUE_VALUES :
163+ return True
164+ if lowered in _BOOL_FALSE_VALUES :
165+ return False
166+ raise argparse .ArgumentTypeError (f"invalid boolean value: { value !r} (choose from: 1, 0, true, false, yes, no, on, off)" )
167+
168+
169+ def _make_literal_type (literal_values : list [Any ]) -> Callable [[str ], Any ]:
170+ """Create an argparse converter for a Literal's exact values."""
171+ value_map = {str (value ): value for value in literal_values }
172+
173+ def _convert (value : str ) -> Any :
174+ if value in value_map :
175+ return value_map [value ]
176+ if value .lower () in _BOOL_TRUE_VALUES :
177+ bool_value = True
178+ elif value .lower () in _BOOL_FALSE_VALUES :
179+ bool_value = False
180+ else :
181+ bool_value = None
182+
183+ if bool_value is not None and bool_value in literal_values :
184+ return bool_value
185+
186+ valid = ', ' .join (str (v ) for v in literal_values )
187+ raise argparse .ArgumentTypeError (f"invalid choice: { value !r} (choose from { valid } )" )
188+
189+ _convert .__name__ = 'literal'
190+ return _convert
191+
192+
193+ class _CollectionStoreAction (argparse ._StoreAction ):
194+ """Store action that can coerce parsed collection values to a container type."""
195+
196+ def __init__ (self , * args : Any , container_factory : Callable [[list [Any ]], Any ] | None = None , ** kwargs : Any ) -> None :
197+ super ().__init__ (* args , ** kwargs )
198+ self ._container_factory = container_factory
199+
200+ def __call__ (
201+ self ,
202+ _parser : argparse .ArgumentParser ,
203+ namespace : argparse .Namespace ,
204+ values : Any ,
205+ _option_string : str | None = None ,
206+ ) -> None :
207+ result = values
208+ if self ._container_factory is not None and isinstance (values , list ):
209+ result = self ._container_factory (values )
210+ setattr (namespace , self .dest , result )
211+
147212
148213def _make_enum_type (enum_class : type [enum .Enum ]) -> Callable [[str ], enum .Enum ]:
149214 """Create an argparse *type* converter for an Enum class.
@@ -165,6 +230,8 @@ def _convert(value: str) -> enum.Enum:
165230 raise argparse .ArgumentTypeError (f"invalid choice: { value !r} (choose from { valid } )" ) from err
166231
167232 _convert .__name__ = enum_class .__name__
233+ # Preserve the enum class for downstream consumers like tab completion.
234+ _convert ._cmd2_enum_class = enum_class
168235 return _convert
169236
170237
@@ -194,13 +261,42 @@ def _unwrap_optional(tp: Any) -> tuple[Any, bool]:
194261 return tp , False
195262
196263
197- def _unwrap_list (tp : Any ) -> tuple [Any , bool ]:
198- """Strip ``list[T]`` and return ``(inner_type, is_list)``."""
199- if get_origin (tp ) is list :
264+ def _unwrap_collection (tp : Any ) -> tuple [Any , str | None ]:
265+ """Strip collection[T] and return ``(inner_type, collection_kind)``."""
266+ origin = get_origin (tp )
267+ if origin is list :
200268 args = get_args (tp )
201269 if args :
202- return args [0 ], True
203- return tp , False
270+ return args [0 ], 'list'
271+
272+ if origin is set :
273+ args = get_args (tp )
274+ if args :
275+ return args [0 ], 'set'
276+
277+ if origin is Collection :
278+ args = get_args (tp )
279+ if args :
280+ return args [0 ], 'collection'
281+
282+ if origin is tuple :
283+ args = get_args (tp )
284+ if len (args ) == 2 and args [1 ] is Ellipsis :
285+ return args [0 ], 'tuple'
286+ return tp , None
287+
288+
289+ def _unwrap_literal (tp : Any ) -> tuple [Any , list [Any ] | None ]:
290+ """Strip ``Literal[...]`` and return ``(base_type, literal_values)``."""
291+ if get_origin (tp ) is Literal :
292+ literal_values = list (get_args (tp ))
293+ if not literal_values :
294+ return Any , []
295+ first_type = type (literal_values [0 ])
296+ if all (type (v ) is first_type for v in literal_values ):
297+ return first_type , literal_values
298+ return Any , literal_values
299+ return tp , None
204300
205301
206302# ---------------------------------------------------------------------------
@@ -243,12 +339,16 @@ def build_parser_from_function(func: Callable[..., Any]) -> argparse.ArgumentPar
243339 # 2. Unwrap Optional[T] / T | None
244340 base_type , is_optional = _unwrap_optional (base_type )
245341
246- # 3. Unwrap list[T]
247- inner_type , is_list = _unwrap_list (base_type )
248- if is_list :
342+ # 3. Unwrap collection[T]
343+ inner_type , collection_kind = _unwrap_collection (base_type )
344+ is_collection = collection_kind is not None
345+ if is_collection :
249346 base_type = inner_type
250347
251- # 4. Determine positional vs option
348+ # 4. Unwrap Literal[...]
349+ base_type , literal_choices = _unwrap_literal (base_type )
350+
351+ # 5. Determine positional vs option
252352 if isinstance (metadata , Argument ):
253353 is_positional = True
254354 elif isinstance (metadata , Option ):
@@ -258,7 +358,7 @@ def build_parser_from_function(func: Callable[..., Any]) -> argparse.ArgumentPar
258358 else :
259359 is_positional = False
260360
261- # 5 . Build add_argument kwargs
361+ # 6 . Build add_argument kwargs
262362 kwargs : dict [str , Any ] = {}
263363
264364 # Help text
@@ -275,12 +375,18 @@ def build_parser_from_function(func: Callable[..., Any]) -> argparse.ArgumentPar
275375 explicit_nargs = metadata .nargs if metadata else None
276376 if explicit_nargs is not None :
277377 kwargs ['nargs' ] = explicit_nargs
278- elif is_list :
378+ elif is_collection :
279379 kwargs ['nargs' ] = '*' if has_default else '+'
380+ if collection_kind in ('set' , 'tuple' ):
381+ kwargs ['action' ] = _CollectionStoreAction
382+ kwargs ['container_factory' ] = set if collection_kind == 'set' else tuple
280383
281384 # Type-specific handling
282385 is_bool_flag = False
283- if base_type is bool and not is_list and not is_positional :
386+ if literal_choices is not None :
387+ kwargs ['type' ] = _make_literal_type (literal_choices )
388+ kwargs ['choices' ] = literal_choices
389+ elif base_type is bool and not is_collection and not is_positional :
284390 is_bool_flag = True
285391 action_str = getattr (metadata , 'action' , None ) if metadata else None
286392 if action_str :
@@ -289,11 +395,17 @@ def build_parser_from_function(func: Callable[..., Any]) -> argparse.ArgumentPar
289395 kwargs ['action' ] = 'store_false'
290396 else :
291397 kwargs ['action' ] = 'store_true'
398+ elif base_type is bool :
399+ kwargs ['type' ] = _parse_bool
292400 elif isinstance (base_type , type ) and issubclass (base_type , enum .Enum ):
401+ # Keep validation in the converter to support any Enum subclass,
402+ # including enums whose members are not directly comparable to raw
403+ # argparse input strings.
293404 kwargs ['type' ] = _make_enum_type (base_type )
294- kwargs ['choices' ] = [m .value for m in base_type ]
295405 elif base_type is pathlib .Path or (isinstance (base_type , type ) and issubclass (base_type , pathlib .Path )):
296406 kwargs ['type' ] = pathlib .Path
407+ elif base_type is decimal .Decimal :
408+ kwargs ['type' ] = decimal .Decimal
297409 elif base_type in (int , float , str ):
298410 if base_type is not str :
299411 kwargs ['type' ] = base_type
@@ -321,7 +433,7 @@ def build_parser_from_function(func: Callable[..., Any]) -> argparse.ArgumentPar
321433 if suppress_tab_hint :
322434 kwargs ['suppress_tab_hint' ] = suppress_tab_hint
323435
324- # 6 . Call add_argument
436+ # 7 . Call add_argument
325437 if is_positional :
326438 parser .add_argument (name , ** kwargs )
327439 else :
0 commit comments