Source code for behave_kit.context.scoped

"""Automatic cleanup of context attributes registered via `@scoped`.

Prevents leaks between scenarios: an attribute set with `@scoped("driver")`
is deleted from the context once its scope ends, instead of silently
surviving into the next scenario.
"""

from __future__ import annotations

import functools
from collections.abc import Callable
from typing import Concatenate, ParamSpec, TypeVar, cast

from behave_kit._core.logging import get_logger
from behave_kit._core.types import Context, Scope

logger = get_logger("context.scoped")

_TRACK_KEY = "_behave_kit_scoped"

P = ParamSpec("P")
R = TypeVar("R")


def _tracked(context: Context) -> list[tuple[str, Scope]]:
    return list(getattr(context, _TRACK_KEY, None) or [])


def _track(context: Context, name: str, scope: Scope) -> None:
    tracked = _tracked(context)
    if (name, scope) not in tracked:
        tracked.append((name, scope))
    setattr(context, _TRACK_KEY, tracked)


[docs] def scoped( name: str, scope: Scope = Scope.SCENARIO, ) -> Callable[ [Callable[Concatenate[Context, P], R]], Callable[Concatenate[Context, P], R], ]: """Decorator: track ``context.<name>`` so it is cleaned up automatically. After the wrapped step function runs, ``name`` is registered for cleanup at the given ``scope`` (see `cleanup_scoped`). """ def decorator( func: Callable[Concatenate[Context, P], R], ) -> Callable[Concatenate[Context, P], R]: """Wrap ``func`` to register ``name`` for cleanup after it runs.""" @functools.wraps(func) def wrapper(context: Context, *args: P.args, **kwargs: P.kwargs) -> R: """Run the wrapped function and track ``name`` for cleanup.""" try: return func(context, *args, **kwargs) finally: _track(context, name, scope) return cast("Callable[Concatenate[Context, P], R]", wrapper) return decorator
[docs] def cleanup_scoped(context: Context, scope: Scope = Scope.SCENARIO) -> None: """Delete every attribute tracked with `@scoped` at the given ``scope``.""" remaining: list[tuple[str, Scope]] = [] for tracked_name, tracked_scope in _tracked(context): if tracked_scope != scope: remaining.append((tracked_name, tracked_scope)) continue try: delattr(context, tracked_name) except AttributeError: logger.warning("Scoped attribute '%s' was already removed or never set", tracked_name) setattr(context, _TRACK_KEY, remaining)