From 02bba9bc5539f217c7b57cdf7d6e54247d6fc8ed Mon Sep 17 00:00:00 2001 From: swilcox Date: Sun, 3 May 2026 17:08:16 -0500 Subject: [PATCH] fix: Thread output into parse_result.instance resolution. `parse`, `parse_async`, `invoke`, and `invoke_async` previously called `parse_result.instance.get(...)` (or `call`/`get_async`/`call_async`) without `output=`, so when `Resolved.handle_exit` caught a `cappa.Exit` raised from an `Arg(parse=...)` callback it saw `output=None` and re-raised silently. The user got a non-zero exit code with no message. Pass `output=parse_result.output` at every call site (matching the adjacent `resolved_dep.get(output=...)` and `resolved.get(output=...)` calls in the same functions) so the message is rendered. Adds a regression test that asserts the formatted error reaches stderr under both the cappa native parser and the argparse backend. --- CHANGELOG.md | 1 + src/cappa/base.py | 20 ++++++++++++++------ tests/invoke/test_output.py | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46683d9..1e37700 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - fix: Ensure automatic bool variants (--foo/--no-foo) are mutually exclusive. - fix: Allow negative number arguments and option values. - fix: Precalculate implicit deps during class construction rather than traversing the output shape. +- fix: Thread `output` into `parse_result.instance` resolution in `parse`/`parse_async`/`invoke`/`invoke_async`, so `cappa.Exit` raised from `Arg(parse=...)` callbacks renders an error message instead of silently exiting non-zero. ## 0.31.0 - fix: Allow options accepting zero-length unbounded num_args. diff --git a/src/cappa/base.py b/src/cappa/base.py index 1384fbf..bf7fb29 100644 --- a/src/cappa/base.py +++ b/src/cappa/base.py @@ -166,8 +166,10 @@ def parse( state=state, ) if exit_stack is not None: - return exit_stack.enter_context(parse_result.instance.get(managed=True)) - return parse_result.instance.call(managed=False) + return exit_stack.enter_context( + parse_result.instance.get(output=parse_result.output, managed=True) + ) + return parse_result.instance.call(output=parse_result.output, managed=False) async def parse_async( @@ -239,9 +241,11 @@ async def parse_async( ) if exit_stack is not None: return await exit_stack.enter_async_context( - parse_result.instance.get_async(managed=True) + parse_result.instance.get_async(output=parse_result.output, managed=True) ) - return await parse_result.instance.call_async(managed=False) + return await parse_result.instance.call_async( + output=parse_result.output, managed=False + ) def invoke( @@ -313,7 +317,9 @@ def invoke( ) def _invoke_with_stack(stack: contextlib.ExitStack): - instance = stack.enter_context(parse_result.instance.get()) + instance = stack.enter_context( + parse_result.instance.get(output=parse_result.output) + ) # Resolve all implicit deps resolved_implicit_deps: dict[Hashable, Any] = {} @@ -412,7 +418,9 @@ async def invoke_async( ) async def _invoke_async_with_stack(stack: contextlib.AsyncExitStack): - instance = await stack.enter_async_context(parse_result.instance.get_async()) + instance = await stack.enter_async_context( + parse_result.instance.get_async(output=parse_result.output) + ) # Resolve all implicit deps resolved_implicit_deps: dict[Hashable, Any] = {} diff --git a/tests/invoke/test_output.py b/tests/invoke/test_output.py index 4cccf96..faa4ce6 100644 --- a/tests/invoke/test_output.py +++ b/tests/invoke/test_output.py @@ -3,6 +3,9 @@ from dataclasses import dataclass from typing import Any +import pytest +from typing_extensions import Annotated + import cappa from tests.utils import Backend, CapsysOutput, backends, invoke @@ -34,3 +37,36 @@ def __call__(self, out: cappa.Output): out = CapsysOutput.from_capsys(capsys) out.stdout = "woah!\n" out.stderr = "" + + +def _raise_value_error(value: str) -> str: + raise ValueError("nope") + + +@backends +def test_invoke_routes_parse_errors_through_output(backend: Backend, capsys: Any): + """A parse= callback that raises should produce a visible error on stderr. + + Regression test: previously `invoke()` (and `invoke_async`/`parse`) did not + pass `output=` into the resolution of `parse_result.instance`, so any + `cappa.Exit` raised from an `Arg(parse=...)` callback would be caught by + `Resolved.handle_exit` with `output=None` and silently re-raised, producing + a non-zero exit with no message. + """ + + @dataclass + class Cmd: + x: Annotated[ + str, + cappa.Arg(parse=_raise_value_error, parse_inference=False, long="--x"), + ] = "default" + + def __call__(self) -> None: + pass + + with pytest.raises(cappa.Exit) as e: + invoke(Cmd, "--x", "foo", backend=backend) + + assert e.value.code == 2 + out = CapsysOutput.from_capsys(capsys) + assert "Invalid value for '--x': nope" in out.stderr