Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion invoke/executor.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"]:
Expand Down
5 changes: 5 additions & 0 deletions sites/www/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
<invoke.tasks.task>` return value type hint change; it actually just makes
Expand Down
30 changes: 29 additions & 1 deletion tests/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"

Expand Down
18 changes: 18 additions & 0 deletions tests/program.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
Task,
UnexpectedExit,
main,
task,
)
from invoke.config import merge_dicts
from invoke.util import Lexicon, cd
Expand Down Expand Up @@ -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):
Expand Down