From 8aaecc76ff041a027ea35a22f87fc6e79a50d3f1 Mon Sep 17 00:00:00 2001 From: SeaStar Deng <37767638+DSeaStar@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:35:37 +0000 Subject: [PATCH] Fix TypeError when a default task is missing required positionals. The default-task fallback skipped the parser, so a custom Program (or inv with no argv) leaked a traceback instead of the usual ParseError. --- invoke/executor.py | 24 +++++++++++++++++++++++- sites/www/changelog.rst | 5 +++++ tests/executor.py | 30 +++++++++++++++++++++++++++++- tests/program.py | 18 ++++++++++++++++++ 4 files changed, 75 insertions(+), 2 deletions(-) diff --git a/invoke/executor.py b/invoke/executor.py index 1b3acbc8d..69c2f3589 100644 --- a/invoke/executor.py +++ b/invoke/executor.py @@ -1,6 +1,8 @@ +import inspect from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union from .config import Config +from .exceptions import ParseError from .parser import ParserContext, ParseResult from .tasks import Call, Task from .util import debug @@ -175,7 +177,27 @@ def normalize( c = Call(self.collection[name], kwargs=kwargs, called_as=name) calls.append(c) if not tasks and self.collection.default is not None: - calls = [Call(self.collection[self.collection.default])] + name = self.collection.default + task = self.collection[name] + # Default-task fallback skips the parser, so required positionals + # would otherwise surface as a TypeError traceback (see #562). + sig = task.argspec(task.body) + skip_kinds = ( + inspect.Parameter.VAR_POSITIONAL, + inspect.Parameter.VAR_KEYWORD, + ) + missing = [ + param.name + for param in sig.parameters.values() + if param.name in task.positional + and param.default is inspect.Signature.empty + and param.kind not in skip_kinds + ] + if missing: + names = ", ".join("'{}'".format(x) for x in missing) + err = "'{}' did not receive required positional arguments: {}" + raise ParseError(err.format(name, names)) + calls = [Call(task)] return calls def dedupe(self, calls: List["Call"]) -> List["Call"]: diff --git a/sites/www/changelog.rst b/sites/www/changelog.rst index c34b253ec..75578ddfe 100644 --- a/sites/www/changelog.rst +++ b/sites/www/changelog.rst @@ -2,6 +2,11 @@ Changelog ========= +- :bug:`562` Invoking a default task that requires positional arguments, + without supplying those arguments (for example a custom + `~invoke.program.Program` binary given no argv), now raises + `~invoke.exceptions.ParseError` with the same message used when the + task is named explicitly, instead of leaking a ``TypeError`` traceback. - :release:`3.0.3 <2026-04-07>` - :support:`- backported` Reverted the `@task ` return value type hint change; it actually just makes diff --git a/tests/executor.py b/tests/executor.py index d2c7322fa..376dff3c0 100644 --- a/tests/executor.py +++ b/tests/executor.py @@ -3,7 +3,16 @@ import pytest from _util import expect -from invoke import Collection, Config, Context, Executor, Task, call, task +from invoke import ( + Collection, + Config, + Context, + Executor, + ParseError, + Task, + call, + task, +) from invoke.parser import ParserContext, ParseResult # TODO: why does this not work as a decorator? probably relaxed's fault - but @@ -84,6 +93,25 @@ def default_tasks_called_when_no_tasks_specified(self): assert isinstance(args[0], Context) assert len(args) == 1 + def default_task_missing_positionals_raises_ParseError(self): + # #562: invoking a default task with required positionals and no + # argv used to TypeError inside the task body. + @task(default=True) + def execute(c, export_type): + pass + + executor = Executor(collection=Collection(execute)) + with pytest.raises(ParseError, match="export_type"): + executor.execute() + + def default_task_with_optional_args_still_runs(self): + @task(default=True) + def execute(c, verbose=False): + return "ok" + + ret = Executor(collection=Collection(execute)).execute() + assert ret[execute] == "ok" + class basic_pre_post: "basic pre/post task functionality" diff --git a/tests/program.py b/tests/program.py index 3b6765cdd..5548adb12 100644 --- a/tests/program.py +++ b/tests/program.py @@ -33,6 +33,7 @@ Task, UnexpectedExit, main, + task, ) from invoke.config import merge_dicts from invoke.util import Lexicon, cd @@ -387,6 +388,23 @@ def can_change_collection_search_root_with_explicit_module_name(self): out="Don't swear!\n", ) + def default_task_missing_positionals_prints_parse_error(self): + # #562: custom Program + default task + missing required + # positionals should be a friendly parser error, not TypeError. + @task(default=True) + def execute(c, export_type): + pass + + expect( + "myapp", + err=( + "'execute' did not receive required positional " + "arguments: 'export_type'\n" + ), + program=Program(namespace=Collection(execute), binary="myapp"), + invoke=False, + ) + @trap @patch("invoke.program.sys.exit") def ParseErrors_display_message_and_exit_1(self, mock_exit):