Skip to content
222 changes: 222 additions & 0 deletions tests/test_kwargs.py
Original file line number Diff line number Diff line change
@@ -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=[],
)
88 changes: 88 additions & 0 deletions typer/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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:
Expand Down
Loading
Loading