Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 14 additions & 6 deletions src/cappa/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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] = {}
Expand Down Expand Up @@ -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] = {}
Expand Down
36 changes: 36 additions & 0 deletions tests/invoke/test_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Loading