From 31dd3fcf32da3a033a03893fcf359be78bdc1a1b Mon Sep 17 00:00:00 2001 From: laurensmiers Date: Tue, 16 Sep 2025 12:16:05 +0200 Subject: [PATCH 1/5] fix: parser: detect '--help' if supplied to task This fixes the invocation of a my_task which has mandatory/positional arguments: ```bash > inv my_task --help ``` When detecting a missing positional arg, we check if the token is '--help/-h'. If so, we store the task/context name to print help for it later and flag it the task/context to be completed without checks. Signed-off-by: laurensmiers --- invoke/parser/context.py | 1 + invoke/parser/parser.py | 36 +++++++++++++++++++++++++++++------- tests/parser_parser.py | 16 ++++++++++++++++ 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/invoke/parser/context.py b/invoke/parser/context.py index 359e9f9e2..1e036e411 100644 --- a/invoke/parser/context.py +++ b/invoke/parser/context.py @@ -96,6 +96,7 @@ def __init__( self.aliases = aliases for arg in args: self.add_arg(arg) + self.skip_checks = False def __repr__(self) -> str: aliases = "" diff --git a/invoke/parser/parser.py b/invoke/parser/parser.py index 43e95df04..189cb14a0 100644 --- a/invoke/parser/parser.py +++ b/invoke/parser/parser.py @@ -296,6 +296,10 @@ def handle(self, token: str) -> None: # need a posarg and the user legitimately wants to give it a value that # just happens to be a valid context name.) elif self.context and self.context.missing_positional_args: + if self._is_token_help(token): + debug("--help passed, skip positional arg") + self._force_help() + return msg = "Context {!r} requires positional args, eating {!r}" debug(msg.format(self.context, token)) self.see_positional_arg(token) @@ -307,10 +311,10 @@ def handle(self, token: str) -> None: debug("Saw (initial-context) flag {!r}".format(token)) flag = self.initial.flags[token] # Special-case for core --help flag: context name is used as value. - if flag.name == "help": - flag.value = self.context.name - msg = "Saw --help in a per-task context, setting task name ({!r}) as its value" # noqa - debug(msg.format(flag.value)) + if self._is_token_help(token): + debug("--help passed") + self._force_help() + return # All others: just enter the 'switch to flag' parser state else: # TODO: handle inverse core flags too? There are none at the @@ -338,15 +342,18 @@ def complete_context(self) -> None: self.context.name if self.context else self.context ) ) - # Ensure all of context's positional args have been given. - if self.context and self.context.missing_positional_args: + if not self.context: + return + + # Ensure all of context's positional args have been given + if not self.context.skip_checks and self.context.missing_positional_args: err = "'{}' did not receive required positional arguments: {}" names = ", ".join( "'{}'".format(x.name) for x in self.context.missing_positional_args ) self.error(err.format(self.context.name, names)) - if self.context and self.context not in self.result: + if self.context not in self.result: self.result.append(self.context) def switch_to_context(self, name: str) -> None: @@ -453,3 +460,18 @@ def see_positional_arg(self, value: Any) -> None: def error(self, msg: str) -> None: raise ParseError(msg, self.context) + + def _is_token_help(self, token: str) -> bool: + try: + flag = self.initial.flags[token] + if flag.name == "help": + return True + except KeyError: + pass + except AttributeError: + pass + return False + + def _force_help(self) -> None: + self.initial.flags["--help"].value = self.context.name + self.context.skip_checks = True diff --git a/tests/parser_parser.py b/tests/parser_parser.py index c750fd8c7..b3eecd6f9 100644 --- a/tests/parser_parser.py +++ b/tests/parser_parser.py @@ -501,6 +501,22 @@ def core_bool_but_per_task_string(self): assert result[0].args.hide.value is False assert result[1].args.hide.value == "both" + def help_passed_when_task_expects_one_positional_arg(self): + init = Context(args=[Argument(names=("help", "h"), optional=True)]) + task1 = Context("mytask", args=[Argument(names=("name", "n"), kind=str, positional=True)]) + parser = Parser(initial=init, contexts=[task1]) + result = parser.parse_argv(["mytask", "--help"]) + assert result[0].flags['--help'].value == "mytask" + + def help_passed_when_task_expects_multiple_positional_arg(self): + init = Context(args=[Argument(names=("help", "h"), optional=True)]) + task1 = Context("mytask", args=[Argument(names=("pos_arg_one", "o"), kind=str, positional=True), + Argument(names=("pos_arg_two", "t"), kind=str, positional=True) + ]) + parser = Parser(initial=init, contexts=[task1]) + result = parser.parse_argv(["mytask", "--help"]) + assert result[0].flags['--help'].value == "mytask" + class help_treats_context_name_as_its_value: def by_itself_base_case(self): task1 = Context("mytask") From 94045b6fe25d5b6552aaf324004703b0b952f3bb Mon Sep 17 00:00:00 2001 From: laurensmiers Date: Tue, 16 Sep 2025 12:20:32 +0200 Subject: [PATCH 2/5] fix: program: print task-help if ParseError occurs This will f.e. print the help of a task when it is not given its mandatory args: ```bash > # Prints help if my_task expects mandatory positional arg > inv my_task ``` If no name is available for the context, f.e. the core/None context, we skip the specific task print since this means it was not a 'user' task which failed. Signed-off-by: laurensmiers --- invoke/program.py | 2 ++ tests/program.py | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/invoke/program.py b/invoke/program.py index c7e5cd004..b65a56e84 100644 --- a/invoke/program.py +++ b/invoke/program.py @@ -403,6 +403,8 @@ def run(self, argv: Optional[List[str]] = None, exit: bool = True) -> None: # problems. if isinstance(e, ParseError): print(e, file=sys.stderr) + if e.context and e.context.name: + self.print_task_help(e.context.name) if isinstance(e, Exit) and e.message: print(e.message, file=sys.stderr) if isinstance(e, UnexpectedExit) and e.result.hide: diff --git a/tests/program.py b/tests/program.py index 2d249b1fb..95b2026ca 100644 --- a/tests/program.py +++ b/tests/program.py @@ -653,6 +653,23 @@ def prints_help_for_task_only(self): for flag in ["-h", "--help"]: expect("-c decorators {} punch".format(flag), out=expected) + def prints_help_if_no_mandatory_arg(self): + expected = """ +Usage: invoke [--core-opts] punch [--options] [other tasks here ...] + +Docstring: + none + +Options: + -h STRING, --why=STRING Motive + -w STRING, --who=STRING Who to punch + +""".lstrip() + expected_error= """ +'punch' did not receive required positional arguments: 'who', 'why' +""".lstrip() + expect("-c decorators punch", out=expected, err=expected_error) + def works_for_unparameterized_tasks(self): expected = """ Usage: invoke [--core-opts] biz [other tasks here ...] From 76cad7eecd10e56160939745cd8e63ea921238e3 Mon Sep 17 00:00:00 2001 From: laurensmiers Date: Tue, 16 Sep 2025 13:15:50 +0200 Subject: [PATCH 3/5] fix: remove unused variable Signed-off-by: laurensmiers --- invoke/parser/parser.py | 1 - 1 file changed, 1 deletion(-) diff --git a/invoke/parser/parser.py b/invoke/parser/parser.py index 189cb14a0..a0f54f335 100644 --- a/invoke/parser/parser.py +++ b/invoke/parser/parser.py @@ -309,7 +309,6 @@ def handle(self, token: str) -> None: # Initial-context flag being given as per-task flag (e.g. --help) elif self.initial and token in self.initial.flags: debug("Saw (initial-context) flag {!r}".format(token)) - flag = self.initial.flags[token] # Special-case for core --help flag: context name is used as value. if self._is_token_help(token): debug("--help passed") From b6439a2b5f7ee91a3b1c6cc152d18daa2adcec75 Mon Sep 17 00:00:00 2001 From: laurensmiers Date: Tue, 16 Sep 2025 13:16:09 +0200 Subject: [PATCH 4/5] fix: flake8 warnings Signed-off-by: laurensmiers --- invoke/parser/parser.py | 3 ++- tests/parser_parser.py | 26 ++++++++++++++++++++------ tests/program.py | 2 +- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/invoke/parser/parser.py b/invoke/parser/parser.py index a0f54f335..005583c2b 100644 --- a/invoke/parser/parser.py +++ b/invoke/parser/parser.py @@ -345,7 +345,8 @@ def complete_context(self) -> None: return # Ensure all of context's positional args have been given - if not self.context.skip_checks and self.context.missing_positional_args: + if not self.context.skip_checks \ + and self.context.missing_positional_args: err = "'{}' did not receive required positional arguments: {}" names = ", ".join( "'{}'".format(x.name) diff --git a/tests/parser_parser.py b/tests/parser_parser.py index b3eecd6f9..f8dbfd97b 100644 --- a/tests/parser_parser.py +++ b/tests/parser_parser.py @@ -502,17 +502,31 @@ def core_bool_but_per_task_string(self): assert result[1].args.hide.value == "both" def help_passed_when_task_expects_one_positional_arg(self): - init = Context(args=[Argument(names=("help", "h"), optional=True)]) - task1 = Context("mytask", args=[Argument(names=("name", "n"), kind=str, positional=True)]) + init = Context(args=[Argument(names=("help", "h"), optional=True)]) # noqa + task1 = Context("mytask", + args=[ + Argument( + names=("name", "n"), + kind=str, + positional=True) + ]) parser = Parser(initial=init, contexts=[task1]) result = parser.parse_argv(["mytask", "--help"]) assert result[0].flags['--help'].value == "mytask" def help_passed_when_task_expects_multiple_positional_arg(self): - init = Context(args=[Argument(names=("help", "h"), optional=True)]) - task1 = Context("mytask", args=[Argument(names=("pos_arg_one", "o"), kind=str, positional=True), - Argument(names=("pos_arg_two", "t"), kind=str, positional=True) - ]) + init = Context(args=[Argument(names=("help", "h"), optional=True)]) # noqa + task1 = Context("mytask", + args=[ + Argument( + names=("pos_arg_one", "o"), + kind=str, + positional=True), + Argument( + names=("pos_arg_two", "t"), + kind=str, + positional=True) + ]) parser = Parser(initial=init, contexts=[task1]) result = parser.parse_argv(["mytask", "--help"]) assert result[0].flags['--help'].value == "mytask" diff --git a/tests/program.py b/tests/program.py index 95b2026ca..100005fab 100644 --- a/tests/program.py +++ b/tests/program.py @@ -665,7 +665,7 @@ def prints_help_if_no_mandatory_arg(self): -w STRING, --who=STRING Who to punch """.lstrip() - expected_error= """ + expected_error = """ 'punch' did not receive required positional arguments: 'who', 'why' """.lstrip() expect("-c decorators punch", out=expected, err=expected_error) From cc72bd00f2cd8767365dbc10f9c57dcab56f2f6c Mon Sep 17 00:00:00 2001 From: laurensmiers Date: Tue, 16 Sep 2025 13:25:01 +0200 Subject: [PATCH 5/5] fix: black warnings Signed-off-by: laurensmiers --- invoke/parser/parser.py | 6 +++-- tests/parser_parser.py | 53 ++++++++++++++++++++++++----------------- 2 files changed, 35 insertions(+), 24 deletions(-) diff --git a/invoke/parser/parser.py b/invoke/parser/parser.py index 005583c2b..1180d67de 100644 --- a/invoke/parser/parser.py +++ b/invoke/parser/parser.py @@ -345,8 +345,10 @@ def complete_context(self) -> None: return # Ensure all of context's positional args have been given - if not self.context.skip_checks \ - and self.context.missing_positional_args: + if ( + not self.context.skip_checks + and self.context.missing_positional_args + ): err = "'{}' did not receive required positional arguments: {}" names = ", ".join( "'{}'".format(x.name) diff --git a/tests/parser_parser.py b/tests/parser_parser.py index f8dbfd97b..6db819a78 100644 --- a/tests/parser_parser.py +++ b/tests/parser_parser.py @@ -502,34 +502,43 @@ def core_bool_but_per_task_string(self): assert result[1].args.hide.value == "both" def help_passed_when_task_expects_one_positional_arg(self): - init = Context(args=[Argument(names=("help", "h"), optional=True)]) # noqa - task1 = Context("mytask", - args=[ - Argument( - names=("name", "n"), - kind=str, - positional=True) - ]) + init = Context( + args=[Argument(names=("help", "h"), optional=True)] + ) + task1 = Context( + "mytask", + args=[ + Argument( + names=("name", "n"), kind=str, positional=True + ) + ], + ) parser = Parser(initial=init, contexts=[task1]) result = parser.parse_argv(["mytask", "--help"]) - assert result[0].flags['--help'].value == "mytask" + assert result[0].flags["--help"].value == "mytask" def help_passed_when_task_expects_multiple_positional_arg(self): - init = Context(args=[Argument(names=("help", "h"), optional=True)]) # noqa - task1 = Context("mytask", - args=[ - Argument( - names=("pos_arg_one", "o"), - kind=str, - positional=True), - Argument( - names=("pos_arg_two", "t"), - kind=str, - positional=True) - ]) + init = Context( + args=[Argument(names=("help", "h"), optional=True)] + ) + task1 = Context( + "mytask", + args=[ + Argument( + names=("pos_arg_one", "o"), + kind=str, + positional=True, + ), + Argument( + names=("pos_arg_two", "t"), + kind=str, + positional=True, + ), + ], + ) parser = Parser(initial=init, contexts=[task1]) result = parser.parse_argv(["mytask", "--help"]) - assert result[0].flags['--help'].value == "mytask" + assert result[0].flags["--help"].value == "mytask" class help_treats_context_name_as_its_value: def by_itself_base_case(self):