Source code for behave_kit.context.dump

"""Dump the Behave context to JSON for post-mortem debugging.

`behave.runner.Context` stores user attributes in an internal stack of
dict "layers" (`context._stack`), not in the instance `__dict__`. This
module reads that stack when available and falls back to `vars(context)`
for simple mock contexts (e.g. `types.SimpleNamespace`) used in tests.
"""

from __future__ import annotations

import json
from pathlib import Path
from typing import Any

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

logger = get_logger("context.dump")
_SERIALIZABLE_SCALARS = (str, int, float, bool, type(None))


def _is_serializable(value: object) -> bool:
    if isinstance(value, _SERIALIZABLE_SCALARS):
        return True
    if isinstance(value, (list, tuple)):
        return all(_is_serializable(item) for item in value)
    if isinstance(value, dict):
        return all(isinstance(key, str) and _is_serializable(val) for key, val in value.items())
    return False


def _raw_attrs(context: Context) -> dict[str, Any]:
    stack = getattr(context, "_stack", None)
    if stack is None:
        return dict(vars(context))
    merged: dict[str, Any] = {}
    for frame in reversed(list(stack)):
        merged.update(frame)
    return merged


def _serializable_attrs(context: Context) -> dict[str, Any]:
    attrs: dict[str, Any] = {}
    for name, value in _raw_attrs(context).items():
        if not isinstance(name, str) or name.startswith("_") or name.startswith("@"):
            continue
        if _is_serializable(value):
            attrs[name] = value
        else:
            logger.warning(
                "Skipping non-serializable context attribute '%s' (%s)",
                name,
                type(value).__name__,
            )
    return attrs


def _safe_filename(name: str) -> str:
    return "".join(char if char.isalnum() else "_" for char in name) or "context"


[docs] def dump_context(context: Context, path: str | Path = "debug/") -> Path: """Write every JSON-serializable attribute of ``context`` to a file. Non-serializable attributes are skipped with a `logging.warning`. Returns: The path of the written file. """ output_dir = Path(path) if output_dir.exists() and not output_dir.is_dir(): raise BehaveKitError( f"Output path '{output_dir}' exists and is not a directory", suggestion="Provide a directory path for the debug dump", ) try: output_dir.mkdir(parents=True, exist_ok=True) except OSError as exc: raise BehaveKitError( f"Cannot create output directory '{output_dir}': {exc}", cause=exc, suggestion="Check the path and permissions", ) from exc attrs = _serializable_attrs(context) scenario = getattr(context, "scenario", None) scenario_name = getattr(scenario, "name", None) or "context" output_file = output_dir / f"{_safe_filename(str(scenario_name))}.json" output_file.write_text(json.dumps(attrs, indent=2, default=str), encoding="utf-8") return output_file
[docs] def dump_context_on_failure( context: Context, scenario: object, path: str | Path = "debug/", ) -> Path | None: """Call `dump_context` only when ``scenario.status`` is ``"failed"``.""" status = getattr(scenario, "status", None) status_name = getattr(status, "name", status) if str(status_name).lower() != "failed": return None return dump_context(context, path)