"""Soft assertions: collect failures instead of stopping at the first one.
The active `SoftAssertCollector` is resolved through `contextvars` rather
than a module-level global, so it is thread-safe and naturally scoped per
scenario (Behave hooks reset it before/after each scenario).
"""
from __future__ import annotations
import collections.abc
import contextlib
import contextvars
from collections.abc import Iterator
from behave_kit._core.boolutil import as_bool as _as_bool
from behave_kit._core.errors import BehaveKitError
from behave_kit._core.types import Context
from behave_kit.assertions.diff import _safe_equal
from behave_kit.assertions.reporter import SoftAssertReport, SoftFailure
[docs]
class SoftAssertCollector:
"""Accumulates assertion failures without raising immediately."""
def __init__(self) -> None:
self._failures: list[SoftFailure] = []
[docs]
def assert_soft(self, condition: bool, msg: str = "") -> None:
"""Record a failure if ``condition`` is falsy.
Args:
condition: Boolean (or bool-coercible) value to check.
msg: Optional message describing the failure.
"""
if not _as_bool(condition):
self._failures.append(SoftFailure(message=msg or "condition was false"))
[docs]
def assert_soft_equals(self, actual: object, expected: object, msg: str = "") -> None:
"""Record a failure if ``actual`` is not equal to ``expected``.
Args:
actual: The value produced by the code under test.
expected: The value ``actual`` is expected to match.
msg: Optional override message for the failure.
"""
if not _safe_equal(actual, expected):
self._failures.append(
SoftFailure(message=msg or "values are not equal", expected=expected, actual=actual)
)
[docs]
def assert_soft_true(self, condition: object, msg: str = "") -> None:
"""Record a failure if ``condition`` is not truthy.
Args:
condition: Value evaluated with scalar-bool coercion.
msg: Optional override message for the failure.
"""
self.assert_soft(_as_bool(condition), msg or "expected a truthy value")
[docs]
def assert_soft_is_none(self, value: object, msg: str = "") -> None:
"""Record a failure if ``value`` is not ``None``.
Args:
value: The value to check.
msg: Optional override message for the failure.
"""
if value is not None:
self._failures.append(
SoftFailure(message=msg or "expected None", expected=None, actual=value)
)
[docs]
def assert_soft_raises(
self,
expected_exception: type[BaseException] | tuple[type[BaseException], ...],
func: collections.abc.Callable[[], object],
msg: str = "",
) -> None:
"""Record a failure if ``func`` does not raise ``expected_exception``.
Args:
expected_exception: Exception type (or tuple of types) that
``func`` is expected to raise.
func: Zero-argument callable to invoke.
msg: Optional override message for the failure.
"""
expected_name = _exception_name(expected_exception)
if not expected_name:
raise BehaveKitError(
"expected_exception must not be empty",
suggestion="Provide at least one exception type",
)
try:
func()
except expected_exception:
return
except Exception as exc:
self._failures.append(
SoftFailure(
message=msg or f"expected {expected_name}, got {type(exc).__name__}",
expected=expected_name,
actual=type(exc).__name__,
)
)
return
self._failures.append(
SoftFailure(
message=msg or f"expected {expected_name}, no exception raised",
expected=expected_name,
actual="no exception",
)
)
@property
def failures(self) -> list[SoftFailure]:
"""Copy of the recorded soft assertion failures."""
return list(self._failures)
[docs]
def report(self) -> SoftAssertReport:
"""Return an immutable report of all recorded failures."""
return SoftAssertReport(failures=list(self._failures))
[docs]
def raise_if_failed(self) -> None:
"""Raise ``AssertionError`` if any failure has been recorded."""
if self._failures:
raise AssertionError(str(self.report()))
[docs]
def clear(self) -> None:
"""Remove every recorded failure."""
self._failures.clear()
def _exception_name(
exc: type[BaseException] | tuple[type[BaseException], ...],
) -> str:
"""Return a readable name for an exception type or tuple of types."""
if isinstance(exc, tuple):
return " or ".join(e.__name__ for e in exc)
return exc.__name__
_collector_var: contextvars.ContextVar[SoftAssertCollector | None] = contextvars.ContextVar(
"behave_kit_soft_collector"
)
[docs]
def use_soft_asserts(context: Context) -> SoftAssertCollector:
"""Activate soft assertions for the given Behave ``context``.
Attaches a fresh `SoftAssertCollector` to ``context._behave_kit_soft``
and makes it the active collector for `assert_soft` and friends.
"""
collector = SoftAssertCollector()
context._behave_kit_soft = collector
_collector_var.set(collector)
return collector
def _active_collector() -> SoftAssertCollector:
try:
collector = _collector_var.get()
except LookupError as exc:
raise BehaveKitError(
"assert_soft() called without soft asserts activated",
suggestion="Use use_soft_asserts(context) or behave_kit.setup(context) first",
) from exc
if collector is None:
raise BehaveKitError(
"assert_soft() called without soft asserts activated",
suggestion="Use use_soft_asserts(context) or behave_kit.setup(context) first",
)
return collector
[docs]
def assert_soft(condition: bool, msg: str = "") -> None:
"""Record a soft assertion failure if ``condition`` is falsy."""
_active_collector().assert_soft(condition, msg)
[docs]
def assert_soft_equals(actual: object, expected: object, msg: str = "") -> None:
"""Record a soft assertion failure if ``actual`` != ``expected``."""
_active_collector().assert_soft_equals(actual, expected, msg)
[docs]
def assert_soft_true(condition: object, msg: str = "") -> None:
"""Record a soft assertion failure if ``condition`` is not truthy."""
_active_collector().assert_soft_true(condition, msg)
[docs]
def assert_soft_is_none(value: object, msg: str = "") -> None:
"""Record a soft assertion failure if ``value`` is not None."""
_active_collector().assert_soft_is_none(value, msg)
[docs]
def assert_soft_raises(
expected_exception: type[BaseException] | tuple[type[BaseException], ...],
func: collections.abc.Callable[[], object],
msg: str = "",
) -> None:
"""Record a soft assertion failure if ``func`` does not raise ``expected_exception``."""
_active_collector().assert_soft_raises(expected_exception, func, msg)
[docs]
@contextlib.contextmanager
def soft_asserts() -> Iterator[SoftAssertCollector]:
"""Context manager for soft assertions outside of Behave (e.g. unit tests).
Raises an `AssertionError` summarizing every failure when the block
exits without an exception of its own.
"""
collector = SoftAssertCollector()
token = _collector_var.set(collector)
try:
yield collector
finally:
_collector_var.reset(token)
collector.raise_if_failed()