diff --git a/src/argh/assembling.py b/src/argh/assembling.py index 7a690ea..3150a8a 100644 --- a/src/argh/assembling.py +++ b/src/argh/assembling.py @@ -153,6 +153,9 @@ def infer_argspecs_from_function( ) def _make_cli_arg_names_options(arg_name) -> Tuple[List[str], List[str]]: + # str.removesuffix() can be used here starting with Python 3.9 + if arg_name.endswith("_"): + arg_name = arg_name[:-1] cliified_arg_name = arg_name.replace("_", "-") positionals = [cliified_arg_name] can_have_short_opt = arg_name[0] not in conflicting_opts @@ -406,6 +409,13 @@ def set_default_command( If the parser was created with ``add_help=True`` (which is by default), option name ``-h`` is silently removed from any argument. + .. note:: + + Function argument names ending with an underscore will have a single + trailing underscore removed before being converted to CLI arguments. + This allows CLI arguments to have names that would otherwise clash with + a reserved word or shadow a builtin. + """ func_signature = inspect.signature(function) diff --git a/src/argh/dispatching.py b/src/argh/dispatching.py index ef413f6..9b332cc 100644 --- a/src/argh/dispatching.py +++ b/src/argh/dispatching.py @@ -373,7 +373,11 @@ def _flat_key(key): kwonly_names = [p.name for p in func_params if p.kind == p.KEYWORD_ONLY] varargs_names = [p.name for p in func_params if p.kind == p.VAR_POSITIONAL] positional_values = [values_by_arg_name[name] for name in positional_names] - values_by_name = dict((k, values_by_arg_name[k]) for k in kwonly_names) + # str.removesuffix() can be used from Python 3.9 onward + values_by_name = dict( + (k, values_by_arg_name[k[:-1] if k.endswith("_") else k]) + for k in kwonly_names + ) # *args if varargs_names: diff --git a/tests/test_assembling.py b/tests/test_assembling.py index 23c17a0..f1dfd35 100644 --- a/tests/test_assembling.py +++ b/tests/test_assembling.py @@ -669,6 +669,24 @@ def cmd(foo_pos, bar_pos, *args, foo_kwonly="foo_kwonly", bar_kwonly): ] +def test_trailing_underscore_in_argument_name(): + "Stripping trailing underscores from named options" + + def cmd(*, foo_="foo", bar_): + return (foo_, bar_) + + parser = argh.ArghParser() + parser.add_argument = MagicMock() + parser.set_default_command( + cmd, name_mapping_policy=NameMappingPolicy.BY_NAME_IF_KWONLY + ) + help_tmpl = argh.constants.DEFAULT_ARGUMENT_TEMPLATE + assert parser.add_argument.mock_calls == [ + call("-f", "--foo", default="foo", type=str, help=help_tmpl), + call("-b", "--bar", required=True, help=help_tmpl), + ] + + @patch("argh.assembling.COMPLETION_ENABLED", True) def test_custom_argument_completer(): "Issue #33: Enable custom per-argument shell completion" diff --git a/tests/test_integration.py b/tests/test_integration.py index f863386..f42c011 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -594,6 +594,18 @@ def cmd(a_b): assert run(parser, "hello").out == "hello\n" +def test_trailing_underscore_keys(): + """One trailing underscore is ignored in function args.""" + + def cmd(*, a, b_, c__): + return f"a='{a}' b_='{b_}' c__='{c__}'" + + parser = DebugArghParser() + parser.set_default_command(cmd) + + assert run(parser, "--a x --b y --c- z").out == "a='x' b_='y' c__='z'\n" + + @mock.patch("argh.assembling.COMPLETION_ENABLED", True) def test_custom_argument_completer(): "Issue #33: Enable custom per-argument shell completion"