Source code for behave_kit.context.substeps

"""Execute Gherkin sub-steps with state isolation and outline substitution.

``run_steps`` wraps Behave's ``context.execute_steps()`` with two
enhancements that make sub-step execution safer and more expressive:

1. **Scenario Outline substitution** — ``<placeholder>`` variables from
   the current outline row are replaced before parsing, so sub-steps
   can reference the same parameters as the parent step.

2. **Table / text preservation** — ``context.table`` and ``context.text``
   are saved before execution and restored in a ``finally`` block, so the
   parent step's tabular or multiline data is never clobbered by a sub-step.

Usage::

    from behave_kit import run_steps

    @when("I complete the checkout flow")
    def step_impl(context):
        run_steps(context, '''
            Given I have items in my cart
            When I enter shipping info for "<city>"
            And I select payment method "<method>"
            Then I should see the order confirmation
        ''')
"""

from __future__ import annotations

import re
from collections.abc import Callable
from typing import cast

from behave_kit._core.errors import SubStepError
from behave_kit._core.logging import get_logger
from behave_kit._core.types import Context

logger = get_logger("context.substeps")

_OUTLINE_PATTERN = re.compile(r"<([^>]+)>")


def _substitute_outline_vars(context: Context, steps_text: str) -> str:
    """Replace ``<placeholder>`` variables from ``context.active_outline``.

    If the context has no ``active_outline`` attribute (i.e. the scenario
    is not a Scenario Outline row), or the outline is empty, the text is
    returned unchanged.

    Raises:
        SubStepError: If ``active_outline`` is present but not a dict, or
            if a ``<placeholder>`` is not found in the outline.
    """
    active_outline = getattr(context, "active_outline", None)
    if not active_outline:
        return steps_text

    if not isinstance(active_outline, dict):
        raise SubStepError(
            f"active_outline must be a dict, got {type(active_outline).__name__}",
            suggestion="Ensure context.active_outline is set from a Scenario Outline row",
        )

    def _replace(match: re.Match[str]) -> str:
        var_name = match.group(1)
        if var_name not in active_outline:
            raise SubStepError(
                f"Outline variable '<{var_name}>' not found in active outline",
                suggestion=(
                    f"Available variables: {', '.join(sorted(active_outline.keys())) or '(none)'}"
                ),
            )
        return str(active_outline[var_name])

    return _OUTLINE_PATTERN.sub(_replace, steps_text)


[docs] def run_steps(context: Context, steps: str) -> None: """Execute Gherkin sub-steps with state isolation. Wraps ``context.execute_steps()`` with: - Scenario Outline ``<placeholder>`` substitution from ``context.active_outline``. - Guaranteed restoration of ``context.table`` and ``context.text``. Args: context: The Behave context for the current scenario. steps: Gherkin step text to execute. Must be a string containing one or more step definitions with keywords (Given/When/Then/And/But). Raises: SubStepError: If called outside a feature context. AssertionError: If any sub-step fails (propagated from Behave). """ if not isinstance(steps, str): raise SubStepError( f"steps must be a string, got {type(steps).__name__}", suggestion="Pass a Gherkin string with Given/When/Then keywords", ) if not steps.strip(): raise SubStepError( "steps must not be empty or whitespace-only", suggestion="Provide at least one step with a Given/When/Then keyword", ) if not getattr(context, "feature", None): raise SubStepError( "run_steps() called outside of a feature context", suggestion="Use run_steps() only from within a step definition", ) if not callable(getattr(context, "execute_steps", None)): raise SubStepError( "context does not have a callable execute_steps method", suggestion="Ensure run_steps() is called with a valid Behave context", ) original_table = getattr(context, "table", None) original_text = getattr(context, "text", None) resolved_steps = _substitute_outline_vars(context, steps) execute_steps = cast("Callable[[str], object]", context.execute_steps) try: execute_steps(resolved_steps) finally: context.table = original_table context.text = original_text