diff --git a/src/click/__init__.py b/src/click/__init__.py index 5e1b156e3..ff0ec8624 100644 --- a/src/click/__init__.py +++ b/src/click/__init__.py @@ -54,6 +54,7 @@ from .types import File as File from .types import FLOAT as FLOAT from .types import FloatRange as FloatRange +from .types import FORWARD as FORWARD from .types import INT as INT from .types import IntRange as IntRange from .types import ParamType as ParamType diff --git a/src/click/parser.py b/src/click/parser.py index c39491f53..db0f9ae1c 100644 --- a/src/click/parser.py +++ b/src/click/parser.py @@ -33,6 +33,7 @@ from .exceptions import BadOptionUsage from .exceptions import NoSuchOption from .exceptions import UsageError +from .types import FORWARD if t.TYPE_CHECKING: from .core import Argument as CoreArgument @@ -321,6 +322,16 @@ def _process_args_for_args(self, state: _ParsingState) -> None: state.largs = args state.rargs = [] + def _stop_process_args_for_options(self, state: _ParsingState) -> bool: + largs: t.Sequence[str] = state.largs + for args in self._args: + if args.obj.type == FORWARD and args.nargs < 0: + return True + if not largs: + break + largs = largs[args.nargs :] + return False + def _process_args_for_options(self, state: _ParsingState) -> None: while state.rargs: arg = state.rargs.pop(0) @@ -331,6 +342,9 @@ def _process_args_for_options(self, state: _ParsingState) -> None: return elif arg[:1] in self._opt_prefixes and arglen > 1: self._process_opts(arg, state) + elif self._stop_process_args_for_options(state): + state.rargs.insert(0, arg) + return elif self.allow_interspersed_args: state.largs.append(arg) else: diff --git a/src/click/types.py b/src/click/types.py index 62e4658b8..a2ed26ed8 100644 --- a/src/click/types.py +++ b/src/click/types.py @@ -201,6 +201,18 @@ def __repr__(self) -> str: return "UNPROCESSED" +class ForwardParamType(ParamType): + name = "text" + + def convert( + self, value: t.Any, param: Parameter | None, ctx: Context | None + ) -> t.Any: + return value + + def __repr__(self) -> str: + return "FORWARD" + + class StringParamType(ParamType): name = "text" @@ -1071,6 +1083,10 @@ def convert_type(ty: t.Any | None, default: t.Any | None = None) -> ParamType: #: .. versionadded:: 4.0 UNPROCESSED = UnprocessedParamType() +#: A dummy parameter type that just does nothing except stops parsing options +#: and arguments when this argument is getting parsed. +FORWARD = ForwardParamType() + #: A unicode string parameter type which is the implicit default. This #: can also be selected by using ``str`` as type. STRING = StringParamType() diff --git a/tests/test_commands.py b/tests/test_commands.py index 5a56799ad..669ca0acd 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -321,6 +321,53 @@ def cli(verbose, args): ] +def test_forward_options(runner): + @click.command() + @click.option("-f") + @click.argument("files", nargs=-1, type=click.FORWARD) + def cmd(f, files): + click.echo(f) + for filename in files: + click.echo(filename) + + args = ["echo", "-foo", "bar", "-f", "-h", "--help"] + result = runner.invoke(cmd, args) + assert result.output.splitlines() == [""] + args + + +def test_forward_options_group(runner): + @click.group() + @click.option("-f") + def cmd(f): + click.echo(f) + + @cmd.command() + @click.option("-a") + @click.argument("src", nargs=1) + @click.argument("dsts", nargs=-1, type=click.FORWARD) + def cp(a, src, dsts): + click.echo(a) + click.echo(src) + for dst in dsts: + click.echo(dst) + + result = runner.invoke( + cmd, + ["-f", "f", "cp", "-a", "a", "src", "dst1", "-a", "dst2", "-h", "--help", "-f"], + ) + assert result.output.splitlines() == [ + "f", + "a", + "src", + "dst1", + "-a", + "dst2", + "-h", + "--help", + "-f", + ] + + @pytest.mark.parametrize("doc", ["CLI HELP", None]) def test_deprecated_in_help_messages(runner, doc): @click.command(deprecated=True, help=doc)