Source code for behave_kit.assertions.soft

"""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()