Skip to content

Commit 6c4391a

Browse files
authored
Merge branch 'master' into test-177
2 parents 8456941 + d527a67 commit 6c4391a

File tree

9 files changed

+397
-58
lines changed

9 files changed

+397
-58
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: 52 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import os
1919
import re
2020
from collections.abc import Iterable, Iterator
21+
from inspect import signature
2122
from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast
2223

2324
import pytest
@@ -28,7 +29,7 @@
2829
from .compat import getfixturedefs, inject_fixture
2930
from .feature import get_feature, get_features
3031
from .steps import StepFunctionContext, get_step_fixture_name
31-
from .utils import CONFIG_STACK, get_args, get_caller_module_locals, get_caller_module_path
32+
from .utils import CONFIG_STACK, get_caller_module_locals, get_caller_module_path, get_required_args, identity
3233

3334
if TYPE_CHECKING:
3435
from _pytest.mark.structures import ParameterSet
@@ -41,10 +42,13 @@
4142

4243
logger = logging.getLogger(__name__)
4344

44-
4545
PYTHON_REPLACE_REGEX = re.compile(r"\W")
4646
ALPHA_REGEX = re.compile(r"^\d+_*")
4747

48+
STEP_ARGUMENT_DATATABLE = "datatable"
49+
STEP_ARGUMENT_DOCSTRING = "docstring"
50+
STEP_ARGUMENTS_RESERVED_NAMES = {STEP_ARGUMENT_DATATABLE, STEP_ARGUMENT_DOCSTRING}
51+
4852

4953
def find_fixturedefs_for_step(step: Step, fixturemanager: FixtureManager, node: Node) -> Iterable[FixtureDef[Any]]:
5054
"""Find the fixture defs that can parse a step."""
@@ -172,11 +176,35 @@ def get_step_function(request: FixtureRequest, step: Step) -> StepFunctionContex
172176
return None
173177

174178

179+
def parse_step_arguments(step: Step, context: StepFunctionContext) -> dict[str, object]:
180+
"""Parse step arguments."""
181+
parsed_args = context.parser.parse_arguments(step.name)
182+
183+
assert parsed_args is not None, (
184+
f"Unexpected `NoneType` returned from " f"parse_arguments(...) in parser: {context.parser!r}"
185+
)
186+
187+
reserved_args = set(parsed_args.keys()) & STEP_ARGUMENTS_RESERVED_NAMES
188+
if reserved_args:
189+
reserved_arguments_str = ", ".join(repr(arg) for arg in reserved_args)
190+
raise exceptions.StepImplementationError(
191+
f"Step {step.name!r} defines argument names that are reserved: {reserved_arguments_str}. "
192+
"Please use different names."
193+
)
194+
195+
converted_args = {key: (context.converters.get(key, identity)(value)) for key, value in parsed_args.items()}
196+
197+
return converted_args
198+
199+
175200
def _execute_step_function(
176201
request: FixtureRequest, scenario: Scenario, step: Step, context: StepFunctionContext
177202
) -> None:
178203
"""Execute step function."""
179204
__tracebackhide__ = True
205+
206+
func_sig = signature(context.step_func)
207+
180208
kw = {
181209
"request": request,
182210
"feature": scenario.feature,
@@ -185,38 +213,32 @@ def _execute_step_function(
185213
"step_func": context.step_func,
186214
"step_func_args": {},
187215
}
188-
189216
request.config.hook.pytest_bdd_before_step(**kw)
190217

191-
# Get the step argument values.
192-
converters = context.converters
193-
kwargs = {}
194-
args = get_args(context.step_func)
195-
196218
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-
)
219+
parsed_args = parse_step_arguments(step=step, context=context)
201220

202-
for arg, value in parsed_args.items():
203-
if arg in converters:
204-
value = converters[arg](value)
205-
kwargs[arg] = value
221+
# Filter out the arguments that are not in the function signature
222+
kwargs = {k: v for k, v in parsed_args.items() if k in func_sig.parameters}
206223

207-
if step.datatable is not None:
208-
kwargs["datatable"] = step.datatable.raw()
224+
if STEP_ARGUMENT_DATATABLE in func_sig.parameters and step.datatable is not None:
225+
kwargs[STEP_ARGUMENT_DATATABLE] = step.datatable.raw()
226+
if STEP_ARGUMENT_DOCSTRING in func_sig.parameters and step.docstring is not None:
227+
kwargs[STEP_ARGUMENT_DOCSTRING] = step.docstring
209228

210-
if step.docstring is not None:
211-
kwargs["docstring"] = step.docstring
212-
213-
kwargs = {arg: kwargs[arg] if arg in kwargs else request.getfixturevalue(arg) for arg in args}
229+
# Fill the missing arguments requesting the fixture values
230+
kwargs |= {
231+
arg: request.getfixturevalue(arg) for arg in get_required_args(context.step_func) if arg not in kwargs
232+
}
214233

215234
kw["step_func_args"] = kwargs
216235

217236
request.config.hook.pytest_bdd_before_step_call(**kw)
218-
# Execute the step as if it was a pytest fixture, so that we can allow "yield" statements in it
237+
238+
# Execute the step as if it was a pytest fixture using `call_fixture_func`,
239+
# so that we can allow "yield" statements in it
219240
return_value = call_fixture_func(fixturefunc=context.step_func, request=request, kwargs=kwargs)
241+
220242
except Exception as exception:
221243
request.config.hook.pytest_bdd_step_error(exception=exception, **kw)
222244
raise
@@ -269,18 +291,20 @@ def decorator(*args: Callable[P, T]) -> Callable[P, T]:
269291
"scenario function can only be used as a decorator. Refer to the documentation."
270292
)
271293
[fn] = args
272-
func_args = get_args(fn)
294+
func_args = get_required_args(fn)
273295

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)
277296
def scenario_wrapper(request: FixtureRequest, _pytest_bdd_example: dict[str, str]) -> Any:
278297
__tracebackhide__ = True
279298
scenario = templated_scenario.render(_pytest_bdd_example)
280299
_execute_scenario(feature, scenario, request)
281300
fixture_values = [request.getfixturevalue(arg) for arg in func_args]
282301
return fn(*fixture_values)
283302

303+
if func_args:
304+
# We need to tell pytest that the original function requires its fixtures,
305+
# otherwise indirect fixtures would not work.
306+
scenario_wrapper = pytest.mark.usefixtures(*func_args)(scenario_wrapper)
307+
284308
example_parametrizations = collect_example_parametrizations(templated_scenario)
285309
if example_parametrizations is not None:
286310
# Parametrize the scenario outlines
@@ -295,7 +319,7 @@ def scenario_wrapper(request: FixtureRequest, _pytest_bdd_example: dict[str, str
295319
config.hook.pytest_bdd_apply_tag(tag=tag, function=scenario_wrapper)
296320

297321
scenario_wrapper.__doc__ = f"{feature_name}: {scenario_name}"
298-
scenario_wrapper.__scenario__ = templated_scenario
322+
scenario_wrapper.__scenario__ = templated_scenario # type: ignore[attr-defined]
299323
return cast(Callable[P, T], scenario_wrapper)
300324

301325
return decorator

src/pytest_bdd/utils.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,12 @@
2020
CONFIG_STACK: list[Config] = []
2121

2222

23-
def get_args(func: Callable[..., Any]) -> list[str]:
24-
"""Get a list of argument names for a function.
23+
def get_required_args(func: Callable[..., Any]) -> list[str]:
24+
"""Get a list of argument that are required for a function.
2525
2626
:param func: The function to inspect.
2727
2828
:return: A list of argument names.
29-
:rtype: list
3029
"""
3130
params = signature(func).parameters.values()
3231
return [
@@ -83,3 +82,8 @@ def setdefault(obj: object, name: str, default: T) -> T:
8382
except AttributeError:
8483
setattr(obj, name, default)
8584
return default
85+
86+
87+
def identity(x: T) -> T:
88+
"""Return the argument."""
89+
return x

0 commit comments

Comments
 (0)