Skip to content

Commit 4ccb683

Browse files
committed
Merge remote-tracking branch 'origin/master' into ab/fix-typing
# Conflicts: # CHANGES.rst # src/pytest_bdd/parser.py # src/pytest_bdd/scenario.py # src/pytest_bdd/utils.py
2 parents 023c6d6 + d527a67 commit 4ccb683

File tree

9 files changed

+397
-54
lines changed

9 files changed

+397
-54
lines changed

CHANGES.rst

+2
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
++++++++++
@@ -24,6 +25,7 @@ Removed
2425
Fixed
2526
+++++
2627
* 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.
2729
* Address many ``mypy`` warnings. The following private attributes are not available anymore (`#658 <https://github.com/pytest-dev/pytest-bdd/pull/658>`_):
2830
* ``_pytest.reports.TestReport.scenario`` (replaced by ``pytest_bdd.reporting.test_report_context`` WeakKeyDictionary)
2931
* ``__scenario__`` attribute of test functions generated by the ``@scenario`` (and ``@scenarios``) decorator (replaced by ``pytest_bdd.scenario.scenario_wrapper_template_registry`` WeakKeyDictionary)

README.rst

+52
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

+4
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

+42-27
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
@@ -19,7 +20,28 @@
1920
from .gherkin_parser import get_gherkin_document
2021
from .types import STEP_TYPE_BY_PARSER_KEYWORD
2122

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

2446

2547
def get_tag_names(tag_data: list[GherkinTag]) -> set[str]:
@@ -188,25 +210,25 @@ def render(self, context: Mapping[str, object]) -> Scenario:
188210
Returns:
189211
Scenario: A Scenario object with steps rendered based on the context.
190212
"""
213+
base_steps = self.all_background_steps + self._steps
191214
scenario_steps = [
192215
Step(
193-
name=step.render(context),
216+
name=render_string(step.name, context),
194217
type=step.type,
195218
indent=step.indent,
196219
line_number=step.line_number,
197220
keyword=step.keyword,
198-
datatable=step.datatable,
199-
docstring=step.docstring,
221+
datatable=step.render_datatable(step.datatable, context) if step.datatable else None,
222+
docstring=render_string(step.docstring, context) if step.docstring else None,
200223
)
201-
for step in self._steps
224+
for step in base_steps
202225
]
203-
steps = self.all_background_steps + scenario_steps
204226
return Scenario(
205227
feature=self.feature,
206228
keyword=self.keyword,
207-
name=self.name,
229+
name=render_string(self.name, context),
208230
line_number=self.line_number,
209-
steps=steps,
231+
steps=scenario_steps,
210232
tags=self.tags,
211233
description=self.description,
212234
rule=self.rule,
@@ -298,31 +320,24 @@ def __str__(self) -> str:
298320
"""
299321
return f'{self.type.capitalize()} "{self.name}"'
300322

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

327342

328343
@dataclass(eq=False)

src/pytest_bdd/scenario.py

+53-24
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
from weakref import WeakKeyDictionary
2324

@@ -28,7 +29,14 @@
2829
from .compat import getfixturedefs, inject_fixture
2930
from .feature import get_feature, get_features
3031
from .steps import StepFunctionContext, get_step_fixture_name, step_function_context_registry
31-
from .utils import CONFIG_STACK, get_args, get_caller_module_locals, get_caller_module_path, registry_get_safe
32+
from .utils import (
33+
CONFIG_STACK,
34+
get_caller_module_locals,
35+
get_caller_module_path,
36+
get_required_args,
37+
identity,
38+
registry_get_safe,
39+
)
3240

3341
if TYPE_CHECKING:
3442
from _pytest.mark.structures import ParameterSet
@@ -40,10 +48,13 @@
4048

4149
logger = logging.getLogger(__name__)
4250

43-
4451
PYTHON_REPLACE_REGEX = re.compile(r"\W")
4552
ALPHA_REGEX = re.compile(r"^\d+_*")
4653

54+
STEP_ARGUMENT_DATATABLE = "datatable"
55+
STEP_ARGUMENT_DOCSTRING = "docstring"
56+
STEP_ARGUMENTS_RESERVED_NAMES = {STEP_ARGUMENT_DATATABLE, STEP_ARGUMENT_DOCSTRING}
57+
4758
scenario_wrapper_template_registry: WeakKeyDictionary[Callable[..., object], ScenarioTemplate] = WeakKeyDictionary()
4859

4960

@@ -173,11 +184,35 @@ def get_step_function(request: FixtureRequest, step: Step) -> StepFunctionContex
173184
return None
174185

175186

187+
def parse_step_arguments(step: Step, context: StepFunctionContext) -> dict[str, object]:
188+
"""Parse step arguments."""
189+
parsed_args = context.parser.parse_arguments(step.name)
190+
191+
assert parsed_args is not None, (
192+
f"Unexpected `NoneType` returned from " f"parse_arguments(...) in parser: {context.parser!r}"
193+
)
194+
195+
reserved_args = set(parsed_args.keys()) & STEP_ARGUMENTS_RESERVED_NAMES
196+
if reserved_args:
197+
reserved_arguments_str = ", ".join(repr(arg) for arg in reserved_args)
198+
raise exceptions.StepImplementationError(
199+
f"Step {step.name!r} defines argument names that are reserved: {reserved_arguments_str}. "
200+
"Please use different names."
201+
)
202+
203+
converted_args = {key: (context.converters.get(key, identity)(value)) for key, value in parsed_args.items()}
204+
205+
return converted_args
206+
207+
176208
def _execute_step_function(
177209
request: FixtureRequest, scenario: Scenario, step: Step, context: StepFunctionContext
178210
) -> None:
179211
"""Execute step function."""
180212
__tracebackhide__ = True
213+
214+
func_sig = signature(context.step_func)
215+
181216
kw = {
182217
"request": request,
183218
"feature": scenario.feature,
@@ -186,38 +221,32 @@ def _execute_step_function(
186221
"step_func": context.step_func,
187222
"step_func_args": {},
188223
}
189-
190224
request.config.hook.pytest_bdd_before_step(**kw)
191225

192-
# Get the step argument values.
193-
converters = context.converters
194-
kwargs = {}
195-
args = get_args(context.step_func)
196-
197226
try:
198-
parsed_args = context.parser.parse_arguments(step.name)
199-
assert parsed_args is not None, (
200-
f"Unexpected `NoneType` returned from " f"parse_arguments(...) in parser: {context.parser!r}"
201-
)
202-
203-
for arg, value in parsed_args.items():
204-
if arg in converters:
205-
value = converters[arg](value)
206-
kwargs[arg] = value
227+
parsed_args = parse_step_arguments(step=step, context=context)
207228

208-
if step.datatable is not None:
209-
kwargs["datatable"] = step.datatable.raw()
229+
# Filter out the arguments that are not in the function signature
230+
kwargs = {k: v for k, v in parsed_args.items() if k in func_sig.parameters}
210231

211-
if step.docstring is not None:
212-
kwargs["docstring"] = step.docstring
232+
if STEP_ARGUMENT_DATATABLE in func_sig.parameters and step.datatable is not None:
233+
kwargs[STEP_ARGUMENT_DATATABLE] = step.datatable.raw()
234+
if STEP_ARGUMENT_DOCSTRING in func_sig.parameters and step.docstring is not None:
235+
kwargs[STEP_ARGUMENT_DOCSTRING] = step.docstring
213236

214-
kwargs = {arg: kwargs[arg] if arg in kwargs else request.getfixturevalue(arg) for arg in args}
237+
# Fill the missing arguments requesting the fixture values
238+
kwargs |= {
239+
arg: request.getfixturevalue(arg) for arg in get_required_args(context.step_func) if arg not in kwargs
240+
}
215241

216242
kw["step_func_args"] = kwargs
217243

218244
request.config.hook.pytest_bdd_before_step_call(**kw)
219-
# Execute the step as if it was a pytest fixture, so that we can allow "yield" statements in it
245+
246+
# Execute the step as if it was a pytest fixture using `call_fixture_func`,
247+
# so that we can allow "yield" statements in it
220248
return_value = call_fixture_func(fixturefunc=context.step_func, request=request, kwargs=kwargs)
249+
221250
except Exception as exception:
222251
request.config.hook.pytest_bdd_step_error(exception=exception, **kw)
223252
raise
@@ -270,7 +299,7 @@ def decorator(*args: Callable[..., T]) -> Callable[[FixtureRequest, dict[str, st
270299
"scenario function can only be used as a decorator. Refer to the documentation."
271300
)
272301
[fn] = args
273-
func_args = get_args(fn)
302+
func_args = get_required_args(fn)
274303

275304
def scenario_wrapper(request: FixtureRequest, _pytest_bdd_example: dict[str, str]) -> T:
276305
__tracebackhide__ = True

src/pytest_bdd/utils.py

+7-3
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,12 @@
2121
CONFIG_STACK: list[Config] = []
2222

2323

24-
def get_args(func: Callable[..., object]) -> list[str]:
25-
"""Get a list of argument names for a function.
24+
def get_required_args(func: Callable[..., object]) -> list[str]:
25+
"""Get a list of argument that are required for a function.
2626
2727
:param func: The function to inspect.
2828
2929
:return: A list of argument names.
30-
:rtype: list
3130
"""
3231
params = signature(func).parameters.values()
3332
return [
@@ -86,6 +85,11 @@ def setdefault(obj: object, name: str, default: T) -> T:
8685
return default
8786

8887

88+
def identity(x: T) -> T:
89+
"""Return the argument."""
90+
return x
91+
92+
8993
@overload
9094
def registry_get_safe(registry: WeakKeyDictionary[K, V], key: object, default: T) -> V | T: ...
9195
@overload

0 commit comments

Comments
 (0)