"""Step definitions for PyYAML security dependency verification (#9055). Note: the shared steps for reading pyproject.toml and verifying importability are defined in ``tdd_a2a_sdk_dependency_steps.py`` to avoid AmbiguousStep conflicts. This file contains only the PyYAML-specific step definitions. """ from __future__ import annotations import re from typing import Any from behave import then, when from behave.runner import Context from packaging.version import parse as parse_version @then('the dependency list should include a package matching "{pattern}"') def step_dependency_matches_pattern(context: Context, pattern: str) -> None: """Assert that a dependency in the project dependencies matches the given regex pattern.""" deps: list[str] = context.project_dependencies found = any(re.search(pattern, dep) for dep in deps) assert found, ( f"No dependency matching '{pattern}' found in " f"[project.dependencies]. Current deps: {deps}" ) @when("I read the PyYAML dependency specification from pyproject.toml") def step_read_pyyaml_dependency(context: Context) -> None: """Read and parse the PyYAML dependency from pyproject.toml.""" content = context.pyproject_path.read_bytes() data: dict[str, Any] = _load_toml(content) deps: list[str] = data.get("project", {}).get("dependencies", []) # Find the pyyaml dependency entry pyyaml_dep = None for dep in deps: if dep.strip().startswith("pyyaml"): pyyaml_dep = dep.strip() break assert pyyaml_dep is not None, ( f"PyYAML dependency not found in [project.dependencies]. Current deps: {deps}" ) # Parse the minimum version from the constraint # Examples: "pyyaml>=6.0.3", "pyyaml>=6.0", "pyyaml==6.0.2" match = re.search(r"(?:>=|==|~>)([\d]+(?:\.[\d]+)*(?:\.\w+)*)", pyyaml_dep) assert match is not None, ( f"Could not parse version from PyYAML dependency: {pyyaml_dep}" ) context.pyyaml_version_spec = pyyaml_dep context.pyyaml_min_version = match.group(1) @then('the minimum version should be at least "{expected}"') def step_pyyaml_minimum_version(context: Context, expected: str) -> None: """Assert that the PyYAML minimum version is >= expected version.""" min_version = context.pyyaml_min_version assert parse_version(min_version) >= parse_version(expected), ( f"PyYAML minimum version {min_version} is less than required {expected}. " f"Dependency spec: {context.pyyaml_version_spec}" ) def _flatten_toml_dict(d: dict[Any, Any]) -> dict[str, Any]: """Recursively convert a tomlkit dict to a plain Python dict. tomlkit wraps every value in a proxy object that behaves like the underlying Python type but is not identical to it. This helper strips those wrappers so the result is a plain ``dict[str, Any]`` compatible with ``tomllib`` output. """ result: dict[str, Any] = {} for key, value in d.items(): if isinstance(value, dict): result[key] = _flatten_toml_dict(value) else: result[key] = value return result def _toml_to_python(data: object) -> dict[str, Any]: """Convert a tomlkit document to a plain Python dict. Delegates recursive flattening to the module-level ``_flatten_toml_dict`` helper so the logic stays testable in isolation and ruff does not flag nested function definitions. """ if isinstance(data, dict): return _flatten_toml_dict(data) raise TypeError(f"Expected dict-like TOML table, got {type(data).__name__}") def _load_toml(content: bytes) -> dict[str, Any]: """Load a TOML file from bytes. Uses tomllib if available, else fallback.""" try: import tomllib return tomllib.loads(content.decode("utf-8")) except ImportError: # pragma: no cover — Python <3.11 # Fallback for older Python versions without tomllib import tomlkit parsed_doc = tomlkit.parse(content.decode("utf-8")) return _toml_to_python(parsed_doc)