diff --git a/tests/test_kwargs.py b/tests/test_kwargs.py new file mode 100644 index 0000000000..9d7582ef12 --- /dev/null +++ b/tests/test_kwargs.py @@ -0,0 +1,222 @@ +import json +from collections.abc import Sequence +from pathlib import Path +from typing import Any + +import typer +from typer.testing import CliRunner + +runner = CliRunner() + + +def _kitchen_sink_helper( + cli_input: Sequence[str], + *, + expected_filepath: str, + expected_option: str = "", + 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( + filepath: Path, + option: str = "", + flag: bool = False, + *args: str, + **kwargs: Any, + ) -> None: + typer.echo( + json.dumps( + { + "filepath": str(filepath), + "option": option, + "flag": flag, + "args": list(args), + "kwargs": kwargs, + } + ) + ) + + result = runner.invoke(app, cli_input) + assert result.exit_code == 0, result.output + assert json.loads(result.output.strip()) == { + "filepath": expected_filepath, + "option": expected_option, + "flag": expected_flag, + "args": expected_args or [], + "kwargs": expected_kwargs or {}, + } + + +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: + app = typer.Typer() + + @app.command() + def args(filepath: Path, *args: str) -> None: + typer.echo(json.dumps({"filepath": str(filepath), "args": list(args)})) + + @app.command() + def kwargs(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 + + 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_unknown_kwarg_and_trailing_args() -> None: + _kitchen_sink_helper( + ["--unknown-key", "value", "input.txt", "arg1", "arg2"], + expected_filepath="input.txt", + expected_kwargs={"unknown_key": "value"}, + expected_args=["arg1", "arg2"], + ) + + +def test_known_flag() -> None: + _kitchen_sink_helper( + ["input.txt", "--flag"], + expected_filepath="input.txt", + expected_flag=True, + ) + + +def test_separator_absorbs_flag() -> None: + _kitchen_sink_helper( + ["input.txt", "--", "--flag"], + expected_filepath="input.txt", + expected_args=["--flag"], + ) + + +def test_equivalent_form_1() -> None: + _kitchen_sink_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_equivalent_form_2() -> None: + _kitchen_sink_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_equivalent_form_3() -> None: + _kitchen_sink_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_equivalent_form_4() -> None: + _kitchen_sink_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_unknown_flag_requires_value() -> None: + _kitchen_sink_helper( + ["--unknown-flag", "true", "input.txt", "arg1", "arg2"], + expected_filepath="input.txt", + expected_kwargs={"unknown_flag": "true"}, + expected_args=["arg1", "arg2"], + ) + + +def test_command2_args() -> None: + _separate_args_kwargs_helper( + ["args", "input.txt", "arg1", "arg2"], + expected_filepath="input.txt", + expected_args=["arg1", "arg2"], + ) + + +def test_command2_kwargs() -> None: + _separate_args_kwargs_helper( + ["kwargs", "input.txt", "--key", "value"], + 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"} + + +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=[], + ) diff --git a/typer/core.py b/typer/core.py index 48fee64e34..e86976e0cb 100644 --- a/typer/core.py +++ b/typer/core.py @@ -644,6 +644,42 @@ 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). + + 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) + + 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("-"): + extra[key] = args[next_i] + i += 2 + else: + remaining.append(arg) + i += 1 + else: + remaining.append(arg) + i += 1 + return extra, remaining + + class TyperCommand(click.core.Command): def __init__( self, @@ -663,7 +699,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 +721,54 @@ 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]: + 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("--") + positional_extra: list[str] = list(args[sep + 1 :]) + args = args[:sep] + else: + positional_extra = [] + + 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( self, ctx: click.Context, formatter: click.HelpFormatter ) -> None: diff --git a/typer/main.py b/typer/main.py index 6febf2091e..eee99846eb 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,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 + return ( + params, + convertors, + context_param_name, + var_keyword_param_name, + var_positional_param_name, + ) def get_command_from_info( @@ -1396,8 +1418,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 +1434,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 +1450,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 +1520,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 +1535,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: + continue + 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 +1564,29 @@ 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, )