Source code for behave_kit.steps.data_driven

"""`@data_driven` — run a step once per row of a data file.

Loads test data via `behave_kit.data.load_data` and executes the decorated
step function once for each row, injecting the row's keys as keyword
arguments (non-identifier characters are replaced by ``_``).
"""

from __future__ import annotations

import functools
import re
from collections.abc import Callable
from pathlib import Path
from typing import Any, Concatenate, ParamSpec, TypeVar, cast

from behave_kit._core.errors import BehaveKitError
from behave_kit._core.types import Context
from behave_kit.data.loader import load_examples_from

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

_NON_IDENTIFIER = re.compile(r"\W")


def _sanitize_key(key: object) -> str:
    """Turn a row key into a valid Python identifier usable as a kwarg name."""
    sanitized = _NON_IDENTIFIER.sub("_", str(key))
    if sanitized[:1].isdigit():
        sanitized = f"_{sanitized}"
    if not sanitized.isidentifier():
        raise BehaveKitError(
            f"Data row key '{key}' cannot be converted to a Python identifier",
            suggestion="Rename the column to letters, digits and underscores",
        )
    return sanitized


[docs] def data_driven( path: str | Path, ) -> Callable[ [Callable[Concatenate[Context, P], R]], Callable[Concatenate[Context, P], R], ]: """Decorator: run the step once per row in the data file at ``path``. Each row (a ``dict``) is unpacked as keyword arguments into the step function. Column names are sanitized to valid Python identifiers (non-identifier characters become ``_``). Usage:: @when("I login as {username}") @data_driven("tests/data/users.csv") def step(context, username=None, password=None): ... Note: ``@data_driven`` must be placed *inside* (below) the Behave step decorator so that the wrapped function is what Behave registers. Raises: BehaveKitError: If the data file cannot be loaded or a row is not a dict. """ data_path = Path(path) def decorator( func: Callable[Concatenate[Context, P], R], ) -> Callable[Concatenate[Context, P], R]: """Wrap ``func`` to execute once per data row.""" @functools.wraps(func) def wrapper(context: Context, *args: P.args, **kwargs: P.kwargs) -> R: """Execute the step for each row in the data file.""" rows = load_examples_from(data_path) if not rows: raise BehaveKitError( f"No data rows found in '{data_path}'", suggestion="Ensure the file contains at least one data row", ) last_result: R | None = None for row in rows: if not isinstance(row, dict): raise BehaveKitError( f"Data row is {type(row).__name__}, expected dict", suggestion="Ensure the data file contains rows of key-value pairs", ) row_kwargs: dict[str, Any] = {_sanitize_key(k): v for k, v in row.items()} merged = {**row_kwargs, **kwargs} last_result = func(context, *args, **merged) return cast("R", last_result) return cast("Callable[Concatenate[Context, P], R]", wrapper) return decorator