Skip to content

Commit 5d17d8c

Browse files
authored
Merge pull request #748 from pytest-dev/raise-error-if-step-uses-reserved-arguments
Raise an error if a step defines reserved argument names (`datatable`, `docstring`)
2 parents c70f526 + c3a49c2 commit 5d17d8c

File tree

6 files changed

+129
-18
lines changed

6 files changed

+129
-18
lines changed

CHANGES.rst

Lines changed: 1 addition & 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
++++++++++

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/scenario.py

Lines changed: 29 additions & 18 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

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

tests/steps/test_docstring.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,3 +193,48 @@ def _():
193193
)
194194
result = pytester.runpytest("-s")
195195
result.assert_outcomes(passed=1)
196+
197+
198+
def test_docstring_step_argument_is_reserved_and_cannot_be_used(pytester):
199+
pytester.makefile(
200+
".feature",
201+
reserved_docstring_arg=textwrap.dedent(
202+
"""\
203+
Feature: Reserved docstring argument
204+
205+
Scenario: Reserved docstring argument
206+
Given this step has a {docstring} argument
207+
Then the test fails
208+
"""
209+
),
210+
)
211+
212+
pytester.makepyfile(
213+
textwrap.dedent(
214+
"""\
215+
from pytest_bdd import scenario, given, then, parsers
216+
217+
@scenario("reserved_docstring_arg.feature", "Reserved docstring argument")
218+
def test_docstring():
219+
pass
220+
221+
222+
@given(parsers.parse("this step has a {docstring} argument"))
223+
def _(docstring):
224+
pass
225+
226+
227+
@then("the test fails")
228+
def _():
229+
pass
230+
"""
231+
)
232+
)
233+
234+
result = pytester.runpytest()
235+
result.assert_outcomes(failed=1)
236+
result.stdout.fnmatch_lines(
237+
[
238+
"*Step 'this step has a {docstring} argument' defines argument names that are reserved: 'docstring'. Please use different names.*"
239+
]
240+
)

0 commit comments

Comments
 (0)