Skip to content

Commit 39a948c

Browse files
committed
Merge remote-tracking branch 'origin/master' into fork/yunxuo/master
2 parents b453257 + 5d17d8c commit 39a948c

File tree

9 files changed

+322
-50
lines changed

9 files changed

+322
-50
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: 39 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
from .compat import getfixturedefs, inject_fixture
3030
from .feature import get_feature, get_features
3131
from .steps import StepFunctionContext, get_step_fixture_name
32-
from .utils import CONFIG_STACK, get_args, get_caller_module_locals, get_caller_module_path
32+
from .utils import CONFIG_STACK, get_args, get_caller_module_locals, get_caller_module_path, identity
3333

3434
if TYPE_CHECKING:
3535
from _pytest.mark.structures import ParameterSet
@@ -42,10 +42,13 @@
4242

4343
logger = logging.getLogger(__name__)
4444

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

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

5053
def find_fixturedefs_for_step(step: Step, fixturemanager: FixtureManager, node: Node) -> Iterable[FixtureDef[Any]]:
5154
"""Find the fixture defs that can parse a step."""
@@ -173,28 +176,40 @@ def get_step_function(request: FixtureRequest, step: Step) -> StepFunctionContex
173176
return None
174177

175178

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+
176200
def _execute_step_function(
177201
request: FixtureRequest, scenario: Scenario, step: Step, context: StepFunctionContext
178202
) -> None:
179203
"""Execute step function."""
180204
__tracebackhide__ = True
181205

182206
func_sig = signature(context.step_func)
183-
converters = context.converters
184207

185208
def _get_parsed_arguments() -> dict:
186209
"""Parse and convert step arguments."""
187-
parsed_args = context.parser.parse_arguments(step.name)
188-
if parsed_args is None:
189-
raise ValueError(f"Unexpected `NoneType` returned from parse_arguments(...) in parser: {context.parser!r}")
190-
kwargs = {}
191-
for arg, value in parsed_args.items():
192-
param = func_sig.parameters.get(arg)
193-
if param:
194-
if arg in converters:
195-
value = converters[arg](value)
196-
kwargs[arg] = value
197-
return kwargs
210+
parsed_args = parse_step_arguments(step=step, context=context)
211+
212+
return {k: v for k, v in parsed_args.items() if k in func_sig.parameters}
198213

199214
def _get_argument_values(kwargs: dict) -> dict:
200215
"""Get default values or request fixture values for missing arguments."""
@@ -216,17 +231,16 @@ def _get_argument_values(kwargs: dict) -> dict:
216231
"step_func": context.step_func,
217232
"step_func_args": {},
218233
}
219-
220234
request.config.hook.pytest_bdd_before_step(**kw)
221235

222236
try:
223237
# Use internal methods without passing redundant arguments
224238
kwargs = _get_parsed_arguments()
225239

226-
if "datatable" in func_sig.parameters and step.datatable is not None:
227-
kwargs["datatable"] = step.datatable.raw()
228-
if "docstring" in func_sig.parameters and step.docstring is not None:
229-
kwargs["docstring"] = step.docstring
240+
if STEP_ARGUMENT_DATATABLE in func_sig.parameters and step.datatable is not None:
241+
kwargs[STEP_ARGUMENT_DATATABLE] = step.datatable.raw()
242+
if STEP_ARGUMENT_DOCSTRING in func_sig.parameters and step.docstring is not None:
243+
kwargs[STEP_ARGUMENT_DOCSTRING] = step.docstring
230244

231245
kwargs = _get_argument_values(kwargs)
232246

@@ -290,16 +304,18 @@ def decorator(*args: Callable[P, T]) -> Callable[P, T]:
290304
[fn] = args
291305
func_args = get_args(fn)
292306

293-
# We need to tell pytest that the original function requires its fixtures,
294-
# otherwise indirect fixtures would not work.
295-
@pytest.mark.usefixtures(*func_args)
296307
def scenario_wrapper(request: FixtureRequest, _pytest_bdd_example: dict[str, str]) -> Any:
297308
__tracebackhide__ = True
298309
scenario = templated_scenario.render(_pytest_bdd_example)
299310
_execute_scenario(feature, scenario, request)
300311
fixture_values = [request.getfixturevalue(arg) for arg in func_args]
301312
return fn(*fixture_values)
302313

314+
if func_args:
315+
# We need to tell pytest that the original function requires its fixtures,
316+
# otherwise indirect fixtures would not work.
317+
scenario_wrapper = pytest.mark.usefixtures(*func_args)(scenario_wrapper)
318+
303319
example_parametrizations = collect_example_parametrizations(templated_scenario)
304320
if example_parametrizations is not None:
305321
# Parametrize the scenario outlines
@@ -314,7 +330,7 @@ def scenario_wrapper(request: FixtureRequest, _pytest_bdd_example: dict[str, str
314330
config.hook.pytest_bdd_apply_tag(tag=tag, function=scenario_wrapper)
315331

316332
scenario_wrapper.__doc__ = f"{feature_name}: {scenario_name}"
317-
scenario_wrapper.__scenario__ = templated_scenario
333+
scenario_wrapper.__scenario__ = templated_scenario # type: ignore[attr-defined]
318334
return cast(Callable[P, T], scenario_wrapper)
319335

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