From 1a2baff6e42fe00719787fb5eb0b977ceb5737d2 Mon Sep 17 00:00:00 2001 From: Jason Allen Date: Thu, 5 Sep 2024 13:15:27 +0100 Subject: [PATCH 01/17] First commit for using the official gherkin parser (trying to maintain public API and current codebase as much as possible) --- poetry.lock | 15 +- pyproject.toml | 1 + src/pytest_bdd/parser.py | 441 +++++++--------------- src/pytest_bdd/scenario.py | 7 +- src/pytest_bdd/steps.py | 2 - src/pytest_bdd/types.py | 8 - tests/feature/test_background.py | 6 +- tests/feature/test_multiline.py | 96 +---- tests/feature/test_no_scenario.py | 2 +- tests/feature/test_outline.py | 6 +- tests/feature/test_scenario.py | 46 --- tests/feature/test_scenarios.py | 2 + tests/feature/test_steps.py | 104 +++-- tests/feature/test_tags.py | 63 ---- tests/feature/test_wrong.py | 2 +- tests/generation/test_generate_missing.py | 6 - tests/steps/test_common.py | 22 -- 17 files changed, 243 insertions(+), 586 deletions(-) diff --git a/poetry.lock b/poetry.lock index b72242e53..f082c5147 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] name = "cachetools" @@ -155,6 +155,17 @@ docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1 testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"] typing = ["typing-extensions (>=4.8)"] +[[package]] +name = "gherkin-official" +version = "29.0.0" +description = "Gherkin parser (official, by Cucumber team)" +optional = false +python-versions = "*" +files = [ + {file = "gherkin_official-29.0.0-py3-none-any.whl", hash = "sha256:26967b0d537a302119066742669e0e8b663e632769330be675457ae993e1d1bc"}, + {file = "gherkin_official-29.0.0.tar.gz", hash = "sha256:dbea32561158f02280d7579d179b019160d072ce083197625e2f80a6776bb9eb"}, +] + [[package]] name = "iniconfig" version = "2.0.0" @@ -554,4 +565,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.0" python-versions = ">=3.8" -content-hash = "b40d47067f444deec4964404014795593f1b602f8a2f6376279bb5a27d5e18be" +content-hash = "6b52d5b35db2892ae49a2d655a8f19fb430b59b3f8c4dc6881526f0729424580" diff --git a/pyproject.toml b/pyproject.toml index f8464ec77..7d874a5a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ parse-type = "*" pytest = ">=6.2.0" typing-extensions = "*" packaging = "*" +gherkin-official = "^29.0.0" [tool.poetry.group.dev.dependencies] tox = ">=4.11.3" diff --git a/src/pytest_bdd/parser.py b/src/pytest_bdd/parser.py index 533bb4ff1..18a2928d8 100644 --- a/src/pytest_bdd/parser.py +++ b/src/pytest_bdd/parser.py @@ -1,205 +1,47 @@ from __future__ import annotations +import linecache import os.path import re import textwrap -import typing from collections import OrderedDict from dataclasses import dataclass, field -from functools import cached_property -from typing import cast +from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple -from . import exceptions, types +from gherkin.errors import CompositeParserException +from gherkin.parser import Parser +from gherkin.token_scanner import TokenScanner + +from .exceptions import FeatureError +from .types import GIVEN, THEN, WHEN -SPLIT_LINE_RE = re.compile(r"(?") COMMENT_RE = re.compile(r"(^|(?<=\s))#") -STEP_PREFIXES = [ - ("Feature: ", types.FEATURE), - ("Scenario Outline: ", types.SCENARIO_OUTLINE), - ("Examples:", types.EXAMPLES), - ("Scenario: ", types.SCENARIO), - ("Background:", types.BACKGROUND), - ("Given ", types.GIVEN), - ("When ", types.WHEN), - ("Then ", types.THEN), - ("@", types.TAG), - # Continuation of the previously mentioned step type - ("And ", None), - ("But ", None), -] - -TYPES_WITH_DESCRIPTIONS = [types.FEATURE, types.SCENARIO, types.SCENARIO_OUTLINE] - -if typing.TYPE_CHECKING: - from typing import Any, Iterable, Mapping, Match, Sequence - - -def split_line(line: str) -> list[str]: - """Split the given Examples line. - - :param str|unicode line: Feature file Examples line. - - :return: List of strings. - """ - return [cell.replace("\\|", "|").strip() for cell in SPLIT_LINE_RE.split(line)[1:-1]] - - -def parse_line(line: str) -> tuple[str, str]: - """Parse step line to get the step prefix (Scenario, Given, When, Then or And) and the actual step name. - - :param line: Line of the Feature file. - - :return: `tuple` in form ("", ""). - """ - for prefix, _ in STEP_PREFIXES: - if line.startswith(prefix): - return prefix.strip(), line[len(prefix) :].strip() - return "", line def strip_comments(line: str) -> str: - """Remove comments. - - :param str line: Line of the Feature file. - - :return: Stripped line. - """ + """Remove comments from a line of text.""" if res := COMMENT_RE.search(line): line = line[: res.start()] return line.strip() -def get_step_type(line: str) -> str | None: - """Detect step type by the beginning of the line. - - :param str line: Line of the Feature file. - - :return: SCENARIO, GIVEN, WHEN, THEN, or `None` if can't be detected. - """ - for prefix, _type in STEP_PREFIXES: - if line.startswith(prefix): - return _type - return None - - def parse_feature(basedir: str, filename: str, encoding: str = "utf-8") -> Feature: - """Parse the feature file. - - :param str basedir: Feature files base directory. - :param str filename: Relative path to the feature file. - :param str encoding: Feature file encoding (utf-8 by default). - """ - __tracebackhide__ = True + """Parse a feature file into a Feature object.""" abs_filename = os.path.abspath(os.path.join(basedir, filename)) rel_filename = os.path.join(os.path.basename(basedir), filename) - feature = Feature( - scenarios=OrderedDict(), - filename=abs_filename, - rel_filename=rel_filename, - line_number=1, - name=None, - tags=set(), - background=None, - description="", - ) - scenario: ScenarioTemplate | None = None - mode: str | None = None - prev_mode = None - description: list[str] = [] - step = None - multiline_step = False - prev_line = None - with open(abs_filename, encoding=encoding) as f: - content = f.read() - - for line_number, line in enumerate(content.splitlines(), start=1): - unindented_line = line.lstrip() - line_indent = len(line) - len(unindented_line) - if step and (step.indent < line_indent or ((not unindented_line) and multiline_step)): - multiline_step = True - # multiline step, so just add line and continue - step.add_line(line) - continue - else: - step = None - multiline_step = False - stripped_line = line.strip() - clean_line = strip_comments(line) - if not clean_line and (not prev_mode or prev_mode not in TYPES_WITH_DESCRIPTIONS): - # Blank lines are included in feature and scenario descriptions - continue - mode = get_step_type(clean_line) or mode - - allowed_prev_mode = (types.BACKGROUND, types.GIVEN, types.WHEN) - - if not scenario and prev_mode not in allowed_prev_mode and mode in types.STEP_TYPES: - raise exceptions.FeatureError( - "Step definition outside of a Scenario or a Background", line_number, clean_line, filename - ) - - if mode == types.FEATURE: - if prev_mode is None or prev_mode == types.TAG: - _, feature.name = parse_line(clean_line) - feature.line_number = line_number - feature.tags = get_tags(prev_line) - elif prev_mode == types.FEATURE: - # Do not include comments in descriptions - if not stripped_line.startswith("#"): - description.append(clean_line) - else: - raise exceptions.FeatureError( - "Multiple features are not allowed in a single feature file", - line_number, - clean_line, - filename, - ) - - prev_mode = mode - - # Remove Feature, Given, When, Then, And - keyword, parsed_line = parse_line(clean_line) - - if mode in [types.SCENARIO, types.SCENARIO_OUTLINE]: - # Lines between the scenario declaration - # and the scenario's first step line - # are considered part of the scenario description. - if scenario and not keyword: - # Do not include comments in descriptions - if not stripped_line.startswith("#"): - scenario.add_description_line(clean_line) - continue - tags = get_tags(prev_line) - scenario = ScenarioTemplate( - feature=feature, - name=parsed_line, - line_number=line_number, - tags=tags, - templated=mode == types.SCENARIO_OUTLINE, - ) - feature.scenarios[parsed_line] = scenario - elif mode == types.BACKGROUND: - feature.background = Background(feature=feature, line_number=line_number) - elif mode == types.EXAMPLES: - mode = types.EXAMPLES_HEADERS - scenario.examples.line_number = line_number - elif mode == types.EXAMPLES_HEADERS: - scenario.examples.set_param_names([l for l in split_line(parsed_line) if l]) - mode = types.EXAMPLE_LINE - elif mode == types.EXAMPLE_LINE: - scenario.examples.add_example(list(split_line(stripped_line))) - elif mode and mode not in (types.FEATURE, types.TAG): - step = Step(name=parsed_line, type=mode, indent=line_indent, line_number=line_number, keyword=keyword) - if feature.background and not scenario: - feature.background.add_step(step) - else: - scenario = cast(ScenarioTemplate, scenario) - scenario.add_step(step) - prev_line = clean_line - - feature.description = "\n".join(description).strip() - return feature + file_contents = f.read() + try: + gherkin_document = Parser().parse(TokenScanner(file_contents)) + except CompositeParserException as e: + raise FeatureError( + e.args[0], + e.errors[0].location["line"], + linecache.getline(abs_filename, e.errors[0].location["line"]).rstrip("\n"), + abs_filename, + ) from e + return dict_to_feature(abs_filename, rel_filename, gherkin_document) @dataclass(eq=False) @@ -215,20 +57,37 @@ class Feature: @dataclass(eq=False) -class ScenarioTemplate: - """A scenario template. +class Examples: + 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) - Created when parsing the feature file, it will then be combined with the examples to create a Scenario. - """ + def set_param_names(self, keys: Iterable[str]) -> None: + self.example_params = [str(key) for key in keys] + + def add_example(self, values: Sequence[str]) -> None: + self.examples.append([str(value) if value is not None else "" for value in values]) + + def as_contexts(self) -> Iterable[dict[str, Any]]: + for row in self.examples: + assert len(self.example_params) == len(row) + yield dict(zip(self.example_params, row)) + def __bool__(self) -> bool: + return bool(self.examples) + + +@dataclass(eq=False) +class ScenarioTemplate: feature: Feature name: str line_number: int templated: bool + description: str | None = None tags: set[str] = field(default_factory=set) - examples: Examples | None = field(default_factory=lambda: Examples()) _steps: list[Step] = field(init=False, default_factory=list) - _description_lines: list[str] = field(init=False, default_factory=list) + examples: Examples | None = field(default_factory=Examples) def add_step(self, step: Step) -> None: step.scenario = self @@ -236,24 +95,20 @@ def add_step(self, step: Step) -> None: @property def steps(self) -> list[Step]: - background = self.feature.background - return (background.steps if background else []) + self._steps + return (self.feature.background.steps if self.feature.background else []) + self._steps def render(self, context: Mapping[str, Any]) -> Scenario: background_steps = self.feature.background.steps if self.feature.background else [] - if not self.templated: - scenario_steps = self._steps - else: - scenario_steps = [ - Step( - name=step.render(context), - type=step.type, - indent=step.indent, - line_number=step.line_number, - keyword=step.keyword, - ) - for step in self._steps - ] + scenario_steps = [ + Step( + name=step.render(context), + type=step.type, + indent=step.indent, + line_number=step.line_number, + keyword=step.keyword, + ) + for step in self._steps + ] steps = background_steps + scenario_steps return Scenario( feature=self.feature, @@ -261,22 +116,9 @@ def render(self, context: Mapping[str, Any]) -> Scenario: line_number=self.line_number, steps=steps, tags=self.tags, - description=self._description_lines, + description=self.description, ) - def add_description_line(self, description_line): - """Add a description line to the scenario. - :param str description_line: - """ - self._description_lines.append(description_line) - - @property - def description(self): - """Get the scenario's description. - :return: The scenario description - """ - return "\n".join(self._description_lines) - @dataclass(eq=False) class Scenario: @@ -284,8 +126,8 @@ class Scenario: name: str line_number: int steps: list[Step] + description: str | None = None tags: set[str] = field(default_factory=set) - description: list[str] = field(default_factory=list) @dataclass(eq=False) @@ -307,50 +149,7 @@ def __init__(self, name: str, type: str, indent: int, line_number: int, keyword: self.line_number = line_number self.keyword = keyword - self.failed = False - self.scenario = None - self.background = None - self.lines = [] - - def add_line(self, line: str) -> None: - """Add line to the multiple step. - - :param str line: Line of text - the continuation of the step name. - """ - self.lines.append(line) - self._invalidate_full_name_cache() - - @cached_property - def full_name(self) -> str: - multilines_content = textwrap.dedent("\n".join(self.lines)) if self.lines else "" - - # Remove the multiline quotes, if present. - multilines_content = re.sub( - pattern=r'^"""\n(?P.*)\n"""$', - repl=r"\g", - string=multilines_content, - flags=re.DOTALL, # Needed to make the "." match also new lines - ) - - lines = [self._name] + [multilines_content] - return "\n".join(lines).strip() - - def _invalidate_full_name_cache(self) -> None: - """Invalidate the full_name cache.""" - if "full_name" in self.__dict__: - del self.full_name - - @property - def name(self) -> str: - return self.full_name - - @name.setter - def name(self, value: str) -> None: - self._name = value - self._invalidate_full_name_cache() - def __str__(self) -> str: - """Full step name including the type.""" return f'{self.type.capitalize()} "{self.name}"' @property @@ -358,9 +157,9 @@ def params(self) -> tuple[str, ...]: return tuple(frozenset(STEP_PARAM_RE.findall(self.name))) def render(self, context: Mapping[str, Any]) -> str: - def replacer(m: Match): + def replacer(m: re.Match) -> str: varname = m.group(1) - return str(context[varname]) + return str(context.get(varname, f"")) return STEP_PARAM_RE.sub(replacer, self.name) @@ -372,48 +171,94 @@ class Background: steps: list[Step] = field(init=False, default_factory=list) def add_step(self, step: Step) -> None: - """Add step to the background.""" step.background = self self.steps.append(step) -@dataclass(eq=False) -class Examples: - """Example table.""" - - line_number: int | None = field(default=None) - name: str | None = field(default=None) - - example_params: list[str] = field(init=False, default_factory=list) - examples: list[Sequence[str]] = field(init=False, default_factory=list) - - def set_param_names(self, keys: Iterable[str]) -> None: - self.example_params = [str(key) for key in keys] - - def add_example(self, values: Sequence[str]) -> None: - self.examples.append(values) - - def as_contexts(self) -> Iterable[dict[str, Any]]: - if not self.examples: - return - - header, rows = self.example_params, self.examples - - for row in rows: - assert len(header) == len(row) - yield dict(zip(header, row)) - - def __bool__(self) -> bool: - return bool(self.examples) - +def dict_to_feature(abs_filename: str, rel_filename: str, data: dict) -> Feature: + def get_tag_names(tag_data: list[dict]) -> set[str]: + return {tag["name"].lstrip("@") for tag in tag_data} + + def get_step_type(keyword: str) -> str | None: + return { + "given": GIVEN, + "when": WHEN, + "then": THEN, + }.get(keyword) + + def parse_steps(steps_data: list[dict]) -> list[Step]: + steps = [] + current_step_type = None + for step_data in steps_data: + keyword = step_data["keyword"].strip().lower() + current_step_type = get_step_type(keyword) or current_step_type + name = strip_comments(step_data["text"]) + if "docString" in step_data: + doc_string = textwrap.dedent(step_data["docString"]["content"]) + name = f"{name}\n{doc_string}" + steps.append( + Step( + name=name, + type=current_step_type, + indent=step_data["location"]["column"] - 1, + line_number=step_data["location"]["line"], + keyword=keyword.title(), + ) + ) + return steps + + def parse_scenario(scenario_data: dict, feature: Feature) -> ScenarioTemplate: + scenario = ScenarioTemplate( + feature=feature, + name=strip_comments(scenario_data["name"]), + line_number=scenario_data["location"]["line"], + templated=False, + tags=get_tag_names(scenario_data["tags"]), + description=textwrap.dedent(scenario_data.get("description", "")), + ) + for step in parse_steps(scenario_data["steps"]): + scenario.add_step(step) + + if "examples" in scenario_data: + for example_data in scenario_data["examples"]: + examples = Examples( + line_number=example_data["location"]["line"], + name=example_data["name"], + ) + param_names = [cell["value"] for cell in example_data["tableHeader"]["cells"]] + examples.set_param_names(param_names) + for row in example_data["tableBody"]: + values = [cell["value"] or "" for cell in row["cells"]] + examples.add_example(values) + scenario.examples = examples + + return scenario + + def parse_background(background_data: dict, feature: Feature) -> Background: + background = Background( + feature=feature, + line_number=background_data["location"]["line"], + ) + background.steps = parse_steps(background_data["steps"]) + return background -def get_tags(line: str | None) -> set[str]: - """Get tags out of the given line. + feature_data = data["feature"] + feature = Feature( + scenarios=OrderedDict(), + filename=abs_filename, + rel_filename=rel_filename, + name=strip_comments(feature_data["name"]), + tags=get_tag_names(feature_data["tags"]), + background=None, + line_number=feature_data["location"]["line"], + description=textwrap.dedent(feature_data.get("description", "")), + ) - :param str line: Feature file text line. + for child in feature_data["children"]: + if "background" in child: + feature.background = parse_background(child["background"], feature) + elif "scenario" in child: + scenario = parse_scenario(child["scenario"], feature) + feature.scenarios[scenario.name] = scenario - :return: List of tags. - """ - if not line or not line.strip().startswith("@"): - return set() - return {tag.lstrip("@") for tag in line.strip().split(" @") if len(tag) > 1} + return feature diff --git a/src/pytest_bdd/scenario.py b/src/pytest_bdd/scenario.py index 709288139..80c6a0283 100644 --- a/src/pytest_bdd/scenario.py +++ b/src/pytest_bdd/scenario.py @@ -17,7 +17,7 @@ import logging import os import re -from typing import TYPE_CHECKING, Any, Callable, Iterable, Iterator, TypeVar, cast +from typing import TYPE_CHECKING, Any, Callable, Iterable, Iterator, Optional, TypeVar, cast import pytest from _pytest.fixtures import FixtureDef, FixtureManager, FixtureRequest, call_fixture_func @@ -90,7 +90,7 @@ def iterparentnodeids(nodeid: str) -> Iterator[str]: """ SEP = "/" pos = 0 - first_colons: Optional[int] = nodeid.find("::") + first_colons: int | None = nodeid.find("::") if first_colons == -1: first_colons = None # The root Session node - always present. @@ -312,6 +312,7 @@ def scenario( :param str feature_name: Feature file name. Absolute or relative to the configured feature base path. :param str scenario_name: Scenario name. :param str encoding: Feature file encoding. + :param features_base_dir: Optional base dir location for locating feature files. If not set, it will try and resolve using property set in .ini file, then the caller_module_path. """ __tracebackhide__ = True scenario_name = scenario_name @@ -347,7 +348,7 @@ def get_features_base_dir(caller_module_path: str) -> str: def get_from_ini(key: str, default: str) -> str: """Get value from ini config. Return default if value has not been set. - Use if the default value is dynamic. Otherwise set default on addini call. + Use if the default value is dynamic. Otherwise, set default on addini call. """ config = CONFIG_STACK[-1] value = config.getini(key) diff --git a/src/pytest_bdd/steps.py b/src/pytest_bdd/steps.py index 7642a6e84..81967fa11 100644 --- a/src/pytest_bdd/steps.py +++ b/src/pytest_bdd/steps.py @@ -43,10 +43,8 @@ def _(article): from typing import Any, Callable, Iterable, Literal, TypeVar import pytest -from _pytest.fixtures import FixtureRequest from typing_extensions import ParamSpec -from . import compat from .parser import Step from .parsers import StepParser, get_parser from .types import GIVEN, THEN, WHEN diff --git a/src/pytest_bdd/types.py b/src/pytest_bdd/types.py index 8faf940a4..66f20df68 100644 --- a/src/pytest_bdd/types.py +++ b/src/pytest_bdd/types.py @@ -2,16 +2,8 @@ from __future__ import annotations -FEATURE = "feature" -SCENARIO_OUTLINE = "scenario outline" -EXAMPLES = "examples" -EXAMPLES_HEADERS = "example headers" -EXAMPLE_LINE = "example line" -SCENARIO = "scenario" -BACKGROUND = "background" GIVEN = "given" WHEN = "when" THEN = "then" -TAG = "tag" STEP_TYPES = (GIVEN, WHEN, THEN) diff --git a/tests/feature/test_background.py b/tests/feature/test_background.py index be0490e83..4f7fc0c86 100644 --- a/tests/feature/test_background.py +++ b/tests/feature/test_background.py @@ -2,14 +2,16 @@ import textwrap -FEATURE = """\ +FEATURE = '''\ Feature: Background support Background: Given foo has a value "bar" And a background step with multiple lines: + """ one two + """ Scenario: Basic usage @@ -21,7 +23,7 @@ Then foo should have value "dummy" And foo should not have value "bar" -""" +''' STEPS = r"""\ import re diff --git a/tests/feature/test_multiline.py b/tests/feature/test_multiline.py index 2d531b5d5..ff407e487 100644 --- a/tests/feature/test_multiline.py +++ b/tests/feature/test_multiline.py @@ -24,52 +24,7 @@ ''' ), "Some\n\nExtra\nLines", - ), - ( - textwrap.dedent( - """\ - Feature: Multiline - Scenario: Multiline step using sub indentation - Given I have a step with: - Some - - Extra - Lines - Then the text should be parsed with correct indentation - """ - ), - "Some\n\nExtra\nLines", - ), - ( - textwrap.dedent( - """\ - Feature: Multiline - Scenario: Multiline step using sub indentation - Given I have a step with: - Some - - Extra - Lines - - Then the text should be parsed with correct indentation - """ - ), - " Some\n\n Extra\nLines", - ), - ( - textwrap.dedent( - """\ - Feature: Multiline - Scenario: Multiline step using sub indentation - Given I have a step with: - Some - Extra - Lines - - """ - ), - "Some\nExtra\nLines", - ), + ) ], ) def test_multiline(pytester, feature_text, expected_text): @@ -104,52 +59,3 @@ def _(text): ) result = pytester.runpytest() result.assert_outcomes(passed=1) - - -def test_multiline_wrong_indent(pytester): - """Multiline step using sub indentation wrong indent.""" - - pytester.makefile( - ".feature", - multiline=textwrap.dedent( - """\ - - Feature: Multiline - Scenario: Multiline step using sub indentation wrong indent - Given I have a step with: - Some - - Extra - Lines - Then the text should be parsed with correct indentation - - """ - ), - ) - - pytester.makepyfile( - textwrap.dedent( - """\ - from pytest_bdd import parsers, given, then, scenario - - - @scenario("multiline.feature", "Multiline step using sub indentation wrong indent") - def test_multiline(request): - pass - - - @given(parsers.parse("I have a step with:\\n{{text}}"), target_fixture="text") - def _(text): - return text - - - @then("the text should be parsed with correct indentation") - def _(text): - assert text == expected_text - - """ - ) - ) - result = pytester.runpytest() - result.assert_outcomes(failed=1) - result.stdout.fnmatch_lines("*StepDefinitionNotFoundError: Step definition is not found:*") diff --git a/tests/feature/test_no_scenario.py b/tests/feature/test_no_scenario.py index f3bcd7d3c..5eb68e11c 100644 --- a/tests/feature/test_no_scenario.py +++ b/tests/feature/test_no_scenario.py @@ -27,4 +27,4 @@ def test_no_scenarios(pytester): ) ) result = pytester.runpytest() - result.stdout.fnmatch_lines(["*FeatureError: Step definition outside of a Scenario or a Background.*"]) + result.stdout.fnmatch_lines(["*FeatureError*"]) diff --git a/tests/feature/test_outline.py b/tests/feature/test_outline.py index c8bfe9c48..b1a635bc9 100644 --- a/tests/feature/test_outline.py +++ b/tests/feature/test_outline.py @@ -171,7 +171,7 @@ def test_outline_with_escaped_pipes(pytester): pytester.makefile( ".feature", outline=textwrap.dedent( - r"""\ + r""" Feature: Outline With Special characters Scenario Outline: Outline with escaped pipe character @@ -217,6 +217,6 @@ def _(string): r"bork |", r"bork||bork", r"|", - r"bork \\", - r"bork \\|", + "bork \\", + "bork \\|", ] diff --git a/tests/feature/test_scenario.py b/tests/feature/test_scenario.py index f494d8cef..c23d5e040 100644 --- a/tests/feature/test_scenario.py +++ b/tests/feature/test_scenario.py @@ -146,49 +146,3 @@ def _(): ) result = pytester.runpytest_subprocess(*pytest_params) result.assert_outcomes(passed=1) - - -def test_angular_brakets_are_not_parsed(pytester): - """Test that angular brackets are not parsed for "Scenario"s. - - (They should be parsed only when used in "Scenario Outline") - - """ - pytester.makefile( - ".feature", - simple=""" - Feature: Simple feature - Scenario: Simple scenario - Given I have a - Then pass - - Scenario Outline: Outlined scenario - Given I have a templated - Then pass - - Examples: - | foo | - | bar | - """, - ) - pytester.makepyfile( - """ - from pytest_bdd import scenarios, given, then, parsers - - scenarios("simple.feature") - - @given("I have a ") - def _(): - return "tag" - - @given(parsers.parse("I have a templated {foo}")) - def _(foo): - return "foo" - - @then("pass") - def _(): - pass - """ - ) - result = pytester.runpytest() - result.assert_outcomes(passed=2) diff --git a/tests/feature/test_scenarios.py b/tests/feature/test_scenarios.py index ccfcf14a2..9e0407c4f 100644 --- a/tests/feature/test_scenarios.py +++ b/tests/feature/test_scenarios.py @@ -26,6 +26,7 @@ def _(): features.joinpath("test.feature").write_text( textwrap.dedent( """ +Feature: Test scenarios Scenario: Test scenario Given I have a bar """ @@ -37,6 +38,7 @@ def _(): subfolder.joinpath("test.feature").write_text( textwrap.dedent( """ +Feature: Test scenarios Scenario: Test subfolder scenario Given I have a bar diff --git a/tests/feature/test_steps.py b/tests/feature/test_steps.py index 30b731c0a..95a3a9893 100644 --- a/tests/feature/test_steps.py +++ b/tests/feature/test_steps.py @@ -354,6 +354,7 @@ def test_step_hooks(pytester): pytester.makefile( ".feature", test=""" +Feature: StepHandler hooks Scenario: When step has hook on failure Given I have a bar When it fails @@ -471,16 +472,21 @@ def test_step_trace(pytester): pytester.makefile( ".feature", test=""" - Scenario: When step has failure - Given I have a bar - When it fails + Feature: StepHandler hooks + Scenario: When step has hook on failure + Given I have a bar + When it fails - Scenario: When step is not found - Given not found + Scenario: When step's dependency a has failure + Given I have a bar + When it's dependency fails - Scenario: When step validation error happens - Given foo - And foo + Scenario: When step is not found + Given not found + + Scenario: When step validation error happens + Given foo + And foo """, ) pytester.makepyfile( @@ -489,19 +495,27 @@ def test_step_trace(pytester): from pytest_bdd import given, when, scenario @given('I have a bar') - def _(): + def i_have_bar(): return 'bar' @when('it fails') - def _(): + def when_it_fails(): raise Exception('when fails') - @scenario('test.feature', 'When step has failure') - def test_when_fails_inline(): + @pytest.fixture + def dependency(): + raise Exception('dependency fails') + + @when("it's dependency fails") + def when_dependency_fails(dependency): pass - @scenario('test.feature', 'When step has failure') - def test_when_fails_decorated(): + @scenario('test.feature', "When step's dependency a has failure") + def test_when_dependency_fails(): + pass + + @scenario('test.feature', 'When step has hook on failure') + def test_when_fails(): pass @scenario('test.feature', 'When step is not found') @@ -509,7 +523,7 @@ def test_when_not_found(): pass @when('foo') - def _(): + def foo(): return 'foo' @scenario('test.feature', 'When step validation error happens') @@ -517,25 +531,47 @@ def test_when_step_validation_error(): pass """ ) - result = pytester.runpytest("-k test_when_fails_inline", "-vv") - result.assert_outcomes(failed=1) - result.stdout.fnmatch_lines(["*test_when_fails_inline*FAILED"]) - assert "INTERNALERROR" not in result.stdout.str() - - result = pytester.runpytest("-k test_when_fails_decorated", "-vv") - result.assert_outcomes(failed=1) - result.stdout.fnmatch_lines(["*test_when_fails_decorated*FAILED"]) - assert "INTERNALERROR" not in result.stdout.str() - - result = pytester.runpytest("-k test_when_not_found", "-vv") - result.assert_outcomes(failed=1) - result.stdout.fnmatch_lines(["*test_when_not_found*FAILED"]) - assert "INTERNALERROR" not in result.stdout.str() - - result = pytester.runpytest("-k test_when_step_validation_error", "-vv") - result.assert_outcomes(failed=1) - result.stdout.fnmatch_lines(["*test_when_step_validation_error*FAILED"]) - assert "INTERNALERROR" not in result.stdout.str() + reprec = pytester.inline_run("-k test_when_fails") + reprec.assertoutcome(failed=1) + + calls = reprec.getcalls("pytest_bdd_before_scenario") + assert calls[0].request + + calls = reprec.getcalls("pytest_bdd_after_scenario") + assert calls[0].request + + calls = reprec.getcalls("pytest_bdd_before_step") + assert calls[0].request + + calls = reprec.getcalls("pytest_bdd_before_step_call") + assert calls[0].request + + calls = reprec.getcalls("pytest_bdd_after_step") + assert calls[0].request + + calls = reprec.getcalls("pytest_bdd_step_error") + assert calls[0].request + + reprec = pytester.inline_run("-k test_when_not_found") + reprec.assertoutcome(failed=1) + + calls = reprec.getcalls("pytest_bdd_step_func_lookup_error") + assert calls[0].request + + reprec = pytester.inline_run("-k test_when_step_validation_error") + reprec.assertoutcome(failed=1) + + reprec = pytester.inline_run("-k test_when_dependency_fails", "-vv") + reprec.assertoutcome(failed=1) + + calls = reprec.getcalls("pytest_bdd_before_step") + assert len(calls) == 2 + + calls = reprec.getcalls("pytest_bdd_before_step_call") + assert len(calls) == 1 + + calls = reprec.getcalls("pytest_bdd_step_error") + assert calls[0].request def test_steps_with_yield(pytester): diff --git a/tests/feature/test_tags.py b/tests/feature/test_tags.py index f1dea8035..20a64dc8e 100644 --- a/tests/feature/test_tags.py +++ b/tests/feature/test_tags.py @@ -4,8 +4,6 @@ import pytest -from pytest_bdd.parser import get_tags - def test_tags_selector(pytester): """Test tests selection by tags.""" @@ -162,51 +160,6 @@ def _(): result.stdout.fnmatch_lines(["*= 1 skipped, 1 xpassed * =*"]) -def test_tag_with_spaces(pytester): - pytester.makefile( - ".ini", - pytest=textwrap.dedent( - """ - [pytest] - markers = - test with spaces - """ - ), - ) - pytester.makeconftest( - """ - import pytest - - @pytest.hookimpl(tryfirst=True) - def pytest_bdd_apply_tag(tag, function): - assert tag == 'test with spaces' - """ - ) - pytester.makefile( - ".feature", - test=""" - Feature: Tag with spaces - - @test with spaces - Scenario: Tags - Given I have a bar - """, - ) - pytester.makepyfile( - """ - from pytest_bdd import given, scenarios - - @given('I have a bar') - def _(): - return 'bar' - - scenarios('test.feature') - """ - ) - result = pytester.runpytest_subprocess() - result.stdout.fnmatch_lines(["*= 1 passed * =*"]) - - def test_at_in_scenario(pytester): pytester.makefile( ".feature", @@ -238,19 +191,3 @@ def _(): strict_option = "--strict-markers" result = pytester.runpytest_subprocess(strict_option) result.stdout.fnmatch_lines(["*= 2 passed * =*"]) - - -@pytest.mark.parametrize( - "line, expected", - [ - ("@foo @bar", {"foo", "bar"}), - ("@with spaces @bar", {"with spaces", "bar"}), - ("@double @double", {"double"}), - (" @indented", {"indented"}), - (None, set()), - ("foobar", set()), - ("", set()), - ], -) -def test_get_tags(line, expected): - assert get_tags(line) == expected diff --git a/tests/feature/test_wrong.py b/tests/feature/test_wrong.py index f8c405439..002cd671c 100644 --- a/tests/feature/test_wrong.py +++ b/tests/feature/test_wrong.py @@ -50,4 +50,4 @@ def test_wrong(): ) result = pytester.runpytest() result.assert_outcomes(errors=1) - result.stdout.fnmatch_lines("*FeatureError: Multiple features are not allowed in a single feature file.*") + result.stdout.fnmatch_lines("*FeatureError: *") diff --git a/tests/generation/test_generate_missing.py b/tests/generation/test_generate_missing.py index d6be9be6f..4d02e0f4c 100644 --- a/tests/generation/test_generate_missing.py +++ b/tests/generation/test_generate_missing.py @@ -29,11 +29,9 @@ def test_generate_missing(pytester): Scenario: Scenario tests which are already bound to the tests stay as is Given I have a bar - Scenario: Code is generated for scenarios which are not bound to any tests Given I have a bar - Scenario: Code is generated for scenario steps which are not yet defined(implemented) Given I have a custom bar """ @@ -80,10 +78,6 @@ def test_missing_steps(): ] ) - result.stdout.fnmatch_lines( - ['Step Given "I have a foobar" is not defined in the background of the feature "Missing code generation" *'] - ) - result.stdout.fnmatch_lines(["Please place the code above to the test file(s):"]) diff --git a/tests/steps/test_common.py b/tests/steps/test_common.py index 7108aaab5..1342b6d25 100644 --- a/tests/steps/test_common.py +++ b/tests/steps/test_common.py @@ -316,25 +316,3 @@ def _(n): objects = collect_dumped_objects(result) assert objects == ["foo", ("foo parametrized", 1), "foo", ("foo parametrized", 2), "foo", ("foo parametrized", 3)] - - -def test_step_name_is_cached(): - """Test that the step name is cached and not re-computed eache time.""" - step = parser.Step(name="step name", type="given", indent=8, line_number=3, keyword="Given") - assert step.name == "step name" - - # manipulate the step name directly and validate the cache value is still returned - step._name = "incorrect step name" - assert step.name == "step name" - - # change the step name using the property and validate the cache has been invalidated - step.name = "new step name" - assert step.name == "new step name" - - # manipulate the step lines and validate the cache value is still returned - step.lines.append("step line 1") - assert step.name == "new step name" - - # add a step line and validate the cache has been invalidated - step.add_line("step line 2") - assert step.name == "new step name\nstep line 1\nstep line 2" From 65c06e42569da133511cfa64b10ab86c65c280ad Mon Sep 17 00:00:00 2001 From: Jason Allen Date: Thu, 5 Sep 2024 13:24:54 +0100 Subject: [PATCH 02/17] Improve docstrings in parser.py --- src/pytest_bdd/parser.py | 206 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 204 insertions(+), 2 deletions(-) diff --git a/src/pytest_bdd/parser.py b/src/pytest_bdd/parser.py index 18a2928d8..c6d819a52 100644 --- a/src/pytest_bdd/parser.py +++ b/src/pytest_bdd/parser.py @@ -20,14 +20,33 @@ def strip_comments(line: str) -> str: - """Remove comments from a line of text.""" + """Remove comments from a line of text. + + Args: + line (str): The line of text from which to remove comments. + + Returns: + str: The line of text without comments, with leading and trailing whitespace removed. + """ if res := COMMENT_RE.search(line): line = line[: res.start()] return line.strip() def parse_feature(basedir: str, filename: str, encoding: str = "utf-8") -> Feature: - """Parse a feature file into a Feature object.""" + """Parse a feature file into a Feature object. + + Args: + basedir (str): The base directory of the feature file. + filename (str): The name of the feature file. + encoding (str): The encoding of the feature file (default is "utf-8"). + + Returns: + Feature: A Feature object representing the parsed feature file. + + Raises: + FeatureError: If there is an error parsing the feature file. + """ abs_filename = os.path.abspath(os.path.join(basedir, filename)) rel_filename = os.path.join(os.path.basename(basedir), filename) with open(abs_filename, encoding=encoding) as f: @@ -46,6 +65,19 @@ def parse_feature(basedir: str, filename: str, encoding: str = "utf-8") -> Featu @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 (Optional[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: OrderedDict[str, ScenarioTemplate] filename: str rel_filename: str @@ -58,28 +90,70 @@ class Feature: @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) 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) -> Iterable[dict[str, Any]]: + """Generate contexts for the examples. + + Yields: + Dict[str, Any]: 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 ScenarioTemplate: + """Represents a scenario template within a feature. + + Attributes: + feature (Feature): The feature to which this scenario belongs. + 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. + """ + feature: Feature name: str line_number: int @@ -90,14 +164,32 @@ class ScenarioTemplate: examples: Examples | None = field(default_factory=Examples) 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 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.feature.background.steps if self.feature.background else []) + self._steps def render(self, context: Mapping[str, Any]) -> Scenario: + """Render the scenario with the given context. + + Args: + context (Mapping[str, Any]): The context for rendering steps. + + Returns: + Scenario: A Scenario object with steps rendered based on the context. + """ background_steps = self.feature.background.steps if self.feature.background else [] scenario_steps = [ Step( @@ -122,6 +214,17 @@ def render(self, context: Mapping[str, Any]) -> Scenario: @dataclass(eq=False) class Scenario: + """Represents a scenario with steps. + + Attributes: + feature (Feature): The feature to which this scenario belongs. + 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 name: str line_number: int @@ -132,6 +235,20 @@ class Scenario: @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). + lines (List[str]): Additional lines for the step (internal use only). + """ + type: str _name: str line_number: int @@ -143,6 +260,15 @@ class Step: lines: list[str] = field(init=False, default_factory=list) def __init__(self, name: str, type: str, indent: int, line_number: int, keyword: str) -> 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 @@ -150,13 +276,32 @@ def __init__(self, name: str, type: str, indent: int, line_number: int, keyword: self.keyword = keyword 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}"' @property def params(self) -> tuple[str, ...]: + """Get the parameters in the step name. + + Returns: + Tuple[str, ...]: A tuple of parameter names found in the step name. + """ return tuple(frozenset(STEP_PARAM_RE.findall(self.name))) def render(self, context: Mapping[str, Any]) -> str: + """Render the step name with the given context. + + Args: + context (Mapping[str, Any]): The context for rendering the step name. + + Returns: + str: The rendered step name with parameters replaced by their values from the context. + """ + def replacer(m: re.Match) -> str: varname = m.group(1) return str(context.get(varname, f"")) @@ -166,20 +311,60 @@ def replacer(m: re.Match) -> str: @dataclass(eq=False) class Background: + """Represents the background steps for a feature. + + Attributes: + feature (Feature): The feature to which this background belongs. + line_number (int): The line number where the background starts in the file. + steps (List[Step]): The list of steps in the background. + """ + feature: Feature 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) def dict_to_feature(abs_filename: str, rel_filename: str, data: dict) -> Feature: + """Convert a dictionary representation of a feature into a Feature object. + + Args: + abs_filename (str): The absolute path of the feature file. + rel_filename (str): The relative path of the feature file. + data (dict): The dictionary containing the feature data. + + Returns: + Feature: A Feature object representing the parsed feature data. + """ + def get_tag_names(tag_data: list[dict]) -> set[str]: + """Extract tag names from tag data. + + Args: + tag_data (List[dict]): The tag data to extract names from. + + Returns: + set[str]: A set of tag names. + """ return {tag["name"].lstrip("@") for tag in tag_data} def get_step_type(keyword: str) -> str | None: + """Map a step keyword to its corresponding type. + + Args: + keyword (str): The keyword for the step (e.g., 'given', 'when', 'then'). + + Returns: + str | None: The type of the step, or None if the keyword is unknown. + """ return { "given": GIVEN, "when": WHEN, @@ -187,6 +372,14 @@ def get_step_type(keyword: str) -> str | None: }.get(keyword) def parse_steps(steps_data: list[dict]) -> 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. + """ steps = [] current_step_type = None for step_data in steps_data: @@ -208,6 +401,15 @@ def parse_steps(steps_data: list[dict]) -> list[Step]: return steps def parse_scenario(scenario_data: dict, feature: Feature) -> 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. + + Returns: + ScenarioTemplate: A ScenarioTemplate object representing the parsed scenario. + """ scenario = ScenarioTemplate( feature=feature, name=strip_comments(scenario_data["name"]), From abe5e7935651bf6ab23cc88d1e450807734b4343 Mon Sep 17 00:00:00 2001 From: Jason Allen Date: Thu, 5 Sep 2024 13:25:20 +0100 Subject: [PATCH 03/17] Improve docstrings in parser.py --- src/pytest_bdd/parser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pytest_bdd/parser.py b/src/pytest_bdd/parser.py index c6d819a52..7d6bade20 100644 --- a/src/pytest_bdd/parser.py +++ b/src/pytest_bdd/parser.py @@ -6,7 +6,7 @@ import textwrap from collections import OrderedDict from dataclasses import dataclass, field -from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple +from typing import Any, Iterable, List, Mapping, Optional, Sequence from gherkin.errors import CompositeParserException from gherkin.parser import Parser From 240ac6d2a89d9618aee4fc0668c8d5dd29506b1c Mon Sep 17 00:00:00 2001 From: Jason Allen Date: Thu, 5 Sep 2024 18:41:47 +0100 Subject: [PATCH 04/17] Fix issues and create a FeatureParser class to consolidate parsing logic --- src/pytest_bdd/feature.py | 4 +- src/pytest_bdd/parser.py | 136 ++++++++++++++++----------------- tests/feature/test_scenario.py | 46 +++++++++++ tests/feature/test_steps.py | 2 +- 4 files changed, 116 insertions(+), 72 deletions(-) diff --git a/src/pytest_bdd/feature.py b/src/pytest_bdd/feature.py index 54a15e3af..ee4bd90b8 100644 --- a/src/pytest_bdd/feature.py +++ b/src/pytest_bdd/feature.py @@ -29,7 +29,7 @@ import glob import os.path -from .parser import Feature, parse_feature +from .parser import Feature, FeatureParser # Global features dictionary features: dict[str, Feature] = {} @@ -52,7 +52,7 @@ def get_feature(base_path: str, filename: str, encoding: str = "utf-8") -> Featu full_name = os.path.abspath(os.path.join(base_path, filename)) feature = features.get(full_name) if not feature: - feature = parse_feature(base_path, filename, encoding=encoding) + feature = FeatureParser(base_path, filename, encoding).parse() features[full_name] = feature return feature diff --git a/src/pytest_bdd/parser.py b/src/pytest_bdd/parser.py index 7d6bade20..fca4fa600 100644 --- a/src/pytest_bdd/parser.py +++ b/src/pytest_bdd/parser.py @@ -6,7 +6,7 @@ import textwrap from collections import OrderedDict from dataclasses import dataclass, field -from typing import Any, Iterable, List, Mapping, Optional, Sequence +from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence from gherkin.errors import CompositeParserException from gherkin.parser import Parser @@ -33,36 +33,6 @@ def strip_comments(line: str) -> str: return line.strip() -def parse_feature(basedir: str, filename: str, encoding: str = "utf-8") -> Feature: - """Parse a feature file into a Feature object. - - Args: - basedir (str): The base directory of the feature file. - filename (str): The name of the feature file. - encoding (str): The encoding of the feature file (default is "utf-8"). - - Returns: - Feature: A Feature object representing the parsed feature file. - - Raises: - FeatureError: If there is an error parsing the feature file. - """ - abs_filename = os.path.abspath(os.path.join(basedir, filename)) - rel_filename = os.path.join(os.path.basename(basedir), filename) - with open(abs_filename, encoding=encoding) as f: - file_contents = f.read() - try: - gherkin_document = Parser().parse(TokenScanner(file_contents)) - except CompositeParserException as e: - raise FeatureError( - e.args[0], - e.errors[0].location["line"], - linecache.getline(abs_filename, e.errors[0].location["line"]).rstrip("\n"), - abs_filename, - ) from e - return dict_to_feature(abs_filename, rel_filename, gherkin_document) - - @dataclass(eq=False) class Feature: """Represents a feature parsed from a feature file. @@ -293,18 +263,19 @@ def params(self) -> tuple[str, ...]: return tuple(frozenset(STEP_PARAM_RE.findall(self.name))) def render(self, context: Mapping[str, Any]) -> str: - """Render the step name with the given context. + """Render the step name with the given context, but avoid replacing text inside angle brackets if context is missing. Args: context (Mapping[str, Any]): The context for rendering the step name. Returns: - str: The rendered step name with parameters replaced by their values from the context. + str: The rendered step name with parameters replaced only if they exist in the context. """ def replacer(m: re.Match) -> str: varname = m.group(1) - return str(context.get(varname, f"")) + # If the context contains the variable, replace it. Otherwise, leave it unchanged. + return str(context.get(varname, f"<{varname}>")) return STEP_PARAM_RE.sub(replacer, self.name) @@ -333,18 +304,21 @@ def add_step(self, step: Step) -> None: self.steps.append(step) -def dict_to_feature(abs_filename: str, rel_filename: str, data: dict) -> Feature: - """Convert a dictionary representation of a feature into a Feature object. +class FeatureParser: + """Converts a feature file into a Feature object. Args: - abs_filename (str): The absolute path of the feature file. - rel_filename (str): The relative path of the feature file. - data (dict): The dictionary containing the feature data. - - Returns: - Feature: A Feature object representing the parsed feature data. + 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 + + @staticmethod def get_tag_names(tag_data: list[dict]) -> set[str]: """Extract tag names from tag data. @@ -356,6 +330,7 @@ def get_tag_names(tag_data: list[dict]) -> set[str]: """ return {tag["name"].lstrip("@") for tag in tag_data} + @staticmethod def get_step_type(keyword: str) -> str | None: """Map a step keyword to its corresponding type. @@ -371,7 +346,7 @@ def get_step_type(keyword: str) -> str | None: "then": THEN, }.get(keyword) - def parse_steps(steps_data: list[dict]) -> list[Step]: + def parse_steps(self, steps_data: list[dict]) -> list[Step]: """Parse a list of step data into Step objects. Args: @@ -384,7 +359,7 @@ def parse_steps(steps_data: list[dict]) -> list[Step]: current_step_type = None for step_data in steps_data: keyword = step_data["keyword"].strip().lower() - current_step_type = get_step_type(keyword) or current_step_type + current_step_type = self.get_step_type(keyword) or current_step_type name = strip_comments(step_data["text"]) if "docString" in step_data: doc_string = textwrap.dedent(step_data["docString"]["content"]) @@ -400,7 +375,7 @@ def parse_steps(steps_data: list[dict]) -> list[Step]: ) return steps - def parse_scenario(scenario_data: dict, feature: Feature) -> ScenarioTemplate: + def parse_scenario(self, scenario_data: dict, feature: Feature) -> ScenarioTemplate: """Parse a scenario data dictionary into a ScenarioTemplate object. Args: @@ -415,10 +390,10 @@ def parse_scenario(scenario_data: dict, feature: Feature) -> ScenarioTemplate: name=strip_comments(scenario_data["name"]), line_number=scenario_data["location"]["line"], templated=False, - tags=get_tag_names(scenario_data["tags"]), + tags=self.get_tag_names(scenario_data["tags"]), description=textwrap.dedent(scenario_data.get("description", "")), ) - for step in parse_steps(scenario_data["steps"]): + for step in self.parse_steps(scenario_data["steps"]): scenario.add_step(step) if "examples" in scenario_data: @@ -436,31 +411,54 @@ def parse_scenario(scenario_data: dict, feature: Feature) -> ScenarioTemplate: return scenario - def parse_background(background_data: dict, feature: Feature) -> Background: + def parse_background(self, background_data: dict, feature: Feature) -> Background: background = Background( feature=feature, line_number=background_data["location"]["line"], ) - background.steps = parse_steps(background_data["steps"]) + background.steps = self.parse_steps(background_data["steps"]) return background - feature_data = data["feature"] - feature = Feature( - scenarios=OrderedDict(), - filename=abs_filename, - rel_filename=rel_filename, - name=strip_comments(feature_data["name"]), - tags=get_tag_names(feature_data["tags"]), - background=None, - line_number=feature_data["location"]["line"], - description=textwrap.dedent(feature_data.get("description", "")), - ) - - for child in feature_data["children"]: - if "background" in child: - feature.background = parse_background(child["background"], feature) - elif "scenario" in child: - scenario = parse_scenario(child["scenario"], feature) - feature.scenarios[scenario.name] = scenario - - return feature + def _parse_feature_file(self) -> dict: + """Parse a feature file into a Feature object. + + Returns: + Dict: A Gherkin document representation of the feature file. + + Raises: + FeatureError: If there is an error parsing the feature file. + """ + with open(self.abs_filename, encoding=self.encoding) as f: + file_contents = f.read() + try: + return Parser().parse(TokenScanner(file_contents)) + except CompositeParserException as e: + raise FeatureError( + e.args[0], + e.errors[0].location["line"], + linecache.getline(self.abs_filename, e.errors[0].location["line"]).rstrip("\n"), + self.abs_filename, + ) from e + + def parse(self): + data = self._parse_feature_file() + feature_data = data["feature"] + feature = Feature( + scenarios=OrderedDict(), + filename=self.abs_filename, + rel_filename=self.rel_filename, + name=strip_comments(feature_data["name"]), + tags=self.get_tag_names(feature_data["tags"]), + background=None, + line_number=feature_data["location"]["line"], + description=textwrap.dedent(feature_data.get("description", "")), + ) + + for child in feature_data["children"]: + if "background" in child: + feature.background = self.parse_background(child["background"], feature) + elif "scenario" in child: + scenario = self.parse_scenario(child["scenario"], feature) + feature.scenarios[scenario.name] = scenario + + return feature diff --git a/tests/feature/test_scenario.py b/tests/feature/test_scenario.py index c23d5e040..669d45caf 100644 --- a/tests/feature/test_scenario.py +++ b/tests/feature/test_scenario.py @@ -146,3 +146,49 @@ def _(): ) result = pytester.runpytest_subprocess(*pytest_params) result.assert_outcomes(passed=1) + + +def test_angular_brackets_are_not_parsed(pytester): + """Test that angular brackets are not parsed for "Scenario"s. + + (They should be parsed only when used in "Scenario Outline") + + """ + pytester.makefile( + ".feature", + simple=""" + Feature: Simple feature + Scenario: Simple scenario + Given I have a + Then pass + + Scenario Outline: Outlined scenario + Given I have a templated + Then pass + + Examples: + | foo | + | bar | + """, + ) + pytester.makepyfile( + """ + from pytest_bdd import scenarios, given, then, parsers + + scenarios("simple.feature") + + @given("I have a ") + def _(): + return "tag" + + @given(parsers.parse("I have a templated {foo}")) + def _(foo): + return "foo" + + @then("pass") + def _(): + pass + """ + ) + result = pytester.runpytest() + result.assert_outcomes(passed=2) diff --git a/tests/feature/test_steps.py b/tests/feature/test_steps.py index 95a3a9893..94c11bcc3 100644 --- a/tests/feature/test_steps.py +++ b/tests/feature/test_steps.py @@ -523,7 +523,7 @@ def test_when_not_found(): pass @when('foo') - def foo(): + def _(): return 'foo' @scenario('test.feature', 'When step validation error happens') From e7b5326a43830c5eea0ab79585769347a79f6fce Mon Sep 17 00:00:00 2001 From: Jason Allen Date: Fri, 6 Sep 2024 10:34:17 +0100 Subject: [PATCH 05/17] Forgot to go back and implement the templated bool --- src/pytest_bdd/parser.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/pytest_bdd/parser.py b/src/pytest_bdd/parser.py index fca4fa600..2c0641cf7 100644 --- a/src/pytest_bdd/parser.py +++ b/src/pytest_bdd/parser.py @@ -295,7 +295,7 @@ class Background: steps: list[Step] = field(init=False, default_factory=list) def add_step(self, step: Step) -> None: - """Add a step to the background. + """Add a step to txhe background. Args: step (Step): The step to add. @@ -385,18 +385,19 @@ def parse_scenario(self, scenario_data: dict, feature: Feature) -> ScenarioTempl Returns: ScenarioTemplate: A ScenarioTemplate object representing the parsed scenario. """ + templated = "examples" in scenario_data scenario = ScenarioTemplate( feature=feature, name=strip_comments(scenario_data["name"]), line_number=scenario_data["location"]["line"], - templated=False, + templated=templated, tags=self.get_tag_names(scenario_data["tags"]), description=textwrap.dedent(scenario_data.get("description", "")), ) for step in self.parse_steps(scenario_data["steps"]): scenario.add_step(step) - if "examples" in scenario_data: + if templated: for example_data in scenario_data["examples"]: examples = Examples( line_number=example_data["location"]["line"], From 2f3e029200bc13b42dd98d03dc6f150ab1912fa5 Mon Sep 17 00:00:00 2001 From: Jason Allen Date: Fri, 6 Sep 2024 10:41:25 +0100 Subject: [PATCH 06/17] Remove unused import --- src/pytest_bdd/scenario.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pytest_bdd/scenario.py b/src/pytest_bdd/scenario.py index 80c6a0283..870ae014f 100644 --- a/src/pytest_bdd/scenario.py +++ b/src/pytest_bdd/scenario.py @@ -17,7 +17,7 @@ import logging import os import re -from typing import TYPE_CHECKING, Any, Callable, Iterable, Iterator, Optional, TypeVar, cast +from typing import TYPE_CHECKING, Any, Callable, Iterable, Iterator, TypeVar, cast import pytest from _pytest.fixtures import FixtureDef, FixtureManager, FixtureRequest, call_fixture_func From cc9b37fc7f108479ce26cf0b9b2638e88553f21f Mon Sep 17 00:00:00 2001 From: Jason Allen Date: Fri, 6 Sep 2024 15:49:49 +0100 Subject: [PATCH 07/17] Move Gherkin parsing to pydantic models for easier future reference of available data and implementing features. --- poetry.lock | 162 ++++++++++++++++++++++++++++++- pyproject.toml | 1 + src/pytest_bdd/gherkin_parser.py | 131 +++++++++++++++++++++++++ src/pytest_bdd/parser.py | 111 ++++++++++----------- 4 files changed, 343 insertions(+), 62 deletions(-) create mode 100644 src/pytest_bdd/gherkin_parser.py diff --git a/poetry.lock b/poetry.lock index f082c5147..2d77af476 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,19 @@ # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.9\""} + [[package]] name = "cachetools" version = "5.3.3" @@ -395,6 +409,130 @@ files = [ dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] +[[package]] +name = "pydantic" +version = "2.9.0" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic-2.9.0-py3-none-any.whl", hash = "sha256:f66a7073abd93214a20c5f7b32d56843137a7a2e70d02111f3be287035c45370"}, + {file = "pydantic-2.9.0.tar.gz", hash = "sha256:c7a8a9fdf7d100afa49647eae340e2d23efa382466a8d177efcd1381e9be5598"}, +] + +[package.dependencies] +annotated-types = ">=0.4.0" +pydantic-core = "2.23.2" +typing-extensions = [ + {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, + {version = ">=4.6.1", markers = "python_version < \"3.13\""}, +] +tzdata = {version = "*", markers = "python_version >= \"3.9\""} + +[package.extras] +email = ["email-validator (>=2.0.0)"] + +[[package]] +name = "pydantic-core" +version = "2.23.2" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic_core-2.23.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:7d0324a35ab436c9d768753cbc3c47a865a2cbc0757066cb864747baa61f6ece"}, + {file = "pydantic_core-2.23.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:276ae78153a94b664e700ac362587c73b84399bd1145e135287513442e7dfbc7"}, + {file = "pydantic_core-2.23.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:964c7aa318da542cdcc60d4a648377ffe1a2ef0eb1e996026c7f74507b720a78"}, + {file = "pydantic_core-2.23.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1cf842265a3a820ebc6388b963ead065f5ce8f2068ac4e1c713ef77a67b71f7c"}, + {file = "pydantic_core-2.23.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae90b9e50fe1bd115b24785e962b51130340408156d34d67b5f8f3fa6540938e"}, + {file = "pydantic_core-2.23.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ae65fdfb8a841556b52935dfd4c3f79132dc5253b12c0061b96415208f4d622"}, + {file = "pydantic_core-2.23.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c8aa40f6ca803f95b1c1c5aeaee6237b9e879e4dfb46ad713229a63651a95fb"}, + {file = "pydantic_core-2.23.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c53100c8ee5a1e102766abde2158077d8c374bee0639201f11d3032e3555dfbc"}, + {file = "pydantic_core-2.23.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d6b9dd6aa03c812017411734e496c44fef29b43dba1e3dd1fa7361bbacfc1354"}, + {file = "pydantic_core-2.23.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b18cf68255a476b927910c6873d9ed00da692bb293c5b10b282bd48a0afe3ae2"}, + {file = "pydantic_core-2.23.2-cp310-none-win32.whl", hash = "sha256:e460475719721d59cd54a350c1f71c797c763212c836bf48585478c5514d2854"}, + {file = "pydantic_core-2.23.2-cp310-none-win_amd64.whl", hash = "sha256:5f3cf3721eaf8741cffaf092487f1ca80831202ce91672776b02b875580e174a"}, + {file = "pydantic_core-2.23.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7ce8e26b86a91e305858e018afc7a6e932f17428b1eaa60154bd1f7ee888b5f8"}, + {file = "pydantic_core-2.23.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7e9b24cca4037a561422bf5dc52b38d390fb61f7bfff64053ce1b72f6938e6b2"}, + {file = "pydantic_core-2.23.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:753294d42fb072aa1775bfe1a2ba1012427376718fa4c72de52005a3d2a22178"}, + {file = "pydantic_core-2.23.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:257d6a410a0d8aeb50b4283dea39bb79b14303e0fab0f2b9d617701331ed1515"}, + {file = "pydantic_core-2.23.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c8319e0bd6a7b45ad76166cc3d5d6a36c97d0c82a196f478c3ee5346566eebfd"}, + {file = "pydantic_core-2.23.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7a05c0240f6c711eb381ac392de987ee974fa9336071fb697768dfdb151345ce"}, + {file = "pydantic_core-2.23.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d5b0ff3218858859910295df6953d7bafac3a48d5cd18f4e3ed9999efd2245f"}, + {file = "pydantic_core-2.23.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:96ef39add33ff58cd4c112cbac076726b96b98bb8f1e7f7595288dcfb2f10b57"}, + {file = "pydantic_core-2.23.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0102e49ac7d2df3379ef8d658d3bc59d3d769b0bdb17da189b75efa861fc07b4"}, + {file = "pydantic_core-2.23.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a6612c2a844043e4d10a8324c54cdff0042c558eef30bd705770793d70b224aa"}, + {file = "pydantic_core-2.23.2-cp311-none-win32.whl", hash = "sha256:caffda619099cfd4f63d48462f6aadbecee3ad9603b4b88b60cb821c1b258576"}, + {file = "pydantic_core-2.23.2-cp311-none-win_amd64.whl", hash = "sha256:6f80fba4af0cb1d2344869d56430e304a51396b70d46b91a55ed4959993c0589"}, + {file = "pydantic_core-2.23.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:4c83c64d05ffbbe12d4e8498ab72bdb05bcc1026340a4a597dc647a13c1605ec"}, + {file = "pydantic_core-2.23.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6294907eaaccf71c076abdd1c7954e272efa39bb043161b4b8aa1cd76a16ce43"}, + {file = "pydantic_core-2.23.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a801c5e1e13272e0909c520708122496647d1279d252c9e6e07dac216accc41"}, + {file = "pydantic_core-2.23.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cc0c316fba3ce72ac3ab7902a888b9dc4979162d320823679da270c2d9ad0cad"}, + {file = "pydantic_core-2.23.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6b06c5d4e8701ac2ba99a2ef835e4e1b187d41095a9c619c5b185c9068ed2a49"}, + {file = "pydantic_core-2.23.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82764c0bd697159fe9947ad59b6db6d7329e88505c8f98990eb07e84cc0a5d81"}, + {file = "pydantic_core-2.23.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b1a195efd347ede8bcf723e932300292eb13a9d2a3c1f84eb8f37cbbc905b7f"}, + {file = "pydantic_core-2.23.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b7efb12e5071ad8d5b547487bdad489fbd4a5a35a0fc36a1941517a6ad7f23e0"}, + {file = "pydantic_core-2.23.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:5dd0ec5f514ed40e49bf961d49cf1bc2c72e9b50f29a163b2cc9030c6742aa73"}, + {file = "pydantic_core-2.23.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:820f6ee5c06bc868335e3b6e42d7ef41f50dfb3ea32fbd523ab679d10d8741c0"}, + {file = "pydantic_core-2.23.2-cp312-none-win32.whl", hash = "sha256:3713dc093d5048bfaedbba7a8dbc53e74c44a140d45ede020dc347dda18daf3f"}, + {file = "pydantic_core-2.23.2-cp312-none-win_amd64.whl", hash = "sha256:e1895e949f8849bc2757c0dbac28422a04be031204df46a56ab34bcf98507342"}, + {file = "pydantic_core-2.23.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:da43cbe593e3c87d07108d0ebd73771dc414488f1f91ed2e204b0370b94b37ac"}, + {file = "pydantic_core-2.23.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:64d094ea1aa97c6ded4748d40886076a931a8bf6f61b6e43e4a1041769c39dd2"}, + {file = "pydantic_core-2.23.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:084414ffe9a85a52940b49631321d636dadf3576c30259607b75516d131fecd0"}, + {file = "pydantic_core-2.23.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:043ef8469f72609c4c3a5e06a07a1f713d53df4d53112c6d49207c0bd3c3bd9b"}, + {file = "pydantic_core-2.23.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3649bd3ae6a8ebea7dc381afb7f3c6db237fc7cebd05c8ac36ca8a4187b03b30"}, + {file = "pydantic_core-2.23.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6db09153d8438425e98cdc9a289c5fade04a5d2128faff8f227c459da21b9703"}, + {file = "pydantic_core-2.23.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5668b3173bb0b2e65020b60d83f5910a7224027232c9f5dc05a71a1deac9f960"}, + {file = "pydantic_core-2.23.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1c7b81beaf7c7ebde978377dc53679c6cba0e946426fc7ade54251dfe24a7604"}, + {file = "pydantic_core-2.23.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:ae579143826c6f05a361d9546446c432a165ecf1c0b720bbfd81152645cb897d"}, + {file = "pydantic_core-2.23.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:19f1352fe4b248cae22a89268720fc74e83f008057a652894f08fa931e77dced"}, + {file = "pydantic_core-2.23.2-cp313-none-win32.whl", hash = "sha256:e1a79ad49f346aa1a2921f31e8dbbab4d64484823e813a002679eaa46cba39e1"}, + {file = "pydantic_core-2.23.2-cp313-none-win_amd64.whl", hash = "sha256:582871902e1902b3c8e9b2c347f32a792a07094110c1bca6c2ea89b90150caac"}, + {file = "pydantic_core-2.23.2-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:743e5811b0c377eb830150d675b0847a74a44d4ad5ab8845923d5b3a756d8100"}, + {file = "pydantic_core-2.23.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6650a7bbe17a2717167e3e23c186849bae5cef35d38949549f1c116031b2b3aa"}, + {file = "pydantic_core-2.23.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:56e6a12ec8d7679f41b3750ffa426d22b44ef97be226a9bab00a03365f217b2b"}, + {file = "pydantic_core-2.23.2-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:810ca06cca91de9107718dc83d9ac4d2e86efd6c02cba49a190abcaf33fb0472"}, + {file = "pydantic_core-2.23.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:785e7f517ebb9890813d31cb5d328fa5eda825bb205065cde760b3150e4de1f7"}, + {file = "pydantic_core-2.23.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ef71ec876fcc4d3bbf2ae81961959e8d62f8d74a83d116668409c224012e3af"}, + {file = "pydantic_core-2.23.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d50ac34835c6a4a0d456b5db559b82047403c4317b3bc73b3455fefdbdc54b0a"}, + {file = "pydantic_core-2.23.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16b25a4a120a2bb7dab51b81e3d9f3cde4f9a4456566c403ed29ac81bf49744f"}, + {file = "pydantic_core-2.23.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:41ae8537ad371ec018e3c5da0eb3f3e40ee1011eb9be1da7f965357c4623c501"}, + {file = "pydantic_core-2.23.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:07049ec9306ec64e955b2e7c40c8d77dd78ea89adb97a2013d0b6e055c5ee4c5"}, + {file = "pydantic_core-2.23.2-cp38-none-win32.whl", hash = "sha256:086c5db95157dc84c63ff9d96ebb8856f47ce113c86b61065a066f8efbe80acf"}, + {file = "pydantic_core-2.23.2-cp38-none-win_amd64.whl", hash = "sha256:67b6655311b00581914aba481729971b88bb8bc7996206590700a3ac85e457b8"}, + {file = "pydantic_core-2.23.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:358331e21a897151e54d58e08d0219acf98ebb14c567267a87e971f3d2a3be59"}, + {file = "pydantic_core-2.23.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c4d9f15ffe68bcd3898b0ad7233af01b15c57d91cd1667f8d868e0eacbfe3f87"}, + {file = "pydantic_core-2.23.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0123655fedacf035ab10c23450163c2f65a4174f2bb034b188240a6cf06bb123"}, + {file = "pydantic_core-2.23.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e6e3ccebdbd6e53474b0bb7ab8b88e83c0cfe91484b25e058e581348ee5a01a5"}, + {file = "pydantic_core-2.23.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc535cb898ef88333cf317777ecdfe0faac1c2a3187ef7eb061b6f7ecf7e6bae"}, + {file = "pydantic_core-2.23.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aab9e522efff3993a9e98ab14263d4e20211e62da088298089a03056980a3e69"}, + {file = "pydantic_core-2.23.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05b366fb8fe3d8683b11ac35fa08947d7b92be78ec64e3277d03bd7f9b7cda79"}, + {file = "pydantic_core-2.23.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7568f682c06f10f30ef643a1e8eec4afeecdafde5c4af1b574c6df079e96f96c"}, + {file = "pydantic_core-2.23.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:cdd02a08205dc90238669f082747612cb3c82bd2c717adc60f9b9ecadb540f80"}, + {file = "pydantic_core-2.23.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:1a2ab4f410f4b886de53b6bddf5dd6f337915a29dd9f22f20f3099659536b2f6"}, + {file = "pydantic_core-2.23.2-cp39-none-win32.whl", hash = "sha256:0448b81c3dfcde439551bb04a9f41d7627f676b12701865c8a2574bcea034437"}, + {file = "pydantic_core-2.23.2-cp39-none-win_amd64.whl", hash = "sha256:4cebb9794f67266d65e7e4cbe5dcf063e29fc7b81c79dc9475bd476d9534150e"}, + {file = "pydantic_core-2.23.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e758d271ed0286d146cf7c04c539a5169a888dd0b57026be621547e756af55bc"}, + {file = "pydantic_core-2.23.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:f477d26183e94eaafc60b983ab25af2a809a1b48ce4debb57b343f671b7a90b6"}, + {file = "pydantic_core-2.23.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da3131ef2b940b99106f29dfbc30d9505643f766704e14c5d5e504e6a480c35e"}, + {file = "pydantic_core-2.23.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:329a721253c7e4cbd7aad4a377745fbcc0607f9d72a3cc2102dd40519be75ed2"}, + {file = "pydantic_core-2.23.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7706e15cdbf42f8fab1e6425247dfa98f4a6f8c63746c995d6a2017f78e619ae"}, + {file = "pydantic_core-2.23.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:e64ffaf8f6e17ca15eb48344d86a7a741454526f3a3fa56bc493ad9d7ec63936"}, + {file = "pydantic_core-2.23.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:dd59638025160056687d598b054b64a79183f8065eae0d3f5ca523cde9943940"}, + {file = "pydantic_core-2.23.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:12625e69b1199e94b0ae1c9a95d000484ce9f0182f9965a26572f054b1537e44"}, + {file = "pydantic_core-2.23.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5d813fd871b3d5c3005157622ee102e8908ad6011ec915a18bd8fde673c4360e"}, + {file = "pydantic_core-2.23.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:1eb37f7d6a8001c0f86dc8ff2ee8d08291a536d76e49e78cda8587bb54d8b329"}, + {file = "pydantic_core-2.23.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ce7eaf9a98680b4312b7cebcdd9352531c43db00fca586115845df388f3c465"}, + {file = "pydantic_core-2.23.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f087879f1ffde024dd2788a30d55acd67959dcf6c431e9d3682d1c491a0eb474"}, + {file = "pydantic_core-2.23.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6ce883906810b4c3bd90e0ada1f9e808d9ecf1c5f0b60c6b8831d6100bcc7dd6"}, + {file = "pydantic_core-2.23.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a8031074a397a5925d06b590121f8339d34a5a74cfe6970f8a1124eb8b83f4ac"}, + {file = "pydantic_core-2.23.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:23af245b8f2f4ee9e2c99cb3f93d0e22fb5c16df3f2f643f5a8da5caff12a653"}, + {file = "pydantic_core-2.23.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:c57e493a0faea1e4c38f860d6862ba6832723396c884fbf938ff5e9b224200e2"}, + {file = "pydantic_core-2.23.2.tar.gz", hash = "sha256:95d6bf449a1ac81de562d65d180af5d8c19672793c81877a2eda8fde5d08f2fd"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" + [[package]] name = "pygments" version = "2.17.2" @@ -542,6 +680,28 @@ files = [ {file = "typing_extensions-4.10.0.tar.gz", hash = "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb"}, ] +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] + +[[package]] +name = "tzdata" +version = "2024.1" +description = "Provider of IANA time zone data" +optional = false +python-versions = ">=2" +files = [ + {file = "tzdata-2024.1-py2.py3-none-any.whl", hash = "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252"}, + {file = "tzdata-2024.1.tar.gz", hash = "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd"}, +] + [[package]] name = "virtualenv" version = "20.25.1" @@ -565,4 +725,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.0" python-versions = ">=3.8" -content-hash = "6b52d5b35db2892ae49a2d655a8f19fb430b59b3f8c4dc6881526f0729424580" +content-hash = "14509c113eb897776c0adc8e930775188765786b3dd9022359ba19733833b363" diff --git a/pyproject.toml b/pyproject.toml index 7d874a5a0..a5d9a15c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,7 @@ pytest = ">=6.2.0" typing-extensions = "*" packaging = "*" gherkin-official = "^29.0.0" +pydantic = "^2.9.0" [tool.poetry.group.dev.dependencies] tox = ">=4.11.3" diff --git a/src/pytest_bdd/gherkin_parser.py b/src/pytest_bdd/gherkin_parser.py new file mode 100644 index 000000000..35ac61a97 --- /dev/null +++ b/src/pytest_bdd/gherkin_parser.py @@ -0,0 +1,131 @@ +import linecache +from pathlib import Path +from typing import List, Optional, Union + +from gherkin.errors import CompositeParserException +from gherkin.parser import Parser +from gherkin.token_scanner import TokenScanner +from pydantic import BaseModel + + +class Location(BaseModel): + column: int + line: int + + +class Comment(BaseModel): + location: Location + text: str + + +class Cell(BaseModel): + location: Location + value: str + + +class Row(BaseModel): + id: str + location: Location + cells: List[Cell] + + +class DataTable(BaseModel): + name: Optional[str] = None + location: Location + tableHeader: Optional[Row] = None + tableBody: Optional[List[Row]] = None + + +class DocString(BaseModel): + content: str + delimiter: str + location: Location + + +class Step(BaseModel): + id: str + keyword: str + keywordType: str + location: Location + text: str + dataTable: Optional[DataTable] = None + docString: Optional[DocString] = None + + +class Tag(BaseModel): + id: str + location: Location + name: str + + +class Scenario(BaseModel): + id: str + keyword: str + location: Location + name: str + description: str + steps: List[Step] + tags: List[Tag] + examples: Optional[List[DataTable]] = None + + +class Rule(BaseModel): + id: str + keyword: str + location: Location + name: str + description: str + tags: List[Tag] + children: List[Scenario] + + +class Background(BaseModel): + id: str + keyword: str + location: Location + name: str + description: str + steps: List[Step] + + +class Child(BaseModel): + background: Optional[Background] = None + rule: Optional[Rule] = None + scenario: Optional[Scenario] = None + + +class Feature(BaseModel): + keyword: str + location: Location + tags: List[Tag] + name: str + description: str + children: List[Child] + + +class GherkinDocument(BaseModel): + feature: Feature + comments: List[Comment] + + +class GherkinParser: + def __init__(self, abs_filename: str = None, encoding: str = "utf-8"): + self.abs_filename = Path(abs_filename) if abs_filename else None + self.encoding = encoding + + with open(self.abs_filename, encoding=self.encoding) as f: + self.feature_file_text = f.read() + try: + self.gherkin_data = Parser().parse(TokenScanner(self.feature_file_text)) + except CompositeParserException as e: + from src.pytest_bdd import exceptions + + raise exceptions.FeatureError( + e.args[0], + e.errors[0].location["line"], + linecache.getline(str(self.abs_filename), e.errors[0].location["line"]).rstrip("\n"), + self.abs_filename, + ) from e + + def to_gherkin_document(self) -> GherkinDocument: + return GherkinDocument(**self.gherkin_data) diff --git a/src/pytest_bdd/parser.py b/src/pytest_bdd/parser.py index 2c0641cf7..b411575ab 100644 --- a/src/pytest_bdd/parser.py +++ b/src/pytest_bdd/parser.py @@ -1,6 +1,5 @@ from __future__ import annotations -import linecache import os.path import re import textwrap @@ -8,11 +7,12 @@ from dataclasses import dataclass, field from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence -from gherkin.errors import CompositeParserException -from gherkin.parser import Parser -from gherkin.token_scanner import TokenScanner - -from .exceptions import FeatureError +from .gherkin_parser import Background as GherkinBackground +from .gherkin_parser import Feature as GherkinFeature +from .gherkin_parser import GherkinDocument, GherkinParser +from .gherkin_parser import Scenario as GherkinScenario +from .gherkin_parser import Step as GherkinStep +from .gherkin_parser import Tag as GherkinTag from .types import GIVEN, THEN, WHEN STEP_PARAM_RE = re.compile(r"<(.+?)>") @@ -295,7 +295,7 @@ class Background: steps: list[Step] = field(init=False, default_factory=list) def add_step(self, step: Step) -> None: - """Add a step to txhe background. + """Add a step to the background. Args: step (Step): The step to add. @@ -319,7 +319,7 @@ def __init__(self, basedir: str, filename: str, encoding: str = "utf-8"): self.encoding = encoding @staticmethod - def get_tag_names(tag_data: list[dict]) -> set[str]: + def get_tag_names(tag_data: list[GherkinTag]) -> set[str]: """Extract tag names from tag data. Args: @@ -328,7 +328,7 @@ def get_tag_names(tag_data: list[dict]) -> set[str]: Returns: set[str]: A set of tag names. """ - return {tag["name"].lstrip("@") for tag in tag_data} + return {tag.name.lstrip("@") for tag in tag_data} @staticmethod def get_step_type(keyword: str) -> str | None: @@ -346,7 +346,7 @@ def get_step_type(keyword: str) -> str | None: "then": THEN, }.get(keyword) - def parse_steps(self, steps_data: list[dict]) -> list[Step]: + def parse_steps(self, steps_data: list[GherkinStep]) -> list[Step]: """Parse a list of step data into Step objects. Args: @@ -358,24 +358,24 @@ def parse_steps(self, steps_data: list[dict]) -> list[Step]: steps = [] current_step_type = None for step_data in steps_data: - keyword = step_data["keyword"].strip().lower() + keyword = step_data.keyword.strip().lower() current_step_type = self.get_step_type(keyword) or current_step_type - name = strip_comments(step_data["text"]) - if "docString" in step_data: - doc_string = textwrap.dedent(step_data["docString"]["content"]) + name = strip_comments(step_data.text) + if step_data.docString: + doc_string = textwrap.dedent(step_data.docString.content) name = f"{name}\n{doc_string}" steps.append( Step( name=name, type=current_step_type, - indent=step_data["location"]["column"] - 1, - line_number=step_data["location"]["line"], + indent=step_data.location.column - 1, + line_number=step_data.location.line, keyword=keyword.title(), ) ) return steps - def parse_scenario(self, scenario_data: dict, feature: Feature) -> ScenarioTemplate: + def parse_scenario(self, scenario_data: GherkinScenario, feature: Feature) -> ScenarioTemplate: """Parse a scenario data dictionary into a ScenarioTemplate object. Args: @@ -385,42 +385,41 @@ def parse_scenario(self, scenario_data: dict, feature: Feature) -> ScenarioTempl Returns: ScenarioTemplate: A ScenarioTemplate object representing the parsed scenario. """ - templated = "examples" in scenario_data + templated = bool(scenario_data.examples) scenario = ScenarioTemplate( feature=feature, - name=strip_comments(scenario_data["name"]), - line_number=scenario_data["location"]["line"], + name=strip_comments(scenario_data.name), + line_number=scenario_data.location.line, templated=templated, - tags=self.get_tag_names(scenario_data["tags"]), - description=textwrap.dedent(scenario_data.get("description", "")), + tags=self.get_tag_names(scenario_data.tags), + description=textwrap.dedent(scenario_data.description), ) - for step in self.parse_steps(scenario_data["steps"]): + for step in self.parse_steps(scenario_data.steps): scenario.add_step(step) - if templated: - for example_data in scenario_data["examples"]: - examples = Examples( - line_number=example_data["location"]["line"], - name=example_data["name"], - ) - param_names = [cell["value"] for cell in example_data["tableHeader"]["cells"]] - examples.set_param_names(param_names) - for row in example_data["tableBody"]: - values = [cell["value"] or "" for cell in row["cells"]] - examples.add_example(values) - scenario.examples = examples + for example_data in scenario_data.examples: + examples = Examples( + line_number=example_data.location.line, + name=example_data.name, + ) + param_names = [cell.value for cell in example_data.tableHeader.cells] + examples.set_param_names(param_names) + for row in example_data.tableBody: + values = [cell.value or "" for cell in row.cells] + examples.add_example(values) + scenario.examples = examples return scenario - def parse_background(self, background_data: dict, feature: Feature) -> Background: + def parse_background(self, background_data: GherkinBackground, feature: Feature) -> Background: background = Background( feature=feature, - line_number=background_data["location"]["line"], + line_number=background_data.location.line, ) - background.steps = self.parse_steps(background_data["steps"]) + background.steps = self.parse_steps(background_data.steps) return background - def _parse_feature_file(self) -> dict: + def _parse_feature_file(self) -> GherkinDocument: """Parse a feature file into a Feature object. Returns: @@ -429,37 +428,27 @@ def _parse_feature_file(self) -> dict: Raises: FeatureError: If there is an error parsing the feature file. """ - with open(self.abs_filename, encoding=self.encoding) as f: - file_contents = f.read() - try: - return Parser().parse(TokenScanner(file_contents)) - except CompositeParserException as e: - raise FeatureError( - e.args[0], - e.errors[0].location["line"], - linecache.getline(self.abs_filename, e.errors[0].location["line"]).rstrip("\n"), - self.abs_filename, - ) from e + return GherkinParser(self.abs_filename, self.encoding).to_gherkin_document() def parse(self): - data = self._parse_feature_file() - feature_data = data["feature"] + gherkin_doc: GherkinDocument = self._parse_feature_file() + feature_data: GherkinFeature = gherkin_doc.feature feature = Feature( scenarios=OrderedDict(), filename=self.abs_filename, rel_filename=self.rel_filename, - name=strip_comments(feature_data["name"]), - tags=self.get_tag_names(feature_data["tags"]), + name=strip_comments(feature_data.name), + tags=self.get_tag_names(feature_data.tags), background=None, - line_number=feature_data["location"]["line"], - description=textwrap.dedent(feature_data.get("description", "")), + line_number=feature_data.location.line, + description=textwrap.dedent(feature_data.description), ) - for child in feature_data["children"]: - if "background" in child: - feature.background = self.parse_background(child["background"], feature) - elif "scenario" in child: - scenario = self.parse_scenario(child["scenario"], feature) + for child in feature_data.children: + if child.background: + feature.background = self.parse_background(child.background, feature) + elif child.scenario: + scenario = self.parse_scenario(child.scenario, feature) feature.scenarios[scenario.name] = scenario return feature From 4e17ccb21714fd8bd0ed3d94bac2dee8beb4965f Mon Sep 17 00:00:00 2001 From: Jason Allen Date: Fri, 6 Sep 2024 17:27:00 +0100 Subject: [PATCH 08/17] Move the calculating of given/when/then to pydantic models, as well as removing tabbing from docstring in steps (aka multiline steps) --- src/pytest_bdd/gherkin_parser.py | 47 +++++++++++++++++++++++++++++--- src/pytest_bdd/parser.py | 15 +++------- 2 files changed, 47 insertions(+), 15 deletions(-) diff --git a/src/pytest_bdd/gherkin_parser.py b/src/pytest_bdd/gherkin_parser.py index 35ac61a97..b2b248004 100644 --- a/src/pytest_bdd/gherkin_parser.py +++ b/src/pytest_bdd/gherkin_parser.py @@ -1,11 +1,15 @@ import linecache +import textwrap from pathlib import Path -from typing import List, Optional, Union +from typing import List, Optional from gherkin.errors import CompositeParserException from gherkin.parser import Parser from gherkin.token_scanner import TokenScanner -from pydantic import BaseModel +from pydantic import BaseModel, field_validator, model_validator + +from . import exceptions +from .types import STEP_TYPES class Location(BaseModel): @@ -41,6 +45,10 @@ class DocString(BaseModel): delimiter: str location: Location + @field_validator("content", mode="before") + def dedent_content(cls, value: str) -> str: + return textwrap.dedent(value) + class Step(BaseModel): id: str @@ -51,6 +59,18 @@ class Step(BaseModel): dataTable: Optional[DataTable] = None docString: Optional[DocString] = None + @field_validator("keyword", mode="before") + def normalize_keyword(cls, value: str) -> str: + return value.lower().strip() + + @property + def given_when_then(self) -> str: + return self._gwt + + @given_when_then.setter + def given_when_then(self, gwt: str) -> None: + self._gwt = gwt + class Tag(BaseModel): id: str @@ -68,6 +88,12 @@ class Scenario(BaseModel): tags: List[Tag] examples: Optional[List[DataTable]] = None + @model_validator(mode="after") + def process_steps(cls, instance): + steps = instance.steps + instance.steps = _compute_given_when_then(steps) + return instance + class Rule(BaseModel): id: str @@ -87,6 +113,12 @@ class Background(BaseModel): description: str steps: List[Step] + @model_validator(mode="after") + def process_steps(cls, instance): + steps = instance.steps + instance.steps = _compute_given_when_then(steps) + return instance + class Child(BaseModel): background: Optional[Background] = None @@ -108,6 +140,15 @@ class GherkinDocument(BaseModel): comments: List[Comment] +def _compute_given_when_then(steps: list[Step]) -> list[Step]: + last_gwt = None + for step in steps: + if step.keyword in STEP_TYPES: + last_gwt = step.keyword + step.given_when_then = last_gwt + return steps + + class GherkinParser: def __init__(self, abs_filename: str = None, encoding: str = "utf-8"): self.abs_filename = Path(abs_filename) if abs_filename else None @@ -118,8 +159,6 @@ def __init__(self, abs_filename: str = None, encoding: str = "utf-8"): try: self.gherkin_data = Parser().parse(TokenScanner(self.feature_file_text)) except CompositeParserException as e: - from src.pytest_bdd import exceptions - raise exceptions.FeatureError( e.args[0], e.errors[0].location["line"], diff --git a/src/pytest_bdd/parser.py b/src/pytest_bdd/parser.py index b411575ab..1c4adc0f4 100644 --- a/src/pytest_bdd/parser.py +++ b/src/pytest_bdd/parser.py @@ -220,7 +220,7 @@ class Step: """ type: str - _name: str + name: str line_number: int indent: int keyword: str @@ -356,21 +356,17 @@ def parse_steps(self, steps_data: list[GherkinStep]) -> list[Step]: List[Step]: A list of Step objects. """ steps = [] - current_step_type = None for step_data in steps_data: - keyword = step_data.keyword.strip().lower() - current_step_type = self.get_step_type(keyword) or current_step_type name = strip_comments(step_data.text) if step_data.docString: - doc_string = textwrap.dedent(step_data.docString.content) - name = f"{name}\n{doc_string}" + name = f"{name}\n{step_data.docString.content}" steps.append( Step( name=name, - type=current_step_type, + type=step_data.given_when_then, indent=step_data.location.column - 1, line_number=step_data.location.line, - keyword=keyword.title(), + keyword=step_data.keyword.title(), ) ) return steps @@ -424,9 +420,6 @@ def _parse_feature_file(self) -> GherkinDocument: Returns: Dict: A Gherkin document representation of the feature file. - - Raises: - FeatureError: If there is an error parsing the feature file. """ return GherkinParser(self.abs_filename, self.encoding).to_gherkin_document() From 57b9e55723aa4f7a32d4accb70547039182ca168 Mon Sep 17 00:00:00 2001 From: Jason Allen Date: Fri, 6 Sep 2024 17:31:50 +0100 Subject: [PATCH 09/17] Fix silly mistakes --- src/pytest_bdd/gherkin_parser.py | 2 +- src/pytest_bdd/parser.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/pytest_bdd/gherkin_parser.py b/src/pytest_bdd/gherkin_parser.py index b2b248004..2d6b35d91 100644 --- a/src/pytest_bdd/gherkin_parser.py +++ b/src/pytest_bdd/gherkin_parser.py @@ -151,7 +151,7 @@ def _compute_given_when_then(steps: list[Step]) -> list[Step]: class GherkinParser: def __init__(self, abs_filename: str = None, encoding: str = "utf-8"): - self.abs_filename = Path(abs_filename) if abs_filename else None + self.abs_filename = Path(abs_filename) self.encoding = encoding with open(self.abs_filename, encoding=self.encoding) as f: diff --git a/src/pytest_bdd/parser.py b/src/pytest_bdd/parser.py index 1c4adc0f4..57c8e0a99 100644 --- a/src/pytest_bdd/parser.py +++ b/src/pytest_bdd/parser.py @@ -346,7 +346,8 @@ def get_step_type(keyword: str) -> str | None: "then": THEN, }.get(keyword) - def parse_steps(self, steps_data: list[GherkinStep]) -> list[Step]: + @staticmethod + def parse_steps(steps_data: list[GherkinStep]) -> list[Step]: """Parse a list of step data into Step objects. Args: From ff1a92606f12fee4e6c69d5a74f924784f375960 Mon Sep 17 00:00:00 2001 From: Jason Allen Date: Fri, 6 Sep 2024 18:50:33 +0100 Subject: [PATCH 10/17] Fix type hints for py3.8 --- src/pytest_bdd/gherkin_parser.py | 2 +- src/pytest_bdd/parser.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pytest_bdd/gherkin_parser.py b/src/pytest_bdd/gherkin_parser.py index 2d6b35d91..f26e69e8c 100644 --- a/src/pytest_bdd/gherkin_parser.py +++ b/src/pytest_bdd/gherkin_parser.py @@ -140,7 +140,7 @@ class GherkinDocument(BaseModel): comments: List[Comment] -def _compute_given_when_then(steps: list[Step]) -> list[Step]: +def _compute_given_when_then(steps: List[Step]) -> List[Step]: last_gwt = None for step in steps: if step.keyword in STEP_TYPES: diff --git a/src/pytest_bdd/parser.py b/src/pytest_bdd/parser.py index 57c8e0a99..4a9c8e289 100644 --- a/src/pytest_bdd/parser.py +++ b/src/pytest_bdd/parser.py @@ -338,7 +338,7 @@ def get_step_type(keyword: str) -> str | None: keyword (str): The keyword for the step (e.g., 'given', 'when', 'then'). Returns: - str | None: The type of the step, or None if the keyword is unknown. + Optional[str]: The type of the step, or None if the keyword is unknown. """ return { "given": GIVEN, From 692f0d572bf120a3355671a411f2c3d2851662c4 Mon Sep 17 00:00:00 2001 From: Jason Allen Date: Sun, 8 Sep 2024 11:49:29 +0100 Subject: [PATCH 11/17] Start removing old parser --- src/pytest_bdd/compat.py | 5 + src/pytest_bdd/feature.py | 3 +- src/pytest_bdd/generation.py | 30 ++-- src/pytest_bdd/gherkin_parser.py | 249 ++++++++++++++++++++++++++- src/pytest_bdd/hooks.py | 4 +- src/pytest_bdd/parser.py | 49 +++--- src/pytest_bdd/plugin.py | 6 +- src/pytest_bdd/reporting.py | 19 +- src/pytest_bdd/scenario.py | 78 +++++---- src/pytest_bdd/steps.py | 4 +- src/pytest_bdd/templates/test.py.mak | 2 +- 11 files changed, 342 insertions(+), 107 deletions(-) diff --git a/src/pytest_bdd/compat.py b/src/pytest_bdd/compat.py index 079f7de01..7f47aad88 100644 --- a/src/pytest_bdd/compat.py +++ b/src/pytest_bdd/compat.py @@ -24,6 +24,8 @@ def inject_fixture(request: FixtureRequest, arg: str, value: Any) -> None: :param arg: argument name :param value: argument value """ + if "scenario" in arg: + print("Yippee!") request._fixturemanager._register_fixture( name=arg, @@ -43,6 +45,9 @@ def inject_fixture(request: FixtureRequest, arg: str, value: Any) -> None: :param arg: argument name :param value: argument value """ + if "scenario" in arg: + print("Yippee!") + fd = FixtureDef( fixturemanager=request._fixturemanager, baseid=None, diff --git a/src/pytest_bdd/feature.py b/src/pytest_bdd/feature.py index ee4bd90b8..cb5689ce5 100644 --- a/src/pytest_bdd/feature.py +++ b/src/pytest_bdd/feature.py @@ -29,7 +29,8 @@ import glob import os.path -from .parser import Feature, FeatureParser +from .gherkin_parser import Feature +from .parser import FeatureParser # Global features dictionary features: dict[str, Feature] = {} diff --git a/src/pytest_bdd/generation.py b/src/pytest_bdd/generation.py index bfde4a9b7..763c3627a 100644 --- a/src/pytest_bdd/generation.py +++ b/src/pytest_bdd/generation.py @@ -24,7 +24,8 @@ from _pytest.main import Session from _pytest.python import Function - from .parser import Feature, ScenarioTemplate, Step + from .gherkin_parser import Feature, Scenario, Step + template_lookup = TemplateLookup(directories=[os.path.join(os.path.dirname(__file__), "templates")]) @@ -57,7 +58,7 @@ def cmdline_main(config: Config) -> int | None: return None # Make mypy happy -def generate_code(features: list[Feature], scenarios: list[ScenarioTemplate], steps: list[Step]) -> str: +def generate_code(features: list[Feature], scenarios: list[Scenario], steps: list[Step]) -> str: """Generate test code for the given filenames.""" grouped_steps = group_steps(steps) template = template_lookup.get_template("test.py.mak") @@ -79,7 +80,7 @@ def show_missing_code(config: Config) -> int: return wrap_session(config, _show_missing_code_main) -def print_missing_code(scenarios: list[ScenarioTemplate], steps: list[Step]) -> None: +def print_missing_code(scenarios: list[Scenario], steps: list[Step]) -> None: """Print missing code with TerminalWriter.""" tw = TerminalWriter() scenario = step = None @@ -87,8 +88,8 @@ def print_missing_code(scenarios: list[ScenarioTemplate], steps: list[Step]) -> for scenario in scenarios: tw.line() tw.line( - 'Scenario "{scenario.name}" is not bound to any test in the feature "{scenario.feature.name}"' - " in the file {scenario.feature.filename}:{scenario.line_number}".format(scenario=scenario), + 'Scenario "{scenario.name}" is not bound to any test in the feature "{scenario.parent.name}"' + " in the file {scenario.parent}:{scenario.location.line}".format(scenario=scenario), red=True, ) @@ -99,16 +100,16 @@ def print_missing_code(scenarios: list[ScenarioTemplate], steps: list[Step]) -> tw.line() if step.scenario is not None: tw.line( - """Step {step} is not defined in the scenario "{step.scenario.name}" in the feature""" - """ "{step.scenario.feature.name}" in the file""" - """ {step.scenario.feature.filename}:{step.line_number}""".format(step=step), + """Step {step} is not defined in the scenario "{step.parent.name}" in the feature""" + """ "{step.parent.parent.name}" in the file""" + """ {step.parent.parent.filename}:{step.location.line}""".format(step=step), red=True, ) elif step.background is not None: tw.line( """Step {step} is not defined in the background of the feature""" - """ "{step.background.feature.name}" in the file""" - """ {step.background.feature.filename}:{step.line_number}""".format(step=step), + """ "{step.background.parent.name}" in the file""" + """ {step.background.parent.filename}:{step.location.line}""".format(step=step), red=True, ) @@ -134,7 +135,7 @@ def _find_step_fixturedef( return getfixturedefs(fixturemanager, bdd_name, item) -def parse_feature_files(paths: list[str], **kwargs: Any) -> tuple[list[Feature], list[ScenarioTemplate], list[Step]]: +def parse_feature_files(paths: list[str], **kwargs: Any) -> tuple[list[Feature], list[Scenario], list[Step]]: """Parse feature files of given paths. :param paths: `list` of paths (file or dirs) @@ -153,16 +154,17 @@ def parse_feature_files(paths: list[str], **kwargs: Any) -> tuple[list[Feature], def group_steps(steps: list[Step]) -> list[Step]: """Group steps by type.""" - steps = sorted(steps, key=lambda step: step.type) + steps = sorted(steps, key=lambda step: step.given_when_then) seen_steps = set() grouped_steps = [] for step in itertools.chain.from_iterable( - sorted(group, key=lambda step: step.name) for _, group in itertools.groupby(steps, lambda step: step.type) + sorted(group, key=lambda step: step.name) + for _, group in itertools.groupby(steps, lambda step: step.given_when_then) ): if step.name not in seen_steps: grouped_steps.append(step) seen_steps.add(step.name) - grouped_steps.sort(key=lambda step: STEP_TYPES.index(step.type)) + grouped_steps.sort(key=lambda step: STEP_TYPES.index(step.given_when_then)) return grouped_steps diff --git a/src/pytest_bdd/gherkin_parser.py b/src/pytest_bdd/gherkin_parser.py index f26e69e8c..99f1c69e7 100644 --- a/src/pytest_bdd/gherkin_parser.py +++ b/src/pytest_bdd/gherkin_parser.py @@ -1,7 +1,8 @@ import linecache +import re import textwrap from pathlib import Path -from typing import List, Optional +from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple, Union from gherkin.errors import CompositeParserException from gherkin.parser import Parser @@ -11,6 +12,27 @@ from . import exceptions from .types import STEP_TYPES +STEP_PARAM_RE = re.compile(r"<(.+?)>") +COMMENT_RE = re.compile(r"(^|(?<=\s))#") + + +def check_instance_by_name(obj: Any, class_name: str) -> bool: + return obj.__class__.__name__ == class_name + + +def strip_comments(line: str) -> str: + """Remove comments from a line of text. + + Args: + line (str): The line of text from which to remove comments. + + Returns: + str: The line of text without comments, with leading and trailing whitespace removed. + """ + if res := COMMENT_RE.search(line): + line = line[: res.start()] + return line.strip() + class Location(BaseModel): column: int @@ -39,6 +61,24 @@ class DataTable(BaseModel): tableHeader: Optional[Row] = None tableBody: Optional[List[Row]] = None + def as_contexts(self) -> Iterable[Dict[str, Any]]: + """ + Generate contexts for the examples. + + Yields: + Dict[str, Any]: A dictionary mapping parameter names to their values for each example row. + """ + if not self.tableHeader or not self.tableBody: + return # If header or body is missing, there's nothing to yield + + # Extract parameter names from the tableHeader (row with headers) + example_params = [cell.value for cell in self.tableHeader.cells] + + for row in self.tableBody: + assert len(example_params) == len(row.cells), "Row length does not match header length" + # Map parameter names (from header) to values (from the row) + yield dict(zip(example_params, [cell.value for cell in row.cells])) + class DocString(BaseModel): content: str @@ -58,19 +98,65 @@ class Step(BaseModel): text: str dataTable: Optional[DataTable] = None docString: Optional[DocString] = None + name: Optional[str] = None + parent: Optional[Union["Background", "Scenario"]] = None + failed: bool = False + duration: Optional[float] = None + + def generate_initial_name(self) -> str: + """Generate an initial name based on the step's text and optional docString.""" + _name = strip_comments(self.text) + if self.docString: + _name = f"{_name}\n{self.docString.content}" + return _name + + @model_validator(mode="after") + def set_name(cls, instance): + """Set the 'name' attribute after model validation if it is not already provided.""" + if not instance.name: + instance.name = instance.generate_initial_name() + return instance @field_validator("keyword", mode="before") def normalize_keyword(cls, value: str) -> str: - return value.lower().strip() + """Normalize the keyword (e.g., Given, When, Then).""" + return value.title().strip() @property def given_when_then(self) -> str: + """Get the Given/When/Then form of the step.""" return self._gwt @given_when_then.setter def given_when_then(self, gwt: str) -> None: + """Set the Given/When/Then form of the step.""" self._gwt = gwt + def __str__(self) -> str: + """Return a string representation of the step.""" + return f'{self.given_when_then.capitalize()} "{self.name}"' + + @property + def params(self) -> Tuple[str, ...]: + """Get the parameters in the step name.""" + return tuple(frozenset(STEP_PARAM_RE.findall(self.name))) + + def render(self, context: Mapping[str, Any]) -> None: + """Render the step name with the given context and update the instance. + + Args: + context (Mapping[str, Any]): The context for rendering the step name. + """ + + def replacer(m: re.Match) -> str: + varname = m.group(1) + # If the context contains the variable, replace it. Otherwise, leave it unchanged. + return str(context[varname]) + + # Render the name and update the instance's text attribute + rendered_name = STEP_PARAM_RE.sub(replacer, self.name) + self.name = rendered_name + class Tag(BaseModel): id: str @@ -87,6 +173,11 @@ class Scenario(BaseModel): steps: List[Step] tags: List[Tag] examples: Optional[List[DataTable]] = None + parent: Optional[Union["Feature", "Rule"]] = None + + @field_validator("description", mode="before") + def dedent_description(cls, value: str) -> str: + return textwrap.dedent(value) @model_validator(mode="after") def process_steps(cls, instance): @@ -94,6 +185,37 @@ def process_steps(cls, instance): instance.steps = _compute_given_when_then(steps) return instance + @model_validator(mode="after") + def process_scenario_for_steps(cls, instance): + for step in instance.steps: + step.parent = instance # Set parent as Scenario + return instance + + @property + def tag_names(self) -> List[str]: + return get_tag_names(self.tags) + + def render(self, context: Mapping[str, Any]) -> None: + """Render the scenario's steps with the given context. + + Args: + context (Mapping[str, Any]): The context for rendering steps. + """ + for step in self.steps: + step.render(context) + + @property + def feature(self): + if check_instance_by_name(self.parent, "Feature"): + return self.parent + return None + + @property + def rule(self): + if check_instance_by_name(self.parent, "Rule"): + return self.parent + return None + class Rule(BaseModel): id: str @@ -103,6 +225,21 @@ class Rule(BaseModel): description: str tags: List[Tag] children: List[Scenario] + parent: Optional["Feature"] = None # Forward reference + + @field_validator("description", mode="before") + def dedent_description(cls, value: str) -> str: + return textwrap.dedent(value) + + @model_validator(mode="after") + def process_scenarios(cls, instance): + for scenario in instance.children: + scenario.parent = instance + return instance + + @property + def tag_names(self) -> List[str]: + return get_tag_names(self.tags) class Background(BaseModel): @@ -112,18 +249,47 @@ class Background(BaseModel): name: str description: str steps: List[Step] + parent: Optional["Feature"] = None # Forward reference + + @field_validator("description", mode="before") + def dedent_description(cls, value: str) -> str: + return textwrap.dedent(value) @model_validator(mode="after") - def process_steps(cls, instance): + def process_given_when_then(cls, instance): steps = instance.steps instance.steps = _compute_given_when_then(steps) return instance + @model_validator(mode="after") + def process_background_for_steps(cls, instance): + for step in instance.steps: + step.parent = instance # Set parent as Background + return instance + + def render(self, context: Mapping[str, Any]) -> None: + """Render the scenario's steps with the given context. + + Args: + context (Mapping[str, Any]): The context for rendering steps. + """ + for step in self.steps: + step.render(context) + class Child(BaseModel): background: Optional[Background] = None rule: Optional[Rule] = None scenario: Optional[Scenario] = None + parent: Optional[Union["Feature", "Rule"]] = None + + @model_validator(mode="after") + def assign_parents(cls, instance): + if instance.scenario: + instance.scenario.parent = instance.parent + if instance.background: + instance.background.parent = instance.parent + return instance class Feature(BaseModel): @@ -133,6 +299,59 @@ class Feature(BaseModel): name: str description: str children: List[Child] + abs_filename: Optional[str] = None + rel_filename: Optional[str] = None + + @field_validator("description", mode="before") + def dedent_description(cls, value: str) -> str: + return textwrap.dedent(value) + + @model_validator(mode="after") + def assign_child_parents(cls, instance): + for child in instance.children: + child.parent = instance + if child.scenario: + child.scenario.parent = instance + if child.background: + child.background.parent = instance + return instance + + @property + def filename(self) -> Optional[str]: + """ + Returns the file name from abs_filename, if available. + """ + if self.abs_filename: + return Path(self.abs_filename).name + return None + + @property + def scenarios(self) -> List[Scenario]: + return [child.scenario for child in self.children if child.scenario] + + @property + def backgrounds(self) -> List[Background]: + return [child.background for child in self.children if child.background] + + @property + def rules(self) -> List[Rule]: + return [child.rule for child in self.children if child.rule] + + def get_child_by_name(self, name: str) -> Optional[Union[Scenario, Background]]: + """ + Returns the child (Scenario or Background) that has the given name. + """ + for scenario in self.scenarios: + if scenario.name == name: + return scenario + for background in self.backgrounds: + if background.name == name: + return background + return None + + @property + def tag_names(self) -> List[str]: + return get_tag_names(self.tags) class GherkinDocument(BaseModel): @@ -143,19 +362,27 @@ class GherkinDocument(BaseModel): def _compute_given_when_then(steps: List[Step]) -> List[Step]: last_gwt = None for step in steps: - if step.keyword in STEP_TYPES: - last_gwt = step.keyword + if step.keyword.lower() in STEP_TYPES: + last_gwt = step.keyword.lower() step.given_when_then = last_gwt return steps +def get_tag_names(tags: List[Tag]): + return [tag.name.lstrip("@") for tag in tags] + + class GherkinParser: - def __init__(self, abs_filename: str = None, encoding: str = "utf-8"): + def __init__(self, abs_filename: str, rel_filename: str, encoding: str = "utf-8"): self.abs_filename = Path(abs_filename) + self.rel_filename = rel_filename self.encoding = encoding - with open(self.abs_filename, encoding=self.encoding) as f: - self.feature_file_text = f.read() + try: + with open(self.abs_filename, encoding=self.encoding) as f: + self.feature_file_text = f.read() + except FileNotFoundError: + print("Oh, poo!") try: self.gherkin_data = Parser().parse(TokenScanner(self.feature_file_text)) except CompositeParserException as e: @@ -167,4 +394,8 @@ def __init__(self, abs_filename: str = None, encoding: str = "utf-8"): ) from e def to_gherkin_document(self) -> GherkinDocument: - return GherkinDocument(**self.gherkin_data) + gherkin_document = GherkinDocument(**self.gherkin_data) + # Pass abs_filename to the feature + gherkin_document.feature.abs_filename = str(self.abs_filename) + gherkin_document.feature.rel_filename = self.rel_filename + return gherkin_document diff --git a/src/pytest_bdd/hooks.py b/src/pytest_bdd/hooks.py index 9351b2e30..f3fea7ad1 100644 --- a/src/pytest_bdd/hooks.py +++ b/src/pytest_bdd/hooks.py @@ -5,7 +5,7 @@ """Pytest-bdd pytest hooks.""" -def pytest_bdd_before_scenario(request, feature, scenario): +def pytest_bdd_before_scenario(request, scenario): """Called before scenario is executed.""" @@ -29,7 +29,7 @@ def pytest_bdd_step_error(request, feature, scenario, step, step_func, step_func """Called when step function failed to execute.""" -def pytest_bdd_step_func_lookup_error(request, feature, scenario, step, exception): +def pytest_bdd_step_func_lookup_error(request, scenario, step, exception): """Called when step lookup failed.""" diff --git a/src/pytest_bdd/parser.py b/src/pytest_bdd/parser.py index 4a9c8e289..fca0b892d 100644 --- a/src/pytest_bdd/parser.py +++ b/src/pytest_bdd/parser.py @@ -209,14 +209,11 @@ class Step: Attributes: type (str): The type of step (e.g., 'given', 'when', 'then'). - _name (str): The name of the step. + 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). - lines (List[str]): Additional lines for the step (internal use only). """ type: str @@ -225,9 +222,6 @@ class Step: indent: int keyword: str failed: bool = field(init=False, default=False) - scenario: ScenarioTemplate | None = field(init=False, default=None) - background: Background | None = field(init=False, default=None) - lines: list[str] = field(init=False, default_factory=list) def __init__(self, name: str, type: str, indent: int, line_number: int, keyword: str) -> None: """Initialize a step. @@ -422,27 +416,28 @@ def _parse_feature_file(self) -> GherkinDocument: Returns: Dict: A Gherkin document representation of the feature file. """ - return GherkinParser(self.abs_filename, self.encoding).to_gherkin_document() + return GherkinParser(self.abs_filename, self.rel_filename, self.encoding).to_gherkin_document() def parse(self): gherkin_doc: GherkinDocument = self._parse_feature_file() feature_data: GherkinFeature = gherkin_doc.feature - feature = Feature( - scenarios=OrderedDict(), - filename=self.abs_filename, - rel_filename=self.rel_filename, - name=strip_comments(feature_data.name), - tags=self.get_tag_names(feature_data.tags), - background=None, - line_number=feature_data.location.line, - description=textwrap.dedent(feature_data.description), - ) - - for child in feature_data.children: - if child.background: - feature.background = self.parse_background(child.background, feature) - elif child.scenario: - scenario = self.parse_scenario(child.scenario, feature) - feature.scenarios[scenario.name] = scenario - - return feature + return feature_data + # feature = Feature( + # scenarios=OrderedDict(), + # filename=self.abs_filename, + # rel_filename=self.rel_filename, + # name=strip_comments(feature_data.name), + # tags=self.get_tag_names(feature_data.tags), + # background=None, + # line_number=feature_data.location.line, + # description=textwrap.dedent(feature_data.description), + # ) + # + # for child in feature_data.children: + # if child.background: + # feature.background = self.parse_background(child.background, feature) + # elif child.scenario: + # scenario = self.parse_scenario(child.scenario, feature) + # feature.scenarios[scenario.name] = scenario + # + # return feature diff --git a/src/pytest_bdd/plugin.py b/src/pytest_bdd/plugin.py index 96ab7a859..3d4918c0e 100644 --- a/src/pytest_bdd/plugin.py +++ b/src/pytest_bdd/plugin.py @@ -18,7 +18,7 @@ from _pytest.runner import CallInfo from pluggy._result import _Result - from .parser import Feature, Scenario, Step + from .gherkin_parser import Feature, Scenario, Step P = ParamSpec("P") @@ -88,8 +88,8 @@ def pytest_runtest_makereport(item: Item, call: CallInfo) -> Generator[None, _Re @pytest.hookimpl(tryfirst=True) -def pytest_bdd_before_scenario(request: FixtureRequest, feature: Feature, scenario: Scenario) -> None: - reporting.before_scenario(request, feature, scenario) +def pytest_bdd_before_scenario(request: FixtureRequest, scenario: Scenario) -> None: + reporting.before_scenario(request, scenario) @pytest.hookimpl(tryfirst=True) diff --git a/src/pytest_bdd/reporting.py b/src/pytest_bdd/reporting.py index f0a3d0145..7ce9df59d 100644 --- a/src/pytest_bdd/reporting.py +++ b/src/pytest_bdd/reporting.py @@ -17,7 +17,7 @@ from _pytest.reports import TestReport from _pytest.runner import CallInfo - from .parser import Feature, Scenario, Step + from .gherkin_parser import Feature, Scenario, Step class StepReport: @@ -42,9 +42,9 @@ def serialize(self) -> dict[str, Any]: """ return { "name": self.step.name, - "type": self.step.type, + "type": self.step.given_when_then, "keyword": self.step.keyword, - "line_number": self.step.line_number, + "line_number": self.step.location.line, "failed": self.failed, "duration": self.duration, } @@ -77,7 +77,6 @@ def __init__(self, scenario: Scenario) -> None: """Scenario report constructor. :param pytest_bdd.parser.Scenario scenario: Scenario. - :param node: pytest test node object """ self.scenario: Scenario = scenario self.step_reports: list[StepReport] = [] @@ -106,20 +105,20 @@ def serialize(self) -> dict[str, Any]: :rtype: dict """ scenario = self.scenario - feature = scenario.feature + feature = scenario.parent return { "steps": [step_report.serialize() for step_report in self.step_reports], "name": scenario.name, - "line_number": scenario.line_number, - "tags": sorted(scenario.tags), + "line_number": scenario.location.line, + "tags": sorted(scenario.tag_names), "feature": { "name": feature.name, "filename": feature.filename, "rel_filename": feature.rel_filename, - "line_number": feature.line_number, + "line_number": feature.location.line, "description": feature.description, - "tags": sorted(feature.tags), + "tags": sorted(feature.tag_names), }, } @@ -146,7 +145,7 @@ def runtest_makereport(item: Item, call: CallInfo, rep: TestReport) -> None: rep.item = {"name": item.name} -def before_scenario(request: FixtureRequest, feature: Feature, scenario: Scenario) -> None: +def before_scenario(request: FixtureRequest, scenario: Scenario) -> None: """Create scenario report for the item.""" request.node.__scenario_report__ = ScenarioReport(scenario=scenario) diff --git a/src/pytest_bdd/scenario.py b/src/pytest_bdd/scenario.py index 870ae014f..7c7219aad 100644 --- a/src/pytest_bdd/scenario.py +++ b/src/pytest_bdd/scenario.py @@ -33,7 +33,7 @@ from _pytest.mark.structures import ParameterSet from _pytest.nodes import Node - from .parser import Feature, Scenario, ScenarioTemplate, Step + from .gherkin_parser import Feature, Scenario, Step P = ParamSpec("P") T = TypeVar("T") @@ -55,7 +55,7 @@ def find_fixturedefs_for_step(step: Step, fixturemanager: FixtureManager, node: if step_func_context is None: continue - if step_func_context.type is not None and step_func_context.type != step.type: + if step_func_context.type is not None and step_func_context.type != step.given_when_then: continue match = step_func_context.parser.is_matching(step.name) @@ -172,14 +172,14 @@ def get_step_function(request, step: Step) -> StepFunctionContext | None: def _execute_step_function( - request: FixtureRequest, scenario: Scenario, step: Step, context: StepFunctionContext + request: FixtureRequest, gherkin_scenario: Scenario, step: Step, context: StepFunctionContext ) -> None: """Execute step function.""" __tracebackhide__ = True kw = { "request": request, - "feature": scenario.feature, - "scenario": scenario, + "feature": gherkin_scenario.feature, + "scenario": gherkin_scenario, "step": step, "step_func": context.step_func, "step_func_args": {}, @@ -218,37 +218,39 @@ def _execute_step_function( request.config.hook.pytest_bdd_after_step(**kw) -def _execute_scenario(feature: Feature, scenario: Scenario, request: FixtureRequest) -> None: +def _execute_scenario(gherkin_scenario: Scenario, request: FixtureRequest) -> None: """Execute the scenario. - :param feature: Feature. - :param scenario: Scenario. + :param gherkin_scenario: Scenario. :param request: request. - :param encoding: Encoding. """ __tracebackhide__ = True - request.config.hook.pytest_bdd_before_scenario(request=request, feature=feature, scenario=scenario) + request.config.hook.pytest_bdd_before_scenario(request=request, scenario=gherkin_scenario) try: - for step in scenario.steps: + for step in gherkin_scenario.steps: step_func_context = get_step_function(request=request, step=step) if step_func_context is None: exc = exceptions.StepDefinitionNotFoundError( f"Step definition is not found: {step}. " - f'Line {step.line_number} in scenario "{scenario.name}" in the feature "{scenario.feature.filename}"' + f'Line {step.location.line} in scenario "{gherkin_scenario.name}" in the feature "{gherkin_scenario.parent.filename}"' ) request.config.hook.pytest_bdd_step_func_lookup_error( - request=request, feature=feature, scenario=scenario, step=step, exception=exc + request=request, + feature=gherkin_scenario.parent, + scenario=gherkin_scenario, + step=step, + exception=exc, ) raise exc - _execute_step_function(request, scenario, step, step_func_context) + _execute_step_function(request, gherkin_scenario, step, step_func_context) finally: - request.config.hook.pytest_bdd_after_scenario(request=request, feature=feature, scenario=scenario) + request.config.hook.pytest_bdd_after_scenario( + request=request, feature=gherkin_scenario.parent, scenario=gherkin_scenario + ) -def _get_scenario_decorator( - feature: Feature, feature_name: str, templated_scenario: ScenarioTemplate, scenario_name: str -) -> Callable[[Callable[P, T]], Callable[P, T]]: +def _get_scenario_decorator(feature: Feature, gherkin_scenario: Scenario) -> Callable[[Callable[P, T]], Callable[P, T]]: # HACK: Ideally we would use `def decorator(fn)`, but we want to return a custom exception # when the decorator is misused. # Pytest inspect the signature to determine the required fixtures, and in that case it would look @@ -268,12 +270,12 @@ def decorator(*args: Callable[P, T]) -> Callable[P, T]: @pytest.mark.usefixtures(*func_args) def scenario_wrapper(request: FixtureRequest, _pytest_bdd_example: dict[str, str]) -> Any: __tracebackhide__ = True - scenario = templated_scenario.render(_pytest_bdd_example) - _execute_scenario(feature, scenario, request) + gherkin_scenario.render(_pytest_bdd_example) + _execute_scenario(gherkin_scenario, request) fixture_values = [request.getfixturevalue(arg) for arg in func_args] return fn(*fixture_values) - example_parametrizations = collect_example_parametrizations(templated_scenario) + example_parametrizations = collect_example_parametrizations(gherkin_scenario) if example_parametrizations is not None: # Parametrize the scenario outlines scenario_wrapper = pytest.mark.parametrize( @@ -281,21 +283,25 @@ def scenario_wrapper(request: FixtureRequest, _pytest_bdd_example: dict[str, str example_parametrizations, )(scenario_wrapper) - for tag in templated_scenario.tags.union(feature.tags): + for tag in list(set(gherkin_scenario.tag_names + feature.tag_names)): config = CONFIG_STACK[-1] config.hook.pytest_bdd_apply_tag(tag=tag, function=scenario_wrapper) - scenario_wrapper.__doc__ = f"{feature_name}: {scenario_name}" - scenario_wrapper.__scenario__ = templated_scenario + scenario_wrapper.__doc__ = f"{feature.name}: {gherkin_scenario.name}" + scenario_wrapper.__scenario__ = gherkin_scenario return cast(Callable[P, T], scenario_wrapper) return decorator def collect_example_parametrizations( - templated_scenario: ScenarioTemplate, + gherkin_scenario: Scenario, ) -> list[ParameterSet] | None: - if contexts := list(templated_scenario.examples.as_contexts()): + examples = gherkin_scenario.examples + if not examples: + return None + if contexts := list(examples[0].as_contexts()): + print(contexts) return [pytest.param(context, id="-".join(context.values())) for context in contexts] else: return None @@ -315,7 +321,6 @@ def scenario( :param features_base_dir: Optional base dir location for locating feature files. If not set, it will try and resolve using property set in .ini file, then the caller_module_path. """ __tracebackhide__ = True - scenario_name = scenario_name caller_module_path = get_caller_module_path() # Get the feature @@ -324,17 +329,14 @@ def scenario( feature = get_feature(features_base_dir, feature_name, encoding=encoding) # Get the scenario - try: - scenario = feature.scenarios[scenario_name] - except KeyError: + gherkin_scenario = feature.get_child_by_name(scenario_name) + if gherkin_scenario is None: feature_name = feature.name or "[Empty]" raise exceptions.ScenarioNotFound( f'Scenario "{scenario_name}" in feature "{feature_name}" in {feature.filename} is not found.' ) - return _get_scenario_decorator( - feature=feature, feature_name=feature_name, templated_scenario=scenario, scenario_name=scenario_name - ) + return _get_scenario_decorator(feature=feature, gherkin_scenario=gherkin_scenario) def get_features_base_dir(caller_module_path: str) -> str: @@ -414,17 +416,17 @@ def scenarios(*feature_paths: str, **kwargs: Any) -> None: ) for feature in get_features(abs_feature_paths): - for scenario_name, scenario_object in feature.scenarios.items(): + for gherkin_scenario in feature.scenarios: # skip already bound scenarios - if (scenario_object.feature.filename, scenario_name) not in module_scenarios: + if (feature.filename, gherkin_scenario.name) not in module_scenarios: - @scenario(feature.filename, scenario_name, **kwargs) + @scenario(feature.filename, gherkin_scenario.name, features_base_dir=features_base_dir, **kwargs) def _scenario() -> None: pass # pragma: no cover - for test_name in get_python_name_generator(scenario_name): + for test_name in get_python_name_generator(gherkin_scenario.name): if test_name not in caller_locals: - # found an unique test name + # found a unique test name caller_locals[test_name] = _scenario break found = True diff --git a/src/pytest_bdd/steps.py b/src/pytest_bdd/steps.py index 81967fa11..23ea28a6f 100644 --- a/src/pytest_bdd/steps.py +++ b/src/pytest_bdd/steps.py @@ -45,7 +45,7 @@ def _(article): import pytest from typing_extensions import ParamSpec -from .parser import Step +from .gherkin_parser import Step from .parsers import StepParser, get_parser from .types import GIVEN, THEN, WHEN from .utils import get_caller_module_locals @@ -71,7 +71,7 @@ class StepFunctionContext: def get_step_fixture_name(step: Step) -> str: """Get step fixture name""" - return f"{StepNamePrefix.step_impl.value}_{step.type}_{step.name}" + return f"{StepNamePrefix.step_impl.value}_{step.given_when_then}_{step.name}" def given( diff --git a/src/pytest_bdd/templates/test.py.mak b/src/pytest_bdd/templates/test.py.mak index 9f7901539..106f16988 100644 --- a/src/pytest_bdd/templates/test.py.mak +++ b/src/pytest_bdd/templates/test.py.mak @@ -18,7 +18,7 @@ def test_${ make_python_name(scenario.name)}(): % endfor % for step in steps: -@${step.type}(${ make_string_literal(step.name)}) +@${step.given_when_then}(${ make_string_literal(step.name)}) def _(): ${make_python_docstring(step.name)} raise NotImplementedError From e28a68a0f44fedfd3e1f9085d0dbe5c2734fea7c Mon Sep 17 00:00:00 2001 From: Jason Allen Date: Sun, 8 Sep 2024 20:40:29 +0100 Subject: [PATCH 12/17] Old parser removed and renamed gherkin_parser.py to parser.py --- src/pytest_bdd/feature.py | 13 +- src/pytest_bdd/generation.py | 10 +- src/pytest_bdd/gherkin_parser.py | 401 ---------- src/pytest_bdd/parser.py | 693 +++++++++--------- src/pytest_bdd/plugin.py | 2 +- src/pytest_bdd/reporting.py | 5 +- src/pytest_bdd/scenario.py | 6 +- src/pytest_bdd/steps.py | 2 +- ...t_gherkin.py => test_no_strict_gherkin.py} | 0 tests/steps/test_common.py | 2 +- 10 files changed, 358 insertions(+), 776 deletions(-) delete mode 100644 src/pytest_bdd/gherkin_parser.py rename tests/feature/{test_no_sctrict_gherkin.py => test_no_strict_gherkin.py} (100%) diff --git a/src/pytest_bdd/feature.py b/src/pytest_bdd/feature.py index cb5689ce5..a0c21e064 100644 --- a/src/pytest_bdd/feature.py +++ b/src/pytest_bdd/feature.py @@ -29,8 +29,7 @@ import glob import os.path -from .gherkin_parser import Feature -from .parser import FeatureParser +from .parser import Feature, GherkinParser # Global features dictionary features: dict[str, Feature] = {} @@ -50,11 +49,13 @@ def get_feature(base_path: str, filename: str, encoding: str = "utf-8") -> Featu when multiple scenarios are referencing the same file. """ __tracebackhide__ = True - full_name = os.path.abspath(os.path.join(base_path, filename)) - feature = features.get(full_name) + full_filename = os.path.abspath(os.path.join(base_path, filename)) + rel_filename = os.path.join(os.path.basename(base_path), filename) + feature = features.get(full_filename) if not feature: - feature = FeatureParser(base_path, filename, encoding).parse() - features[full_name] = feature + gherkin_document = GherkinParser(full_filename, rel_filename, encoding).to_gherkin_document() + feature = gherkin_document.feature + features[full_filename] = feature return feature diff --git a/src/pytest_bdd/generation.py b/src/pytest_bdd/generation.py index 763c3627a..85fe1b4cb 100644 --- a/src/pytest_bdd/generation.py +++ b/src/pytest_bdd/generation.py @@ -24,7 +24,7 @@ from _pytest.main import Session from _pytest.python import Function - from .gherkin_parser import Feature, Scenario, Step + from .parser import Feature, Scenario, Step template_lookup = TemplateLookup(directories=[os.path.join(os.path.dirname(__file__), "templates")]) @@ -145,10 +145,10 @@ def parse_feature_files(paths: list[str], **kwargs: Any) -> tuple[list[Feature], """ features = get_features(paths, **kwargs) scenarios = sorted( - itertools.chain.from_iterable(feature.scenarios.values() for feature in features), + itertools.chain.from_iterable(feature.scenarios for feature in features), key=lambda scenario: (scenario.feature.name or scenario.feature.filename, scenario.name), ) - steps = sorted((step for scenario in scenarios for step in scenario.steps), key=lambda step: step.name) + steps = sorted((step for scenario in scenarios for step in scenario.all_steps), key=lambda step: step.name) return features, scenarios, steps @@ -192,10 +192,6 @@ def _show_missing_code_main(config: Config, session: Session) -> None: steps.remove(step) except ValueError: pass - for scenario in scenarios: - for step in scenario.steps: - if step.background is None: - steps.remove(step) grouped_steps = group_steps(steps) print_missing_code(scenarios, grouped_steps) diff --git a/src/pytest_bdd/gherkin_parser.py b/src/pytest_bdd/gherkin_parser.py deleted file mode 100644 index 99f1c69e7..000000000 --- a/src/pytest_bdd/gherkin_parser.py +++ /dev/null @@ -1,401 +0,0 @@ -import linecache -import re -import textwrap -from pathlib import Path -from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple, Union - -from gherkin.errors import CompositeParserException -from gherkin.parser import Parser -from gherkin.token_scanner import TokenScanner -from pydantic import BaseModel, field_validator, model_validator - -from . import exceptions -from .types import STEP_TYPES - -STEP_PARAM_RE = re.compile(r"<(.+?)>") -COMMENT_RE = re.compile(r"(^|(?<=\s))#") - - -def check_instance_by_name(obj: Any, class_name: str) -> bool: - return obj.__class__.__name__ == class_name - - -def strip_comments(line: str) -> str: - """Remove comments from a line of text. - - Args: - line (str): The line of text from which to remove comments. - - Returns: - str: The line of text without comments, with leading and trailing whitespace removed. - """ - if res := COMMENT_RE.search(line): - line = line[: res.start()] - return line.strip() - - -class Location(BaseModel): - column: int - line: int - - -class Comment(BaseModel): - location: Location - text: str - - -class Cell(BaseModel): - location: Location - value: str - - -class Row(BaseModel): - id: str - location: Location - cells: List[Cell] - - -class DataTable(BaseModel): - name: Optional[str] = None - location: Location - tableHeader: Optional[Row] = None - tableBody: Optional[List[Row]] = None - - def as_contexts(self) -> Iterable[Dict[str, Any]]: - """ - Generate contexts for the examples. - - Yields: - Dict[str, Any]: A dictionary mapping parameter names to their values for each example row. - """ - if not self.tableHeader or not self.tableBody: - return # If header or body is missing, there's nothing to yield - - # Extract parameter names from the tableHeader (row with headers) - example_params = [cell.value for cell in self.tableHeader.cells] - - for row in self.tableBody: - assert len(example_params) == len(row.cells), "Row length does not match header length" - # Map parameter names (from header) to values (from the row) - yield dict(zip(example_params, [cell.value for cell in row.cells])) - - -class DocString(BaseModel): - content: str - delimiter: str - location: Location - - @field_validator("content", mode="before") - def dedent_content(cls, value: str) -> str: - return textwrap.dedent(value) - - -class Step(BaseModel): - id: str - keyword: str - keywordType: str - location: Location - text: str - dataTable: Optional[DataTable] = None - docString: Optional[DocString] = None - name: Optional[str] = None - parent: Optional[Union["Background", "Scenario"]] = None - failed: bool = False - duration: Optional[float] = None - - def generate_initial_name(self) -> str: - """Generate an initial name based on the step's text and optional docString.""" - _name = strip_comments(self.text) - if self.docString: - _name = f"{_name}\n{self.docString.content}" - return _name - - @model_validator(mode="after") - def set_name(cls, instance): - """Set the 'name' attribute after model validation if it is not already provided.""" - if not instance.name: - instance.name = instance.generate_initial_name() - return instance - - @field_validator("keyword", mode="before") - def normalize_keyword(cls, value: str) -> str: - """Normalize the keyword (e.g., Given, When, Then).""" - return value.title().strip() - - @property - def given_when_then(self) -> str: - """Get the Given/When/Then form of the step.""" - return self._gwt - - @given_when_then.setter - def given_when_then(self, gwt: str) -> None: - """Set the Given/When/Then form of the step.""" - self._gwt = gwt - - def __str__(self) -> str: - """Return a string representation of the step.""" - return f'{self.given_when_then.capitalize()} "{self.name}"' - - @property - def params(self) -> Tuple[str, ...]: - """Get the parameters in the step name.""" - return tuple(frozenset(STEP_PARAM_RE.findall(self.name))) - - def render(self, context: Mapping[str, Any]) -> None: - """Render the step name with the given context and update the instance. - - Args: - context (Mapping[str, Any]): The context for rendering the step name. - """ - - def replacer(m: re.Match) -> str: - varname = m.group(1) - # If the context contains the variable, replace it. Otherwise, leave it unchanged. - return str(context[varname]) - - # Render the name and update the instance's text attribute - rendered_name = STEP_PARAM_RE.sub(replacer, self.name) - self.name = rendered_name - - -class Tag(BaseModel): - id: str - location: Location - name: str - - -class Scenario(BaseModel): - id: str - keyword: str - location: Location - name: str - description: str - steps: List[Step] - tags: List[Tag] - examples: Optional[List[DataTable]] = None - parent: Optional[Union["Feature", "Rule"]] = None - - @field_validator("description", mode="before") - def dedent_description(cls, value: str) -> str: - return textwrap.dedent(value) - - @model_validator(mode="after") - def process_steps(cls, instance): - steps = instance.steps - instance.steps = _compute_given_when_then(steps) - return instance - - @model_validator(mode="after") - def process_scenario_for_steps(cls, instance): - for step in instance.steps: - step.parent = instance # Set parent as Scenario - return instance - - @property - def tag_names(self) -> List[str]: - return get_tag_names(self.tags) - - def render(self, context: Mapping[str, Any]) -> None: - """Render the scenario's steps with the given context. - - Args: - context (Mapping[str, Any]): The context for rendering steps. - """ - for step in self.steps: - step.render(context) - - @property - def feature(self): - if check_instance_by_name(self.parent, "Feature"): - return self.parent - return None - - @property - def rule(self): - if check_instance_by_name(self.parent, "Rule"): - return self.parent - return None - - -class Rule(BaseModel): - id: str - keyword: str - location: Location - name: str - description: str - tags: List[Tag] - children: List[Scenario] - parent: Optional["Feature"] = None # Forward reference - - @field_validator("description", mode="before") - def dedent_description(cls, value: str) -> str: - return textwrap.dedent(value) - - @model_validator(mode="after") - def process_scenarios(cls, instance): - for scenario in instance.children: - scenario.parent = instance - return instance - - @property - def tag_names(self) -> List[str]: - return get_tag_names(self.tags) - - -class Background(BaseModel): - id: str - keyword: str - location: Location - name: str - description: str - steps: List[Step] - parent: Optional["Feature"] = None # Forward reference - - @field_validator("description", mode="before") - def dedent_description(cls, value: str) -> str: - return textwrap.dedent(value) - - @model_validator(mode="after") - def process_given_when_then(cls, instance): - steps = instance.steps - instance.steps = _compute_given_when_then(steps) - return instance - - @model_validator(mode="after") - def process_background_for_steps(cls, instance): - for step in instance.steps: - step.parent = instance # Set parent as Background - return instance - - def render(self, context: Mapping[str, Any]) -> None: - """Render the scenario's steps with the given context. - - Args: - context (Mapping[str, Any]): The context for rendering steps. - """ - for step in self.steps: - step.render(context) - - -class Child(BaseModel): - background: Optional[Background] = None - rule: Optional[Rule] = None - scenario: Optional[Scenario] = None - parent: Optional[Union["Feature", "Rule"]] = None - - @model_validator(mode="after") - def assign_parents(cls, instance): - if instance.scenario: - instance.scenario.parent = instance.parent - if instance.background: - instance.background.parent = instance.parent - return instance - - -class Feature(BaseModel): - keyword: str - location: Location - tags: List[Tag] - name: str - description: str - children: List[Child] - abs_filename: Optional[str] = None - rel_filename: Optional[str] = None - - @field_validator("description", mode="before") - def dedent_description(cls, value: str) -> str: - return textwrap.dedent(value) - - @model_validator(mode="after") - def assign_child_parents(cls, instance): - for child in instance.children: - child.parent = instance - if child.scenario: - child.scenario.parent = instance - if child.background: - child.background.parent = instance - return instance - - @property - def filename(self) -> Optional[str]: - """ - Returns the file name from abs_filename, if available. - """ - if self.abs_filename: - return Path(self.abs_filename).name - return None - - @property - def scenarios(self) -> List[Scenario]: - return [child.scenario for child in self.children if child.scenario] - - @property - def backgrounds(self) -> List[Background]: - return [child.background for child in self.children if child.background] - - @property - def rules(self) -> List[Rule]: - return [child.rule for child in self.children if child.rule] - - def get_child_by_name(self, name: str) -> Optional[Union[Scenario, Background]]: - """ - Returns the child (Scenario or Background) that has the given name. - """ - for scenario in self.scenarios: - if scenario.name == name: - return scenario - for background in self.backgrounds: - if background.name == name: - return background - return None - - @property - def tag_names(self) -> List[str]: - return get_tag_names(self.tags) - - -class GherkinDocument(BaseModel): - feature: Feature - comments: List[Comment] - - -def _compute_given_when_then(steps: List[Step]) -> List[Step]: - last_gwt = None - for step in steps: - if step.keyword.lower() in STEP_TYPES: - last_gwt = step.keyword.lower() - step.given_when_then = last_gwt - return steps - - -def get_tag_names(tags: List[Tag]): - return [tag.name.lstrip("@") for tag in tags] - - -class GherkinParser: - def __init__(self, abs_filename: str, rel_filename: str, encoding: str = "utf-8"): - self.abs_filename = Path(abs_filename) - self.rel_filename = rel_filename - self.encoding = encoding - - try: - with open(self.abs_filename, encoding=self.encoding) as f: - self.feature_file_text = f.read() - except FileNotFoundError: - print("Oh, poo!") - try: - self.gherkin_data = Parser().parse(TokenScanner(self.feature_file_text)) - except CompositeParserException as e: - raise exceptions.FeatureError( - e.args[0], - e.errors[0].location["line"], - linecache.getline(str(self.abs_filename), e.errors[0].location["line"]).rstrip("\n"), - self.abs_filename, - ) from e - - def to_gherkin_document(self) -> GherkinDocument: - gherkin_document = GherkinDocument(**self.gherkin_data) - # Pass abs_filename to the feature - gherkin_document.feature.abs_filename = str(self.abs_filename) - gherkin_document.feature.rel_filename = self.rel_filename - return gherkin_document diff --git a/src/pytest_bdd/parser.py b/src/pytest_bdd/parser.py index fca0b892d..589a1548a 100644 --- a/src/pytest_bdd/parser.py +++ b/src/pytest_bdd/parser.py @@ -1,24 +1,25 @@ -from __future__ import annotations - -import os.path +import linecache import re import textwrap -from collections import OrderedDict -from dataclasses import dataclass, field -from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence - -from .gherkin_parser import Background as GherkinBackground -from .gherkin_parser import Feature as GherkinFeature -from .gherkin_parser import GherkinDocument, GherkinParser -from .gherkin_parser import Scenario as GherkinScenario -from .gherkin_parser import Step as GherkinStep -from .gherkin_parser import Tag as GherkinTag -from .types import GIVEN, THEN, WHEN +from pathlib import Path +from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple, Union + +from gherkin.errors import CompositeParserException +from gherkin.parser import Parser +from gherkin.token_scanner import TokenScanner +from pydantic import BaseModel, field_validator, model_validator + +from . import exceptions +from .types import STEP_TYPES STEP_PARAM_RE = re.compile(r"<(.+?)>") COMMENT_RE = re.compile(r"(^|(?<=\s))#") +def check_instance_by_name(obj: Any, class_name: str) -> bool: + return obj.__class__.__name__ == class_name + + def strip_comments(line: str) -> str: """Remove comments from a line of text. @@ -33,411 +34,395 @@ def strip_comments(line: str) -> str: return line.strip() -@dataclass(eq=False) -class Feature: - """Represents a feature parsed from a feature file. +class Location(BaseModel): + column: int + line: int - 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 (Optional[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: OrderedDict[str, ScenarioTemplate] - filename: str - rel_filename: str - name: str | None - tags: set[str] - background: Background | None - line_number: int - description: str +class Comment(BaseModel): + location: Location + text: str -@dataclass(eq=False) -class Examples: - """Represents examples used in scenarios for parameterization. +class Cell(BaseModel): + location: Location + value: str - 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) - def set_param_names(self, keys: Iterable[str]) -> None: - """Set the parameter names for the examples. +class Row(BaseModel): + id: str + location: Location + cells: List[Cell] - 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. +class DataTable(BaseModel): + name: Optional[str] = None + location: Location + tableHeader: Optional[Row] = None + tableBody: Optional[List[Row]] = None - Args: - values (Sequence[str]): The values for the example row. + def as_contexts(self) -> Iterable[Dict[str, Any]]: """ - self.examples.append([str(value) if value is not None else "" for value in values]) - - def as_contexts(self) -> Iterable[dict[str, Any]]: - """Generate contexts for the examples. + Generate contexts for the examples. Yields: Dict[str, Any]: 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)) + if not self.tableHeader or not self.tableBody: + return # If header or body is missing, there's nothing to yield - def __bool__(self) -> bool: - """Check if there are any examples. + # Extract parameter names from the tableHeader (row with headers) + example_params = [cell.value for cell in self.tableHeader.cells] - Returns: - bool: True if there are examples, False otherwise. - """ - return bool(self.examples) - - -@dataclass(eq=False) -class ScenarioTemplate: - """Represents a scenario template within a feature. - - Attributes: - feature (Feature): The feature to which this scenario belongs. - 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. - """ + for row in self.tableBody: + assert len(example_params) == len(row.cells), "Row length does not match header length" + # Map parameter names (from header) to values (from the row) + yield dict(zip(example_params, [cell.value for cell in row.cells])) - feature: Feature - name: str - line_number: int - templated: bool - description: str | None = None - tags: set[str] = field(default_factory=set) - _steps: list[Step] = field(init=False, default_factory=list) - examples: Examples | None = field(default_factory=Examples) - def add_step(self, step: Step) -> None: - """Add a step to the scenario. +class DocString(BaseModel): + content: str + delimiter: str + location: Location + + @field_validator("content", mode="before") + def dedent_content(cls, value: str) -> str: + return textwrap.dedent(value) - Args: - step (Step): The step to add. - """ - step.scenario = self - self._steps.append(step) + +class Step(BaseModel): + id: str + keyword: str + keywordType: str + location: Location + text: str + dataTable: Optional[DataTable] = None + docString: Optional[DocString] = None + raw_name: Optional[str] = None + name: Optional[str] = None + parent: Optional[Union["Background", "Scenario"]] = None + failed: bool = False + duration: Optional[float] = None @property - def steps(self) -> list[Step]: - """Get all steps for the scenario, including background steps. + def scenario(self) -> Optional["Scenario"]: + """Returns the scenario if the step's parent is a Scenario.""" + if isinstance(self.parent, Scenario): + return self.parent + return None - Returns: - List[Step]: A list of steps, including any background steps from the feature. - """ - return (self.feature.background.steps if self.feature.background else []) + self._steps + @property + def background(self) -> Optional["Background"]: + """Returns the background if the step's parent is a Background.""" + if isinstance(self.parent, Background): + return self.parent + return None + + def generate_initial_name(self) -> None: + """Generate an initial name based on the step's text and optional docString.""" + self.name = strip_comments(self.text) + if self.docString: + self.name = f"{self.name}\n{self.docString.content}" + # Populate a frozen copy of the name untouched by params later + self.raw_name = self.name + + @model_validator(mode="after") + def set_name(cls, instance): + """Set the 'name' attribute after model validation if it is not already provided.""" + instance.generate_initial_name() + return instance + + @field_validator("keyword", mode="before") + def normalize_keyword(cls, value: str) -> str: + """Normalize the keyword (e.g., Given, When, Then).""" + return value.title().strip() - def render(self, context: Mapping[str, Any]) -> Scenario: - """Render the scenario with the given context. + @property + def given_when_then(self) -> str: + """Get the Given/When/Then form of the step.""" + return self._gwt - Args: - context (Mapping[str, Any]): The context for rendering steps. + @given_when_then.setter + def given_when_then(self, gwt: str) -> None: + """Set the Given/When/Then form of the step.""" + self._gwt = gwt + + def __str__(self) -> str: + """Return a string representation of the step.""" + return f'{self.given_when_then.capitalize()} "{self.name}"' - Returns: - Scenario: A Scenario object with steps rendered based on the context. + @property + def params(self) -> Tuple[str, ...]: + """Get the parameters in the step name.""" + return tuple(frozenset(STEP_PARAM_RE.findall(self.raw_name))) + + def render(self, context: Mapping[str, Any]) -> None: + """Render the step name with the given context and update the instance. + + Args: + context (Mapping[str, Any]): The context for rendering the step name. """ - background_steps = self.feature.background.steps if self.feature.background else [] - scenario_steps = [ - Step( - name=step.render(context), - type=step.type, - indent=step.indent, - line_number=step.line_number, - keyword=step.keyword, - ) - for step in self._steps - ] - steps = background_steps + scenario_steps - return Scenario( - feature=self.feature, - name=self.name, - line_number=self.line_number, - steps=steps, - tags=self.tags, - description=self.description, - ) - - -@dataclass(eq=False) -class Scenario: - """Represents a scenario with steps. - - Attributes: - feature (Feature): The feature to which this scenario belongs. - 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 - name: str - line_number: int - steps: list[Step] - description: str | None = None - tags: set[str] = field(default_factory=set) - - -@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). - """ + def replacer(m: re.Match) -> str: + varname = m.group(1) + # If the context contains the variable, replace it. Otherwise, leave it unchanged. + return str(context.get(varname, f"<{varname}>")) + + # Render the name and update the instance's text attribute + rendered_name = STEP_PARAM_RE.sub(replacer, self.raw_name) + self.name = rendered_name - type: str + +class Tag(BaseModel): + id: str + location: Location name: str - line_number: int - indent: int + + +class Scenario(BaseModel): + id: str keyword: str - failed: bool = field(init=False, default=False) + location: Location + name: str + description: str + steps: List[Step] + tags: List[Tag] + examples: Optional[List[DataTable]] = None + parent: Optional[Union["Feature", "Rule"]] = None + + @field_validator("description", mode="before") + def dedent_description(cls, value: str) -> str: + return textwrap.dedent(value) + + @model_validator(mode="after") + def process_steps(cls, instance): + steps = instance.steps + instance.steps = _compute_given_when_then(steps) + return instance + + @model_validator(mode="after") + def process_scenario_for_steps(cls, instance): + for step in instance.steps: + step.parent = instance + return instance + + @property + def tag_names(self) -> List[str]: + return get_tag_names(self.tags) - def __init__(self, name: str, type: str, indent: int, line_number: int, keyword: str) -> None: - """Initialize a step. + def render(self, context: Mapping[str, Any]) -> None: + """Render the scenario's steps with the given context. 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'). + context (Mapping[str, Any]): The context for rendering steps. """ - self.name = name - self.type = type - self.indent = indent - self.line_number = line_number - self.keyword = keyword + for step in self.steps: + step.render(context) - def __str__(self) -> str: - """Return a string representation of the step. + @property + def feature(self): + if check_instance_by_name(self.parent, "Feature"): + return self.parent + return None - Returns: - str: A string representation of the step. - """ - return f'{self.type.capitalize()} "{self.name}"' + @property + def rule(self): + if check_instance_by_name(self.parent, "Rule"): + return self.parent + return None @property - def params(self) -> tuple[str, ...]: - """Get the parameters in the step name. + def all_steps(self) -> List[Step]: + """Get all steps including background steps if present.""" + # Check if the scenario belongs to a feature and if the feature has background steps + background_steps = self.feature.background_steps if self.feature else [] + # Return the combined list of background steps and scenario steps + return background_steps + self.steps - Returns: - Tuple[str, ...]: A tuple of parameter names found in the step name. - """ - return tuple(frozenset(STEP_PARAM_RE.findall(self.name))) - def render(self, context: Mapping[str, Any]) -> str: - """Render the step name with the given context, but avoid replacing text inside angle brackets if context is missing. +class Rule(BaseModel): + id: str + keyword: str + location: Location + name: str + description: str + tags: List[Tag] + children: List[Scenario] + parent: Optional["Feature"] = None - Args: - context (Mapping[str, Any]): The context for rendering the step name. + @field_validator("description", mode="before") + def dedent_description(cls, value: str) -> str: + return textwrap.dedent(value) - Returns: - str: The rendered step name with parameters replaced only if they exist in the context. - """ + @model_validator(mode="after") + def process_scenarios(cls, instance): + for scenario in instance.children: + scenario.parent = instance + return instance - def replacer(m: re.Match) -> str: - varname = m.group(1) - # If the context contains the variable, replace it. Otherwise, leave it unchanged. - return str(context.get(varname, f"<{varname}>")) + @property + def tag_names(self) -> List[str]: + return get_tag_names(self.tags) - return STEP_PARAM_RE.sub(replacer, self.name) +class Background(BaseModel): + id: str + keyword: str + location: Location + name: str + description: str + steps: List[Step] + parent: Optional["Feature"] = None -@dataclass(eq=False) -class Background: - """Represents the background steps for a feature. + @field_validator("description", mode="before") + def dedent_description(cls, value: str) -> str: + return textwrap.dedent(value) - Attributes: - feature (Feature): The feature to which this background belongs. - line_number (int): The line number where the background starts in the file. - steps (List[Step]): The list of steps in the background. - """ + @model_validator(mode="after") + def process_given_when_then(cls, instance): + steps = instance.steps + instance.steps = _compute_given_when_then(steps) + return instance - feature: Feature - line_number: int - steps: list[Step] = field(init=False, default_factory=list) + @model_validator(mode="after") + def process_background_for_steps(cls, instance): + for step in instance.steps: + step.parent = instance + return instance - def add_step(self, step: Step) -> None: - """Add a step to the background. + def render(self, context: Mapping[str, Any]) -> None: + """Render the scenario's steps with the given context. Args: - step (Step): The step to add. + context (Mapping[str, Any]): The context for rendering steps. """ - step.background = self - self.steps.append(step) - + for step in self.steps: + step.render(context) -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. - """ +class Child(BaseModel): + background: Optional[Background] = None + rule: Optional[Rule] = None + scenario: Optional[Scenario] = None + parent: Optional[Union["Feature", "Rule"]] = None - 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 + @model_validator(mode="after") + def assign_parents(cls, instance): + if instance.scenario: + instance.scenario.parent = instance.parent + if instance.background: + instance.background.parent = instance.parent + return instance - @staticmethod - def get_tag_names(tag_data: list[GherkinTag]) -> set[str]: - """Extract tag names from tag data. - Args: - tag_data (List[dict]): The tag data to extract names from. +class Feature(BaseModel): + keyword: str + location: Location + tags: List[Tag] + name: str + description: str + children: List[Child] + abs_filename: Optional[str] = None + rel_filename: Optional[str] = None + + @field_validator("description", mode="before") + def dedent_description(cls, value: str) -> str: + return textwrap.dedent(value) + + @model_validator(mode="after") + def assign_child_parents(cls, instance): + for child in instance.children: + child.parent = instance + if child.scenario: + child.scenario.parent = instance + if child.background: + child.background.parent = instance + return instance - Returns: - set[str]: A set of tag names. + @property + def filename(self) -> Optional[str]: """ - return {tag.name.lstrip("@") for tag in tag_data} + Returns the file name from abs_filename, if available. + """ + if self.abs_filename: + return str(Path(self.abs_filename).resolve()) + return None - @staticmethod - def get_step_type(keyword: str) -> str | None: - """Map a step keyword to its corresponding type. + @property + def scenarios(self) -> List[Scenario]: + return [child.scenario for child in self.children if child.scenario] - Args: - keyword (str): The keyword for the step (e.g., 'given', 'when', 'then'). + @property + def backgrounds(self) -> List[Background]: + return [child.background for child in self.children if child.background] + + @property + def background_steps(self) -> List[Step]: + _steps = [] + for background in self.backgrounds: + _steps.extend(background.steps) + return _steps - Returns: - Optional[str]: The type of the step, or None if the keyword is unknown. + @property + def rules(self) -> List[Rule]: + return [child.rule for child in self.children if child.rule] + + def get_child_by_name(self, name: str) -> Optional[Union[Scenario, Background]]: + """ + Returns the child (Scenario or Background) that has the given name. """ - return { - "given": GIVEN, - "when": WHEN, - "then": THEN, - }.get(keyword) + for scenario in self.scenarios: + if scenario.name == name: + return scenario + for background in self.backgrounds: + if background.name == name: + return background + return None - @staticmethod - def parse_steps(steps_data: list[GherkinStep]) -> list[Step]: - """Parse a list of step data into Step objects. + @property + def tag_names(self) -> List[str]: + return get_tag_names(self.tags) - Args: - steps_data (List[dict]): The list of step data. - Returns: - List[Step]: A list of Step objects. - """ - steps = [] - for step_data in steps_data: - name = strip_comments(step_data.text) - if step_data.docString: - name = f"{name}\n{step_data.docString.content}" - steps.append( - Step( - name=name, - type=step_data.given_when_then, - indent=step_data.location.column - 1, - line_number=step_data.location.line, - keyword=step_data.keyword.title(), - ) - ) - return steps - - def parse_scenario(self, scenario_data: GherkinScenario, feature: Feature) -> ScenarioTemplate: - """Parse a scenario data dictionary into a ScenarioTemplate object. +class GherkinDocument(BaseModel): + feature: Feature + comments: List[Comment] - Args: - scenario_data (dict): The dictionary containing scenario data. - feature (Feature): The feature to which this scenario belongs. - Returns: - ScenarioTemplate: A ScenarioTemplate object representing the parsed scenario. - """ - templated = bool(scenario_data.examples) - scenario = ScenarioTemplate( - feature=feature, - name=strip_comments(scenario_data.name), - line_number=scenario_data.location.line, - templated=templated, - tags=self.get_tag_names(scenario_data.tags), - description=textwrap.dedent(scenario_data.description), - ) - for step in self.parse_steps(scenario_data.steps): - scenario.add_step(step) - - for example_data in scenario_data.examples: - examples = Examples( - line_number=example_data.location.line, - name=example_data.name, - ) - param_names = [cell.value for cell in example_data.tableHeader.cells] - examples.set_param_names(param_names) - for row in example_data.tableBody: - values = [cell.value or "" for cell in row.cells] - examples.add_example(values) - scenario.examples = examples - - return scenario - - def parse_background(self, background_data: GherkinBackground, feature: Feature) -> Background: - background = Background( - feature=feature, - line_number=background_data.location.line, - ) - background.steps = self.parse_steps(background_data.steps) - 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 GherkinParser(self.abs_filename, self.rel_filename, self.encoding).to_gherkin_document() - - def parse(self): - gherkin_doc: GherkinDocument = self._parse_feature_file() - feature_data: GherkinFeature = gherkin_doc.feature - return feature_data - # feature = Feature( - # scenarios=OrderedDict(), - # filename=self.abs_filename, - # rel_filename=self.rel_filename, - # name=strip_comments(feature_data.name), - # tags=self.get_tag_names(feature_data.tags), - # background=None, - # line_number=feature_data.location.line, - # description=textwrap.dedent(feature_data.description), - # ) - # - # for child in feature_data.children: - # if child.background: - # feature.background = self.parse_background(child.background, feature) - # elif child.scenario: - # scenario = self.parse_scenario(child.scenario, feature) - # feature.scenarios[scenario.name] = scenario - # - # return feature +def _compute_given_when_then(steps: List[Step]) -> List[Step]: + last_gwt = None + for step in steps: + if step.keyword.lower() in STEP_TYPES: + last_gwt = step.keyword.lower() + step.given_when_then = last_gwt + return steps + + +def get_tag_names(tags: List[Tag]): + return [tag.name.lstrip("@") for tag in tags] + + +class GherkinParser: + def __init__(self, abs_filename: str, rel_filename: str, encoding: str = "utf-8"): + self.abs_filename = abs_filename + self.rel_filename = rel_filename + self.encoding = encoding + + with open(self.abs_filename, encoding=self.encoding) as f: + self.feature_file_text = f.read() + try: + self.gherkin_data = Parser().parse(TokenScanner(self.feature_file_text)) + except CompositeParserException as e: + raise exceptions.FeatureError( + e.args[0], + e.errors[0].location["line"], + linecache.getline(self.abs_filename, e.errors[0].location["line"]).rstrip("\n"), + self.abs_filename, + ) from e + + def to_gherkin_document(self) -> GherkinDocument: + gherkin_document = GherkinDocument(**self.gherkin_data) + # Pass abs_filename to the feature + gherkin_document.feature.abs_filename = self.abs_filename + gherkin_document.feature.rel_filename = self.rel_filename + return gherkin_document diff --git a/src/pytest_bdd/plugin.py b/src/pytest_bdd/plugin.py index 3d4918c0e..7b863e570 100644 --- a/src/pytest_bdd/plugin.py +++ b/src/pytest_bdd/plugin.py @@ -18,7 +18,7 @@ from _pytest.runner import CallInfo from pluggy._result import _Result - from .gherkin_parser import Feature, Scenario, Step + from .parser import Feature, Scenario, Step P = ParamSpec("P") diff --git a/src/pytest_bdd/reporting.py b/src/pytest_bdd/reporting.py index 7ce9df59d..3c998a255 100644 --- a/src/pytest_bdd/reporting.py +++ b/src/pytest_bdd/reporting.py @@ -17,7 +17,7 @@ from _pytest.reports import TestReport from _pytest.runner import CallInfo - from .gherkin_parser import Feature, Scenario, Step + from .parser import Feature, Scenario, Step class StepReport: @@ -125,7 +125,8 @@ def serialize(self) -> dict[str, Any]: def fail(self) -> None: """Stop collecting information and finalize the report as failed.""" self.current_step_report.finalize(failed=True) - remaining_steps = self.scenario.steps[len(self.step_reports) :] + steps = self.scenario.all_steps + remaining_steps = steps[len(self.step_reports) :] # Fail the rest of the steps and make reports. for step in remaining_steps: diff --git a/src/pytest_bdd/scenario.py b/src/pytest_bdd/scenario.py index 7c7219aad..dffb8de0d 100644 --- a/src/pytest_bdd/scenario.py +++ b/src/pytest_bdd/scenario.py @@ -33,7 +33,7 @@ from _pytest.mark.structures import ParameterSet from _pytest.nodes import Node - from .gherkin_parser import Feature, Scenario, Step + from .parser import Feature, Scenario, Step P = ParamSpec("P") T = TypeVar("T") @@ -228,7 +228,7 @@ def _execute_scenario(gherkin_scenario: Scenario, request: FixtureRequest) -> No request.config.hook.pytest_bdd_before_scenario(request=request, scenario=gherkin_scenario) try: - for step in gherkin_scenario.steps: + for step in gherkin_scenario.all_steps: step_func_context = get_step_function(request=request, step=step) if step_func_context is None: exc = exceptions.StepDefinitionNotFoundError( @@ -420,7 +420,7 @@ def scenarios(*feature_paths: str, **kwargs: Any) -> None: # skip already bound scenarios if (feature.filename, gherkin_scenario.name) not in module_scenarios: - @scenario(feature.filename, gherkin_scenario.name, features_base_dir=features_base_dir, **kwargs) + @scenario(feature.filename, gherkin_scenario.name, **kwargs) def _scenario() -> None: pass # pragma: no cover diff --git a/src/pytest_bdd/steps.py b/src/pytest_bdd/steps.py index 23ea28a6f..c402ff9b0 100644 --- a/src/pytest_bdd/steps.py +++ b/src/pytest_bdd/steps.py @@ -45,7 +45,7 @@ def _(article): import pytest from typing_extensions import ParamSpec -from .gherkin_parser import Step +from .parser import Step from .parsers import StepParser, get_parser from .types import GIVEN, THEN, WHEN from .utils import get_caller_module_locals diff --git a/tests/feature/test_no_sctrict_gherkin.py b/tests/feature/test_no_strict_gherkin.py similarity index 100% rename from tests/feature/test_no_sctrict_gherkin.py rename to tests/feature/test_no_strict_gherkin.py diff --git a/tests/steps/test_common.py b/tests/steps/test_common.py index 1342b6d25..535f785aa 100644 --- a/tests/steps/test_common.py +++ b/tests/steps/test_common.py @@ -4,7 +4,7 @@ import pytest -from pytest_bdd import given, parser, parsers, then, when +from pytest_bdd import given, parsers, then, when from pytest_bdd.utils import collect_dumped_objects From 6b763c45c7b94db285ea98111931a85457463954 Mon Sep 17 00:00:00 2001 From: Jason Allen Date: Sun, 8 Sep 2024 20:41:38 +0100 Subject: [PATCH 13/17] Fix docstring grammar --- src/pytest_bdd/steps.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pytest_bdd/steps.py b/src/pytest_bdd/steps.py index c402ff9b0..a887744dc 100644 --- a/src/pytest_bdd/steps.py +++ b/src/pytest_bdd/steps.py @@ -149,7 +149,7 @@ def step( :return: Decorator function for the step. Example: - >>> @step("there is an wallet", target_fixture="wallet") + >>> @step("there is a wallet", target_fixture="wallet") >>> def _() -> dict[str, int]: >>> return {"eur": 0, "usd": 0} From 28efa4cdb5e8bdeb65355c0bf283c7d96cb4fefa Mon Sep 17 00:00:00 2001 From: Jason Allen Date: Sun, 8 Sep 2024 20:45:52 +0100 Subject: [PATCH 14/17] Remove debug printing accidentally left in! --- src/pytest_bdd/compat.py | 6 ------ src/pytest_bdd/scenario.py | 1 - 2 files changed, 7 deletions(-) diff --git a/src/pytest_bdd/compat.py b/src/pytest_bdd/compat.py index 7f47aad88..37fc75091 100644 --- a/src/pytest_bdd/compat.py +++ b/src/pytest_bdd/compat.py @@ -24,9 +24,6 @@ def inject_fixture(request: FixtureRequest, arg: str, value: Any) -> None: :param arg: argument name :param value: argument value """ - if "scenario" in arg: - print("Yippee!") - request._fixturemanager._register_fixture( name=arg, func=lambda: value, @@ -45,9 +42,6 @@ def inject_fixture(request: FixtureRequest, arg: str, value: Any) -> None: :param arg: argument name :param value: argument value """ - if "scenario" in arg: - print("Yippee!") - fd = FixtureDef( fixturemanager=request._fixturemanager, baseid=None, diff --git a/src/pytest_bdd/scenario.py b/src/pytest_bdd/scenario.py index dffb8de0d..0b7e654dc 100644 --- a/src/pytest_bdd/scenario.py +++ b/src/pytest_bdd/scenario.py @@ -301,7 +301,6 @@ def collect_example_parametrizations( if not examples: return None if contexts := list(examples[0].as_contexts()): - print(contexts) return [pytest.param(context, id="-".join(context.values())) for context in contexts] else: return None From 74ee1e4eb20301cd8c7fe61fce0e8c17114d3d98 Mon Sep 17 00:00:00 2001 From: Jason Allen Date: Sun, 8 Sep 2024 20:56:19 +0100 Subject: [PATCH 15/17] Retain hook args --- src/pytest_bdd/hooks.py | 4 ++-- src/pytest_bdd/plugin.py | 4 ++-- src/pytest_bdd/reporting.py | 29 ++++++++++++++--------------- src/pytest_bdd/scenario.py | 12 +++++------- 4 files changed, 23 insertions(+), 26 deletions(-) diff --git a/src/pytest_bdd/hooks.py b/src/pytest_bdd/hooks.py index f3fea7ad1..9351b2e30 100644 --- a/src/pytest_bdd/hooks.py +++ b/src/pytest_bdd/hooks.py @@ -5,7 +5,7 @@ """Pytest-bdd pytest hooks.""" -def pytest_bdd_before_scenario(request, scenario): +def pytest_bdd_before_scenario(request, feature, scenario): """Called before scenario is executed.""" @@ -29,7 +29,7 @@ def pytest_bdd_step_error(request, feature, scenario, step, step_func, step_func """Called when step function failed to execute.""" -def pytest_bdd_step_func_lookup_error(request, scenario, step, exception): +def pytest_bdd_step_func_lookup_error(request, feature, scenario, step, exception): """Called when step lookup failed.""" diff --git a/src/pytest_bdd/plugin.py b/src/pytest_bdd/plugin.py index 7b863e570..96ab7a859 100644 --- a/src/pytest_bdd/plugin.py +++ b/src/pytest_bdd/plugin.py @@ -88,8 +88,8 @@ def pytest_runtest_makereport(item: Item, call: CallInfo) -> Generator[None, _Re @pytest.hookimpl(tryfirst=True) -def pytest_bdd_before_scenario(request: FixtureRequest, scenario: Scenario) -> None: - reporting.before_scenario(request, scenario) +def pytest_bdd_before_scenario(request: FixtureRequest, feature: Feature, scenario: Scenario) -> None: + reporting.before_scenario(request, feature, scenario) @pytest.hookimpl(tryfirst=True) diff --git a/src/pytest_bdd/reporting.py b/src/pytest_bdd/reporting.py index 3c998a255..7b4decc40 100644 --- a/src/pytest_bdd/reporting.py +++ b/src/pytest_bdd/reporting.py @@ -73,11 +73,13 @@ def duration(self) -> float: class ScenarioReport: """Scenario execution report.""" - def __init__(self, scenario: Scenario) -> None: + def __init__(self, feature: Feature, scenario: Scenario) -> None: """Scenario report constructor. + :param pytest_bdd.parser.Feature feature: Feature. :param pytest_bdd.parser.Scenario scenario: Scenario. """ + self.feature: Feature = feature self.scenario: Scenario = scenario self.step_reports: list[StepReport] = [] @@ -104,21 +106,18 @@ def serialize(self) -> dict[str, Any]: :return: Serialized report. :rtype: dict """ - scenario = self.scenario - feature = scenario.parent - return { "steps": [step_report.serialize() for step_report in self.step_reports], - "name": scenario.name, - "line_number": scenario.location.line, - "tags": sorted(scenario.tag_names), + "name": self.scenario.name, + "line_number": self.scenario.location.line, + "tags": sorted(self.scenario.tag_names), "feature": { - "name": feature.name, - "filename": feature.filename, - "rel_filename": feature.rel_filename, - "line_number": feature.location.line, - "description": feature.description, - "tags": sorted(feature.tag_names), + "name": self.feature.name, + "filename": self.feature.filename, + "rel_filename": self.feature.rel_filename, + "line_number": self.feature.location.line, + "description": self.feature.description, + "tags": sorted(self.feature.tag_names), }, } @@ -146,9 +145,9 @@ def runtest_makereport(item: Item, call: CallInfo, rep: TestReport) -> None: rep.item = {"name": item.name} -def before_scenario(request: FixtureRequest, scenario: Scenario) -> None: +def before_scenario(request: FixtureRequest, feature: Feature, scenario: Scenario) -> None: """Create scenario report for the item.""" - request.node.__scenario_report__ = ScenarioReport(scenario=scenario) + request.node.__scenario_report__ = ScenarioReport(feature=feature, scenario=scenario) def step_error( diff --git a/src/pytest_bdd/scenario.py b/src/pytest_bdd/scenario.py index 0b7e654dc..83d6d3ed1 100644 --- a/src/pytest_bdd/scenario.py +++ b/src/pytest_bdd/scenario.py @@ -218,14 +218,14 @@ def _execute_step_function( request.config.hook.pytest_bdd_after_step(**kw) -def _execute_scenario(gherkin_scenario: Scenario, request: FixtureRequest) -> None: +def _execute_scenario(feature: Feature, gherkin_scenario: Scenario, request: FixtureRequest) -> None: """Execute the scenario. :param gherkin_scenario: Scenario. :param request: request. """ __tracebackhide__ = True - request.config.hook.pytest_bdd_before_scenario(request=request, scenario=gherkin_scenario) + request.config.hook.pytest_bdd_before_scenario(request=request, feature=feature, scenario=gherkin_scenario) try: for step in gherkin_scenario.all_steps: @@ -237,7 +237,7 @@ def _execute_scenario(gherkin_scenario: Scenario, request: FixtureRequest) -> No ) request.config.hook.pytest_bdd_step_func_lookup_error( request=request, - feature=gherkin_scenario.parent, + feature=feature, scenario=gherkin_scenario, step=step, exception=exc, @@ -245,9 +245,7 @@ def _execute_scenario(gherkin_scenario: Scenario, request: FixtureRequest) -> No raise exc _execute_step_function(request, gherkin_scenario, step, step_func_context) finally: - request.config.hook.pytest_bdd_after_scenario( - request=request, feature=gherkin_scenario.parent, scenario=gherkin_scenario - ) + request.config.hook.pytest_bdd_after_scenario(request=request, feature=feature, scenario=gherkin_scenario) def _get_scenario_decorator(feature: Feature, gherkin_scenario: Scenario) -> Callable[[Callable[P, T]], Callable[P, T]]: @@ -271,7 +269,7 @@ def decorator(*args: Callable[P, T]) -> Callable[P, T]: def scenario_wrapper(request: FixtureRequest, _pytest_bdd_example: dict[str, str]) -> Any: __tracebackhide__ = True gherkin_scenario.render(_pytest_bdd_example) - _execute_scenario(gherkin_scenario, request) + _execute_scenario(feature, gherkin_scenario, request) fixture_values = [request.getfixturevalue(arg) for arg in func_args] return fn(*fixture_values) From 86a6366af7031f29e1ec62831a57a3ccf823afd7 Mon Sep 17 00:00:00 2001 From: Jason Allen Date: Mon, 9 Sep 2024 21:50:43 +0100 Subject: [PATCH 16/17] Fixes, performance improvements and speed up tests by avoiding subprocessing where not needed --- src/pytest_bdd/feature.py | 6 +- src/pytest_bdd/generation.py | 8 +- src/pytest_bdd/parser.py | 509 ++++++++++++++++++-------------- src/pytest_bdd/reporting.py | 2 +- src/pytest_bdd/scenario.py | 12 +- tests/feature/test_outline.py | 4 +- tests/feature/test_scenario.py | 6 +- tests/feature/test_scenarios.py | 4 +- tests/feature/test_steps.py | 12 +- tests/feature/test_tags.py | 2 +- 10 files changed, 318 insertions(+), 247 deletions(-) diff --git a/src/pytest_bdd/feature.py b/src/pytest_bdd/feature.py index a0c21e064..9b80b3c50 100644 --- a/src/pytest_bdd/feature.py +++ b/src/pytest_bdd/feature.py @@ -29,7 +29,7 @@ import glob import os.path -from .parser import Feature, GherkinParser +from .parser import Feature, get_gherkin_document # Global features dictionary features: dict[str, Feature] = {} @@ -53,7 +53,7 @@ def get_feature(base_path: str, filename: str, encoding: str = "utf-8") -> Featu rel_filename = os.path.join(os.path.basename(base_path), filename) feature = features.get(full_filename) if not feature: - gherkin_document = GherkinParser(full_filename, rel_filename, encoding).to_gherkin_document() + gherkin_document = get_gherkin_document(full_filename, rel_filename, encoding) feature = gherkin_document.feature features[full_filename] = feature return feature @@ -79,5 +79,5 @@ def get_features(paths: list[str], **kwargs) -> list[Feature]: base, name = os.path.split(path) feature = get_feature(base, name, **kwargs) features.append(feature) - features.sort(key=lambda feature: feature.name or feature.filename) + features.sort(key=lambda feature: feature.name or feature.abs_filename) return features diff --git a/src/pytest_bdd/generation.py b/src/pytest_bdd/generation.py index 85fe1b4cb..faa8f0b84 100644 --- a/src/pytest_bdd/generation.py +++ b/src/pytest_bdd/generation.py @@ -102,14 +102,14 @@ def print_missing_code(scenarios: list[Scenario], steps: list[Step]) -> None: tw.line( """Step {step} is not defined in the scenario "{step.parent.name}" in the feature""" """ "{step.parent.parent.name}" in the file""" - """ {step.parent.parent.filename}:{step.location.line}""".format(step=step), + """ {step.parent.parent.abs_filename}:{step.location.line}""".format(step=step), red=True, ) elif step.background is not None: tw.line( """Step {step} is not defined in the background of the feature""" """ "{step.background.parent.name}" in the file""" - """ {step.background.parent.filename}:{step.location.line}""".format(step=step), + """ {step.background.parent.abs_filename}:{step.location.line}""".format(step=step), red=True, ) @@ -120,7 +120,7 @@ def print_missing_code(scenarios: list[Scenario], steps: list[Step]) -> None: tw.line() features = sorted( - (scenario.feature for scenario in scenarios), key=lambda feature: feature.name or feature.filename + (scenario.feature for scenario in scenarios), key=lambda feature: feature.name or feature.abs_filename ) code = generate_code(features, scenarios, steps) tw.write(code) @@ -146,7 +146,7 @@ def parse_feature_files(paths: list[str], **kwargs: Any) -> tuple[list[Feature], features = get_features(paths, **kwargs) scenarios = sorted( itertools.chain.from_iterable(feature.scenarios for feature in features), - key=lambda scenario: (scenario.feature.name or scenario.feature.filename, scenario.name), + key=lambda scenario: (scenario.feature.name or scenario.feature.abs_filename, scenario.name), ) steps = sorted((step for scenario in scenarios for step in scenario.all_steps), key=lambda step: step.name) return features, scenarios, steps diff --git a/src/pytest_bdd/parser.py b/src/pytest_bdd/parser.py index 589a1548a..1206711b5 100644 --- a/src/pytest_bdd/parser.py +++ b/src/pytest_bdd/parser.py @@ -1,13 +1,13 @@ import linecache import re import textwrap -from pathlib import Path -from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple, Union +from dataclasses import dataclass, field +from functools import cached_property +from typing import Any, Dict, Iterable, List, Mapping, Optional, Set, Union from gherkin.errors import CompositeParserException from gherkin.parser import Parser from gherkin.token_scanner import TokenScanner -from pydantic import BaseModel, field_validator, model_validator from . import exceptions from .types import STEP_TYPES @@ -16,50 +16,66 @@ COMMENT_RE = re.compile(r"(^|(?<=\s))#") -def check_instance_by_name(obj: Any, class_name: str) -> bool: - return obj.__class__.__name__ == class_name - - -def strip_comments(line: str) -> str: - """Remove comments from a line of text. - - Args: - line (str): The line of text from which to remove comments. - - Returns: - str: The line of text without comments, with leading and trailing whitespace removed. - """ - if res := COMMENT_RE.search(line): - line = line[: res.start()] - return line.strip() - - -class Location(BaseModel): +@dataclass(frozen=True) +class Location: column: int line: int + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Location": + return cls(column=data["column"], line=data["line"]) + -class Comment(BaseModel): +@dataclass(frozen=True) +class Comment: location: Location text: str + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Comment": + return cls(location=Location.from_dict(data["location"]), text=data["text"]) -class Cell(BaseModel): + +@dataclass(frozen=True) +class Cell: location: Location value: str + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Cell": + return cls(location=Location.from_dict(data["location"]), value=_convert_to_raw_string(data["value"])) + -class Row(BaseModel): +@dataclass(frozen=True) +class Row: id: str location: Location cells: List[Cell] + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Row": + return cls( + id=data["id"], + location=Location.from_dict(data["location"]), + cells=[Cell.from_dict(cell) for cell in data["cells"]], + ) -class DataTable(BaseModel): - name: Optional[str] = None + +@dataclass(frozen=True) +class DataTable: location: Location + name: Optional[str] = None tableHeader: Optional[Row] = None - tableBody: Optional[List[Row]] = None + tableBody: Optional[List[Row]] = field(default_factory=list) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "DataTable": + return cls( + location=Location.from_dict(data["location"]), + name=data.get("name"), + tableHeader=Row.from_dict(data["tableHeader"]) if data.get("tableHeader") else None, + tableBody=[Row.from_dict(row) for row in data.get("tableBody", [])], + ) def as_contexts(self) -> Iterable[Dict[str, Any]]: """ @@ -80,135 +96,124 @@ def as_contexts(self) -> Iterable[Dict[str, Any]]: yield dict(zip(example_params, [cell.value for cell in row.cells])) -class DocString(BaseModel): +@dataclass(frozen=True) +class DocString: content: str delimiter: str location: Location - @field_validator("content", mode="before") - def dedent_content(cls, value: str) -> str: - return textwrap.dedent(value) + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "DocString": + return cls( + content=textwrap.dedent(data["content"]), + delimiter=data["delimiter"], + location=Location.from_dict(data["location"]), + ) -class Step(BaseModel): +@dataclass +class Step: id: str keyword: str keywordType: str location: Location text: str + name: Optional[str] = None + raw_name: Optional[str] = None dataTable: Optional[DataTable] = None docString: Optional[DocString] = None - raw_name: Optional[str] = None - name: Optional[str] = None parent: Optional[Union["Background", "Scenario"]] = None failed: bool = False duration: Optional[float] = None + def __post_init__(self): + def generate_initial_name(): + """Generate an initial name based on the step's text and optional docString.""" + self.name = _strip_comments(self.text) + if self.docString: + self.name = f"{self.name}\n{self.docString.content}" + # Populate a frozen copy of the name untouched by params later + self.raw_name = self.name + + generate_initial_name() + self.params = tuple(frozenset(STEP_PARAM_RE.findall(self.raw_name))) + + def get_parent_of_type(self, parent_type) -> Optional[Any]: + """Return the parent if it's of the specified type.""" + return self.parent if isinstance(self.parent, parent_type) else None + @property def scenario(self) -> Optional["Scenario"]: - """Returns the scenario if the step's parent is a Scenario.""" - if isinstance(self.parent, Scenario): - return self.parent - return None + return self.get_parent_of_type(Scenario) @property def background(self) -> Optional["Background"]: - """Returns the background if the step's parent is a Background.""" - if isinstance(self.parent, Background): - return self.parent - return None - - def generate_initial_name(self) -> None: - """Generate an initial name based on the step's text and optional docString.""" - self.name = strip_comments(self.text) - if self.docString: - self.name = f"{self.name}\n{self.docString.content}" - # Populate a frozen copy of the name untouched by params later - self.raw_name = self.name - - @model_validator(mode="after") - def set_name(cls, instance): - """Set the 'name' attribute after model validation if it is not already provided.""" - instance.generate_initial_name() - return instance - - @field_validator("keyword", mode="before") - def normalize_keyword(cls, value: str) -> str: - """Normalize the keyword (e.g., Given, When, Then).""" - return value.title().strip() + return self.get_parent_of_type(Background) @property def given_when_then(self) -> str: - """Get the Given/When/Then form of the step.""" - return self._gwt + return getattr(self, "_gwt", "") @given_when_then.setter def given_when_then(self, gwt: str) -> None: - """Set the Given/When/Then form of the step.""" self._gwt = gwt def __str__(self) -> str: """Return a string representation of the step.""" return f'{self.given_when_then.capitalize()} "{self.name}"' - @property - def params(self) -> Tuple[str, ...]: - """Get the parameters in the step name.""" - return tuple(frozenset(STEP_PARAM_RE.findall(self.raw_name))) - def render(self, context: Mapping[str, Any]) -> None: """Render the step name with the given context and update the instance. Args: context (Mapping[str, Any]): The context for rendering the step name. """ - - def replacer(m: re.Match) -> str: - varname = m.group(1) - # If the context contains the variable, replace it. Otherwise, leave it unchanged. - return str(context.get(varname, f"<{varname}>")) - - # Render the name and update the instance's text attribute - rendered_name = STEP_PARAM_RE.sub(replacer, self.raw_name) - self.name = rendered_name - - -class Tag(BaseModel): + _render_steps([self], context) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Step": + return cls( + id=data["id"], + keyword=str(data["keyword"]).capitalize().strip(), + keywordType=data["keywordType"], + location=Location.from_dict(data["location"]), + text=data["text"], + dataTable=DataTable.from_dict(data["dataTable"]) if data.get("dataTable") else None, + docString=DocString.from_dict(data["docString"]) if data.get("docString") else None, + ) + + +@dataclass(frozen=True) +class Tag: id: str location: Location name: str + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Tag": + return cls(id=data["id"], location=Location.from_dict(data["location"]), name=data["name"]) -class Scenario(BaseModel): + +@dataclass +class Scenario: id: str keyword: str location: Location name: str description: str steps: List[Step] - tags: List[Tag] - examples: Optional[List[DataTable]] = None + tags: Set[Tag] + examples: Optional[List[DataTable]] = field(default_factory=list) parent: Optional[Union["Feature", "Rule"]] = None - @field_validator("description", mode="before") - def dedent_description(cls, value: str) -> str: - return textwrap.dedent(value) - - @model_validator(mode="after") - def process_steps(cls, instance): - steps = instance.steps - instance.steps = _compute_given_when_then(steps) - return instance + def __post_init__(self): + self.steps = _compute_given_when_then(self.steps) + for step in self.steps: + step.parent = self - @model_validator(mode="after") - def process_scenario_for_steps(cls, instance): - for step in instance.steps: - step.parent = instance - return instance - - @property - def tag_names(self) -> List[str]: - return get_tag_names(self.tags) + @cached_property + def tag_names(self) -> Set[str]: + return _get_tag_names(self.tags) def render(self, context: Mapping[str, Any]) -> None: """Render the scenario's steps with the given context. @@ -216,56 +221,70 @@ def render(self, context: Mapping[str, Any]) -> None: Args: context (Mapping[str, Any]): The context for rendering steps. """ - for step in self.steps: - step.render(context) + _render_steps(self.steps, context) - @property + @cached_property def feature(self): - if check_instance_by_name(self.parent, "Feature"): - return self.parent - return None + return self.parent if _check_instance_by_name(self.parent, "Feature") else None - @property + @cached_property def rule(self): - if check_instance_by_name(self.parent, "Rule"): - return self.parent - return None + return self.parent if _check_instance_by_name(self.parent, "Rule") else None @property def all_steps(self) -> List[Step]: """Get all steps including background steps if present.""" - # Check if the scenario belongs to a feature and if the feature has background steps background_steps = self.feature.background_steps if self.feature else [] - # Return the combined list of background steps and scenario steps return background_steps + self.steps - -class Rule(BaseModel): + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Scenario": + return cls( + id=data["id"], + keyword=data["keyword"], + location=Location.from_dict(data["location"]), + name=data["name"], + description=textwrap.dedent(data["description"]), + steps=[Step.from_dict(step) for step in data["steps"]], + tags={Tag.from_dict(tag) for tag in data["tags"]}, + examples=[DataTable.from_dict(example) for example in data.get("examples", [])], + ) + + +@dataclass +class Rule: id: str keyword: str location: Location name: str description: str - tags: List[Tag] + tags: Set[Tag] children: List[Scenario] parent: Optional["Feature"] = None - @field_validator("description", mode="before") - def dedent_description(cls, value: str) -> str: - return textwrap.dedent(value) - - @model_validator(mode="after") - def process_scenarios(cls, instance): - for scenario in instance.children: - scenario.parent = instance - return instance - - @property - def tag_names(self) -> List[str]: - return get_tag_names(self.tags) - - -class Background(BaseModel): + def __post_init__(self): + for scenario in self.children: + scenario.parent = self + + @cached_property + def tag_names(self) -> Set[str]: + return _get_tag_names(self.tags) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Rule": + return cls( + id=data["id"], + keyword=data["keyword"], + location=Location.from_dict(data["location"]), + name=data["name"], + description=textwrap.dedent(data["description"]), + tags={Tag.from_dict(tag) for tag in data["tags"]}, + children=[Scenario.from_dict(child) for child in data["children"]], + ) + + +@dataclass +class Background: id: str keyword: str location: Location @@ -274,21 +293,10 @@ class Background(BaseModel): steps: List[Step] parent: Optional["Feature"] = None - @field_validator("description", mode="before") - def dedent_description(cls, value: str) -> str: - return textwrap.dedent(value) - - @model_validator(mode="after") - def process_given_when_then(cls, instance): - steps = instance.steps - instance.steps = _compute_given_when_then(steps) - return instance - - @model_validator(mode="after") - def process_background_for_steps(cls, instance): - for step in instance.steps: - step.parent = instance - return instance + def __post_init__(self): + self.steps = _compute_given_when_then(self.steps) + for step in self.steps: + step.parent = self def render(self, context: Mapping[str, Any]) -> None: """Render the scenario's steps with the given context. @@ -296,57 +304,60 @@ def render(self, context: Mapping[str, Any]) -> None: Args: context (Mapping[str, Any]): The context for rendering steps. """ - for step in self.steps: - step.render(context) - - -class Child(BaseModel): + _render_steps(self.steps, context) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Background": + return cls( + id=data["id"], + keyword=data["keyword"], + location=Location.from_dict(data["location"]), + name=data["name"], + description=textwrap.dedent(data["description"]), + steps=[Step.from_dict(step) for step in data["steps"]], + ) + + +@dataclass +class Child: background: Optional[Background] = None rule: Optional[Rule] = None scenario: Optional[Scenario] = None parent: Optional[Union["Feature", "Rule"]] = None - @model_validator(mode="after") - def assign_parents(cls, instance): - if instance.scenario: - instance.scenario.parent = instance.parent - if instance.background: - instance.background.parent = instance.parent - return instance + def __post_init__(self): + if self.scenario: + self.scenario.parent = self.parent + if self.background: + self.background.parent = self.parent + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Child": + return cls( + background=Background.from_dict(data["background"]) if data.get("background") else None, + rule=Rule.from_dict(data["rule"]) if data.get("rule") else None, + scenario=Scenario.from_dict(data["scenario"]) if data.get("scenario") else None, + ) -class Feature(BaseModel): +@dataclass +class Feature: keyword: str location: Location - tags: List[Tag] + tags: Set[Tag] name: str description: str children: List[Child] abs_filename: Optional[str] = None rel_filename: Optional[str] = None - @field_validator("description", mode="before") - def dedent_description(cls, value: str) -> str: - return textwrap.dedent(value) - - @model_validator(mode="after") - def assign_child_parents(cls, instance): - for child in instance.children: - child.parent = instance + def __post_init__(self): + for child in self.children: + child.parent = self if child.scenario: - child.scenario.parent = instance + child.scenario.parent = self if child.background: - child.background.parent = instance - return instance - - @property - def filename(self) -> Optional[str]: - """ - Returns the file name from abs_filename, if available. - """ - if self.abs_filename: - return str(Path(self.abs_filename).resolve()) - return None + child.background.parent = self @property def scenarios(self) -> List[Scenario]: @@ -359,11 +370,12 @@ def backgrounds(self) -> List[Background]: @property def background_steps(self) -> List[Step]: _steps = [] - for background in self.backgrounds: + backgrounds = self.backgrounds + for background in backgrounds: _steps.extend(background.steps) return _steps - @property + @cached_property def rules(self) -> List[Rule]: return [child.rule for child in self.children if child.rule] @@ -379,50 +391,109 @@ def get_child_by_name(self, name: str) -> Optional[Union[Scenario, Background]]: return background return None - @property - def tag_names(self) -> List[str]: - return get_tag_names(self.tags) + @cached_property + def tag_names(self) -> Set[str]: + return _get_tag_names(self.tags) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Feature": + return cls( + keyword=data["keyword"], + location=Location.from_dict(data["location"]), + tags={Tag.from_dict(tag) for tag in data["tags"]}, + name=data["name"], + description=textwrap.dedent(data["description"]), + children=[Child.from_dict(child) for child in data["children"]], + ) -class GherkinDocument(BaseModel): +@dataclass +class GherkinDocument: feature: Feature comments: List[Comment] + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "GherkinDocument": + return cls( + feature=Feature.from_dict(data["feature"]), + comments=[Comment.from_dict(comment) for comment in data["comments"]], + ) + def _compute_given_when_then(steps: List[Step]) -> List[Step]: last_gwt = None for step in steps: - if step.keyword.lower() in STEP_TYPES: - last_gwt = step.keyword.lower() + lower_keyword = step.keyword.lower() + if lower_keyword in STEP_TYPES: + last_gwt = lower_keyword step.given_when_then = last_gwt return steps -def get_tag_names(tags: List[Tag]): - return [tag.name.lstrip("@") for tag in tags] - - -class GherkinParser: - def __init__(self, abs_filename: str, rel_filename: str, encoding: str = "utf-8"): - self.abs_filename = abs_filename - self.rel_filename = rel_filename - self.encoding = encoding - - with open(self.abs_filename, encoding=self.encoding) as f: - self.feature_file_text = f.read() - try: - self.gherkin_data = Parser().parse(TokenScanner(self.feature_file_text)) - except CompositeParserException as e: - raise exceptions.FeatureError( - e.args[0], - e.errors[0].location["line"], - linecache.getline(self.abs_filename, e.errors[0].location["line"]).rstrip("\n"), - self.abs_filename, - ) from e - - def to_gherkin_document(self) -> GherkinDocument: - gherkin_document = GherkinDocument(**self.gherkin_data) - # Pass abs_filename to the feature - gherkin_document.feature.abs_filename = self.abs_filename - gherkin_document.feature.rel_filename = self.rel_filename - return gherkin_document +def get_gherkin_document(abs_filename: str, rel_filename: str, encoding: str = "utf-8") -> GherkinDocument: + with open(abs_filename, encoding=encoding) as f: + feature_file_text = f.read() + + try: + gherkin_data = Parser().parse(TokenScanner(feature_file_text)) + except CompositeParserException as e: + raise exceptions.FeatureError( + e.args[0], + e.errors[0].location["line"], + linecache.getline(abs_filename, e.errors[0].location["line"]).rstrip("\n"), + abs_filename, + ) from e + + gherkin_doc = GherkinDocument.from_dict(gherkin_data) + gherkin_doc.feature.abs_filename = abs_filename + gherkin_doc.feature.rel_filename = rel_filename + return gherkin_doc + + +def _check_instance_by_name(obj: Any, class_name: str) -> bool: + return obj.__class__.__name__ == class_name + + +def _strip_comments(line: str) -> str: + """Remove comments from a line of text. + + Args: + line (str): The line of text from which to remove comments. + + Returns: + str: The line of text without comments, with leading and trailing whitespace removed. + """ + if "#" not in line: + return line + if res := COMMENT_RE.search(line): + line = line[: res.start()] + return line.strip() + + +def _get_tag_names(tags: Set[Tag]): + return {tag.name.lstrip("@") for tag in tags} + + +def _convert_to_raw_string(normal_string: str) -> str: + return normal_string.replace("\\", "\\\\") + + +def _render_steps(steps: List[Step], context: Mapping[str, Any]) -> None: + """ + Render multiple steps in batch by applying the context to each step's text. + + Args: + steps (List[Step]): The list of steps to render. + context (Mapping[str, Any]): The context to apply to the step names. + """ + # Create a map of parameter replacements for all steps at once + # This will store {param: replacement} for each variable found in steps + replacements = {param: context.get(param, f"<{param}>") for step in steps for param in step.params} + + # Precompute replacement function + def replacer(text: str) -> str: + return STEP_PARAM_RE.sub(lambda m: replacements.get(m.group(1), m.group(0)), text) + + # Apply the replacement in batch + for step in steps: + step.name = replacer(step.raw_name) diff --git a/src/pytest_bdd/reporting.py b/src/pytest_bdd/reporting.py index 7b4decc40..675ad5f9c 100644 --- a/src/pytest_bdd/reporting.py +++ b/src/pytest_bdd/reporting.py @@ -113,7 +113,7 @@ def serialize(self) -> dict[str, Any]: "tags": sorted(self.scenario.tag_names), "feature": { "name": self.feature.name, - "filename": self.feature.filename, + "filename": self.feature.abs_filename, "rel_filename": self.feature.rel_filename, "line_number": self.feature.location.line, "description": self.feature.description, diff --git a/src/pytest_bdd/scenario.py b/src/pytest_bdd/scenario.py index 83d6d3ed1..b7f2418fe 100644 --- a/src/pytest_bdd/scenario.py +++ b/src/pytest_bdd/scenario.py @@ -233,7 +233,7 @@ def _execute_scenario(feature: Feature, gherkin_scenario: Scenario, request: Fix if step_func_context is None: exc = exceptions.StepDefinitionNotFoundError( f"Step definition is not found: {step}. " - f'Line {step.location.line} in scenario "{gherkin_scenario.name}" in the feature "{gherkin_scenario.parent.filename}"' + f'Line {step.location.line} in scenario "{gherkin_scenario.name}" in the feature "{gherkin_scenario.parent.abs_filename}"' ) request.config.hook.pytest_bdd_step_func_lookup_error( request=request, @@ -281,7 +281,7 @@ def scenario_wrapper(request: FixtureRequest, _pytest_bdd_example: dict[str, str example_parametrizations, )(scenario_wrapper) - for tag in list(set(gherkin_scenario.tag_names + feature.tag_names)): + for tag in gherkin_scenario.tag_names | feature.tag_names: config = CONFIG_STACK[-1] config.hook.pytest_bdd_apply_tag(tag=tag, function=scenario_wrapper) @@ -330,7 +330,7 @@ def scenario( if gherkin_scenario is None: feature_name = feature.name or "[Empty]" raise exceptions.ScenarioNotFound( - f'Scenario "{scenario_name}" in feature "{feature_name}" in {feature.filename} is not found.' + f'Scenario "{scenario_name}" in feature "{feature_name}" in {feature.abs_filename} is not found.' ) return _get_scenario_decorator(feature=feature, gherkin_scenario=gherkin_scenario) @@ -407,7 +407,7 @@ def scenarios(*feature_paths: str, **kwargs: Any) -> None: found = False module_scenarios = frozenset( - (attr.__scenario__.feature.filename, attr.__scenario__.name) + (attr.__scenario__.feature.abs_filename, attr.__scenario__.name) for name, attr in caller_locals.items() if hasattr(attr, "__scenario__") ) @@ -415,9 +415,9 @@ def scenarios(*feature_paths: str, **kwargs: Any) -> None: for feature in get_features(abs_feature_paths): for gherkin_scenario in feature.scenarios: # skip already bound scenarios - if (feature.filename, gherkin_scenario.name) not in module_scenarios: + if (feature.abs_filename, gherkin_scenario.name) not in module_scenarios: - @scenario(feature.filename, gherkin_scenario.name, **kwargs) + @scenario(feature.abs_filename, gherkin_scenario.name, **kwargs) def _scenario() -> None: pass # pragma: no cover diff --git a/tests/feature/test_outline.py b/tests/feature/test_outline.py index b1a635bc9..db591266f 100644 --- a/tests/feature/test_outline.py +++ b/tests/feature/test_outline.py @@ -217,6 +217,6 @@ def _(string): r"bork |", r"bork||bork", r"|", - "bork \\", - "bork \\|", + r"bork \\", + r"bork \\|", ] diff --git a/tests/feature/test_scenario.py b/tests/feature/test_scenario.py index 669d45caf..5bc7bb061 100644 --- a/tests/feature/test_scenario.py +++ b/tests/feature/test_scenario.py @@ -28,7 +28,7 @@ def test_not_found(): """ ) ) - result = pytester.runpytest_subprocess(*pytest_params) + result = pytester.runpytest_inprocess(*pytest_params) result.assert_outcomes(errors=1) result.stdout.fnmatch_lines('*Scenario "NOT FOUND" in feature "Scenario is not found" in*') @@ -111,7 +111,7 @@ def test_scenario_not_decorator(pytester, pytest_params): """ ) - result = pytester.runpytest_subprocess(*pytest_params) + result = pytester.runpytest_inprocess(*pytest_params) result.assert_outcomes(failed=1) result.stdout.fnmatch_lines("*ScenarioIsDecoratorOnly: scenario function can only be used as a decorator*") @@ -144,7 +144,7 @@ def _(): pass """ ) - result = pytester.runpytest_subprocess(*pytest_params) + result = pytester.runpytest_inprocess(*pytest_params) result.assert_outcomes(passed=1) diff --git a/tests/feature/test_scenarios.py b/tests/feature/test_scenarios.py index 9e0407c4f..ff7d66b23 100644 --- a/tests/feature/test_scenarios.py +++ b/tests/feature/test_scenarios.py @@ -66,7 +66,7 @@ def test_already_bound(): scenarios('features') """ ) - result = pytester.runpytest_subprocess("-v", "-s", *pytest_params) + result = pytester.runpytest_inprocess("-v", "-s", *pytest_params) result.assert_outcomes(passed=4, failed=1) result.stdout.fnmatch_lines(["*collected 5 items"]) result.stdout.fnmatch_lines(["*test_test_subfolder_scenario *bar!", "PASSED"]) @@ -86,6 +86,6 @@ def test_scenarios_none_found(pytester, pytest_params): scenarios('.') """ ) - result = pytester.runpytest_subprocess(testpath, *pytest_params) + result = pytester.runpytest_inprocess(testpath, *pytest_params) result.assert_outcomes(errors=1) result.stdout.fnmatch_lines(["*NoScenariosFound*"]) diff --git a/tests/feature/test_steps.py b/tests/feature/test_steps.py index 94c11bcc3..56af6b154 100644 --- a/tests/feature/test_steps.py +++ b/tests/feature/test_steps.py @@ -361,7 +361,7 @@ def test_step_hooks(pytester): Scenario: When step's dependency a has failure Given I have a bar - When it's dependency fails + When its dependency fails Scenario: When step is not found Given not found @@ -392,7 +392,7 @@ def _(): def dependency(): raise Exception('dependency fails') - @when("it's dependency fails") + @when("its dependency fails") def _(dependency): pass @@ -479,7 +479,7 @@ def test_step_trace(pytester): Scenario: When step's dependency a has failure Given I have a bar - When it's dependency fails + When its dependency fails Scenario: When step is not found Given not found @@ -495,18 +495,18 @@ def test_step_trace(pytester): from pytest_bdd import given, when, scenario @given('I have a bar') - def i_have_bar(): + def _(): return 'bar' @when('it fails') - def when_it_fails(): + def _(): raise Exception('when fails') @pytest.fixture def dependency(): raise Exception('dependency fails') - @when("it's dependency fails") + @when("its dependency fails") def when_dependency_fails(dependency): pass diff --git a/tests/feature/test_tags.py b/tests/feature/test_tags.py index 20a64dc8e..1ec56d79c 100644 --- a/tests/feature/test_tags.py +++ b/tests/feature/test_tags.py @@ -189,5 +189,5 @@ def _(): """ ) strict_option = "--strict-markers" - result = pytester.runpytest_subprocess(strict_option) + result = pytester.runpytest_inprocess(strict_option) result.stdout.fnmatch_lines(["*= 2 passed * =*"]) From 870fb09ab45b9cca9679aa4e0ac4ea63a790908d Mon Sep 17 00:00:00 2001 From: Jason Allen Date: Thu, 12 Sep 2024 11:07:18 +0100 Subject: [PATCH 17/17] Dump current state of tinkering --- src/pytest_bdd/feature.py | 21 +++++++++++------ src/pytest_bdd/gherkin_terminal_reporter.py | 25 +++++++++------------ src/pytest_bdd/parser.py | 6 +++-- src/pytest_bdd/reporting.py | 20 +++++++++-------- src/pytest_bdd/scenario.py | 7 +++--- 5 files changed, 42 insertions(+), 37 deletions(-) diff --git a/src/pytest_bdd/feature.py b/src/pytest_bdd/feature.py index 9b80b3c50..bf91345f7 100644 --- a/src/pytest_bdd/feature.py +++ b/src/pytest_bdd/feature.py @@ -28,6 +28,7 @@ import glob import os.path +from typing import Iterator from .parser import Feature, get_gherkin_document @@ -67,17 +68,23 @@ def get_features(paths: list[str], **kwargs) -> list[Feature]: :return: `list` of `Feature` objects. """ seen_names = set() - features = [] + _features = [] for path in paths: if path not in seen_names: seen_names.add(path) if os.path.isdir(path): - features.extend( - get_features(glob.iglob(os.path.join(path, "**", "*.feature"), recursive=True), **kwargs) - ) + for feature_file in _find_feature_files(path): + base, name = os.path.split(feature_file) + feature = get_feature(base, name, **kwargs) + _features.append(feature) else: base, name = os.path.split(path) feature = get_feature(base, name, **kwargs) - features.append(feature) - features.sort(key=lambda feature: feature.name or feature.abs_filename) - return features + _features.append(feature) + _features.sort(key=lambda _feature: _feature.name or _feature.abs_filename) + return _features + + +def _find_feature_files(path: str) -> Iterator[str]: + """Recursively find all `.feature` files in a given directory.""" + return glob.iglob(os.path.join(path, "**", "*.feature"), recursive=True) diff --git a/src/pytest_bdd/gherkin_terminal_reporter.py b/src/pytest_bdd/gherkin_terminal_reporter.py index b26a8a7db..5c07130d3 100644 --- a/src/pytest_bdd/gherkin_terminal_reporter.py +++ b/src/pytest_bdd/gherkin_terminal_reporter.py @@ -31,10 +31,8 @@ def configure(config: Config) -> None: raise Exception( "gherkin-terminal-reporter is not compatible with any other terminal reporter." "You can use only one terminal reporter." - "Currently '{0}' is used." - "Please decide to use one by deactivating {0} or gherkin-terminal-reporter.".format( - current_reporter.__class__ - ) + "Currently '{current_reporter.__class__}' is used." + "Please decide to use one by deactivating {current_reporter.__class__} or gherkin-terminal-reporter." ) gherkin_reporter = GherkinTerminalReporter(config) config.pluginmanager.unregister(current_reporter) @@ -70,23 +68,20 @@ def pytest_runtest_logreport(self, report: TestReport) -> Any: if self.verbosity <= 0 or not hasattr(report, "scenario"): return super().pytest_runtest_logreport(rep) + # Common logic for verbosity 1 and greater + self.ensure_newline() + self._tw.write("Feature: ", **feature_markup) + self._tw.write(report.scenario["feature"]["name"], **feature_markup) + self._tw.write("\n") + self._tw.write(" Scenario: ", **scenario_markup) + self._tw.write(report.scenario["name"], **scenario_markup) + if self.verbosity == 1: - self.ensure_newline() - self._tw.write("Feature: ", **feature_markup) - self._tw.write(report.scenario["feature"]["name"], **feature_markup) - self._tw.write("\n") - self._tw.write(" Scenario: ", **scenario_markup) - self._tw.write(report.scenario["name"], **scenario_markup) self._tw.write(" ") self._tw.write(word, **word_markup) self._tw.write("\n") elif self.verbosity > 1: self.ensure_newline() - self._tw.write("Feature: ", **feature_markup) - self._tw.write(report.scenario["feature"]["name"], **feature_markup) - self._tw.write("\n") - self._tw.write(" Scenario: ", **scenario_markup) - self._tw.write(report.scenario["name"], **scenario_markup) self._tw.write("\n") for step in report.scenario["steps"]: self._tw.write(f" {step['keyword']} {step['name']}\n", **scenario_markup) diff --git a/src/pytest_bdd/parser.py b/src/pytest_bdd/parser.py index 1206711b5..4aac8b218 100644 --- a/src/pytest_bdd/parser.py +++ b/src/pytest_bdd/parser.py @@ -239,6 +239,8 @@ def all_steps(self) -> List[Step]: @classmethod def from_dict(cls, data: Dict[str, Any]) -> "Scenario": + if "id" not in data: + print("Hmm...") return cls( id=data["id"], keyword=data["keyword"], @@ -259,7 +261,7 @@ class Rule: name: str description: str tags: Set[Tag] - children: List[Scenario] + children: List["Child"] parent: Optional["Feature"] = None def __post_init__(self): @@ -279,7 +281,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "Rule": name=data["name"], description=textwrap.dedent(data["description"]), tags={Tag.from_dict(tag) for tag in data["tags"]}, - children=[Scenario.from_dict(child) for child in data["children"]], + children=[Child.from_dict(child) for child in data["children"]], ) diff --git a/src/pytest_bdd/reporting.py b/src/pytest_bdd/reporting.py index 675ad5f9c..023858552 100644 --- a/src/pytest_bdd/reporting.py +++ b/src/pytest_bdd/reporting.py @@ -106,18 +106,20 @@ def serialize(self) -> dict[str, Any]: :return: Serialized report. :rtype: dict """ + feature = self.feature + scenario = self.scenario return { "steps": [step_report.serialize() for step_report in self.step_reports], - "name": self.scenario.name, - "line_number": self.scenario.location.line, - "tags": sorted(self.scenario.tag_names), + "name": scenario.name, + "line_number": scenario.location.line, + "tags": sorted(scenario.tag_names), "feature": { - "name": self.feature.name, - "filename": self.feature.abs_filename, - "rel_filename": self.feature.rel_filename, - "line_number": self.feature.location.line, - "description": self.feature.description, - "tags": sorted(self.feature.tag_names), + "name": feature.name, + "filename": feature.abs_filename, + "rel_filename": feature.rel_filename, + "line_number": feature.location.line, + "description": feature.description, + "tags": sorted(feature.tag_names), }, } diff --git a/src/pytest_bdd/scenario.py b/src/pytest_bdd/scenario.py index b7f2418fe..da6f91102 100644 --- a/src/pytest_bdd/scenario.py +++ b/src/pytest_bdd/scenario.py @@ -159,16 +159,15 @@ def get_step_function(request, step: Step) -> StepFunctionContext | None: Then we let `patch_argumented_step_functions` find out what step definition fixtures can parse the current step, and it will inject them for the step fixture name. - Finally we let request.getfixturevalue(...) fetch the step definition fixture. + Finally, we let request.getfixturevalue(...) fetch the step definition fixture. """ __tracebackhide__ = True bdd_name = get_step_fixture_name(step=step) with inject_fixturedefs_for_step(step=step, fixturemanager=request._fixturemanager, node=request.node): - try: + with contextlib.suppress(pytest.FixtureLookupError): return cast(StepFunctionContext, request.getfixturevalue(bdd_name)) - except pytest.FixtureLookupError: - return None + return None def _execute_step_function(