pytest-bdd/src/pytest_bdd/parser.py

544 lines
20 KiB
Python

from __future__ import annotations
import copy
import os.path
import re
import textwrap
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Generator, Iterable, Mapping, Sequence
from gherkin.parser_types import Background as GherkinBackground
from gherkin.parser_types import DataTable as GherkinDataTable
from gherkin.parser_types import GherkinDocument
from gherkin.parser_types import Rule as GherkinRule
from gherkin.parser_types import Scenario as GherkinScenario
from gherkin.parser_types import Step as GherkinStep
from gherkin.parser_types import Tag as GherkinTag
from .exceptions import StepError
from .gherkin_parser import get_gherkin_document
from .types import STEP_TYPE_BY_PARSER_KEYWORD
PARAM_RE = re.compile(r"<(.+?)>")
def render_string(input_string: str, render_context: Mapping[str, object]) -> str:
"""
Render the string with the given context,
but avoid replacing text inside angle brackets if context is missing.
Args:
input_string (str): The string for which to render/replace params.
render_context (Mapping[str, object]): The context for rendering the string.
Returns:
str: The rendered string with parameters replaced only if they exist in the context.
"""
def replacer(m: re.Match) -> str:
varname = m.group(1)
# If the context contains the variable, replace it. Otherwise, leave it unchanged.
return str(render_context.get(varname, f"<{varname}>"))
return PARAM_RE.sub(replacer, input_string)
def get_tag_names(tag_data: list[GherkinTag]) -> set[str]:
"""Extract tag names from tag data.
Args:
tag_data (List[Tag]): The tag data to extract names from.
Returns:
set[str]: A set of tag names.
"""
return {tag["name"].lstrip("@") for tag in tag_data}
@dataclass(eq=False)
class Feature:
"""Represents a feature parsed from a feature file.
Attributes:
scenarios (OrderedDict[str, ScenarioTemplate]): A dictionary of scenarios in the feature.
filename (str): The absolute path of the feature file.
rel_filename (str): The relative path of the feature file.
name (str): The name of the feature.
tags (set[str]): A set of tags associated with the feature.
background (Optional[Background]): The background steps for the feature, if any.
line_number (int): The line number where the feature starts in the file.
description (str): The description of the feature.
"""
scenarios: dict[str, ScenarioTemplate]
filename: str
rel_filename: str
language: str
keyword: str
name: str
tags: set[str]
background: Background | None
line_number: int
description: str
@dataclass(eq=False)
class Examples:
"""Represents examples used in scenarios for parameterization.
Attributes:
line_number (Optional[int]): The line number where the examples start.
name (Optional[str]): The name of the examples.
example_params (List[str]): The names of the parameters for the examples.
examples (List[Sequence[str]]): The list of example rows.
"""
line_number: int | None = None
name: str | None = None
example_params: list[str] = field(default_factory=list)
examples: list[Sequence[str]] = field(default_factory=list)
tags: set[str] = field(default_factory=set)
def set_param_names(self, keys: Iterable[str]) -> None:
"""Set the parameter names for the examples.
Args:
keys (Iterable[str]): The parameter names to set.
"""
self.example_params = [str(key) for key in keys]
def add_example(self, values: Sequence[str]) -> None:
"""Add a new example row.
Args:
values (Sequence[str]): The values for the example row.
"""
self.examples.append([str(value) if value is not None else "" for value in values])
def as_contexts(self) -> Generator[dict[str, str]]:
"""Generate contexts for the examples.
Yields:
dict[str, str]: A dictionary mapping parameter names to their values for each example row.
"""
for row in self.examples:
assert len(self.example_params) == len(row)
yield dict(zip(self.example_params, row))
def __bool__(self) -> bool:
"""Check if there are any examples.
Returns:
bool: True if there are examples, False otherwise.
"""
return bool(self.examples)
@dataclass(eq=False)
class Rule:
keyword: str
name: str
description: str
tags: set[str]
background: Background | None = None
@dataclass(eq=False)
class ScenarioTemplate:
"""Represents a scenario template within a feature.
Attributes:
feature (Feature): The feature to which this scenario belongs.
keyword (str): The keyword used to define the scenario.
name (str): The name of the scenario.
line_number (int): The line number where the scenario starts in the file.
templated (bool): Whether the scenario is templated.
description (Optional[str]): The description of the scenario.
tags (set[str]): A set of tags associated with the scenario.
_steps (List[Step]): The list of steps in the scenario (internal use only).
examples (Optional[Examples]): The examples used for parameterization in the scenario.
rule (Optional[Rule]): The rule to which the scenario may belong (None = no rule).
"""
feature: Feature
keyword: str
name: str
line_number: int
templated: bool
description: str
tags: set[str] = field(default_factory=set)
_steps: list[Step] = field(init=False, default_factory=list)
examples: list[Examples] = field(default_factory=list[Examples])
rule: Rule | None = None
def add_step(self, step: Step) -> None:
"""Add a step to the scenario.
Args:
step (Step): The step to add.
"""
step.scenario = self
self._steps.append(step)
@property
def all_background_steps(self) -> list[Step]:
steps = []
# Add background steps from the feature
if self.feature.background:
steps.extend(self.feature.background.steps)
if self.rule is not None and self.rule.background is not None:
# Add background steps from the rule
steps.extend(self.rule.background.steps)
return steps
@property
def steps(self) -> list[Step]:
"""Get all steps for the scenario, including background steps.
Returns:
List[Step]: A list of steps, including any background steps from the feature.
"""
return self.all_background_steps + self._steps
def render(self, context: Mapping[str, object]) -> Scenario:
"""Render the scenario with the given context.
Args:
context (Mapping[str, object]): The context for rendering steps.
Returns:
Scenario: A Scenario object with steps rendered based on the context.
"""
base_steps = self.all_background_steps + self._steps
scenario_steps = [
Step(
name=render_string(step.name, context),
type=step.type,
indent=step.indent,
line_number=step.line_number,
keyword=step.keyword,
datatable=step.render_datatable(step.datatable, context) if step.datatable else None,
docstring=render_string(step.docstring, context) if step.docstring else None,
)
for step in base_steps
]
return Scenario(
feature=self.feature,
keyword=self.keyword,
name=render_string(self.name, context),
line_number=self.line_number,
steps=scenario_steps,
tags=self.tags,
description=self.description,
rule=self.rule,
)
@dataclass(eq=False)
class Scenario:
"""Represents a scenario with steps.
Attributes:
feature (Feature): The feature to which this scenario belongs.
keyword (str): The keyword used to define the scenario.
name (str): The name of the scenario.
line_number (int): The line number where the scenario starts in the file.
steps (List[Step]): The list of steps in the scenario.
description (Optional[str]): The description of the scenario.
tags (set[str]): A set of tags associated with the scenario.
"""
feature: Feature
keyword: str
name: str
line_number: int
steps: list[Step]
description: str
tags: set[str] = field(default_factory=set)
rule: Rule | None = None
@dataclass(eq=False)
class Step:
"""Represents a step within a scenario or background.
Attributes:
type (str): The type of step (e.g., 'given', 'when', 'then').
name (str): The name of the step.
line_number (int): The line number where the step starts in the file.
indent (int): The indentation level of the step.
keyword (str): The keyword used for the step (e.g., 'Given', 'When', 'Then').
failed (bool): Whether the step has failed (internal use only).
scenario (Optional[ScenarioTemplate]): The scenario to which this step belongs (internal use only).
background (Optional[Background]): The background to which this step belongs (internal use only).
"""
type: str
name: str
line_number: int
indent: int
keyword: str
docstring: str | None = None
datatable: GherkinDataTable | None = None
failed: bool = field(init=False, default=False)
scenario: ScenarioTemplate | None = field(init=False, default=None)
background: Background | None = field(init=False, default=None)
def __init__(
self,
name: str,
type: str,
indent: int,
line_number: int,
keyword: str,
datatable: GherkinDataTable | None = None,
docstring: str | None = None,
) -> None:
"""Initialize a step.
Args:
name (str): The name of the step.
type (str): The type of the step (e.g., 'given', 'when', 'then').
indent (int): The indentation level of the step.
line_number (int): The line number where the step starts in the file.
keyword (str): The keyword used for the step (e.g., 'Given', 'When', 'Then').
"""
self.name = name
self.type = type
self.indent = indent
self.line_number = line_number
self.keyword = keyword
self.datatable = datatable
self.docstring = docstring
def __str__(self) -> str:
"""Return a string representation of the step.
Returns:
str: A string representation of the step.
"""
return f'{self.type.capitalize()} "{self.name}"'
@staticmethod
def render_datatable(datatable: GherkinDataTable, context: Mapping[str, object]) -> GherkinDataTable:
"""
Render the datatable with the given context,
but avoid replacing text inside angle brackets if context is missing.
Args:
datatable (DataTable): The datatable to render.
context (Mapping[str, object]): The context for rendering the datatable.
Returns:
datatable (DataTable): The rendered datatable with parameters replaced only if they exist in the context.
"""
rendered_datatable = copy.deepcopy(datatable)
for row in rendered_datatable["rows"]:
for cell in row["cells"]:
cell["value"] = render_string(cell["value"], context)
return rendered_datatable
@dataclass(eq=False)
class Background:
"""Represents the background steps for a feature.
Attributes:
line_number (int): The line number where the background starts in the file.
steps (List[Step]): The list of steps in the background.
"""
line_number: int
steps: list[Step] = field(init=False, default_factory=list)
def add_step(self, step: Step) -> None:
"""Add a step to the background.
Args:
step (Step): The step to add.
"""
step.background = self
self.steps.append(step)
class FeatureParser:
"""Converts a feature file into a Feature object.
Args:
basedir (str): The basedir for locating feature files.
filename (str): The filename of the feature file.
encoding (str): File encoding of the feature file to parse.
"""
def __init__(self, basedir: str, filename: str, encoding: str = "utf-8"):
self.abs_filename = os.path.abspath(os.path.join(basedir, filename))
self.rel_filename = os.path.join(os.path.basename(basedir), filename)
self.encoding = encoding
def parse_steps(self, steps_data: list[GherkinStep]) -> list[Step]:
"""Parse a list of step data into Step objects.
Args:
steps_data (List[dict]): The list of step data.
Returns:
List[Step]: A list of Step objects.
"""
if not steps_data:
return []
first_step = steps_data[0]
if first_step["keywordType"] not in STEP_TYPE_BY_PARSER_KEYWORD:
raise StepError(
message=f"First step in a scenario or background must start with 'Given', 'When' or 'Then', but got {first_step['keyword'].strip()}.",
line=first_step["location"]["line"],
line_content=first_step["text"],
filename=self.abs_filename,
)
steps = []
current_type = STEP_TYPE_BY_PARSER_KEYWORD[first_step["keywordType"]]
for step in steps_data:
current_type = STEP_TYPE_BY_PARSER_KEYWORD.get(step["keywordType"], current_type)
steps.append(
Step(
name=step["text"],
type=current_type,
indent=step["location"]["column"] - 1,
line_number=step["location"]["line"],
keyword=step["keyword"].strip().title(),
datatable=step["dataTable"] if "dataTable" in step else None,
docstring=textwrap.dedent(step["docString"]["content"]) if "docString" in step else None,
)
)
return steps
def parse_scenario(
self, scenario_data: GherkinScenario, feature: Feature, rule: Rule | None = None
) -> ScenarioTemplate:
"""Parse a scenario data dictionary into a ScenarioTemplate object.
Args:
scenario_data (dict): The dictionary containing scenario data.
feature (Feature): The feature to which this scenario belongs.
rule (Optional[Rule]): The rule to which this scenario may belong. (None = no rule)
Returns:
ScenarioTemplate: A ScenarioTemplate object representing the parsed scenario.
"""
templated = bool(scenario_data["examples"])
scenario = ScenarioTemplate(
feature=feature,
keyword=scenario_data["keyword"],
name=scenario_data["name"],
line_number=scenario_data["location"]["line"],
templated=templated,
tags=get_tag_names(scenario_data["tags"]),
description=textwrap.dedent(scenario_data["description"]),
rule=rule,
)
for step in self.parse_steps(scenario_data["steps"]):
scenario.add_step(step)
# Loop over multiple example tables if they exist
for example_data in scenario_data["examples"]:
examples = Examples(
line_number=example_data["location"]["line"],
name=example_data["name"],
tags=get_tag_names(example_data["tags"]),
)
if example_data["tableHeader"] is not None:
param_names = [cell["value"] for cell in example_data["tableHeader"]["cells"]]
examples.set_param_names(param_names)
if example_data["tableBody"] is not None:
for row in example_data["tableBody"]:
values = [cell["value"] if "value" in cell else "" for cell in row["cells"]]
examples.add_example(values)
scenario.examples.append(examples)
return scenario
def parse_background(self, background_data: GherkinBackground) -> Background:
background = Background(
line_number=background_data["location"]["line"],
)
background.steps = self.parse_steps(background_data["steps"])
for step in background.steps:
step.background = background
return background
def _parse_feature_file(self) -> GherkinDocument:
"""Parse a feature file into a Feature object.
Returns:
Dict: A Gherkin document representation of the feature file.
"""
return get_gherkin_document(self.abs_filename, self.encoding)
def parse(self) -> Feature:
"""Parse the feature file and return a Feature object with its backgrounds, rules, and scenarios."""
gherkin_doc: GherkinDocument = self._parse_feature_file()
feature = Feature(
scenarios={},
keyword=gherkin_doc["feature"]["keyword"],
filename=self.abs_filename,
rel_filename=self.rel_filename,
name=gherkin_doc["feature"]["name"],
tags=get_tag_names(gherkin_doc["feature"]["tags"]),
background=None,
line_number=gherkin_doc["feature"]["location"]["line"],
description=textwrap.dedent(gherkin_doc["feature"]["description"]),
language=gherkin_doc["feature"]["language"],
)
for child in gherkin_doc["feature"]["children"]:
if "background" in child:
feature.background = self.parse_background(child["background"]) # type: ignore[typeddict-item]
elif "rule" in child:
self._parse_and_add_rule(child["rule"], feature) # type: ignore[typeddict-item]
elif "scenario" in child:
self._parse_and_add_scenario(child["scenario"], feature) # type: ignore[typeddict-item]
return feature
def _parse_and_add_rule(self, rule_data: GherkinRule, feature: Feature) -> None:
"""Parse a rule, including its background and scenarios, and add to the feature."""
background = self._extract_rule_background(rule_data)
rule = Rule(
keyword=rule_data["keyword"],
name=rule_data["name"],
description=rule_data["description"],
tags=get_tag_names(rule_data["tags"]),
background=background,
)
for scenario in self._extract_rule_scenarios(rule_data, feature, rule):
feature.scenarios[scenario.name] = scenario
def _extract_rule_background(self, rule_data: GherkinRule) -> Background | None:
"""Extract the first background from rule children if it exists."""
for child in rule_data["children"]:
if "background" in child:
return self.parse_background(child["background"]) # type: ignore[typeddict-item]
return None
def _extract_rule_scenarios(
self, rule_data: GherkinRule, feature: Feature, rule: Rule
) -> Generator[ScenarioTemplate]:
"""Yield each parsed scenario under a rule."""
for child in rule_data["children"]:
if "scenario" in child:
yield self.parse_scenario(child["scenario"], feature, rule) # type: ignore[typeddict-item]
def _parse_and_add_scenario(
self, scenario_data: GherkinScenario, feature: Feature, rule: Rule | None = None
) -> None:
"""Parse an individual scenario and add it to the feature's scenarios."""
scenario = self.parse_scenario(scenario_data, feature, rule)
feature.scenarios[scenario.name] = scenario