Source code for behave_kit.fixtures.manager
"""`FixtureManager` — orchestrates setup/teardown by tags and scope.
Usage in ``environment.py``::
from behave_kit.fixtures import FixtureManager
fixtures = FixtureManager()
def before_scenario(context, scenario):
fixtures.setup_for_scenario(context, scenario)
def after_scenario(context):
fixtures.teardown_scenario(context)
"""
from __future__ import annotations
import difflib
from behave_kit._core.errors import FixtureError
from behave_kit._core.logging import get_logger
from behave_kit._core.types import Context, Scope
from behave_kit.fixtures.registry import (
FixtureTeardown,
fixture_names,
fixture_scope,
get_fixture,
resolve_fixture_order,
)
logger = get_logger("fixtures.manager")
def _extract_quoted_name(message: str) -> str | None:
for char in ("'", '"'):
start = message.find(char)
if start != -1:
end = message.find(char, start + 1)
if end != -1:
return message[start + 1 : end]
return None
[docs]
class FixtureManager:
"""Orchestrates fixture setup/teardown lifecycle by scope."""
def __init__(self) -> None:
self._scenario_teardowns: list[tuple[str, FixtureTeardown]] = []
self._feature_teardowns: list[tuple[str, FixtureTeardown]] = []
@staticmethod
def _get_tags(obj: object) -> set[str]:
tags = getattr(obj, "tags", None) or []
return {str(tag) for tag in tags}
def _run_fixture(self, name: str, context: Context) -> None:
factory = get_fixture(name)
result = factory(context)
if result is None:
return
if (
not isinstance(result, tuple)
or len(result) != 2
or not callable(result[0])
or not callable(result[1])
):
raise FixtureError(
f"Fixture '{name}' returned {type(result).__name__}",
suggestion="Return None or (setup_fn, teardown_fn)",
)
setup_fn, teardown_fn = result
setup_fn(context)
scope = fixture_scope(name)
if scope == Scope.SCENARIO:
self._scenario_teardowns.append((name, teardown_fn))
else:
self._feature_teardowns.append((name, teardown_fn))
def _setup_for_tags(self, context: Context, tags: set[str], scope: Scope) -> None:
available = set(fixture_names(scope))
matched = tags & available
executed: set[str] = set()
for tag in sorted(matched):
try:
order = resolve_fixture_order(tag)
except FixtureError as exc:
if "Circular" in exc.message:
raise
missing = _extract_quoted_name(exc.message) or tag
similar = difflib.get_close_matches(missing, fixture_names())
suggestion = f"Did you mean: {', '.join(similar)}" if similar else exc.suggestion
raise FixtureError(
f"Fixture '{missing}' not found",
suggestion=suggestion,
) from exc
for dep_name in order:
if dep_name not in executed and fixture_scope(dep_name) == scope:
self._run_fixture(dep_name, context)
executed.add(dep_name)
[docs]
def setup_for_scenario(self, context: Context, scenario: object) -> None:
"""Run scenario-scoped fixtures matching ``scenario`` tags."""
tags = self._get_tags(scenario)
self._setup_for_tags(context, tags, Scope.SCENARIO)
[docs]
def setup_for_feature(self, context: Context, feature: object) -> None:
"""Run feature-scoped fixtures matching ``feature`` tags."""
tags = self._get_tags(feature)
self._setup_for_tags(context, tags, Scope.FEATURE)
[docs]
def teardown_scenario(self, context: Context) -> None:
"""Run scenario-scoped teardowns in reverse order."""
for name, teardown in reversed(self._scenario_teardowns):
try:
teardown(context)
except Exception:
logger.exception("Teardown error in fixture '%s'", name)
self._scenario_teardowns.clear()
[docs]
def teardown_feature(self, context: Context) -> None:
"""Run feature-scoped teardowns in reverse order."""
for name, teardown in reversed(self._feature_teardowns):
try:
teardown(context)
except Exception:
logger.exception("Teardown error in fixture '%s'", name)
self._feature_teardowns.clear()