From c55eaeed9e703341540e5340e97deb47d9e0bde2 Mon Sep 17 00:00:00 2001 From: Alistair Pattison Date: Wed, 25 Mar 2026 10:19:33 -0400 Subject: [PATCH 01/13] inital implementation --- tests/test_kwargs.py | 147 +++++++++++++++++++++++++++++++++++++++++++ typer/core.py | 73 +++++++++++++++++++++ typer/main.py | 76 +++++++++++++++++++++- typer/utils.py | 5 ++ 4 files changed, 299 insertions(+), 2 deletions(-) create mode 100644 tests/test_kwargs.py diff --git a/tests/test_kwargs.py b/tests/test_kwargs.py new file mode 100644 index 0000000000..56dcbee11e --- /dev/null +++ b/tests/test_kwargs.py @@ -0,0 +1,147 @@ +import typer +from typer.testing import CliRunner + +runner = CliRunner() + + +def test_kwargs_only() -> None: + app = typer.Typer() + + @app.command() + def cmd(**kwargs: str) -> None: + typer.echo(f"kwargs={kwargs!r}") + + result = runner.invoke(app, ["--flag", "val", "--verbose"]) + assert result.exit_code == 0 + assert "flag" in result.output + assert "val" in result.output + assert "verbose" in result.output + + +def test_kwargs_bool_flag() -> None: + app = typer.Typer() + + @app.command() + def cmd(**kwargs: str) -> None: + import json + + typer.echo(json.dumps(kwargs)) + + result = runner.invoke(app, ["--bool-flag"]) + assert result.exit_code == 0 + assert '"bool_flag": true' in result.output + + +def test_args_only() -> None: + app = typer.Typer() + + @app.command() + def cmd(*args: str) -> None: + typer.echo(f"args={args!r}") + + result = runner.invoke(app, ["--", "opt1", "opt2"]) + assert result.exit_code == 0 + assert "opt1" in result.output + assert "opt2" in result.output + + +def test_args_and_kwargs_together() -> None: + app = typer.Typer() + + @app.command() + def cmd(*args: str, **kwargs: str) -> None: + typer.echo(f"args={args!r} kwargs={kwargs!r}") + + result = runner.invoke(app, ["--flag", "val", "--", "opt1", "opt2"]) + assert result.exit_code == 0 + assert "opt1" in result.output + assert "opt2" in result.output + assert "flag" in result.output + assert "val" in result.output + + +def test_kwargs_before_mandatory_positional() -> None: + app = typer.Typer() + + @app.command() + def cmd(name: str, **kwargs: str) -> None: + typer.echo(f"name={name!r} kwargs={kwargs!r}") + + result = runner.invoke(app, ["--flag", "val", "Bob"]) + assert result.exit_code == 0 + assert "name='Bob'" in result.output + assert "flag" in result.output + assert "val" in result.output + + +def test_double_dash_no_args() -> None: + app = typer.Typer() + + @app.command() + def cmd(*args: str, **kwargs: str) -> None: + typer.echo(f"args={args!r}") + + result = runner.invoke(app, ["--"]) + assert result.exit_code == 0 + assert "args=()" in result.output + + +def test_no_extra_args() -> None: + app = typer.Typer() + + @app.command() + def cmd(*args: str, **kwargs: str) -> None: + typer.echo(f"args={args!r} kwargs={kwargs!r}") + + result = runner.invoke(app, []) + assert result.exit_code == 0 + assert "args=()" in result.output + assert "kwargs={}" in result.output + + +def test_known_options_still_work() -> None: + app = typer.Typer() + + @app.command() + def cmd(name: str, count: int = 1, **kwargs: str) -> None: + typer.echo(f"name={name!r} count={count!r} kwargs={kwargs!r}") + + result = runner.invoke(app, ["--count", "3", "--extra", "foo", "Alice"]) + assert result.exit_code == 0 + assert "name='Alice'" in result.output + assert "count=3" in result.output + assert "extra" in result.output + assert "foo" in result.output + + +def test_regression_no_variadics() -> None: + """Functions without *args/**kwargs should be unaffected.""" + app = typer.Typer() + + @app.command() + def cmd(name: str, count: int = 1) -> None: + typer.echo(f"name={name!r} count={count!r}") + + result = runner.invoke(app, ["--count", "5", "Bob"]) + assert result.exit_code == 0 + assert "name='Bob'" in result.output + assert "count=5" in result.output + + +def test_all_together() -> None: + app = typer.Typer() + + @app.command() + def cmd(mandatory: str, *args: str, **kwargs: str) -> None: + typer.echo(f"mandatory={mandatory!r} args={args!r} kwargs={kwargs!r}") + + result = runner.invoke( + app, ["--flag1", "val", "--bool-flag", "mandatory-arg", "--", "opt1", "opt2"] + ) + assert result.exit_code == 0 + assert "mandatory='mandatory-arg'" in result.output + assert "opt1" in result.output + assert "opt2" in result.output + assert "flag1" in result.output + assert "val" in result.output + assert "bool_flag" in result.output diff --git a/typer/core.py b/typer/core.py index 48fee64e34..bf3a293c1d 100644 --- a/typer/core.py +++ b/typer/core.py @@ -644,6 +644,55 @@ def _typer_main_shell_completion( sys.exit(rv) +def _extract_unknown_options( + known_params: "list[click.Parameter]", args: "list[str]" +) -> "tuple[dict[str, Any], list[str]]": + """Pre-extract unknown --opt [value] pairs; return (extra_kwargs, remaining_args). + + Non-option tokens needed to satisfy known Click Arguments are reserved and + never consumed as option values, so positional arguments are handled correctly. + """ + known_opts: set[str] = set() + for p in known_params: + if isinstance(p, click.Option): + known_opts.update(p.opts) + + # Count how many non-option tokens must be reserved for known positional args. + n_positional = sum( + 1 for p in known_params if isinstance(p, click.Argument) and p.required + ) + + # Indices (in args) of non-option tokens that are candidates for positional args. + non_opt_indices = [i for i, a in enumerate(args) if not a.startswith("-")] + # Reserve the last n_positional of those indices. + reserved: set[int] = set(non_opt_indices[-n_positional:]) if n_positional else set() + + extra: dict[str, Any] = {} + remaining: list[str] = [] + i = 0 + while i < len(args): + arg = args[i] + is_short = arg.startswith("-") and len(arg) == 2 + is_long = arg.startswith("--") + if (is_long or is_short) and arg not in known_opts: + key = arg.lstrip("-").replace("-", "_") + next_i = i + 1 + if ( + next_i < len(args) + and not args[next_i].startswith("-") + and next_i not in reserved + ): + extra[key] = args[next_i] + i += 2 + else: + extra[key] = True + i += 1 + else: + remaining.append(arg) + i += 1 + return extra, remaining + + class TyperCommand(click.core.Command): def __init__( self, @@ -663,7 +712,11 @@ def __init__( # Rich settings rich_markup_mode: MarkupMode = DEFAULT_MARKUP_MODE, rich_help_panel: str | None = None, + var_keyword_param_name: str | None = None, + var_positional_param_name: str | None = None, ) -> None: + self._var_keyword_param_name = var_keyword_param_name + self._var_positional_param_name = var_positional_param_name super().__init__( name=name, context_settings=context_settings, @@ -681,6 +734,26 @@ def __init__( self.rich_markup_mode: MarkupMode = rich_markup_mode self.rich_help_panel = rich_help_panel + def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: + if ( + self._var_keyword_param_name is not None + or self._var_positional_param_name is not None + ): + if "--" in args: + sep = args.index("--") + ctx.meta["_typer_var_positional"] = tuple(args[sep + 1 :]) + args = args[:sep] + else: + ctx.meta["_typer_var_positional"] = () + + if self._var_keyword_param_name is not None: + extra_kwargs, args = _extract_unknown_options(self.params, args) + ctx.meta["_typer_extra_kwargs"] = extra_kwargs + else: + ctx.meta["_typer_extra_kwargs"] = {} + + return super().parse_args(ctx, args) + def format_options( self, ctx: click.Context, formatter: click.HelpFormatter ) -> None: diff --git a/typer/main.py b/typer/main.py index 6febf2091e..637298ae1c 100644 --- a/typer/main.py +++ b/typer/main.py @@ -1321,6 +1321,8 @@ def get_group_from_info( params, convertors, context_param_name, + _, + _, ) = get_params_convertors_ctx_param_name_from_function(solved_info.callback) cls = solved_info.cls or TyperGroup assert issubclass(cls, TyperGroup), f"{cls} should be a subclass of {TyperGroup}" @@ -1362,11 +1364,25 @@ def get_command_name(name: str) -> str: def get_params_convertors_ctx_param_name_from_function( callback: Callable[..., Any] | None, -) -> tuple[list[click.Argument | click.Option], dict[str, Any], str | None]: +) -> tuple[ + list[click.Argument | click.Option], + dict[str, Any], + str | None, + str | None, + str | None, +]: params = [] convertors = {} context_param_name = None + var_keyword_param_name = None + var_positional_param_name = None if callback: + sig = inspect.signature(callback) + for p in sig.parameters.values(): + if p.kind == inspect.Parameter.VAR_KEYWORD: + var_keyword_param_name = p.name + elif p.kind == inspect.Parameter.VAR_POSITIONAL: + var_positional_param_name = p.name parameters = get_params_from_function(callback) for param_name, param in parameters.items(): if lenient_issubclass(param.annotation, click.Context): @@ -1376,7 +1392,7 @@ def get_params_convertors_ctx_param_name_from_function( if convertor: convertors[param_name] = convertor params.append(click_param) - return params, convertors, context_param_name + return params, convertors, context_param_name, var_keyword_param_name, var_positional_param_name def get_command_from_info( @@ -1396,8 +1412,14 @@ def get_command_from_info( params, convertors, context_param_name, + var_keyword_param_name, + var_positional_param_name, ) = get_params_convertors_ctx_param_name_from_function(command_info.callback) cls = command_info.cls or TyperCommand + extra_cls_kwargs: dict[str, Any] = {} + if issubclass(cls, TyperCommand): + extra_cls_kwargs["var_keyword_param_name"] = var_keyword_param_name + extra_cls_kwargs["var_positional_param_name"] = var_positional_param_name command = cls( name=name, context_settings=command_info.context_settings, @@ -1406,6 +1428,8 @@ def get_command_from_info( params=params, convertors=convertors, context_param_name=context_param_name, + var_keyword_param_name=var_keyword_param_name, + var_positional_param_name=var_positional_param_name, pretty_exceptions_short=pretty_exceptions_short, ), params=params, # type: ignore @@ -1420,6 +1444,7 @@ def get_command_from_info( rich_markup_mode=rich_markup_mode, # Rich settings rich_help_panel=command_info.rich_help_panel, + **extra_cls_kwargs, ) return command @@ -1489,6 +1514,8 @@ def get_callback( params: Sequence[click.Parameter] = [], convertors: dict[str, Callable[[str], Any]] | None = None, context_param_name: str | None = None, + var_keyword_param_name: str | None = None, + var_positional_param_name: str | None = None, pretty_exceptions_short: bool, ) -> Callable[..., Any] | None: use_convertors = convertors or {} @@ -1502,6 +1529,26 @@ def get_callback( if param.name: use_params[param.name] = param.default + # Pre-compute ordered param names for positional call when *args is present. + # Params before *args must be passed positionally so Python routes them correctly. + if var_positional_param_name is not None: + _cb_sig = inspect.signature(callback) + _pos_names: list[str] = [] + _kw_only_names: list[str] = [] + for _pname, _p in _cb_sig.parameters.items(): + if _p.kind == inspect.Parameter.VAR_POSITIONAL: + break + if _p.kind in ( + inspect.Parameter.POSITIONAL_OR_KEYWORD, + inspect.Parameter.POSITIONAL_ONLY, + ): + _pos_names.append(_pname) + elif _p.kind == inspect.Parameter.KEYWORD_ONLY: + _kw_only_names.append(_pname) + else: + _pos_names = [] + _kw_only_names = [] + def wrapper(**kwargs: Any) -> Any: _rich_traceback_guard = pretty_exceptions_short # noqa: F841 for k, v in kwargs.items(): @@ -1511,6 +1558,31 @@ def wrapper(**kwargs: Any) -> Any: use_params[k] = v if context_param_name: use_params[context_param_name] = click.get_current_context() + if var_positional_param_name is not None or var_keyword_param_name is not None: + ctx = click.get_current_context() + extra_kw_val: dict[str, Any] = ( + ctx.meta.get("_typer_extra_kwargs", {}) + if var_keyword_param_name is not None + else {} + ) + if var_positional_param_name is not None: + positional_val: tuple[Any, ...] = ctx.meta.get( + "_typer_var_positional", () + ) + # Build positional args for params before *args, then append *args. + pos_call_args = [use_params[n] for n in _pos_names] + kw_call = {n: use_params[n] for n in _kw_only_names} + return callback( + *pos_call_args, *positional_val, **{**kw_call, **extra_kw_val} + ) + else: + # Only **kwargs — safe to call with keyword args. + call_kwargs = { + k: v + for k, v in use_params.items() + if k != var_keyword_param_name + } + return callback(**{**call_kwargs, **extra_kw_val}) return callback(**use_params) update_wrapper(wrapper, callback) diff --git a/typer/utils.py b/typer/utils.py index addf9334d4..6e74998d55 100644 --- a/typer/utils.py +++ b/typer/utils.py @@ -109,6 +109,11 @@ def get_params_from_function(func: Callable[..., Any]) -> dict[str, ParamMeta]: type_hints = get_type_hints(func) params = {} for param in signature.parameters.values(): + if param.kind in ( + inspect.Parameter.VAR_KEYWORD, + inspect.Parameter.VAR_POSITIONAL, + ): + continue annotation, typer_annotations = _split_annotation_from_typer_annotations( param.annotation, ) From 57d072034fbe52850669b2c9b415a8fddc0a9487 Mon Sep 17 00:00:00 2001 From: Alistair Pattison Date: Wed, 25 Mar 2026 13:09:15 -0400 Subject: [PATCH 02/13] clean up tests + new tests --- tests/test_kwargs.py | 230 +++++++++++++++++++++++++++---------------- 1 file changed, 147 insertions(+), 83 deletions(-) diff --git a/tests/test_kwargs.py b/tests/test_kwargs.py index 56dcbee11e..1aab34948a 100644 --- a/tests/test_kwargs.py +++ b/tests/test_kwargs.py @@ -1,63 +1,148 @@ +import json +from collections.abc import Sequence +from typing import Any + import typer from typer.testing import CliRunner runner = CliRunner() -def test_kwargs_only() -> None: +def _args_helper( + cli_input: Sequence[str], + expected_args: list[str], +) -> None: app = typer.Typer() @app.command() - def cmd(**kwargs: str) -> None: - typer.echo(f"kwargs={kwargs!r}") + def cmd(*args: Any) -> None: + typer.echo(json.dumps(args)) - result = runner.invoke(app, ["--flag", "val", "--verbose"]) - assert result.exit_code == 0 - assert "flag" in result.output - assert "val" in result.output - assert "verbose" in result.output + result = runner.invoke(app, cli_input) + assert result.exit_code == 0, "app exited with non-zero status" + assert expected_args == json.loads(result.output.strip()), ( + "args do not match what is expected" + ) -def test_kwargs_bool_flag() -> None: + +def _kwargs_helper( + cli_input: Sequence[str], + expected_kwargs: dict[str, Any], +) -> None: app = typer.Typer() @app.command() - def cmd(**kwargs: str) -> None: - import json - + def cmd(**kwargs: Any) -> None: typer.echo(json.dumps(kwargs)) - result = runner.invoke(app, ["--bool-flag"]) - assert result.exit_code == 0 - assert '"bool_flag": true' in result.output + result = runner.invoke(app, cli_input) + + assert result.exit_code == 0, "app exited with non-zero status" + assert expected_kwargs == json.loads(result.output.strip()), ( + "kwargs do not match what is expected" + ) -def test_args_only() -> None: +def _args_kwargs_helper( + cli_input: Sequence[str], + expected_args: list[str], + expected_kwargs: dict[str, Any], +) -> None: app = typer.Typer() @app.command() - def cmd(*args: str) -> None: - typer.echo(f"args={args!r}") + def cmd(*args: Any, **kwargs: Any) -> None: + typer.echo(json.dumps(args)) + typer.echo(json.dumps(kwargs)) - result = runner.invoke(app, ["--", "opt1", "opt2"]) - assert result.exit_code == 0 - assert "opt1" in result.output - assert "opt2" in result.output + result = runner.invoke(app, cli_input) + assert result.exit_code == 0, "app exited with non-zero status" -def test_args_and_kwargs_together() -> None: - app = typer.Typer() + json_args, json_kwargs = result.output.splitlines() - @app.command() - def cmd(*args: str, **kwargs: str) -> None: - typer.echo(f"args={args!r} kwargs={kwargs!r}") + assert expected_args == json.loads(json_args), "args do not match what is expected" + assert expected_kwargs == json.loads(json_kwargs), ( + "kwargs do not match what is expected" + ) + + +def test_simple_kwarg() -> None: + _kwargs_helper(["--flag", "val"], {"flag": "val"}) + + +def test_multiple_kwargs() -> None: + _kwargs_helper( + ["--flag", "val", "--flag2", "val2"], + {"flag": "val", "flag2": "val2"}, + ) + + +def test_kwargs_bool_flag_alone() -> None: + _kwargs_helper(["--bool-flag"], {"bool_flag": True}) + + +def test_kwargs_two_bool_flags() -> None: + _kwargs_helper( + ["--flag1", "--flag2"], + {"flag1": True, "flag2": True}, + ) + + +def test_kwargs_bool_flags_and_kwargs() -> None: + _kwargs_helper( + ["--option1", "value1", "--flag1", "--flag2", "--option2", "value2"], + {"option1": "value1", "flag1": True, "flag2": True, "option2": "value2"}, + ) + + +def test_args_only_with_separator() -> None: + _args_helper(["--", "opt1", "opt2"], ["opt1", "opt2"]) + + +def test_args_only_without_separator() -> None: + _args_helper(["opt1", "opt2"], ["opt1", "opt2"]) - result = runner.invoke(app, ["--flag", "val", "--", "opt1", "opt2"]) - assert result.exit_code == 0 - assert "opt1" in result.output - assert "opt2" in result.output - assert "flag" in result.output - assert "val" in result.output + +def test_no_args_with_separator() -> None: + _args_helper(["--"], []) + + +def test_no_args_without_separator() -> None: + _args_helper([], []) + + +def test_args_and_kwargs_with_separator() -> None: + _args_kwargs_helper( + ["--flag", "val", "--", "opt1", "opt2"], + ["opt1", "opt2"], + {"flag": "val"}, + ) + + +def test_args_and_kwargs_without_separator() -> None: + _args_kwargs_helper( + ["--flag", "val", "opt1", "opt2"], + ["opt1", "opt2"], + {"flag": "val"}, + ) + + +def test_args_kwargs_bool_flag() -> None: + _args_kwargs_helper( + ["--flag", "val", "--bool", "--", "opt1", "opt2"], + ["opt1", "opt2"], + {"flag": "val", "bool": True}, + ) + + +def test_kwargs_after_separator() -> None: + _args_kwargs_helper( + ["--flag", "val", "--", "opt1", "opt2", "--not-a-kwarg", "value"], + ["opt1", "opt2", "--not-a-kwarg", "value"], + {"flag": "val"}, + ) def test_kwargs_before_mandatory_positional() -> None: @@ -75,73 +160,52 @@ def cmd(name: str, **kwargs: str) -> None: def test_double_dash_no_args() -> None: - app = typer.Typer() - - @app.command() - def cmd(*args: str, **kwargs: str) -> None: - typer.echo(f"args={args!r}") - - result = runner.invoke(app, ["--"]) - assert result.exit_code == 0 - assert "args=()" in result.output + _args_kwargs_helper(["--"], [], {}) def test_no_extra_args() -> None: - app = typer.Typer() - - @app.command() - def cmd(*args: str, **kwargs: str) -> None: - typer.echo(f"args={args!r} kwargs={kwargs!r}") - - result = runner.invoke(app, []) - assert result.exit_code == 0 - assert "args=()" in result.output - assert "kwargs={}" in result.output + _args_kwargs_helper([], [], {}) def test_known_options_still_work() -> None: app = typer.Typer() @app.command() - def cmd(name: str, count: int = 1, **kwargs: str) -> None: - typer.echo(f"name={name!r} count={count!r} kwargs={kwargs!r}") + def cmd(name: str, count: int = 1, **kwargs: Any) -> None: + typer.echo(json.dumps({"name": name, "count": count, "kwargs": kwargs})) result = runner.invoke(app, ["--count", "3", "--extra", "foo", "Alice"]) assert result.exit_code == 0 - assert "name='Alice'" in result.output - assert "count=3" in result.output - assert "extra" in result.output - assert "foo" in result.output - - -def test_regression_no_variadics() -> None: - """Functions without *args/**kwargs should be unaffected.""" - app = typer.Typer() - - @app.command() - def cmd(name: str, count: int = 1) -> None: - typer.echo(f"name={name!r} count={count!r}") - - result = runner.invoke(app, ["--count", "5", "Bob"]) - assert result.exit_code == 0 - assert "name='Bob'" in result.output - assert "count=5" in result.output + assert json.loads(result.output.strip()) == { + "name": "Alice", + "count": 3, + "kwargs": {"extra": "foo"}, + } def test_all_together() -> None: app = typer.Typer() @app.command() - def cmd(mandatory: str, *args: str, **kwargs: str) -> None: - typer.echo(f"mandatory={mandatory!r} args={args!r} kwargs={kwargs!r}") + def cmd(mandatory: str, count: int = 1, *args: str, **kwargs: Any) -> None: + typer.echo( + json.dumps( + { + "mandatory": mandatory, + "count": count, + "args": list(args), + "kwargs": kwargs, + } + ) + ) + + args = ["--flag1", "val", "--bool-flag", "--count", "5", "req1", "--", "opt1"] + result = runner.invoke(app, args) - result = runner.invoke( - app, ["--flag1", "val", "--bool-flag", "mandatory-arg", "--", "opt1", "opt2"] - ) assert result.exit_code == 0 - assert "mandatory='mandatory-arg'" in result.output - assert "opt1" in result.output - assert "opt2" in result.output - assert "flag1" in result.output - assert "val" in result.output - assert "bool_flag" in result.output + assert json.loads(result.output.strip()) == { + "mandatory": "req1", + "count": 5, + "args": ["opt1"], + "kwargs": {"flag1": "val", "bool_flag": True}, + } From 66a95aafef7ef3fcf74d90d8962a71dd1c461972 Mon Sep 17 00:00:00 2001 From: Alistair Pattison Date: Wed, 25 Mar 2026 13:42:02 -0400 Subject: [PATCH 03/13] more tests --- tests/test_kwargs.py | 153 +++++++++++++++++++++++++++++-------------- 1 file changed, 105 insertions(+), 48 deletions(-) diff --git a/tests/test_kwargs.py b/tests/test_kwargs.py index 1aab34948a..559099f108 100644 --- a/tests/test_kwargs.py +++ b/tests/test_kwargs.py @@ -68,6 +68,49 @@ def cmd(*args: Any, **kwargs: Any) -> None: ) +def _kitchen_sink_helper( + cli_input: Sequence[str], + *, + expected_mandatory: str, + expected_count: int = 1, + expected_flag: bool = False, + expected_args: list[str] | None = None, + expected_kwargs: dict[str, Any] | None = None, +) -> None: + app = typer.Typer() + + @app.command() + def cmd( + mandatory: str, + flag: bool = False, + count: int = 1, + *args: Any, + **kwargs: Any, + ) -> None: + typer.echo( + json.dumps( + { + "mandatory": mandatory, + "flag": flag, + "count": count, + "args": list(args), + "kwargs": kwargs, + } + ) + ) + + result = runner.invoke(app, cli_input) + + assert result.exit_code == 0, "app exited with non-zero status" + assert json.loads(result.output.strip()) == { + "mandatory": expected_mandatory, + "flag": expected_flag, + "count": expected_count, + "args": expected_args or [], + "kwargs": expected_kwargs or {}, + } + + def test_simple_kwarg() -> None: _kwargs_helper(["--flag", "val"], {"flag": "val"}) @@ -145,20 +188,6 @@ def test_kwargs_after_separator() -> None: ) -def test_kwargs_before_mandatory_positional() -> None: - app = typer.Typer() - - @app.command() - def cmd(name: str, **kwargs: str) -> None: - typer.echo(f"name={name!r} kwargs={kwargs!r}") - - result = runner.invoke(app, ["--flag", "val", "Bob"]) - assert result.exit_code == 0 - assert "name='Bob'" in result.output - assert "flag" in result.output - assert "val" in result.output - - def test_double_dash_no_args() -> None: _args_kwargs_helper(["--"], [], {}) @@ -167,45 +196,73 @@ def test_no_extra_args() -> None: _args_kwargs_helper([], [], {}) +def test_bool_flag_before_mandatory_arg() -> None: + _kitchen_sink_helper( + cli_input=["--unknown-flag", "Alice"], + expected_mandatory="Alice", + expected_kwargs={"unknown_flag": True}, + ) + + def test_known_options_still_work() -> None: - app = typer.Typer() + _kitchen_sink_helper( + cli_input=["--count", "3", "--extra", "foo", "Alice"], + expected_mandatory="Alice", + expected_count=3, + expected_kwargs={"extra": "foo"}, + ) - @app.command() - def cmd(name: str, count: int = 1, **kwargs: Any) -> None: - typer.echo(json.dumps({"name": name, "count": count, "kwargs": kwargs})) - result = runner.invoke(app, ["--count", "3", "--extra", "foo", "Alice"]) - assert result.exit_code == 0 - assert json.loads(result.output.strip()) == { - "name": "Alice", - "count": 3, - "kwargs": {"extra": "foo"}, - } +def test_kwarg_after_known_option() -> None: + _kitchen_sink_helper( + cli_input=["--count", "3", "Alice", "--extra", "foo"], + expected_mandatory="Alice", + expected_count=3, + expected_kwargs={"extra": "foo"}, + ) -def test_all_together() -> None: - app = typer.Typer() +def test_kwarg_and_args_after_known_option() -> None: + _kitchen_sink_helper( + cli_input=["--count", "3", "Alice", "--extra", "foo", "other", "args"], + expected_mandatory="Alice", + expected_count=3, + expected_args=["other", "args"], + expected_kwargs={"extra": "foo"}, + ) - @app.command() - def cmd(mandatory: str, count: int = 1, *args: str, **kwargs: Any) -> None: - typer.echo( - json.dumps( - { - "mandatory": mandatory, - "count": count, - "args": list(args), - "kwargs": kwargs, - } - ) - ) - args = ["--flag1", "val", "--bool-flag", "--count", "5", "req1", "--", "opt1"] - result = runner.invoke(app, args) +def test_kitchen_sink_with_separator() -> None: + args = ["--flag", "--other-flag", "Alice", "--extra", "foo", "--", "other", "args"] - assert result.exit_code == 0 - assert json.loads(result.output.strip()) == { - "mandatory": "req1", - "count": 5, - "args": ["opt1"], - "kwargs": {"flag1": "val", "bool_flag": True}, - } + _kitchen_sink_helper( + cli_input=args, + expected_mandatory="Alice", + expected_flag=True, + expected_args=["other", "args"], + expected_kwargs={"extra": "foo", "other_flag": True}, + ) + + +def test_kitchen_sink_without_separator() -> None: + args = [ + "--flag1", + "val", + "--bool-flag", + "--count", + "5", + "--flag", + "req1", + "--", + "opt1", + "--not-a", + "kwarg", + ] + _kitchen_sink_helper( + cli_input=args, + expected_mandatory="req1", + expected_flag=True, + expected_count=5, + expected_args=["opt1", "--not-a", "kwarg"], + expected_kwargs={"flag1": "val", "bool_flag": True}, + ) From 7b1d3143eb57113d02320aad1ed883368ca0c5a0 Mon Sep 17 00:00:00 2001 From: Alistair Pattison Date: Wed, 25 Mar 2026 14:40:19 -0400 Subject: [PATCH 04/13] add pr draft --- plan.md | 93 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 plan.md diff --git a/plan.md b/plan.md new file mode 100644 index 0000000000..8d133dd99f --- /dev/null +++ b/plan.md @@ -0,0 +1,93 @@ +# Args and Kwargs support + +This PR adds support for `*args` and `**kwargs` in function signatures: + +```python +# -- command.py -- + +import json +from typing import Any + +import typer + +@app.command() +def cmd( + filepath: Path, + option: str = "", + flag: bool = False, + *args: str, + **kwargs: Any +) -> None: + dump = json.dumps({ + "filepath" : filepath, + "option" : option, + "flag" : flag, + "args" : args, + "kwargs" : kwargs, + + }) + typer.echo(dump) +``` + +Declaring a signature allows passing arbitrary key-value pairs on the command line, +which are then made available in `kwargs`. Unknown trailing arguments are captured in `*args`. + +```bash +./command.py --unknown-key value input.txt arg1 arg2 +# kwargs = { "unknown_key" : "value" }; args = ( "arg1", "arg2" ) +``` + +To avoid ambiguity, everything after `--` will be absorbed into `*args` regardless of +whether or not it matches a known argument. (This is a widely used convention. See e.g. XX.) + +```bash +./command.py input.txt --flag # flag = True +./command.py input.txt -- --flag # args = ("--flag",) +``` + +This feature will not disrupt explicitly declared flags/options. + +```bash +# all of the following commands are parsed equivalently: +# option = "val"; flag = True; args = ( "arg1", "arg2" ); kwargs = { "unknown", "val2" } + +./command.py --option val --flag --unknown val2 input.txt arg1 arg2 +./command.py --flag --unknown val2 input.txt --option val arg1 arg2 +./command.py --flag input.txt --option val --unknown val2 arg1 arg2 +./command.py --flag input.txt --option val --unknown val2 -- arg1 arg2 +``` + +Unknown options *must* have values. +(Without declaring option as a boolean, we can't know a priori whether we +should accept it without a value. To emulate a flag, pass `true` as the value.) + +```bash +./command.py --unknown-flag input.txt arg1 arg2 # not okay +./command.py --unknown-flag true input.txt arg1 arg2 # okay +``` + +Both `*args` and `**kwargs` can of course be declared independently. + +```python +# -- command2.py -- + +from typing import Any + +import typer + + +@app.command() +def args(filepath: Path, *args: str) -> None: + typer.echo(args) + +@app.command() +def kwargs(filepath: Path, **kwargs: Any) -> None: + typer.echo(kwargs) +``` + +```bash +./command2.py args input.txt arg1 arg2 # args = ( "arg1", "arg2" ) +./command2.py kwargs input.txt --key value # kwargs = { "key": "value" } +``` + +Closes #XX. From 7e44cef2fd6d02a4a94929d0e9f1ef4d48d0abfa Mon Sep 17 00:00:00 2001 From: Alistair Pattison Date: Wed, 25 Mar 2026 15:07:36 -0400 Subject: [PATCH 05/13] refine implementation --- tests/test_kwargs.py | 298 ++++++++++++++++--------------------------- typer/core.py | 67 ++++++---- 2 files changed, 147 insertions(+), 218 deletions(-) diff --git a/tests/test_kwargs.py b/tests/test_kwargs.py index 559099f108..6cd99987c2 100644 --- a/tests/test_kwargs.py +++ b/tests/test_kwargs.py @@ -1,5 +1,6 @@ import json from collections.abc import Sequence +from pathlib import Path from typing import Any import typer @@ -8,91 +9,35 @@ runner = CliRunner() -def _args_helper( - cli_input: Sequence[str], - expected_args: list[str], -) -> None: - app = typer.Typer() - - @app.command() - def cmd(*args: Any) -> None: - typer.echo(json.dumps(args)) - - result = runner.invoke(app, cli_input) - - assert result.exit_code == 0, "app exited with non-zero status" - assert expected_args == json.loads(result.output.strip()), ( - "args do not match what is expected" - ) - - -def _kwargs_helper( - cli_input: Sequence[str], - expected_kwargs: dict[str, Any], -) -> None: - app = typer.Typer() - - @app.command() - def cmd(**kwargs: Any) -> None: - typer.echo(json.dumps(kwargs)) - - result = runner.invoke(app, cli_input) - - assert result.exit_code == 0, "app exited with non-zero status" - assert expected_kwargs == json.loads(result.output.strip()), ( - "kwargs do not match what is expected" - ) - - -def _args_kwargs_helper( - cli_input: Sequence[str], - expected_args: list[str], - expected_kwargs: dict[str, Any], -) -> None: - app = typer.Typer() - - @app.command() - def cmd(*args: Any, **kwargs: Any) -> None: - typer.echo(json.dumps(args)) - typer.echo(json.dumps(kwargs)) - - result = runner.invoke(app, cli_input) - - assert result.exit_code == 0, "app exited with non-zero status" - - json_args, json_kwargs = result.output.splitlines() - - assert expected_args == json.loads(json_args), "args do not match what is expected" - assert expected_kwargs == json.loads(json_kwargs), ( - "kwargs do not match what is expected" - ) - - -def _kitchen_sink_helper( +def _command_helper( cli_input: Sequence[str], *, - expected_mandatory: str, - expected_count: int = 1, + expected_filepath: str, + expected_option: str = "", expected_flag: bool = False, expected_args: list[str] | None = None, expected_kwargs: dict[str, Any] | None = None, ) -> None: + """Helper matching the command.py example app from the PR draft. + + def cmd(filepath: Path, option: str = "", flag: bool = False, *args: str, **kwargs: Any) + """ app = typer.Typer() @app.command() def cmd( - mandatory: str, + filepath: Path, + option: str = "", flag: bool = False, - count: int = 1, - *args: Any, + *args: str, **kwargs: Any, ) -> None: typer.echo( json.dumps( { - "mandatory": mandatory, + "filepath": str(filepath), + "option": option, "flag": flag, - "count": count, "args": list(args), "kwargs": kwargs, } @@ -100,169 +45,140 @@ def cmd( ) result = runner.invoke(app, cli_input) - - assert result.exit_code == 0, "app exited with non-zero status" + assert result.exit_code == 0, result.output assert json.loads(result.output.strip()) == { - "mandatory": expected_mandatory, + "filepath": expected_filepath, + "option": expected_option, "flag": expected_flag, - "count": expected_count, "args": expected_args or [], "kwargs": expected_kwargs or {}, } -def test_simple_kwarg() -> None: - _kwargs_helper(["--flag", "val"], {"flag": "val"}) - - -def test_multiple_kwargs() -> None: - _kwargs_helper( - ["--flag", "val", "--flag2", "val2"], - {"flag": "val", "flag2": "val2"}, - ) - - -def test_kwargs_bool_flag_alone() -> None: - _kwargs_helper(["--bool-flag"], {"bool_flag": True}) - - -def test_kwargs_two_bool_flags() -> None: - _kwargs_helper( - ["--flag1", "--flag2"], - {"flag1": True, "flag2": True}, - ) - - -def test_kwargs_bool_flags_and_kwargs() -> None: - _kwargs_helper( - ["--option1", "value1", "--flag1", "--flag2", "--option2", "value2"], - {"option1": "value1", "flag1": True, "flag2": True, "option2": "value2"}, - ) - - -def test_args_only_with_separator() -> None: - _args_helper(["--", "opt1", "opt2"], ["opt1", "opt2"]) +def _command2_helper( + cli_input: Sequence[str], + *, + expected_filepath: str, + expected_args: list[str] | None = None, + expected_kwargs: dict[str, Any] | None = None, +) -> None: + """Helper matching the command2.py multi-command app from the PR draft. + @app.command(name="args") def args_cmd(filepath: Path, *args: str) + @app.command(name="kwargs") def kwargs_cmd(filepath: Path, **kwargs: Any) -def test_args_only_without_separator() -> None: - _args_helper(["opt1", "opt2"], ["opt1", "opt2"]) + CLI input must start with the subcommand name. + """ + app = typer.Typer() + @app.command(name="args") + def args_cmd(filepath: Path, *args: str) -> None: + typer.echo(json.dumps({"filepath": str(filepath), "args": list(args)})) -def test_no_args_with_separator() -> None: - _args_helper(["--"], []) + @app.command(name="kwargs") + def kwargs_cmd(filepath: Path, **kwargs: Any) -> None: + typer.echo(json.dumps({"filepath": str(filepath), "kwargs": kwargs})) + result = runner.invoke(app, cli_input) + assert result.exit_code == 0, result.output -def test_no_args_without_separator() -> None: - _args_helper([], []) + data = json.loads(result.output.strip()) + assert data["filepath"] == expected_filepath + if expected_args is not None: + assert data["args"] == expected_args + if expected_kwargs is not None: + assert data["kwargs"] == expected_kwargs -def test_args_and_kwargs_with_separator() -> None: - _args_kwargs_helper( - ["--flag", "val", "--", "opt1", "opt2"], - ["opt1", "opt2"], - {"flag": "val"}, +def test_unknown_kwarg_and_trailing_args() -> None: + _command_helper( + ["--unknown-key", "value", "input.txt", "arg1", "arg2"], + expected_filepath="input.txt", + expected_kwargs={"unknown_key": "value"}, + expected_args=["arg1", "arg2"], ) -def test_args_and_kwargs_without_separator() -> None: - _args_kwargs_helper( - ["--flag", "val", "opt1", "opt2"], - ["opt1", "opt2"], - {"flag": "val"}, +def test_known_flag() -> None: + _command_helper( + ["input.txt", "--flag"], + expected_filepath="input.txt", + expected_flag=True, ) -def test_args_kwargs_bool_flag() -> None: - _args_kwargs_helper( - ["--flag", "val", "--bool", "--", "opt1", "opt2"], - ["opt1", "opt2"], - {"flag": "val", "bool": True}, +def test_separator_absorbs_flag() -> None: + _command_helper( + ["input.txt", "--", "--flag"], + expected_filepath="input.txt", + expected_args=["--flag"], ) -def test_kwargs_after_separator() -> None: - _args_kwargs_helper( - ["--flag", "val", "--", "opt1", "opt2", "--not-a-kwarg", "value"], - ["opt1", "opt2", "--not-a-kwarg", "value"], - {"flag": "val"}, +def test_equivalent_form_1() -> None: + _command_helper( + ["--option", "val", "--flag", "input.txt", "arg1", "arg2", "--unknown", "val2"], + expected_filepath="input.txt", + expected_option="val", + expected_flag=True, + expected_args=["arg1", "arg2"], + expected_kwargs={"unknown": "val2"}, ) -def test_double_dash_no_args() -> None: - _args_kwargs_helper(["--"], [], {}) - - -def test_no_extra_args() -> None: - _args_kwargs_helper([], [], {}) - - -def test_bool_flag_before_mandatory_arg() -> None: - _kitchen_sink_helper( - cli_input=["--unknown-flag", "Alice"], - expected_mandatory="Alice", - expected_kwargs={"unknown_flag": True}, +def test_equivalent_form_2() -> None: + _command_helper( + ["input.txt", "--option", "val", "--flag", "arg1", "arg2", "--unknown", "val2"], + expected_filepath="input.txt", + expected_option="val", + expected_flag=True, + expected_args=["arg1", "arg2"], + expected_kwargs={"unknown": "val2"}, ) -def test_known_options_still_work() -> None: - _kitchen_sink_helper( - cli_input=["--count", "3", "--extra", "foo", "Alice"], - expected_mandatory="Alice", - expected_count=3, - expected_kwargs={"extra": "foo"}, +def test_equivalent_form_3() -> None: + _command_helper( + ["--unknown", "val2", "input.txt", "--option", "val", "--flag", "arg1", "arg2"], + expected_filepath="input.txt", + expected_option="val", + expected_flag=True, + expected_args=["arg1", "arg2"], + expected_kwargs={"unknown": "val2"}, ) -def test_kwarg_after_known_option() -> None: - _kitchen_sink_helper( - cli_input=["--count", "3", "Alice", "--extra", "foo"], - expected_mandatory="Alice", - expected_count=3, - expected_kwargs={"extra": "foo"}, +def test_equivalent_form_4() -> None: + _command_helper( + ["--flag", "--option", "val", "--unknown", "val2", "input.txt", "arg1", "arg2"], + expected_filepath="input.txt", + expected_option="val", + expected_flag=True, + expected_args=["arg1", "arg2"], + expected_kwargs={"unknown": "val2"}, ) -def test_kwarg_and_args_after_known_option() -> None: - _kitchen_sink_helper( - cli_input=["--count", "3", "Alice", "--extra", "foo", "other", "args"], - expected_mandatory="Alice", - expected_count=3, - expected_args=["other", "args"], - expected_kwargs={"extra": "foo"}, +def test_unknown_flag_requires_value() -> None: + _command_helper( + ["--unknown-flag", "true", "input.txt", "arg1", "arg2"], + expected_filepath="input.txt", + expected_kwargs={"unknown_flag": "true"}, + expected_args=["arg1", "arg2"], ) -def test_kitchen_sink_with_separator() -> None: - args = ["--flag", "--other-flag", "Alice", "--extra", "foo", "--", "other", "args"] - - _kitchen_sink_helper( - cli_input=args, - expected_mandatory="Alice", - expected_flag=True, - expected_args=["other", "args"], - expected_kwargs={"extra": "foo", "other_flag": True}, +def test_command2_args() -> None: + _command2_helper( + ["args", "input.txt", "arg1", "arg2"], + expected_filepath="input.txt", + expected_args=["arg1", "arg2"], ) -def test_kitchen_sink_without_separator() -> None: - args = [ - "--flag1", - "val", - "--bool-flag", - "--count", - "5", - "--flag", - "req1", - "--", - "opt1", - "--not-a", - "kwarg", - ] - _kitchen_sink_helper( - cli_input=args, - expected_mandatory="req1", - expected_flag=True, - expected_count=5, - expected_args=["opt1", "--not-a", "kwarg"], - expected_kwargs={"flag1": "val", "bool_flag": True}, +def test_command2_kwargs() -> None: + _command2_helper( + ["kwargs", "input.txt", "--key", "value"], + expected_filepath="input.txt", + expected_kwargs={"key": "value"}, ) diff --git a/typer/core.py b/typer/core.py index bf3a293c1d..4813a7fc7b 100644 --- a/typer/core.py +++ b/typer/core.py @@ -647,26 +647,17 @@ def _typer_main_shell_completion( def _extract_unknown_options( known_params: "list[click.Parameter]", args: "list[str]" ) -> "tuple[dict[str, Any], list[str]]": - """Pre-extract unknown --opt [value] pairs; return (extra_kwargs, remaining_args). + """Pre-extract unknown --opt value pairs; return (extra_kwargs, remaining_args). - Non-option tokens needed to satisfy known Click Arguments are reserved and - never consumed as option values, so positional arguments are handled correctly. + Unknown options must always have a value. An unknown option with no + following value token is left in ``remaining`` so that Click can emit + an appropriate error. """ known_opts: set[str] = set() for p in known_params: if isinstance(p, click.Option): known_opts.update(p.opts) - # Count how many non-option tokens must be reserved for known positional args. - n_positional = sum( - 1 for p in known_params if isinstance(p, click.Argument) and p.required - ) - - # Indices (in args) of non-option tokens that are candidates for positional args. - non_opt_indices = [i for i, a in enumerate(args) if not a.startswith("-")] - # Reserve the last n_positional of those indices. - reserved: set[int] = set(non_opt_indices[-n_positional:]) if n_positional else set() - extra: dict[str, Any] = {} remaining: list[str] = [] i = 0 @@ -677,15 +668,11 @@ def _extract_unknown_options( if (is_long or is_short) and arg not in known_opts: key = arg.lstrip("-").replace("-", "_") next_i = i + 1 - if ( - next_i < len(args) - and not args[next_i].startswith("-") - and next_i not in reserved - ): + if next_i < len(args) and not args[next_i].startswith("-"): extra[key] = args[next_i] i += 2 else: - extra[key] = True + remaining.append(arg) i += 1 else: remaining.append(arg) @@ -735,23 +722,49 @@ def __init__( self.rich_help_panel = rich_help_panel def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: - if ( - self._var_keyword_param_name is not None - or self._var_positional_param_name is not None - ): - if "--" in args: + var_keyword = self._var_keyword_param_name is not None + var_positional = self._var_positional_param_name is not None + if var_keyword or var_positional: + has_sep = "--" in args + if has_sep: sep = args.index("--") - ctx.meta["_typer_var_positional"] = tuple(args[sep + 1 :]) + positional_extra: list[str] = list(args[sep + 1 :]) args = args[:sep] else: - ctx.meta["_typer_var_positional"] = () + positional_extra = [] - if self._var_keyword_param_name is not None: + if var_keyword: extra_kwargs, args = _extract_unknown_options(self.params, args) ctx.meta["_typer_extra_kwargs"] = extra_kwargs else: ctx.meta["_typer_extra_kwargs"] = {} + if var_positional and not has_sep: + # Walk remaining args to find positional tokens beyond the + # required named Click Arguments and pull them into *args. + n = sum( + 1 for p in self.params if isinstance(p, click.Argument) and p.required + ) + pos_idx: list[int] = [] + i = 0 + while i < len(args): + if args[i].startswith("-"): + consumes = any( + isinstance(p, click.Option) + and args[i] in p.opts + and not p.is_flag + for p in self.params + ) + i += 2 if consumes else 1 + else: + pos_idx.append(i) + i += 1 + extra_idx = set(pos_idx[n:]) + positional_extra = [args[j] for j in sorted(extra_idx)] + args = [a for j, a in enumerate(args) if j not in extra_idx] + + ctx.meta["_typer_var_positional"] = tuple(positional_extra) + return super().parse_args(ctx, args) def format_options( From 898b46653385f7d92983d6861114fcb6e7894bd5 Mon Sep 17 00:00:00 2001 From: Alistair Pattison Date: Wed, 25 Mar 2026 15:12:19 -0400 Subject: [PATCH 06/13] tweak test formatting/names --- tests/test_kwargs.py | 43 ++++++++++++++++--------------------------- 1 file changed, 16 insertions(+), 27 deletions(-) diff --git a/tests/test_kwargs.py b/tests/test_kwargs.py index 6cd99987c2..13c5c7844c 100644 --- a/tests/test_kwargs.py +++ b/tests/test_kwargs.py @@ -9,7 +9,7 @@ runner = CliRunner() -def _command_helper( +def _kitchen_sink_helper( cli_input: Sequence[str], *, expected_filepath: str, @@ -18,10 +18,6 @@ def _command_helper( expected_args: list[str] | None = None, expected_kwargs: dict[str, Any] | None = None, ) -> None: - """Helper matching the command.py example app from the PR draft. - - def cmd(filepath: Path, option: str = "", flag: bool = False, *args: str, **kwargs: Any) - """ app = typer.Typer() @app.command() @@ -55,28 +51,21 @@ def cmd( } -def _command2_helper( +def _separate_args_kwargs_helper( cli_input: Sequence[str], *, expected_filepath: str, expected_args: list[str] | None = None, expected_kwargs: dict[str, Any] | None = None, ) -> None: - """Helper matching the command2.py multi-command app from the PR draft. - - @app.command(name="args") def args_cmd(filepath: Path, *args: str) - @app.command(name="kwargs") def kwargs_cmd(filepath: Path, **kwargs: Any) - - CLI input must start with the subcommand name. - """ app = typer.Typer() - @app.command(name="args") - def args_cmd(filepath: Path, *args: str) -> None: + @app.command() + def args(filepath: Path, *args: str) -> None: typer.echo(json.dumps({"filepath": str(filepath), "args": list(args)})) - @app.command(name="kwargs") - def kwargs_cmd(filepath: Path, **kwargs: Any) -> None: + @app.command() + def kwargs(filepath: Path, **kwargs: Any) -> None: typer.echo(json.dumps({"filepath": str(filepath), "kwargs": kwargs})) result = runner.invoke(app, cli_input) @@ -91,7 +80,7 @@ def kwargs_cmd(filepath: Path, **kwargs: Any) -> None: def test_unknown_kwarg_and_trailing_args() -> None: - _command_helper( + _kitchen_sink_helper( ["--unknown-key", "value", "input.txt", "arg1", "arg2"], expected_filepath="input.txt", expected_kwargs={"unknown_key": "value"}, @@ -100,7 +89,7 @@ def test_unknown_kwarg_and_trailing_args() -> None: def test_known_flag() -> None: - _command_helper( + _kitchen_sink_helper( ["input.txt", "--flag"], expected_filepath="input.txt", expected_flag=True, @@ -108,7 +97,7 @@ def test_known_flag() -> None: def test_separator_absorbs_flag() -> None: - _command_helper( + _kitchen_sink_helper( ["input.txt", "--", "--flag"], expected_filepath="input.txt", expected_args=["--flag"], @@ -116,7 +105,7 @@ def test_separator_absorbs_flag() -> None: def test_equivalent_form_1() -> None: - _command_helper( + _kitchen_sink_helper( ["--option", "val", "--flag", "input.txt", "arg1", "arg2", "--unknown", "val2"], expected_filepath="input.txt", expected_option="val", @@ -127,7 +116,7 @@ def test_equivalent_form_1() -> None: def test_equivalent_form_2() -> None: - _command_helper( + _kitchen_sink_helper( ["input.txt", "--option", "val", "--flag", "arg1", "arg2", "--unknown", "val2"], expected_filepath="input.txt", expected_option="val", @@ -138,7 +127,7 @@ def test_equivalent_form_2() -> None: def test_equivalent_form_3() -> None: - _command_helper( + _kitchen_sink_helper( ["--unknown", "val2", "input.txt", "--option", "val", "--flag", "arg1", "arg2"], expected_filepath="input.txt", expected_option="val", @@ -149,7 +138,7 @@ def test_equivalent_form_3() -> None: def test_equivalent_form_4() -> None: - _command_helper( + _kitchen_sink_helper( ["--flag", "--option", "val", "--unknown", "val2", "input.txt", "arg1", "arg2"], expected_filepath="input.txt", expected_option="val", @@ -160,7 +149,7 @@ def test_equivalent_form_4() -> None: def test_unknown_flag_requires_value() -> None: - _command_helper( + _kitchen_sink_helper( ["--unknown-flag", "true", "input.txt", "arg1", "arg2"], expected_filepath="input.txt", expected_kwargs={"unknown_flag": "true"}, @@ -169,7 +158,7 @@ def test_unknown_flag_requires_value() -> None: def test_command2_args() -> None: - _command2_helper( + _separate_args_kwargs_helper( ["args", "input.txt", "arg1", "arg2"], expected_filepath="input.txt", expected_args=["arg1", "arg2"], @@ -177,7 +166,7 @@ def test_command2_args() -> None: def test_command2_kwargs() -> None: - _command2_helper( + _separate_args_kwargs_helper( ["kwargs", "input.txt", "--key", "value"], expected_filepath="input.txt", expected_kwargs={"key": "value"}, From a0773e75e3fad5d25d315148fb3bf3e9d5a240d3 Mon Sep 17 00:00:00 2001 From: Alistair Pattison Date: Wed, 25 Mar 2026 15:18:41 -0400 Subject: [PATCH 07/13] refine pr --- plan.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/plan.md b/plan.md index 8d133dd99f..c8c52dbd05 100644 --- a/plan.md +++ b/plan.md @@ -58,15 +58,14 @@ This feature will not disrupt explicitly declared flags/options. ``` Unknown options *must* have values. -(Without declaring option as a boolean, we can't know a priori whether we -should accept it without a value. To emulate a flag, pass `true` as the value.) +(To emulate a boolean flag, simply pass a value so that `kwargs.get("unknown_flag")` is truthy.) ```bash ./command.py --unknown-flag input.txt arg1 arg2 # not okay ./command.py --unknown-flag true input.txt arg1 arg2 # okay ``` -Both `*args` and `**kwargs` can of course be declared independently. +Both `*args` and `**kwargs` can of course be declared without the other. ```python # -- command2.py -- @@ -91,3 +90,7 @@ def kwargs(filepath: Path, **kwargs: Any) -> None: ``` Closes #XX. + +## Potential TODO items + +- Allow `=` to declare kwargs, for example `./command --unknown=value input.txt` From 964b2ccf6b39ad86c737de38c3560cc57e1f0b2a Mon Sep 17 00:00:00 2001 From: Alistair Pattison Date: Wed, 25 Mar 2026 15:19:39 -0400 Subject: [PATCH 08/13] formatting --- typer/core.py | 4 +++- typer/main.py | 12 ++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/typer/core.py b/typer/core.py index 4813a7fc7b..e86976e0cb 100644 --- a/typer/core.py +++ b/typer/core.py @@ -743,7 +743,9 @@ def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: # Walk remaining args to find positional tokens beyond the # required named Click Arguments and pull them into *args. n = sum( - 1 for p in self.params if isinstance(p, click.Argument) and p.required + 1 + for p in self.params + if isinstance(p, click.Argument) and p.required ) pos_idx: list[int] = [] i = 0 diff --git a/typer/main.py b/typer/main.py index 637298ae1c..4db19e2a62 100644 --- a/typer/main.py +++ b/typer/main.py @@ -1392,7 +1392,13 @@ def get_params_convertors_ctx_param_name_from_function( if convertor: convertors[param_name] = convertor params.append(click_param) - return params, convertors, context_param_name, var_keyword_param_name, var_positional_param_name + return ( + params, + convertors, + context_param_name, + var_keyword_param_name, + var_positional_param_name, + ) def get_command_from_info( @@ -1578,9 +1584,7 @@ def wrapper(**kwargs: Any) -> Any: else: # Only **kwargs — safe to call with keyword args. call_kwargs = { - k: v - for k, v in use_params.items() - if k != var_keyword_param_name + k: v for k, v in use_params.items() if k != var_keyword_param_name } return callback(**{**call_kwargs, **extra_kw_val}) return callback(**use_params) From 4802d8d5f26c467fc66f8e4dff79a448a6a5e4a3 Mon Sep 17 00:00:00 2001 From: Alistair Pattison Date: Wed, 25 Mar 2026 15:39:45 -0400 Subject: [PATCH 09/13] update test coverage --- tests/test_kwargs.py | 31 +++++++++++++++++++++++++++++++ typer/main.py | 2 +- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/tests/test_kwargs.py b/tests/test_kwargs.py index 13c5c7844c..22cd195bbc 100644 --- a/tests/test_kwargs.py +++ b/tests/test_kwargs.py @@ -171,3 +171,34 @@ def test_command2_kwargs() -> None: expected_filepath="input.txt", expected_kwargs={"key": "value"}, ) + + +def test_unknown_option_without_value_errors() -> None: + """Unknown option with no value is left in remaining so Click emits an error. + + Covers core.py _extract_unknown_options else-branch (lines 676-677). + """ + app = typer.Typer() + + @app.command() + def cmd(**kwargs: Any) -> None: + typer.echo(json.dumps(kwargs)) + + result = runner.invoke(app, ["--unknown-flag"]) + assert result.exit_code != 0 + + +def test_keyword_only_param_after_args() -> None: + """Named option declared after *args (keyword-only) is passed correctly. + + Covers main.py _kw_only_names branch (lines 1552-1553). + """ + app = typer.Typer() + + @app.command() + def cmd(*args: str, option: str = "default") -> None: + typer.echo(json.dumps({"args": list(args), "option": option})) + + result = runner.invoke(app, ["--option", "custom", "a", "b"]) + assert result.exit_code == 0, result.output + assert json.loads(result.output.strip()) == {"args": ["a", "b"], "option": "custom"} diff --git a/typer/main.py b/typer/main.py index 4db19e2a62..eee99846eb 100644 --- a/typer/main.py +++ b/typer/main.py @@ -1543,7 +1543,7 @@ def get_callback( _kw_only_names: list[str] = [] for _pname, _p in _cb_sig.parameters.items(): if _p.kind == inspect.Parameter.VAR_POSITIONAL: - break + continue if _p.kind in ( inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.POSITIONAL_ONLY, From 1f8715c1fdd9afb33971741fc050942a6e48b734 Mon Sep 17 00:00:00 2001 From: Alistair Pattison Date: Wed, 25 Mar 2026 15:40:00 -0400 Subject: [PATCH 10/13] refine usage of -- --- plan.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plan.md b/plan.md index c8c52dbd05..a63ef16a4d 100644 --- a/plan.md +++ b/plan.md @@ -43,6 +43,9 @@ whether or not it matches a known argument. (This is a widely used convention. S ```bash ./command.py input.txt --flag # flag = True ./command.py input.txt -- --flag # args = ("--flag",) + +./command.py input.txt arg1 --unknown option # args = ( "arg1" ); kwargs = { "unknown" : "option" } +./command.py input.txt -- arg1 --unknown option # args = ( "arg1", "--unknown", "option" ) ``` This feature will not disrupt explicitly declared flags/options. From cde1835fd98c952be400bc978cf39f99b5a0635d Mon Sep 17 00:00:00 2001 From: Alistair Pattison Date: Wed, 25 Mar 2026 15:49:40 -0400 Subject: [PATCH 11/13] add description/tests for empty *args --- plan.md | 8 ++++++++ tests/test_kwargs.py | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/plan.md b/plan.md index a63ef16a4d..7128b408be 100644 --- a/plan.md +++ b/plan.md @@ -60,6 +60,14 @@ This feature will not disrupt explicitly declared flags/options. ./command.py --flag input.txt --option val --unknown val2 -- arg1 arg2 ``` +Empty args are handled gracefully. + +```bash +# both of the following produce args = () +./command.py input.txt +./command.py input.txt -- +``` + Unknown options *must* have values. (To emulate a boolean flag, simply pass a value so that `kwargs.get("unknown_flag")` is truthy.) diff --git a/tests/test_kwargs.py b/tests/test_kwargs.py index 22cd195bbc..9d7582ef12 100644 --- a/tests/test_kwargs.py +++ b/tests/test_kwargs.py @@ -202,3 +202,21 @@ def cmd(*args: str, option: str = "default") -> None: result = runner.invoke(app, ["--option", "custom", "a", "b"]) assert result.exit_code == 0, result.output assert json.loads(result.output.strip()) == {"args": ["a", "b"], "option": "custom"} + + +def test_empty_args_without_separator() -> None: + """./command.py input.txt → args = ()""" + _kitchen_sink_helper( + ["input.txt"], + expected_filepath="input.txt", + expected_args=[], + ) + + +def test_empty_args_with_separator() -> None: + """./command.py input.txt -- → args = ()""" + _kitchen_sink_helper( + ["input.txt", "--"], + expected_filepath="input.txt", + expected_args=[], + ) From 7888f30e874b4b71c4b6ce704e45e93957c40470 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 20:08:24 +0000 Subject: [PATCH 12/13] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plan.md b/plan.md index 7128b408be..d4ca630ae9 100644 --- a/plan.md +++ b/plan.md @@ -54,7 +54,7 @@ This feature will not disrupt explicitly declared flags/options. # all of the following commands are parsed equivalently: # option = "val"; flag = True; args = ( "arg1", "arg2" ); kwargs = { "unknown", "val2" } -./command.py --option val --flag --unknown val2 input.txt arg1 arg2 +./command.py --option val --flag --unknown val2 input.txt arg1 arg2 ./command.py --flag --unknown val2 input.txt --option val arg1 arg2 ./command.py --flag input.txt --option val --unknown val2 arg1 arg2 ./command.py --flag input.txt --option val --unknown val2 -- arg1 arg2 From 2a0b8eb04a85365f1d166565794a278edfc34428 Mon Sep 17 00:00:00 2001 From: Alistair Pattison Date: Wed, 25 Mar 2026 16:09:54 -0400 Subject: [PATCH 13/13] remove plan.md --- plan.md | 107 -------------------------------------------------------- 1 file changed, 107 deletions(-) delete mode 100644 plan.md diff --git a/plan.md b/plan.md deleted file mode 100644 index d4ca630ae9..0000000000 --- a/plan.md +++ /dev/null @@ -1,107 +0,0 @@ -# Args and Kwargs support - -This PR adds support for `*args` and `**kwargs` in function signatures: - -```python -# -- command.py -- - -import json -from typing import Any - -import typer - -@app.command() -def cmd( - filepath: Path, - option: str = "", - flag: bool = False, - *args: str, - **kwargs: Any -) -> None: - dump = json.dumps({ - "filepath" : filepath, - "option" : option, - "flag" : flag, - "args" : args, - "kwargs" : kwargs, - - }) - typer.echo(dump) -``` - -Declaring a signature allows passing arbitrary key-value pairs on the command line, -which are then made available in `kwargs`. Unknown trailing arguments are captured in `*args`. - -```bash -./command.py --unknown-key value input.txt arg1 arg2 -# kwargs = { "unknown_key" : "value" }; args = ( "arg1", "arg2" ) -``` - -To avoid ambiguity, everything after `--` will be absorbed into `*args` regardless of -whether or not it matches a known argument. (This is a widely used convention. See e.g. XX.) - -```bash -./command.py input.txt --flag # flag = True -./command.py input.txt -- --flag # args = ("--flag",) - -./command.py input.txt arg1 --unknown option # args = ( "arg1" ); kwargs = { "unknown" : "option" } -./command.py input.txt -- arg1 --unknown option # args = ( "arg1", "--unknown", "option" ) -``` - -This feature will not disrupt explicitly declared flags/options. - -```bash -# all of the following commands are parsed equivalently: -# option = "val"; flag = True; args = ( "arg1", "arg2" ); kwargs = { "unknown", "val2" } - -./command.py --option val --flag --unknown val2 input.txt arg1 arg2 -./command.py --flag --unknown val2 input.txt --option val arg1 arg2 -./command.py --flag input.txt --option val --unknown val2 arg1 arg2 -./command.py --flag input.txt --option val --unknown val2 -- arg1 arg2 -``` - -Empty args are handled gracefully. - -```bash -# both of the following produce args = () -./command.py input.txt -./command.py input.txt -- -``` - -Unknown options *must* have values. -(To emulate a boolean flag, simply pass a value so that `kwargs.get("unknown_flag")` is truthy.) - -```bash -./command.py --unknown-flag input.txt arg1 arg2 # not okay -./command.py --unknown-flag true input.txt arg1 arg2 # okay -``` - -Both `*args` and `**kwargs` can of course be declared without the other. - -```python -# -- command2.py -- - -from typing import Any - -import typer - - -@app.command() -def args(filepath: Path, *args: str) -> None: - typer.echo(args) - -@app.command() -def kwargs(filepath: Path, **kwargs: Any) -> None: - typer.echo(kwargs) -``` - -```bash -./command2.py args input.txt arg1 arg2 # args = ( "arg1", "arg2" ) -./command2.py kwargs input.txt --key value # kwargs = { "key": "value" } -``` - -Closes #XX. - -## Potential TODO items - -- Allow `=` to declare kwargs, for example `./command --unknown=value input.txt`