Skip to content

Commit 441c28b

Browse files
authored
Merge branch 'master' into test-551
2 parents dd3266d + 5d17d8c commit 441c28b

File tree

9 files changed

+318
-49
lines changed

9 files changed

+318
-49
lines changed

CHANGES.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Added
1414

1515
Changed
1616
+++++++
17+
* Step arguments ``"datatable"`` and ``"docstring"`` are now reserved, and they can't be used as step argument names.
1718

1819
Deprecated
1920
++++++++++
@@ -23,6 +24,8 @@ Removed
2324

2425
Fixed
2526
+++++
27+
* Fixed an issue with the upcoming pytest release related to the use of ``@pytest.mark.usefixtures`` with an empty list.
28+
* Render template variables in docstrings and datatable cells with example table entries, as we already do for steps definitions.
2629

2730
Security
2831
++++++++

README.rst

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,58 @@ Example:
513513
def should_have_left_cucumbers(cucumbers, left):
514514
assert cucumbers["start"] - cucumbers["eat"] == left
515515
516+
517+
Example parameters from example tables can not only be used in steps, but also embedded directly within docstrings and datatables, allowing for dynamic substitution.
518+
This provides added flexibility for scenarios that require complex setups or validations.
519+
520+
Example:
521+
522+
.. code-block:: gherkin
523+
524+
# content of docstring_and_datatable_with_params.feature
525+
526+
Feature: Docstring and Datatable with example parameters
527+
Scenario Outline: Using parameters in docstrings and datatables
528+
Given the following configuration:
529+
"""
530+
username: <username>
531+
password: <password>
532+
"""
533+
When the user logs in
534+
Then the response should contain:
535+
| field | value |
536+
| username | <username> |
537+
| logged_in | true |
538+
539+
Examples:
540+
| username | password |
541+
| user1 | pass123 |
542+
| user2 | 123secure |
543+
544+
.. code-block:: python
545+
546+
from pytest_bdd import scenarios, given, when, then
547+
import json
548+
549+
# Load scenarios from the feature file
550+
scenarios("docstring_and_datatable_with_params.feature")
551+
552+
553+
@given("the following configuration:")
554+
def given_user_config(docstring):
555+
print(docstring)
556+
557+
558+
@when("the user logs in")
559+
def user_logs_in(logged_in):
560+
logged_in = True
561+
562+
563+
@then("the response should contain:")
564+
def response_should_contain(datatable):
565+
assert datatable[1][1] in ["user1", "user2"]
566+
567+
516568
Rules
517569
-----
518570

src/pytest_bdd/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
from __future__ import annotations
44

55

6+
class StepImplementationError(Exception):
7+
"""Step implementation error."""
8+
9+
610
class ScenarioIsDecoratorOnly(Exception):
711
"""Scenario can be only used as decorator."""
812

src/pytest_bdd/parser.py

Lines changed: 42 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import copy
34
import os.path
45
import re
56
import textwrap
@@ -20,7 +21,28 @@
2021
from .gherkin_parser import get_gherkin_document
2122
from .types import STEP_TYPE_BY_PARSER_KEYWORD
2223

23-
STEP_PARAM_RE = re.compile(r"<(.+?)>")
24+
PARAM_RE = re.compile(r"<(.+?)>")
25+
26+
27+
def render_string(input_string: str, render_context: Mapping[str, object]) -> str:
28+
"""
29+
Render the string with the given context,
30+
but avoid replacing text inside angle brackets if context is missing.
31+
32+
Args:
33+
input_string (str): The string for which to render/replace params.
34+
render_context (Mapping[str, object]): The context for rendering the string.
35+
36+
Returns:
37+
str: The rendered string with parameters replaced only if they exist in the context.
38+
"""
39+
40+
def replacer(m: re.Match) -> str:
41+
varname = m.group(1)
42+
# If the context contains the variable, replace it. Otherwise, leave it unchanged.
43+
return str(render_context.get(varname, f"<{varname}>"))
44+
45+
return PARAM_RE.sub(replacer, input_string)
2446

2547

2648
def get_tag_names(tag_data: list[GherkinTag]) -> set[str]:
@@ -189,25 +211,25 @@ def render(self, context: Mapping[str, Any]) -> Scenario:
189211
Returns:
190212
Scenario: A Scenario object with steps rendered based on the context.
191213
"""
214+
base_steps = self.all_background_steps + self._steps
192215
scenario_steps = [
193216
Step(
194-
name=step.render(context),
217+
name=render_string(step.name, context),
195218
type=step.type,
196219
indent=step.indent,
197220
line_number=step.line_number,
198221
keyword=step.keyword,
199-
datatable=step.datatable,
200-
docstring=step.docstring,
222+
datatable=step.render_datatable(step.datatable, context) if step.datatable else None,
223+
docstring=render_string(step.docstring, context) if step.docstring else None,
201224
)
202-
for step in self._steps
225+
for step in base_steps
203226
]
204-
steps = self.all_background_steps + scenario_steps
205227
return Scenario(
206228
feature=self.feature,
207229
keyword=self.keyword,
208-
name=self.name,
230+
name=render_string(self.name, context),
209231
line_number=self.line_number,
210-
steps=steps,
232+
steps=scenario_steps,
211233
tags=self.tags,
212234
description=self.description,
213235
rule=self.rule,
@@ -299,31 +321,24 @@ def __str__(self) -> str:
299321
"""
300322
return f'{self.type.capitalize()} "{self.name}"'
301323

302-
@property
303-
def params(self) -> tuple[str, ...]:
304-
"""Get the parameters in the step name.
305-
306-
Returns:
307-
Tuple[str, ...]: A tuple of parameter names found in the step name.
324+
@staticmethod
325+
def render_datatable(datatable: DataTable, context: Mapping[str, object]) -> DataTable:
308326
"""
309-
return tuple(frozenset(STEP_PARAM_RE.findall(self.name)))
310-
311-
def render(self, context: Mapping[str, Any]) -> str:
312-
"""Render the step name with the given context, but avoid replacing text inside angle brackets if context is missing.
327+
Render the datatable with the given context,
328+
but avoid replacing text inside angle brackets if context is missing.
313329
314330
Args:
315-
context (Mapping[str, Any]): The context for rendering the step name.
331+
datatable (DataTable): The datatable to render.
332+
context (Mapping[str, Any]): The context for rendering the datatable.
316333
317334
Returns:
318-
str: The rendered step name with parameters replaced only if they exist in the context.
335+
datatable (DataTable): The rendered datatable with parameters replaced only if they exist in the context.
319336
"""
320-
321-
def replacer(m: re.Match) -> str:
322-
varname = m.group(1)
323-
# If the context contains the variable, replace it. Otherwise, leave it unchanged.
324-
return str(context.get(varname, f"<{varname}>"))
325-
326-
return STEP_PARAM_RE.sub(replacer, self.name)
337+
rendered_datatable = copy.deepcopy(datatable)
338+
for row in rendered_datatable.rows:
339+
for cell in row.cells:
340+
cell.value = render_string(cell.value, context)
341+
return rendered_datatable
327342

328343

329344
@dataclass(eq=False)

src/pytest_bdd/scenario.py

Lines changed: 35 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
from .compat import getfixturedefs, inject_fixture
2929
from .feature import get_feature, get_features
3030
from .steps import StepFunctionContext, get_step_fixture_name
31-
from .utils import CONFIG_STACK, get_args, get_caller_module_locals, get_caller_module_path
31+
from .utils import CONFIG_STACK, get_args, get_caller_module_locals, get_caller_module_path, identity
3232

3333
if TYPE_CHECKING:
3434
from _pytest.mark.structures import ParameterSet
@@ -41,10 +41,13 @@
4141

4242
logger = logging.getLogger(__name__)
4343

44-
4544
PYTHON_REPLACE_REGEX = re.compile(r"\W")
4645
ALPHA_REGEX = re.compile(r"^\d+_*")
4746

47+
STEP_ARGUMENT_DATATABLE = "datatable"
48+
STEP_ARGUMENT_DOCSTRING = "docstring"
49+
STEP_ARGUMENTS_RESERVED_NAMES = {STEP_ARGUMENT_DATATABLE, STEP_ARGUMENT_DOCSTRING}
50+
4851

4952
def find_fixturedefs_for_step(step: Step, fixturemanager: FixtureManager, node: Node) -> Iterable[FixtureDef[Any]]:
5053
"""Find the fixture defs that can parse a step."""
@@ -172,6 +175,27 @@ def get_step_function(request: FixtureRequest, step: Step) -> StepFunctionContex
172175
return None
173176

174177

178+
def parse_step_arguments(step: Step, context: StepFunctionContext) -> dict[str, object]:
179+
"""Parse step arguments."""
180+
parsed_args = context.parser.parse_arguments(step.name)
181+
182+
assert parsed_args is not None, (
183+
f"Unexpected `NoneType` returned from " f"parse_arguments(...) in parser: {context.parser!r}"
184+
)
185+
186+
reserved_args = set(parsed_args.keys()) & STEP_ARGUMENTS_RESERVED_NAMES
187+
if reserved_args:
188+
reserved_arguments_str = ", ".join(repr(arg) for arg in reserved_args)
189+
raise exceptions.StepImplementationError(
190+
f"Step {step.name!r} defines argument names that are reserved: {reserved_arguments_str}. "
191+
"Please use different names."
192+
)
193+
194+
converted_args = {key: (context.converters.get(key, identity)(value)) for key, value in parsed_args.items()}
195+
196+
return converted_args
197+
198+
175199
def _execute_step_function(
176200
request: FixtureRequest, scenario: Scenario, step: Step, context: StepFunctionContext
177201
) -> None:
@@ -185,30 +209,17 @@ def _execute_step_function(
185209
"step_func": context.step_func,
186210
"step_func_args": {},
187211
}
188-
189212
request.config.hook.pytest_bdd_before_step(**kw)
190-
191-
# Get the step argument values.
192-
converters = context.converters
193-
kwargs = {}
194213
args = get_args(context.step_func)
195214

196215
try:
197-
parsed_args = context.parser.parse_arguments(step.name)
198-
assert parsed_args is not None, (
199-
f"Unexpected `NoneType` returned from " f"parse_arguments(...) in parser: {context.parser!r}"
200-
)
201-
202-
for arg, value in parsed_args.items():
203-
if arg in converters:
204-
value = converters[arg](value)
205-
kwargs[arg] = value
216+
kwargs = parse_step_arguments(step=step, context=context)
206217

207218
if step.datatable is not None:
208-
kwargs["datatable"] = step.datatable.raw()
219+
kwargs[STEP_ARGUMENT_DATATABLE] = step.datatable.raw()
209220

210221
if step.docstring is not None:
211-
kwargs["docstring"] = step.docstring
222+
kwargs[STEP_ARGUMENT_DOCSTRING] = step.docstring
212223

213224
kwargs = {arg: kwargs[arg] if arg in kwargs else request.getfixturevalue(arg) for arg in args}
214225

@@ -271,16 +282,18 @@ def decorator(*args: Callable[P, T]) -> Callable[P, T]:
271282
[fn] = args
272283
func_args = get_args(fn)
273284

274-
# We need to tell pytest that the original function requires its fixtures,
275-
# otherwise indirect fixtures would not work.
276-
@pytest.mark.usefixtures(*func_args)
277285
def scenario_wrapper(request: FixtureRequest, _pytest_bdd_example: dict[str, str]) -> Any:
278286
__tracebackhide__ = True
279287
scenario = templated_scenario.render(_pytest_bdd_example)
280288
_execute_scenario(feature, scenario, request)
281289
fixture_values = [request.getfixturevalue(arg) for arg in func_args]
282290
return fn(*fixture_values)
283291

292+
if func_args:
293+
# We need to tell pytest that the original function requires its fixtures,
294+
# otherwise indirect fixtures would not work.
295+
scenario_wrapper = pytest.mark.usefixtures(*func_args)(scenario_wrapper)
296+
284297
example_parametrizations = collect_example_parametrizations(templated_scenario)
285298
if example_parametrizations is not None:
286299
# Parametrize the scenario outlines
@@ -295,7 +308,7 @@ def scenario_wrapper(request: FixtureRequest, _pytest_bdd_example: dict[str, str
295308
config.hook.pytest_bdd_apply_tag(tag=tag, function=scenario_wrapper)
296309

297310
scenario_wrapper.__doc__ = f"{feature_name}: {scenario_name}"
298-
scenario_wrapper.__scenario__ = templated_scenario
311+
scenario_wrapper.__scenario__ = templated_scenario # type: ignore[attr-defined]
299312
return cast(Callable[P, T], scenario_wrapper)
300313

301314
return decorator

src/pytest_bdd/utils.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,8 @@ def setdefault(obj: object, name: str, default: T) -> T:
8383
except AttributeError:
8484
setattr(obj, name, default)
8585
return default
86+
87+
88+
def identity(x: T) -> T:
89+
"""Return the argument."""
90+
return x

tests/datatable/test_datatable.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,3 +210,48 @@ def test_datatable():
210210
)
211211
result = pytester.runpytest("-s")
212212
result.assert_outcomes(passed=1)
213+
214+
215+
def test_datatable_step_argument_is_reserved_and_cannot_be_used(pytester):
216+
pytester.makefile(
217+
".feature",
218+
reserved_datatable_arg=textwrap.dedent(
219+
"""\
220+
Feature: Reserved datatable argument
221+
222+
Scenario: Reserved datatable argument
223+
Given this step has a {datatable} argument
224+
Then the test fails
225+
"""
226+
),
227+
)
228+
229+
pytester.makepyfile(
230+
textwrap.dedent(
231+
"""\
232+
from pytest_bdd import scenario, given, then, parsers
233+
234+
@scenario("reserved_datatable_arg.feature", "Reserved datatable argument")
235+
def test_datatable():
236+
pass
237+
238+
239+
@given(parsers.parse("this step has a {datatable} argument"))
240+
def _(datatable):
241+
pass
242+
243+
244+
@then("the test fails")
245+
def _():
246+
pass
247+
"""
248+
)
249+
)
250+
251+
result = pytester.runpytest()
252+
result.assert_outcomes(failed=1)
253+
result.stdout.fnmatch_lines(
254+
[
255+
"*Step 'this step has a {datatable} argument' defines argument names that are reserved: 'datatable'. Please use different names.*"
256+
]
257+
)

0 commit comments

Comments
 (0)