diff --git a/invoke/__init__.py b/invoke/__init__.py index b7072675..6f6b4c49 100644 --- a/invoke/__init__.py +++ b/invoke/__init__.py @@ -20,6 +20,8 @@ UnpicklableConfigMember, WatcherError, CommandTimedOut, + InvalidUsageException, + TaskInvalidUsageException, ) from .executor import Executor # noqa from .loader import FilesystemLoader # noqa diff --git a/invoke/exceptions.py b/invoke/exceptions.py index 19ca563b..17cd8e07 100644 --- a/invoke/exceptions.py +++ b/invoke/exceptions.py @@ -14,6 +14,7 @@ from .parser import ParserContext from .runners import Result from .util import ExceptionWrapper + from .tasks import Task class CollectionNotFound(Exception): @@ -423,3 +424,32 @@ class SubprocessPipeError(Exception): """ pass + + +class InvalidUsageException(Exception): + """ + Some problem was encountered during parameter validation while in a task. + + Should be raised by the task itself. + + .. versionadded:: 2.1 + """ + + pass + + +class TaskInvalidUsageException(Exception): + """ + Wraper for InvalidUsageException when is's raised to throw it upper. + + Should be raised by the executor. + + .. versionadded:: 2.1 + """ + + def __init__(self, task: "Task", exception: InvalidUsageException) -> None: + self.task = task + self.exception = exception + + def __str__(self) -> str: + return "Task {} usage error: {}".format(self.task.name, self.exception) diff --git a/invoke/executor.py b/invoke/executor.py index 08aa74e3..503c3bbe 100644 --- a/invoke/executor.py +++ b/invoke/executor.py @@ -4,6 +4,8 @@ from .parser import ParserContext from .util import debug from .tasks import Call, Task +from .exceptions import InvalidUsageException, TaskInvalidUsageException + if TYPE_CHECKING: from .collection import Collection @@ -137,7 +139,10 @@ def execute( # being parameterized), handing in this config for use there. context = call.make_context(config) args = (context, *call.args) - result = call.task(*args, **call.kwargs) + try: + result = call.task(*args, **call.kwargs) + except InvalidUsageException as e: + raise TaskInvalidUsageException(task=call.task, exception=e) if autoprint: print(result) # TODO: handle the non-dedupe case / the same-task-different-args diff --git a/invoke/program.py b/invoke/program.py index c7e5cd00..5b267050 100644 --- a/invoke/program.py +++ b/invoke/program.py @@ -19,7 +19,13 @@ from . import Collection, Config, Executor, FilesystemLoader from .completion.complete import complete, print_completion_script from .parser import Parser, ParserContext, Argument -from .exceptions import UnexpectedExit, CollectionNotFound, ParseError, Exit +from .exceptions import ( + UnexpectedExit, + CollectionNotFound, + ParseError, + Exit, + TaskInvalidUsageException, +) from .terminals import pty_size from .util import debug, enable_logging, helpline @@ -401,6 +407,14 @@ def run(self, argv: Optional[List[str]] = None, exit: bool = True) -> None: # Print error messages from parser, runner, etc if necessary; # prevents messy traceback but still clues interactive user into # problems. + if ( + isinstance(e, ParseError) + and e.context is not None + and hasattr(self, "collection") + ): + name = self.collection.transform(e.context.name or "") + if e.context is not None and name in self.collection.tasks: + self.print_task_help(name) if isinstance(e, ParseError): print(e, file=sys.stderr) if isinstance(e, Exit) and e.message: @@ -580,7 +594,11 @@ def execute(self) -> None: module = import_module(module_path) klass = getattr(module, class_name) executor = klass(self.collection, self.config, self.core) - executor.execute(*self.tasks) + try: + executor.execute(*self.tasks) + except TaskInvalidUsageException as e: + print("{}\n".format(e)) + self.print_task_help(self.collection.transform(e.task.name)) def normalize_argv(self, argv: Optional[List[str]]) -> None: """ diff --git a/tests/_support/decorators.py b/tests/_support/decorators.py index 320f6315..2102039f 100644 --- a/tests/_support/decorators.py +++ b/tests/_support/decorators.py @@ -70,3 +70,10 @@ def iterable_values(c, mylist=None): @task(incrementable=["verbose"]) def incrementable_values(c, verbose=None): pass + + +@task +def invalid_usage_exception(c): + from invoke import InvalidUsageException + + raise InvalidUsageException("Invalid task usage!") diff --git a/tests/program.py b/tests/program.py index 2a6d4f01..ab4b8207 100644 --- a/tests/program.py +++ b/tests/program.py @@ -308,6 +308,7 @@ def copying_from_task_context_does_not_set_empty_list_values(self): # .value = actually ends up creating a # list-of-lists. p = Program() + # Set up core-args parser context with an iterable arg that hasn't # seen any value yet def filename_args(): @@ -647,6 +648,49 @@ def prints_help_for_task_only(self): for flag in ["-h", "--help"]: expect("-c decorators {} punch".format(flag), out=expected) + def prints_help_for_task_that_rises_invalid_usage_exception(self): + expected = """ +Task invalid_usage_exception usage error: Invalid task usage! + +Usage: invoke [--core-opts] invalid-usage-exception [other tasks here ...] + +Docstring: + none + +Options: + none + +""".lstrip() + + expect( + "-c decorators invalid-usage-exception", + out=expected, + ) + + def prints_help_for_with_invalid_parameters(self): + out_expected = """ +Usage: invoke [--core-opts] two-positionals [--options] [other tasks here ...] + +Docstring: + none + +Options: + -n STRING, --nonpos=STRING + -o STRING, --pos2=STRING + -p STRING, --pos1=STRING + +""".lstrip() + + err_expected = """ +'two-positionals' did not receive required positional arguments: 'pos1', 'pos2' +""".lstrip() # noqa + + expect( + "-c decorators two-positionals", + out=out_expected, + err=err_expected, + ) + def works_for_unparameterized_tasks(self): expected = """ Usage: invoke [--core-opts] biz [other tasks here ...] @@ -1374,6 +1418,7 @@ def env_vars_load_with_prefix(self, monkeypatch): def env_var_prefix_can_be_overridden(self, monkeypatch): monkeypatch.setenv("MYAPP_RUN_HIDE", "both") + # This forces the execution stuff, including Executor, to run # NOTE: it's not really possible to rework the impl so this test is # cleaner - tasks require per-task/per-collection config, which can