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