Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 173 additions & 2 deletions poetry.lock

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ parse-type = "*"
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"
Expand Down
1 change: 0 additions & 1 deletion src/pytest_bdd/compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ def inject_fixture(request: FixtureRequest, arg: str, value: Any) -> None:
:param arg: argument name
:param value: argument value
"""

request._fixturemanager._register_fixture(
name=arg,
func=lambda: value,
Expand Down
33 changes: 21 additions & 12 deletions src/pytest_bdd/feature.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@

import glob
import os.path
from typing import Iterator

from .parser import Feature, parse_feature
from .parser import Feature, get_gherkin_document

# Global features dictionary
features: dict[str, Feature] = {}
Expand All @@ -49,11 +50,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 = parse_feature(base_path, filename, encoding=encoding)
features[full_name] = feature
gherkin_document = get_gherkin_document(full_filename, rel_filename, encoding)
feature = gherkin_document.feature
features[full_filename] = feature
return feature


Expand All @@ -65,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.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)
42 changes: 20 additions & 22 deletions src/pytest_bdd/generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
from _pytest.main import Session
from _pytest.python import Function

from .parser import Feature, ScenarioTemplate, Step
from .parser import Feature, Scenario, Step


template_lookup = TemplateLookup(directories=[os.path.join(os.path.dirname(__file__), "templates")])

Expand Down Expand Up @@ -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")
Expand All @@ -79,16 +80,16 @@ 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

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,
)

Expand All @@ -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.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.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.abs_filename}:{step.location.line}""".format(step=step),
red=True,
)

Expand All @@ -119,7 +120,7 @@ def print_missing_code(scenarios: list[ScenarioTemplate], steps: list[Step]) ->
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)
Expand All @@ -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)
Expand All @@ -144,25 +145,26 @@ 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),
key=lambda scenario: (scenario.feature.name or scenario.feature.filename, scenario.name),
itertools.chain.from_iterable(feature.scenarios for feature in features),
key=lambda scenario: (scenario.feature.name or scenario.feature.abs_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


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


Expand Down Expand Up @@ -190,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)

Expand Down
25 changes: 10 additions & 15 deletions src/pytest_bdd/gherkin_terminal_reporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Loading