diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a787b161..59f389ce 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,3 +11,8 @@ updates: interval: "weekly" reviewers: - dskrypa + open-pull-requests-limit: 1 + groups: + all-deps: + patterns: + - "*" diff --git a/docs/_src/parameters.rst b/docs/_src/parameters.rst index ef78806b..219ea8b9 100644 --- a/docs/_src/parameters.rst +++ b/docs/_src/parameters.rst @@ -147,12 +147,21 @@ more about how attribute names are used to automatically generate ``--long`` opt **Unique Option initialization parameters:** :choices: A container that holds the specific values that users must pick from. By default, any value is allowed. -:nargs: The number of values that are expected/required when this parameter is specified. Defaults to ``+`` - when ``action='append'``, and to ``1`` otherwise. See :ref:`parameters:Parameters:nargs` for more info. -:action: The action to take on individual parsed values. Supported actions include ``store`` and ``append``. - Defaults to ``store`` when ``nargs=1`` (the default if neither action nor nargs are specified), and to ``append`` - otherwise. A single value will be stored when ``action='store'``, and a list of values will be stored when - ``action='append'``. +:nargs: The number of values that are expected/required when this parameter is specified. Defaults to ``+`` when + ``action='append'`` or ``action='append_default'``, and to ``1`` otherwise. See :ref:`parameters:Parameters:nargs` + for more info. +:action: The action to take on individual parsed values. Supported actions include ``store``, ``append``, and + ``append_default``. Defaults to ``store`` when ``nargs=1`` (the default if neither action nor nargs are specified), + and to ``append`` otherwise. A single value will be stored when ``action='store'``, and a list of values will be + stored when ``action='append'`` or ``action='append_default'``. The difference between the two append actions is + that using ``append_default`` will result in the default value(s) being included at the beginning of the list of + values. + + .. version-changed:: 2026-07-04 + + The ``append`` action was changed to exclude default value(s). To restore the behavior from previous versions, + explicitly specify ``action='append_default'``. + :allow_leading_dash: Whether string values may begin with a dash (``-``). By default, if a value begins with a dash, it is only accepted if it appears to be a negative numeric value. Use ``True`` / ``always`` / ``AllowLeadingDash.ALWAYS`` to allow any value that begins with a dash (as long as it is not an option string for an @@ -388,6 +397,18 @@ The generic :class:`.Positional` parameter that accepts arbitrary values or list allows 0 values to have the same effect as making the Parameter not required (the ``required`` option is not supported for Positional Parameters). Only the last Positional in a given :class:`.Command` may allow a variable / unbound number of arguments. +:action: The action to take on individual parsed values. Supported actions include ``store``, ``append``, and + ``append_default``. Defaults to ``store`` when ``nargs=1`` (the default if neither action nor nargs are specified), + and to ``append`` otherwise. A single value will be stored when ``action='store'``, and a list of values will be + stored when ``action='append'`` or ``action='append_default'``. The difference between the two append actions is + that using ``append_default`` will result in the default value(s) being included at the beginning of the list of + values. + + .. version-changed:: 2026-07-04 + + The ``append`` action was changed to exclude default value(s). To restore the behavior from previous versions, + explicitly specify ``action='append_default'``. + :default: Only supported when ``action='store'`` and 0 values are allowed by the specified ``nargs``. Defaults to ``None`` under those conditions. :choices: A container that holds the specific values that users must pick from. By default, any value is allowed. diff --git a/lib/cli_command_parser/nargs.py b/lib/cli_command_parser/nargs.py index a7fb2744..f12c53e7 100644 --- a/lib/cli_command_parser/nargs.py +++ b/lib/cli_command_parser/nargs.py @@ -130,7 +130,7 @@ def __contains__(self, num: int) -> bool: """See :meth:`.satisfied`""" return self.satisfied(num) - def __eq__(self, other) -> bool: + def matches(self, other: Any) -> bool: match other: case Nargs(): return self._eq_nargs(other) @@ -139,6 +139,8 @@ def __eq__(self, other) -> bool: case _: return NotImplemented + __eq__ = matches + def _eq_nargs(self, other: Nargs) -> bool: if not self._has_upper_bound: return other.max is self.max and self.min == other.min diff --git a/lib/cli_command_parser/parameters/actions.py b/lib/cli_command_parser/parameters/actions.py index b52df027..0cf80919 100644 --- a/lib/cli_command_parser/parameters/actions.py +++ b/lib/cli_command_parser/parameters/actions.py @@ -7,19 +7,21 @@ from abc import ABC, abstractmethod from enum import Enum -from typing import TYPE_CHECKING, ClassVar, Generic, NoReturn, TypeVar, Union +from typing import TYPE_CHECKING, ClassVar, Generic, NoReturn, TypeVar from ..context import ctx from ..exceptions import BadArgument, InvalidChoice, MissingArgument, ParamConflict, ParamUsageError, TooManyArguments from ..inputs import InputType from ..nargs import Nargs -from ..utils import _NotSet, camel_to_snake_case +from ..utils import _NotSet, _NotSetType, camel_to_snake_case if TYPE_CHECKING: from ..commands import Command from ..typing import Bool, OptStr from .base import BaseFlag, Parameter + Found = int | NoReturn + __all__ = [ 'ParamAction', 'Store', @@ -46,19 +48,21 @@ def __str__(self) -> str: P = TypeVar('P', bound='Parameter') F = TypeVar('F', bound='BaseFlag') -Found = Union[int, NoReturn] class ParamAction(ABC, Generic[P]): __slots__ = ('param',) name: str - # param: P - default = _NotSet + default: None | _NotSetType = _NotSet accepts_values: bool = False accepts_consts: bool = False def __init_subclass__( - cls, default=_PANotSet, accepts_values: bool | None = None, accepts_consts: bool | None = None, **kwargs + cls, + default: None | _PANotSetType = _PANotSet, + accepts_values: bool | None = None, + accepts_consts: bool | None = None, + **kwargs, ): super().__init_subclass__(**kwargs) cls.name = camel_to_snake_case(cls.__name__) @@ -104,13 +108,6 @@ def add_value(self, value: str, *, combo: bool = False, joined: bool = False, en def add_env_value(self, value: str, env_var: str) -> Found: return self.add_value(value, env_var=env_var) - # Note: Not used yet - # def add_values(self, values: Sequence[str], *, combo: bool = False) -> Found: - # added = 0 - # for value in values: - # added += self.add_value(value, combo=combo) - # return added - def add_const(self, *, opt: OptStr = None, combo: bool = False) -> Found: # noqa ctx.record_action(self.param) raise MissingArgument(self.param) @@ -135,14 +132,6 @@ def would_accept_all(self, values: list[str], combo: bool = False) -> bool: else: return valid_values and len(values) in self.param.nargs - # Note: Not used yet - # def _prep_and_validate(self, values: Sequence[str], combo: bool) -> Iterator[T_co]: - # prepare_value, validate = self.param.prepare_value, self.param.validate - # for value in values: - # value = prepare_value(value, combo) - # validate(value) - # yield value - # endregion # region Backtracking @@ -198,22 +187,13 @@ def set_value(self, value): def append_value(self, value): parsed = ctx.get_parsed_value(self.param) if parsed is _NotSet: - parsed = self.get_default() + parsed = [] ctx.set_parsed_value(self.param, parsed) elif self.param.nargs.max_reached(parsed): raise TooManyArguments(self.param, f'already found {len(parsed)} values') parsed.append(value) - # Note: Not used yet - # def extend_values(self, values: Iterable[T_co]): - # parsed = ctx.get_parsed_value(self.param) - # if parsed is _NotSet: - # parsed = self.get_default() - # ctx.set_parsed_value(self.param, parsed) - # - # parsed.extend(values) - class _ConstAction(ParamAction[F], ABC): __slots__ = () @@ -239,14 +219,6 @@ def append_const(self, const): parsed.append(const) - # def extend_consts(self, consts): - # parsed = ctx.get_parsed_value(self.param) - # if parsed is _NotSet: - # parsed = self.get_default() - # ctx.set_parsed_value(self.param, parsed) - # - # parsed.extend(consts) - def add_env_value(self, value: str, env_var: str) -> Found: const, use_value = self.param.get_env_const(value, env_var) # The const may only be _NotSet once StoreValueOrConst / AppendValueOrConst are put into use @@ -280,17 +252,6 @@ def add_value(self, value: str, *, combo: bool = False, joined: Bool = False, en self.set_value(value) return 1 - # Note: Not used yet - # def add_values(self, values: Sequence[str], *, combo: bool = False) -> Found: - # ctx.record_action(self.param) - # if not values: - # raise MissingArgument(self.param) - # elif (val_count := len(values)) not in self.param.nargs: - # raise BadArgument(self.param, f'expected nargs={self.param.nargs} values but found {val_count}') - # - # self.set_value([value for value in self._prep_and_validate(values, combo)]) - # return val_count - # endregion # region Parsing @@ -316,17 +277,6 @@ def add_value(self, value: str, *, combo: bool = False, joined: Bool = False, en self.append_value(value) return 1 - # Note: Not used yet - # def add_values(self, values: Sequence[str], *, combo: bool = False) -> Found: - # ctx.record_action(self.param) - # if not values: - # raise MissingArgument(self.param) - # elif (val_count := len(values)) not in (nargs := self.param.nargs): - # raise BadArgument(self.param, f'expected {nargs=} values but found {val_count}') - # - # self.extend_values(value for value in self._prep_and_validate(values, combo)) - # return val_count - # endregion # region Parsing @@ -398,6 +348,20 @@ def finalize_value(self, value): # endregion +class AppendDefault(Append): + __slots__ = () + + def append_value(self, value): + parsed = ctx.get_parsed_value(self.param) + if parsed is _NotSet: + parsed = self.get_default() + ctx.set_parsed_value(self.param, parsed) + elif self.param.nargs.max_reached(parsed): + raise TooManyArguments(self.param, f'already found {len(parsed)} values') + + parsed.append(value) + + class BasicConstAction(_ConstAction, ABC, accepts_consts=True): __slots__ = () default_nargs = Nargs(0) diff --git a/lib/cli_command_parser/parameters/base.py b/lib/cli_command_parser/parameters/base.py index 40c4d07e..8ef6931c 100644 --- a/lib/cli_command_parser/parameters/base.py +++ b/lib/cli_command_parser/parameters/base.py @@ -51,6 +51,12 @@ class Param(Generic[T]): + """ + Primarily used for type checking. Generally, explicit annotations for parameters are not necessary. In some cases, + such as when indicating that a common parameter will exist with different definitions in one or more subclasses, + then the use of this class for the annotations may be useful. + """ + __slots__ = () if TYPE_CHECKING: @@ -626,56 +632,6 @@ class - it is not meant to be used directly. strict_env: Bool use_env_value: Bool - # region Init Overloads - - if TYPE_CHECKING: - - @overload - def __init__( - self: BaseOption[T, _NotSetType], - *option_strs: str, - action: str, - help: OptStr = None, # noqa - hide: Bool = False, - metavar: OptStr = None, - name: OptStr = None, - name_mode: OptionNameMode | OptStr | _NotSetType = _NotSet, - required: Literal[True], - default: _NotSetType = _NotSet, - default_cb: None = None, - cb_with_cmd: Bool = False, - show_default: Bool = None, - strict_default: Bool = False, - env_var: OptStrs = None, - strict_env: bool = True, - use_env_value: Bool = None, - show_env_var: Bool = None, - ): ... - - @overload - def __init__( - self: BaseOption[T, D], - *option_strs: str, - action: str, - help: OptStr = None, # noqa - hide: Bool = False, - metavar: OptStr = None, - name: OptStr = None, - name_mode: OptionNameMode | OptStr | _NotSetType = _NotSet, - required: Bool = False, - default: D | _NotSetType = _NotSet, - default_cb: DefaultFunc[D] | None = None, - cb_with_cmd: Bool = False, - show_default: Bool = None, - strict_default: Bool = False, - env_var: OptStrs = None, - strict_env: bool = True, - use_env_value: Bool = None, - show_env_var: Bool = None, - ): ... - - # endregion - def __init__( self, *option_strs: str, @@ -741,13 +697,15 @@ def __init__(self, default: AllowLeadingDash = AllowLeadingDash.NUMERIC): def __set_name__(self, owner, name: str): self.name = name - @overload - def __get__(self, instance: None, owner: Any) -> AllowLeadingDashProperty: ... + if TYPE_CHECKING: - @overload - def __get__(self, instance: AnyParam, owner: Any) -> AllowLeadingDash: ... + @overload + def __get__(self, instance: Literal[None], owner: Any = None) -> Self: ... + + @overload + def __get__(self, instance: AnyParam, owner: Any = None) -> AllowLeadingDash: ... - def __get__(self, instance: AnyParam | None, owner: Any) -> AllowLeadingDash | AllowLeadingDashProperty: + def __get__(self, instance: AnyParam | None, owner: Any = None) -> AllowLeadingDash | Self: if instance is None: return self return instance.__dict__.get(self.name, self.default) diff --git a/lib/cli_command_parser/parameters/options.py b/lib/cli_command_parser/parameters/options.py index ec6f5c5e..ef53ab2d 100644 --- a/lib/cli_command_parser/parameters/options.py +++ b/lib/cli_command_parser/parameters/options.py @@ -8,7 +8,7 @@ import logging from functools import partial, update_wrapper -from typing import TYPE_CHECKING, Any, Callable, Literal, NoReturn, Type, overload +from typing import TYPE_CHECKING, Any, Callable, Collection, Literal, NoReturn, Type, overload try: from typing import Never @@ -20,7 +20,7 @@ from ..nargs import Nargs from ..typing import B, D, T from ..utils import _NotSet, _NotSetType, str_to_bool -from .actions import Append, AppendConst, Count, Store, StoreConst +from .actions import Append, AppendConst, AppendDefault, Count, Store, StoreConst from .base import AllowLeadingDashProperty, BaseFlag, BaseOption from .option_strings import TriFlagOptionStrings @@ -31,6 +31,9 @@ from ..typing import Bool, ChoicesType, InputTypeFunc, OptStr, OptStrs, TypeFunc from ._typing import CommandMethod, DefaultFunc, LeadingDash + OptAct = Literal['store', 'append', 'append_default'] | None + ConstAct = Literal['store_const', 'append_const'] + __all__ = [ 'Option', 'Flag', @@ -44,11 +47,8 @@ ] log = logging.getLogger(__name__) -OptAct = Literal['store', 'append'] | None -ConstAct = Literal['store_const', 'append_const'] - -class Option(BaseOption[T, D], actions=(Store, Append)): +class Option(BaseOption[T, D], actions=(Store, Append, AppendDefault)): """ A generic option that can be specified as ``--foo bar`` or by using other similar forms. @@ -84,6 +84,7 @@ class Option(BaseOption[T, D], actions=(Store, Append)): # region Init Overloads if TYPE_CHECKING: + # region Required @overload def __init__( @@ -92,8 +93,7 @@ def __init__( required: Literal[True], type: InputTypeFunc[T] = None, # noqa choices: ChoicesType[T] = None, - default: _NotSetType = _NotSet, - default_cb: None = None, + strict_default: Bool = ..., nargs: NargsSingle = None, **kwargs, ): ... @@ -105,12 +105,15 @@ def __init__( required: Literal[True], type: InputTypeFunc[T] = None, # noqa choices: ChoicesType[T] = None, - default: _NotSetType = _NotSet, - default_cb: None = None, + strict_default: Bool = ..., nargs: NargsMultiple, **kwargs, ): ... + # endregion + + # region nargs=1/?/None + @overload def __init__( self: Option[T, None], @@ -118,8 +121,8 @@ def __init__( required: Literal[False] = False, type: InputTypeFunc[T] = None, # noqa choices: ChoicesType[T] = None, - default: _NotSetType = _NotSet, - default_cb: DefaultFunc[D] | None = None, + default_cb: None = None, + strict_default: Bool = ..., nargs: NargsSingle = None, **kwargs, ): ... @@ -132,7 +135,8 @@ def __init__( type: InputTypeFunc[T] = None, # noqa choices: ChoicesType[T] = None, default: D, - default_cb: DefaultFunc[D] | None = None, + default_cb: None = None, + strict_default: Bool = ..., nargs: NargsSingle = None, **kwargs, ): ... @@ -144,34 +148,38 @@ def __init__( required: Literal[False] = False, type: InputTypeFunc[T] = None, # noqa choices: ChoicesType[T] = None, - default: _NotSetType = _NotSet, default_cb: DefaultFunc[D], + strict_default: Bool = ..., nargs: NargsSingle = None, **kwargs, ): ... + # endregion + @overload def __init__( - self: Option[list[T], list[T]], + self: Option[list[T], D], *option_strs: str, required: Literal[False] = False, type: InputTypeFunc[T] = None, # noqa choices: ChoicesType[T] = None, - default: _NotSetType = _NotSet, - default_cb: DefaultFunc[D] | None = None, + default: D, + default_cb: None = None, + strict_default: Literal[True], nargs: NargsMultiple, **kwargs, ): ... @overload def __init__( - self: Option[list[T], D], + self: Option[list[T], list[D]], *option_strs: str, required: Literal[False] = False, type: InputTypeFunc[T] = None, # noqa choices: ChoicesType[T] = None, - default: D, - default_cb: DefaultFunc[D] | None = None, + default: D | Collection[D], + default_cb: None = None, + strict_default: Literal[False] = False, nargs: NargsMultiple, **kwargs, ): ... @@ -183,8 +191,34 @@ def __init__( required: Literal[False] = False, type: InputTypeFunc[T] = None, # noqa choices: ChoicesType[T] = None, - default: _NotSetType = _NotSet, default_cb: DefaultFunc[D], + strict_default: Literal[True], + nargs: NargsMultiple, + **kwargs, + ): ... + + @overload + def __init__( + self: Option[list[T], list[D]], + *option_strs: str, + required: Literal[False] = False, + type: InputTypeFunc[T] = None, # noqa + choices: ChoicesType[T] = None, + default_cb: DefaultFunc[D] | DefaultFunc[Collection[D]], + strict_default: Literal[False] = False, + nargs: NargsMultiple, + **kwargs, + ): ... + + @overload + def __init__( + # Not required, but no explicit default was provided + self: Option[list[T], Never], + *option_strs: str, + required: Literal[False] = False, + type: InputTypeFunc[T] = None, # noqa + choices: ChoicesType[T] = None, + strict_default: Bool = ..., nargs: NargsMultiple, **kwargs, ): ... @@ -196,8 +230,9 @@ def __init__( required: Bool = False, type: InputTypeFunc[T] = None, # noqa choices: ChoicesType[T] = None, - default: D | _NotSetType = _NotSet, - default_cb: DefaultFunc[D] | None = None, + default: D | Collection[D] | _NotSetType = _NotSet, + default_cb: DefaultFunc[D] | DefaultFunc[Collection[D]] | None = None, + strict_default: Bool = False, nargs: NargsValue | None = None, action: OptAct = None, help: OptStr = None, # noqa @@ -208,7 +243,6 @@ def __init__( allow_leading_dash: LeadingDash | None = None, cb_with_cmd: Bool = False, show_default: Bool = None, - strict_default: Bool = False, env_var: OptStrs = None, strict_env: bool = True, use_env_value: Bool = None, @@ -222,7 +256,7 @@ def __init__( *option_strs: str, nargs: NargsValue | None = None, action: OptAct = None, - default: D | _NotSetType = _NotSet, + default: D | Collection[D] | _NotSetType = _NotSet, required: Bool = False, type: InputTypeFunc[T] = None, # noqa choices: ChoicesType[T] = None, @@ -232,15 +266,15 @@ def __init__( _nargs: Nargs | None = _validate_option_nargs(nargs) if not action: if _nargs is not None: - action = 'store' if _nargs == 1 else 'append' + action = 'store' if _nargs.matches(1) else 'append' else: action = 'store' - elif _nargs is not None and action == 'store' and _nargs != 1: + elif _nargs is not None and action == 'store' and not _nargs.matches(1): raise ParameterDefinitionError(f'Invalid nargs={_nargs} for {action=}') super().__init__(*option_strs, action=action, default=default, required=required, **kwargs) - self.nargs = self.action.default_nargs if _nargs is None else _nargs - self.type = normalize_input_type(type, choices) # type: ignore[assignment] + self.nargs = _nargs or self.action.default_nargs + self.type = normalize_input_type(type, choices) self.allow_leading_dash = allow_leading_dash def _handle_bad_action(self, action: str) -> NoReturn: diff --git a/lib/cli_command_parser/parameters/positionals.py b/lib/cli_command_parser/parameters/positionals.py index df00264c..f59530ed 100644 --- a/lib/cli_command_parser/parameters/positionals.py +++ b/lib/cli_command_parser/parameters/positionals.py @@ -18,7 +18,7 @@ from ..nargs import Nargs from ..typing import D, T from ..utils import _NotSet, _NotSetType -from .actions import Append, Store +from .actions import Append, AppendDefault, Store from .base import AllowLeadingDashProperty, BasePositional if TYPE_CHECKING: @@ -26,10 +26,12 @@ from ..typing import Bool, ChoicesType, InputTypeFunc, OptStr from ._typing import DefaultFunc, LeadingDash + PosAct = Literal['store', 'append', 'append_default'] | None + __all__ = ['Positional'] -class Positional(BasePositional[T, D], default_ok=True, actions=(Store, Append)): +class Positional(BasePositional[T, D], default_ok=True, actions=(Store, Append, AppendDefault)): """ A parameter that must be provided positionally. @@ -66,7 +68,7 @@ class Positional(BasePositional[T, D], default_ok=True, actions=(Store, Append)) def __init__( self: Positional[T, Never], nargs: Literal[1, None] = None, - action: Literal['store', 'append'] | None = None, + action: PosAct = None, type: InputTypeFunc[T] = None, # noqa *, choices: ChoicesType[T] = None, @@ -81,7 +83,7 @@ def __init__( def __init__( self: Positional[T, D], nargs: Literal['?'], - action: Literal['store', 'append'] | None = None, + action: PosAct = None, type: InputTypeFunc[T] = None, # noqa default: D | _NotSetType = _NotSet, *, @@ -98,7 +100,7 @@ def __init__( def __init__( self: Positional[list[T], list[D]], nargs: NargsMultiple, - action: Literal['store', 'append'] | None = None, + action: PosAct = None, type: InputTypeFunc[T] = None, # noqa default: D | _NotSetType = _NotSet, *, @@ -115,7 +117,7 @@ def __init__( def __init__( self, nargs: NargsValue | None = None, - action: Literal['store', 'append'] | None = None, + action: PosAct = None, type: InputTypeFunc[T] = None, # noqa default: D | _NotSetType = _NotSet, *, @@ -131,7 +133,7 @@ def __init__( def __init__( self, nargs: NargsValue | None = None, - action: Literal['store', 'append'] | None = None, + action: PosAct = None, type: InputTypeFunc[T] = None, # noqa default: D | _NotSetType = _NotSet, *, @@ -163,5 +165,5 @@ def __init__( ) kwargs.setdefault('required', required) super().__init__(action=action, default=default, default_cb=default_cb, **kwargs) - self.type = normalize_input_type(type, choices) # type: ignore[assignment] + self.type = normalize_input_type(type, choices) self.allow_leading_dash = allow_leading_dash diff --git a/tests/test_inputs/test_time_inputs.py b/tests/test_inputs/test_time_inputs.py index a57c9743..832160eb 100755 --- a/tests/test_inputs/test_time_inputs.py +++ b/tests/test_inputs/test_time_inputs.py @@ -416,6 +416,19 @@ def test_date_default_collection_type_fix_tuple(self): class Foo(Command): bar = Option('-b', type=Date(), nargs='+', default=('2022-01-01', JAN_1_2022)) + cases = [ + ([], [JAN_1_2022, JAN_1_2022]), + (['-b', '2022-02-02', '2022-03-03'], [FEB_2_2022, MAR_3_2022]), + ] + for argv, expected in cases: + with self.subTest(expected=expected, argv=argv): + foo = Foo.parse(argv) + self.assertEqual(expected, foo.bar) + + def test_date_append_default_collection_type_fix_tuple(self): + class Foo(Command): + bar = Option('-b', type=Date(), nargs='+', default=('2022-01-01', JAN_1_2022), action='append_default') + cases = [ ([], [JAN_1_2022, JAN_1_2022]), (['-b', '2022-02-02', '2022-03-03'], [JAN_1_2022, JAN_1_2022, FEB_2_2022, MAR_3_2022]), @@ -429,6 +442,19 @@ def test_date_default_collection_type_fix_single(self): class Foo(Command): bar = Option('-b', type=Date(), nargs='+', default=JAN_1_2022) + cases = [ + ([], [JAN_1_2022]), + (['-b', '2022-02-02', '2022-03-03'], [FEB_2_2022, MAR_3_2022]), + ] + for argv, expected in cases: + with self.subTest(expected=expected, argv=argv): + foo = Foo.parse(argv) + self.assertEqual(expected, foo.bar) + + def test_date_append_default_collection_type_fix_single(self): + class Foo(Command): + bar = Option('-b', type=Date(), nargs='+', default=JAN_1_2022, action='append_default') + cases = [ ([], [JAN_1_2022]), (['-b', '2022-02-02', '2022-03-03'], [JAN_1_2022, FEB_2_2022, MAR_3_2022]), @@ -459,17 +485,24 @@ def __contains__(self, item): default = Custom((JAN_1_2022,)) - class Foo(Command): - bar = Option('-b', type=Date(), nargs='+', default=default) - - cases = [ - ([], [JAN_1_2022]), - (['-b', '2022-02-02', '2022-03-03'], [JAN_1_2022, FEB_2_2022, MAR_3_2022]), - ] - for argv, expected in cases: - with self.subTest(expected=expected, argv=argv): - foo = Foo.parse(argv) - self.assertEqual(expected, foo.bar) + action_expected_map = { + 'append': [FEB_2_2022, MAR_3_2022], + 'append_default': [JAN_1_2022, FEB_2_2022, MAR_3_2022], + } + for action, expected in action_expected_map.items(): + with self.subTest(action=action): + + class Foo(Command): + bar = Option('-b', type=Date(), nargs='+', default=default, action=action) + + cases = [ + ([], [JAN_1_2022]), + (['-b', '2022-02-02', '2022-03-03'], expected), + ] + for argv, argv_expected in cases: + with self.subTest(argv_expected=argv_expected, argv=argv): + foo = Foo.parse(argv) + self.assertEqual(argv_expected, foo.bar) if __name__ == '__main__': diff --git a/tests/test_parsing/test_parse_options.py b/tests/test_parsing/test_parse_options.py index a39b8fa2..fe9378c9 100755 --- a/tests/test_parsing/test_parse_options.py +++ b/tests/test_parsing/test_parse_options.py @@ -227,6 +227,27 @@ class Foo(Command): self.assert_parse_fails_cases(Foo, fail_cases, UsageError) def test_defaults_with_nargs_multi(self): + success_cases = [ + ([], {'bar': [1]}), + (['-b', '2'], {'bar': [2]}), + (['-b=2'], {'bar': [2]}), + (['--bar', '2', '3'], {'bar': [2, 3]}), + ] + fail_cases = [ + ['-b=2', '3'], # argparse also rejects this + ['-b'], + ] + + for default in (1, [1]): + with self.subTest(default=default): + + class Foo(Command): + bar = Option('-b', nargs='+', type=int, default=default) + + self.assert_parse_results_cases(Foo, success_cases) + self.assert_parse_fails_cases(Foo, fail_cases, UsageError) + + def test_defaults_with_nargs_multi_append_default(self): success_cases = [ ([], {'bar': [1]}), (['-b', '2'], {'bar': [1, 2]}), @@ -242,7 +263,7 @@ def test_defaults_with_nargs_multi(self): with self.subTest(default=default): class Foo(Command): - bar = Option('-b', nargs='+', type=int, default=default) + bar = Option('-b', nargs='+', type=int, default=default, action='append_default') self.assert_parse_results_cases(Foo, success_cases) self.assert_parse_fails_cases(Foo, fail_cases, UsageError) @@ -300,9 +321,30 @@ class Foo(Command): success_cases = [ ([], {'bar': ['xyz'], 'baz': default}), - (['-b', 'a'], {'bar': ['xyz', 'a'], 'baz': default}), - (['-b', 'a', '-b', 'b'], {'bar': ['xyz', 'a', 'b'], 'baz': default}), - (['-b', 'a', 'b'], {'bar': ['xyz', 'a', 'b'], 'baz': default}), + (['-b', 'a'], {'bar': ['a'], 'baz': default}), + (['-b', 'a', '-b', 'b'], {'bar': ['a', 'b'], 'baz': default}), + (['-b', 'a', 'b'], {'bar': ['a', 'b'], 'baz': default}), + (['-B', 'a'], {'bar': ['xyz'], 'baz': ['a']}), + ] + self.assert_parse_results_cases(Foo, success_cases) + fail_cases = [(['-B'], UsageError), (['-b'], UsageError)] + self.assert_parse_fails_cases(Foo, fail_cases) + + def test_append_default_strict_default(self): + default = {'xyz': 'abc'} + + class Foo(Command): + foo = Option('-f', nargs='+', action='append_default') + bar = Option('-b', nargs='+', action='append_default', default=default) + baz = Option('-B', nargs='+', action='append_default', default=default, strict_default=True) + + success_cases = [ + ([], {'foo': [], 'bar': ['xyz'], 'baz': default}), + (['-b', 'a'], {'foo': [], 'bar': ['xyz', 'a'], 'baz': default}), + (['-b', 'a', '-b', 'b'], {'foo': [], 'bar': ['xyz', 'a', 'b'], 'baz': default}), + (['-b', 'a', 'b'], {'foo': [], 'bar': ['xyz', 'a', 'b'], 'baz': default}), + (['-f', 'a'], {'foo': ['a'], 'bar': ['xyz'], 'baz': default}), + (['-f', 'a', 'b'], {'foo': ['a', 'b'], 'bar': ['xyz'], 'baz': default}), ] self.assert_parse_results_cases(Foo, success_cases) fail_cases = [ @@ -312,10 +354,45 @@ class Foo(Command): ] self.assert_parse_fails_cases(Foo, fail_cases) + def test_append_default_strict_default_list(self): + default = ['xyz', 'abc'] + + class Foo(Command): + foo = Option('-f', nargs='+', action='append_default') + bar = Option('-b', nargs='+', action='append_default', default=default) + baz = Option('-B', nargs='+', action='append_default', default=default, strict_default=True) + + success_cases = [ + ([], {'foo': [], 'bar': default, 'baz': default}), + (['-b', 'a'], {'foo': [], 'bar': ['xyz', 'abc', 'a'], 'baz': default}), + (['-b', 'a', '-b', 'b'], {'foo': [], 'bar': ['xyz', 'abc', 'a', 'b'], 'baz': default}), + (['-b', 'a', 'b'], {'foo': [], 'bar': ['xyz', 'abc', 'a', 'b'], 'baz': default}), + (['-B', 'a'], {'foo': [], 'bar': default, 'baz': ['xyz', 'abc', 'a']}), + (['-f', 'a'], {'foo': ['a'], 'bar': default, 'baz': default}), + (['-f', 'a', 'b'], {'foo': ['a', 'b'], 'bar': default, 'baz': default}), + ] + self.assert_parse_results_cases(Foo, success_cases) + fail_cases = [(['-B'], UsageError), (['-b'], UsageError)] + self.assert_parse_fails_cases(Foo, fail_cases) + def test_append_fix_str_to_range(self): class Foo(Command): bar = Option('-b', type=range(10), nargs='+', action='append', default='5') + success_cases = [ + ([], {'bar': [5]}), + (['-b', '1'], {'bar': [1]}), + (['-b', '1', '-b', '2'], {'bar': [1, 2]}), + (['-b', '1', '2'], {'bar': [1, 2]}), + ] + self.assert_parse_results_cases(Foo, success_cases) + fail_cases = [['-b'], ['-b', 'a']] + self.assert_argv_parse_fails_cases(Foo, fail_cases) + + def test_append_default_fix_str_to_range(self): + class Foo(Command): + bar = Option('-b', type=range(10), nargs='+', action='append_default', default='5') + success_cases = [ ([], {'bar': [5]}), (['-b', '1'], {'bar': [5, 1]}), diff --git a/tests/test_parsing/test_parse_positionals.py b/tests/test_parsing/test_parse_positionals.py index 86ce6922..0520da8b 100755 --- a/tests/test_parsing/test_parse_positionals.py +++ b/tests/test_parsing/test_parse_positionals.py @@ -187,8 +187,19 @@ class Foo(Command): cases = [ ([], {'foo': ['bar']}), - # (['baz'], {'foo': ['baz']}), # TODO: This is what it probably *should* be - (['baz'], {'foo': ['bar', 'baz']}), + (['a'], {'foo': ['a']}), + (['a', 'b'], {'foo': ['a', 'b']}), + ] + self.assert_parse_results_cases(Foo, cases) + + def test_positional_nargs_star_append_default(self): + class Foo(Command): + foo = Positional(nargs='*', default='bar', action='append_default') + + cases = [ + ([], {'foo': ['bar']}), + (['a'], {'foo': ['bar', 'a']}), + (['a', 'b'], {'foo': ['bar', 'a', 'b']}), ] self.assert_parse_results_cases(Foo, cases) diff --git a/tests/test_typing/modules/inputs.py b/tests/test_typing/modules/inputs.py index fef66aae..36199ec6 100644 --- a/tests/test_typing/modules/inputs.py +++ b/tests/test_typing/modules/inputs.py @@ -82,6 +82,16 @@ class InputsExample(Command): # The next one doesn't work, but it probably never should have been implemented to work # regex_converted = Option(type=re.compile('foo.*bar')) + with ParamGroup('Complex'): + ints_default_list = Option(nargs='+', type=int, default=[1]) + ints_default_set = Option(nargs='+', type=int, default={1, 2}) + ints_default_set_strict = Option(nargs='+', type=int, default={1, 2}, strict_default=True) + ints_default_single = Option(nargs='+', type=int, default=1) + mixed_types_default_single = Option(nargs='+', default=1) + # The next two don't work, but it would be a relatively strange use case to need + # mixed_types_default_list = Option(nargs='+', default=[1]) + # mixed_types_default_tuple = Option(nargs='+', default=(1,)) + def main(self) -> None: reveal_type(self.positional) # str reveal_type(self.positional_int) # int @@ -140,3 +150,11 @@ def main(self) -> None: reveal_type(self.regex_match) # re.Match[str] | None reveal_type(self.regex_dict) # dict[str, str] | None # reveal_type(self.regex_converted) # str | None + + reveal_type(self.ints_default_list) # list[int] + reveal_type(self.ints_default_set) # list[int] + reveal_type(self.ints_default_set_strict) # list[int] | set[int] + reveal_type(self.ints_default_single) # list[int] + reveal_type(self.mixed_types_default_single) # list[str] | list[int] + # reveal_type(self.mixed_types_default_list) # list[str] | list[int] + # reveal_type(self.mixed_types_default_tuple) # list[str] | list[int] diff --git a/tests/test_typing/test_inputs_typing.py b/tests/test_typing/test_inputs_typing.py index 24592c7b..9db2c892 100755 --- a/tests/test_typing/test_inputs_typing.py +++ b/tests/test_typing/test_inputs_typing.py @@ -20,7 +20,7 @@ class TestInputsTyping(TestCase): def assert_revealed_types_are_correct(self, name: str): for result in RevealedTypeChecker(name): - with self.subTest(line=result.line): + with self.subTest(line=result.line, attr=result.name): if reason := result.failure_reason(): self.fail(reason)