Skip to content

Commit 15e6e69

Browse files
authored
Merge pull request #544 from pytest-dev/fix-542
Fix step function resolution logic
2 parents 96cb467 + a6de47a commit 15e6e69

18 files changed

+459
-142
lines changed

.coveragerc

Lines changed: 0 additions & 5 deletions
This file was deleted.

CHANGES.rst

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ Changelog
44
Unreleased
55
----------
66
- Fix bug where steps without parsers would take precedence over steps with parsers. `#534 <https://github.com/pytest-dev/pytest-bdd/pull/534>`_
7-
- Step functions can now be decorated multiple times with @given, @when, @then. Previously every decorator would override ``converters`` and ``target_fixture`` every at every application. `#534 <https://github.com/pytest-dev/pytest-bdd/pull/534>`_ `#525 <https://github.com/pytest-dev/pytest-bdd/issues/525>`_
7+
- Step functions can now be decorated multiple times with @given, @when, @then. Previously every decorator would override ``converters`` and ``target_fixture`` every at every application. `#534 <https://github.com/pytest-dev/pytest-bdd/pull/534>`_ `#544 <https://github.com/pytest-dev/pytest-bdd/pull/544>`_ `#525 <https://github.com/pytest-dev/pytest-bdd/issues/525>`_
88
- ``parsers.re`` now does a `fullmatch <https://docs.python.org/3/library/re.html#re.fullmatch>`_ instead of a partial match. This is to make it work just like the other parsers, since they don't ignore non-matching characters at the end of the string. `#539 <https://github.com/pytest-dev/pytest-bdd/pull/539>`_
9+
- Require pytest>=6.2 `#534 <https://github.com/pytest-dev/pytest-bdd/pull/534>`_
10+
911

1012
6.0.1
1113
-----

poetry.lock

Lines changed: 58 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,15 @@ glob2 = "*"
3939
Mako = "*"
4040
parse = "*"
4141
parse-type = "*"
42-
pytest = ">=5.0"
42+
pytest = ">=6.2.0"
4343
typing-extensions = "*"
4444

4545
[tool.poetry.dev-dependencies]
4646
tox = "^3.25.1"
4747
mypy = "^0.961"
4848
types-setuptools = "^57.4.18"
4949
pytest-xdist = "^2.5.0"
50+
coverage = {extras = ["toml"], version = "^6.4.2"}
5051

5152
[build-system]
5253
requires = ["poetry-core>=1.0.0"]
@@ -61,6 +62,19 @@ profile = "black"
6162
line_length = 120
6263
multi_line_output = 3
6364

65+
[tool.coverage.report]
66+
exclude_lines = [
67+
"if TYPE_CHECKING:",
68+
"if typing\\.TYPE_CHECKING:",
69+
]
70+
[tool.coverage.run]
71+
branch = true
72+
include =[
73+
"pytest_bdd/*",
74+
"tests/*",
75+
]
76+
77+
6478
[tool.mypy]
6579
python_version = "3.7"
6680
warn_return_any = true

pytest_bdd/generation.py

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from mako.lookup import TemplateLookup
1010

1111
from .feature import get_features
12-
from .scenario import find_argumented_step_function, make_python_docstring, make_python_name, make_string_literal
12+
from .scenario import inject_fixturedefs_for_step, make_python_docstring, make_python_name, make_string_literal
1313
from .steps import get_step_fixture_name
1414
from .types import STEP_TYPES
1515

@@ -124,18 +124,12 @@ def print_missing_code(scenarios: list[ScenarioTemplate], steps: list[Step]) ->
124124

125125

126126
def _find_step_fixturedef(
127-
fixturemanager: FixtureManager, item: Function, name: str, type_: str
127+
fixturemanager: FixtureManager, item: Function, step: Step
128128
) -> Sequence[FixtureDef[Any]] | None:
129129
"""Find step fixturedef."""
130-
step_fixture_name = get_step_fixture_name(name, type_)
131-
fixturedefs = fixturemanager.getfixturedefs(step_fixture_name, item.nodeid)
132-
if fixturedefs is not None:
133-
return fixturedefs
134-
135-
step_func_context = find_argumented_step_function(name, type_, fixturemanager)
136-
if step_func_context is not None:
137-
return fixturemanager.getfixturedefs(step_func_context.name, item.nodeid)
138-
return None
130+
with inject_fixturedefs_for_step(step=step, fixturemanager=fixturemanager, nodeid=item.nodeid):
131+
bdd_name = get_step_fixture_name(step=step)
132+
return fixturemanager.getfixturedefs(bdd_name, item.nodeid)
139133

140134

141135
def parse_feature_files(paths: list[str], **kwargs: Any) -> tuple[list[Feature], list[ScenarioTemplate], list[Step]]:
@@ -192,7 +186,7 @@ def _show_missing_code_main(config: Config, session: Session) -> None:
192186
if scenario in scenarios:
193187
scenarios.remove(scenario)
194188
for step in scenario.steps:
195-
fixturedefs = _find_step_fixturedef(fm, item, step.name, step.type)
189+
fixturedefs = _find_step_fixturedef(fm, item, step=step)
196190
if fixturedefs:
197191
try:
198192
steps.remove(step)

pytest_bdd/scenario.py

Lines changed: 81 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,19 @@
1212
"""
1313
from __future__ import annotations
1414

15+
import contextlib
16+
import logging
1517
import os
1618
import re
17-
from typing import TYPE_CHECKING, Callable, cast
19+
from typing import TYPE_CHECKING, Callable, Iterator, cast
1820

1921
import pytest
20-
from _pytest.fixtures import FixtureManager, FixtureRequest, call_fixture_func
22+
from _pytest.fixtures import FixtureDef, FixtureManager, FixtureRequest, call_fixture_func
23+
from _pytest.nodes import iterparentnodeids
2124

2225
from . import exceptions
2326
from .feature import get_feature, get_features
24-
from .steps import StepFunctionContext, inject_fixture
27+
from .steps import StepFunctionContext, get_step_fixture_name, inject_fixture
2528
from .utils import CONFIG_STACK, get_args, get_caller_module_locals, get_caller_module_path
2629

2730
if TYPE_CHECKING:
@@ -31,28 +34,89 @@
3134

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

37+
38+
logger = logging.getLogger(__name__)
39+
40+
3441
PYTHON_REPLACE_REGEX = re.compile(r"\W")
3542
ALPHA_REGEX = re.compile(r"^\d+_*")
3643

3744

38-
def find_argumented_step_function(name: str, type_: str, fixturemanager: FixtureManager) -> StepFunctionContext | None:
39-
"""Find argumented step fixture name."""
45+
def find_fixturedefs_for_step(step: Step, fixturemanager: FixtureManager, nodeid: str) -> Iterable[FixtureDef[Any]]:
46+
"""Find the fixture defs that can parse a step."""
4047
# happens to be that _arg2fixturedefs is changed during the iteration so we use a copy
41-
for fixturename, fixturedefs in list(fixturemanager._arg2fixturedefs.items()):
42-
for fixturedef in reversed(fixturedefs):
48+
fixture_def_by_name = list(fixturemanager._arg2fixturedefs.items())
49+
for i, (fixturename, fixturedefs) in enumerate(fixture_def_by_name):
50+
for pos, fixturedef in enumerate(fixturedefs):
4351
step_func_context = getattr(fixturedef.func, "_pytest_bdd_step_context", None)
4452
if step_func_context is None:
4553
continue
4654

47-
if step_func_context.type != type_:
55+
if step_func_context.type != step.type:
4856
continue
4957

50-
match = step_func_context.parser.is_matching(name)
58+
match = step_func_context.parser.is_matching(step.name)
5159
if not match:
5260
continue
5361

54-
return step_func_context
55-
return None
62+
if fixturedef not in (fixturemanager.getfixturedefs(fixturename, nodeid) or []):
63+
continue
64+
65+
yield fixturedef
66+
67+
68+
@contextlib.contextmanager
69+
def inject_fixturedefs_for_step(step: Step, fixturemanager: FixtureManager, nodeid: str) -> Iterator[None]:
70+
"""Inject fixture definitions that can parse a step.
71+
72+
We fist iterate over all the fixturedefs that can parse the step.
73+
74+
Then we sort them by their "path" (list of parent IDs) so that we respect the fixture scoping rules.
75+
76+
Finally, we inject them into the request.
77+
"""
78+
bdd_name = get_step_fixture_name(step=step)
79+
80+
fixturedefs = list(find_fixturedefs_for_step(step=step, fixturemanager=fixturemanager, nodeid=nodeid))
81+
82+
# Sort the fixture definitions by their "path", so that the `bdd_name` fixture will
83+
# respect the fixture scope
84+
85+
def get_fixture_path(fixture_def: FixtureDef) -> list[str]:
86+
return list(iterparentnodeids(fixture_def.baseid))
87+
88+
fixturedefs.sort(key=lambda x: get_fixture_path(x))
89+
90+
if not fixturedefs:
91+
yield
92+
return
93+
94+
logger.debug("Adding providers for fixture %r: %r", bdd_name, fixturedefs)
95+
fixturemanager._arg2fixturedefs[bdd_name] = fixturedefs
96+
97+
try:
98+
yield
99+
finally:
100+
del fixturemanager._arg2fixturedefs[bdd_name]
101+
102+
103+
def get_step_function(request, step: Step) -> StepFunctionContext | None:
104+
"""Get the step function (context) for the given step.
105+
106+
We first figure out what's the step fixture name that we have to inject.
107+
108+
Then we let `patch_argumented_step_functions` find out what step definition fixtures can parse the current step,
109+
and it will inject them for the step fixture name.
110+
111+
Finally we let request.getfixturevalue(...) fetch the step definition fixture.
112+
"""
113+
bdd_name = get_step_fixture_name(step=step)
114+
115+
with inject_fixturedefs_for_step(step=step, fixturemanager=request._fixturemanager, nodeid=request.node.nodeid):
116+
try:
117+
return cast(StepFunctionContext, request.getfixturevalue(bdd_name))
118+
except pytest.FixtureLookupError:
119+
return None
56120

57121

58122
def _execute_step_function(
@@ -76,7 +140,11 @@ def _execute_step_function(
76140
args = get_args(context.step_func)
77141

78142
try:
79-
for arg, value in context.parser.parse_arguments(step.name).items():
143+
parsed_args = context.parser.parse_arguments(step.name)
144+
assert parsed_args is not None, (
145+
f"Unexpected `NoneType` returned from " f"parse_arguments(...) in parser: {context.parser!r}"
146+
)
147+
for arg, value in parsed_args.items():
80148
if arg in converters:
81149
value = converters[arg](value)
82150
kwargs[arg] = value
@@ -108,7 +176,7 @@ def _execute_scenario(feature: Feature, scenario: Scenario, request: FixtureRequ
108176
request.config.hook.pytest_bdd_before_scenario(request=request, feature=feature, scenario=scenario)
109177

110178
for step in scenario.steps:
111-
context = find_argumented_step_function(step.name, step.type, request._fixturemanager)
179+
context = get_step_function(request=request, step=step)
112180
if context is None:
113181
exc = exceptions.StepDefinitionNotFoundError(
114182
f"Step definition is not found: {step}. "

0 commit comments

Comments
 (0)