304 lines
9.5 KiB
Python
304 lines
9.5 KiB
Python
# SPDX-FileCopyrightText: 2026 Art Sea LLC
|
|
#
|
|
# SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
"""Behavioral tests for per-invocation argument aspects."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import inspect
|
|
from typing import Callable, Generic, Protocol, TypeVar, cast
|
|
|
|
import pytest
|
|
|
|
from band_aider.aspects import Aspect, Caller, aspect
|
|
from band_aider.aspects.aspect import AspectAmbiguityError, AspectResolutionError
|
|
|
|
|
|
T = TypeVar("T")
|
|
|
|
|
|
class Source:
|
|
"""Source supplies itself through a direct invocation frame."""
|
|
|
|
def invoke(self, target: Target) -> tuple[Source, str]:
|
|
"""Invoke one target without supplying its caller aspect."""
|
|
|
|
injected_execute = cast(Callable[[], tuple[Source, str]], target.execute)
|
|
return injected_execute()
|
|
|
|
|
|
class Target:
|
|
"""Target resolves a typed source aspect."""
|
|
|
|
@aspect
|
|
def execute(self, caller: Caller[Source]) -> tuple[Source, str]:
|
|
"""Return the typed value and the actual direct caller frame name."""
|
|
|
|
frame = caller.frame
|
|
assert frame is not None
|
|
return caller.value, frame.f_code.co_name
|
|
|
|
|
|
def test_caller_injects_the_typed_direct_caller_without_exposing_wrapper_frame() -> None:
|
|
"""An omitted Caller resolves the typed value from the actual invoker frame."""
|
|
|
|
source = Source()
|
|
value, frame_name = source.invoke(Target())
|
|
|
|
assert value is source
|
|
assert frame_name == "invoke"
|
|
|
|
|
|
def test_caller_does_not_depend_on_the_receiver_name() -> None:
|
|
"""Resolution is type-based even when a caller receiver is not named self."""
|
|
|
|
class AlternateSource:
|
|
"""Use a nonstandard receiver name."""
|
|
|
|
def invoke(this, target: AlternateTarget) -> AlternateSource: # pyright: ignore[reportSelfClsParameterName]
|
|
"""Invoke one target while retaining the alternate receiver value."""
|
|
|
|
injected_execute = cast(
|
|
Callable[[], AlternateSource],
|
|
target.execute,
|
|
)
|
|
return injected_execute()
|
|
|
|
class AlternateTarget:
|
|
"""Resolve AlternateSource from the invocation frame."""
|
|
|
|
@aspect
|
|
def execute(self, caller: Caller[AlternateSource]) -> AlternateSource:
|
|
"""Return the resolved alternate source."""
|
|
|
|
return caller.value
|
|
|
|
source = AlternateSource()
|
|
|
|
assert source.invoke(AlternateTarget()) is source
|
|
|
|
|
|
def test_explicit_aspect_argument_is_preserved() -> None:
|
|
"""An explicit aspect-bearing argument remains untouched by injection."""
|
|
|
|
class OverrideTarget:
|
|
"""Expose the supplied argument directly."""
|
|
|
|
@aspect
|
|
def execute(self, caller: Caller[Source]) -> object:
|
|
"""Return the explicit caller value unchanged."""
|
|
|
|
return caller
|
|
|
|
def provided_caller(source: Source) -> Caller[Source]:
|
|
"""Build one real explicit Caller value from this test's active frame."""
|
|
|
|
frame = inspect.currentframe()
|
|
assert frame is not None
|
|
return Caller(frame, (source,), {}, Source)
|
|
|
|
provided = provided_caller(Source())
|
|
|
|
assert OverrideTarget().execute(provided) is provided
|
|
provided.release()
|
|
|
|
|
|
def test_missing_caller_value_fails_with_decorated_parameter_context() -> None:
|
|
"""An absent compatible caller value is an explicit resolution failure."""
|
|
|
|
class MissingTarget:
|
|
"""Require a Source unavailable in the calling frame."""
|
|
|
|
@aspect
|
|
def execute(self, caller: Caller[Source]) -> Source:
|
|
"""Return the caller only if resolution succeeds."""
|
|
|
|
return caller.value
|
|
|
|
def invoke_without_source(target: MissingTarget) -> Source:
|
|
"""Invoke from a frame without a Source value."""
|
|
|
|
injected_execute = cast(Callable[[], Source], target.execute)
|
|
return injected_execute()
|
|
|
|
with pytest.raises(AspectResolutionError, match=r"MissingTarget\.execute\.caller"):
|
|
invoke_without_source(MissingTarget())
|
|
|
|
|
|
def test_ambiguous_caller_values_fail_instead_of_selecting_one() -> None:
|
|
"""Several compatible values in one caller frame are an explicit ambiguity."""
|
|
|
|
class AmbiguousTarget:
|
|
"""Require one Source from an intentionally ambiguous invocation."""
|
|
|
|
@aspect
|
|
def execute(self, caller: Caller[Source]) -> Source:
|
|
"""Return the caller only if resolution succeeds."""
|
|
|
|
return caller.value
|
|
|
|
def invoke_with_two_sources(target: AmbiguousTarget) -> Source:
|
|
"""Expose two distinct Source values in the same direct caller frame."""
|
|
|
|
first = Source()
|
|
second = Source()
|
|
injected_execute = cast(Callable[[], Source], target.execute)
|
|
return injected_execute()
|
|
|
|
with pytest.raises(AspectAmbiguityError, match=r"AmbiguousTarget\.execute\.caller"):
|
|
invoke_with_two_sources(AmbiguousTarget())
|
|
|
|
|
|
def test_caller_rejects_a_protocol_shaped_value_without_formal_implementation() -> None:
|
|
"""A Caller protocol target requires explicit inheritance rather than matching attributes."""
|
|
|
|
class Formatter(Protocol):
|
|
"""Formatter requires a declared rendering contract."""
|
|
|
|
depth: int
|
|
|
|
def __call__(self, body: str) -> str:
|
|
"""Render one body."""
|
|
|
|
...
|
|
|
|
class Lookalike:
|
|
"""Lookalike has the attributes but does not implement Formatter formally."""
|
|
|
|
depth = 4
|
|
|
|
def __call__(self, body: str) -> str:
|
|
"""Return the received body."""
|
|
|
|
return body
|
|
|
|
class ProtocolTarget:
|
|
"""Resolve a formally implemented formatter from its direct caller."""
|
|
|
|
@aspect
|
|
def execute(self, caller: Caller[Formatter]) -> Formatter:
|
|
"""Return the resolved formatter."""
|
|
|
|
return caller.value
|
|
|
|
def invoke(target: ProtocolTarget, candidate: Lookalike) -> Formatter:
|
|
"""Expose only the non-formal formatter candidate to the decorated call."""
|
|
|
|
injected_execute = cast(Callable[[], Formatter], target.execute)
|
|
return injected_execute()
|
|
|
|
with pytest.raises(AspectResolutionError, match=r"ProtocolTarget\.execute\.caller"):
|
|
invoke(ProtocolTarget(), Lookalike())
|
|
|
|
|
|
def test_multiple_aspects_resolve_independently_from_one_caller_frame() -> None:
|
|
"""Several omitted aspect parameters resolve their distinct target types."""
|
|
|
|
class Context:
|
|
"""Context is a second caller-visible aspect target."""
|
|
|
|
class MultiTarget:
|
|
"""Require both Source and Context aspects."""
|
|
|
|
@aspect
|
|
def execute(
|
|
self,
|
|
source: Caller[Source],
|
|
context: Caller[Context],
|
|
) -> tuple[Source, Context]:
|
|
"""Return both independently resolved values."""
|
|
|
|
return source.value, context.value
|
|
|
|
def invoke(target: MultiTarget, source: Source, context: Context) -> tuple[Source, Context]:
|
|
"""Expose exactly one value for each requested aspect target."""
|
|
|
|
injected_execute = cast(
|
|
Callable[[], tuple[Source, Context]],
|
|
target.execute,
|
|
)
|
|
return injected_execute()
|
|
|
|
source = Source()
|
|
context = Context()
|
|
|
|
assert invoke(MultiTarget(), source, context) == (source, context)
|
|
|
|
|
|
def test_aspect_preserves_ordinary_binding_and_signature() -> None:
|
|
"""Aspect injection augments normal positional, keyword, and variadic binding."""
|
|
|
|
class OrdinaryTarget:
|
|
"""Combine ordinary arguments with one injectable caller aspect."""
|
|
|
|
@aspect
|
|
def execute(
|
|
self,
|
|
value: int,
|
|
default: int = 7,
|
|
*items: int,
|
|
caller: Caller[Source],
|
|
label: str = "default",
|
|
**extras: int,
|
|
) -> tuple[Source, int, int, tuple[int, ...], str, dict[str, int]]:
|
|
"""Return ordinary bindings alongside the resolved source."""
|
|
|
|
return caller.value, value, default, items, label, extras
|
|
|
|
def invoke(source: Source, target: OrdinaryTarget) -> tuple[Source, int, int, tuple[int, ...], str, dict[str, int]]:
|
|
"""Call the decorated method with ordinary argument forms."""
|
|
|
|
injected_execute = cast(
|
|
Callable[..., tuple[Source, int, int, tuple[int, ...], str, dict[str, int]]],
|
|
target.execute,
|
|
)
|
|
return injected_execute(1, 2, 3, 4, label="named", extra=5)
|
|
|
|
source = Source()
|
|
target = OrdinaryTarget()
|
|
|
|
assert invoke(source, target) == (source, 1, 2, (3, 4), "named", {"extra": 5})
|
|
assert "Caller[Source]" in str(
|
|
inspect.signature(target.execute).parameters["caller"].annotation
|
|
)
|
|
|
|
|
|
def test_aspect_leaves_functions_without_aspect_parameters_ordinary() -> None:
|
|
"""Decorating a normal function without aspects preserves ordinary calls."""
|
|
|
|
class PlainTarget:
|
|
"""Expose a normal wrapped method."""
|
|
|
|
@aspect
|
|
def execute(self, value: int) -> int:
|
|
"""Return the normal argument without aspect resolution."""
|
|
|
|
return value
|
|
|
|
assert PlainTarget().execute(3) == 3
|
|
|
|
|
|
def test_aspect_detection_is_generic_over_aspect_subtypes() -> None:
|
|
"""The decorator discovers Aspect subclasses without knowing Caller."""
|
|
|
|
class Fixed(Aspect[T], Generic[T]):
|
|
"""Resolve a fixed value for generic detection coverage."""
|
|
|
|
def create(self) -> T:
|
|
"""Return one fixed value independent of caller lookup."""
|
|
|
|
return cast(T, 42)
|
|
|
|
class FixedTarget:
|
|
"""Receive one non-Caller aspect value."""
|
|
|
|
@aspect
|
|
def execute(self, fixed: Fixed[int]) -> int:
|
|
"""Return the custom aspect value."""
|
|
|
|
return fixed.value
|
|
|
|
injected_execute = cast(Callable[[], int], FixedTarget().execute)
|
|
|
|
assert injected_execute() == 42
|