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
59 changes: 59 additions & 0 deletions tests/test_single_command_callback.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import typer
from typer.testing import CliRunner

runner: CliRunner = CliRunner()


def test_result_callback_single_command() -> None:
# A list to capture the result from the callback
captured_results: list[str] = []

def my_callback(value: str) -> None:
captured_results.append(value)

# Create app with a result_callback
app: typer.Typer = typer.Typer(result_callback=my_callback)

@app.command()
def main() -> str:
return "single_command_result"

# Invoke the app (using the single command fast-path)
result = runner.invoke(app, [])

# Verify the command ran successfully
assert result.exit_code == 0

# CRITICAL: Verify the callback was actually executed
assert "single_command_result" in captured_results, (
"Result callback was not triggered for single command!"
)


def test_result_callback_single_command_placeholder() -> None:
from typer.models import DefaultPlaceholder

# A list to capture the result from the callback
captured_results: list[str] = []

def my_callback(value: str) -> None:
captured_results.append(value)

# Create app and manually inject a DefaultPlaceholder for the result_callback
app = typer.Typer()
app.info.result_callback = DefaultPlaceholder(my_callback)

@app.command()
def main() -> str:
return "placeholder_result"

# Invoke the app
result = runner.invoke(app, [])

# Verify the command ran successfully
assert result.exit_code == 0

# Verify the callback was actually executed via the placeholder path
assert "placeholder_result" in captured_results, (
"Result callback was not triggered via placeholder!"
)
19 changes: 19 additions & 0 deletions typer/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -1200,6 +1200,25 @@ def get_command(typer_instance: Typer) -> click.Command:
if typer_instance._add_completion:
click_command.params.append(click_install_param)
click_command.params.append(click_show_param)

click_callback = click_command.callback
use_result_callback = None
if typer_instance.info.result_callback:
if isinstance(typer_instance.info.result_callback, DefaultPlaceholder):
use_result_callback = typer_instance.info.result_callback.value
else:
use_result_callback = typer_instance.info.result_callback

if click_callback and use_result_callback:

def callback_wrapper(*args: Any, **kwargs: Any) -> Any:
result = click_callback(*args, **kwargs)
ctx = click.get_current_context()
return ctx.invoke(use_result_callback, result)
Comment on lines +1214 to +1217
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A regression test should be added for this new single-command result_callback execution path (Issue #445). The repo has extensive CLI tests using CliRunner, but there doesn’t appear to be any existing coverage for result_callback; without a test it’s easy to reintroduce this optimization bug later.

Copilot uses AI. Check for mistakes.

update_wrapper(callback_wrapper, click_callback)
click_command.callback = callback_wrapper

return click_command
raise RuntimeError(
"Could not get a command for this Typer instance"
Expand Down
Loading