From cd471dbe660a02a576ef3a9aa1748b7783a58638 Mon Sep 17 00:00:00 2001 From: yunusyun Date: Fri, 8 Nov 2024 17:39:33 +0800 Subject: [PATCH 01/32] fix: defalut value not covered by parameters passed through feature file --- src/pytest_bdd/scenario.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/pytest_bdd/scenario.py b/src/pytest_bdd/scenario.py index 08a53c3..f228332 100644 --- a/src/pytest_bdd/scenario.py +++ b/src/pytest_bdd/scenario.py @@ -210,7 +210,9 @@ def _execute_step_function( if step.docstring is not None: kwargs["docstring"] = step.docstring - kwargs = {arg: kwargs[arg] if arg in kwargs else request.getfixturevalue(arg) for arg in args} + for arg in args: + if arg not in kwargs: + kwargs[arg] = request.getfixturevalue(arg) kw["step_func_args"] = kwargs From 7393c8f14a7b74ad8fbd0602a8371923269899e8 Mon Sep 17 00:00:00 2001 From: yunusyun Date: Fri, 15 Nov 2024 17:17:58 +0800 Subject: [PATCH 02/32] test: add test case for scenario --- tests/feature/test_scenario.py | 43 ++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/feature/test_scenario.py b/tests/feature/test_scenario.py index b8664e2..bf7c46f 100644 --- a/tests/feature/test_scenario.py +++ b/tests/feature/test_scenario.py @@ -279,3 +279,46 @@ def test_multilanguage_support(pytester): ("given", "che uso uno step con ", "esempio 2"), ("then", "va tutto bene"), ] + + +def test_default_value_in_not_parsed(pytester): + """Test that angular brackets are not parsed for "Scenario"s. + + (They should be parsed only when used in "Scenario Outline") + + """ + pytester.makefile( + ".feature", + simple=""" + Feature: Simple feature + Scenario: Simple scenario + Given a user with username + Then check username defaultuser + + Scenario Outline: Outlined scenario + Given a user with username + Then check username + + Examples: + | username | + | user1 | + """, + ) + pytester.makepyfile( + """ + from pytest_bdd import scenarios, given, then, parsers + + scenarios("simple.feature") + + @given('a user with username', target_fixture="user") + @given(parsers.parse('a user with username {username}'), target_fixture="user") + def create_user(username="defaultuser"): + return username + + @then(parsers.parse("check username {username}")) + def _(user, username): + assert user == username + """ + ) + result = pytester.runpytest() + result.assert_outcomes(passed=2) From a9ce43f242e0bcbc64568d7c6d0cde559a75ad5a Mon Sep 17 00:00:00 2001 From: jsa34 <31512041+jsa34@users.noreply.github.com> Date: Tue, 19 Nov 2024 19:03:38 +0100 Subject: [PATCH 03/32] Update scenario.py Add regression tested code to not break when Args not in the method signature are present --- src/pytest_bdd/scenario.py | 61 +++++++++++++++++++++++++------------- 1 file changed, 40 insertions(+), 21 deletions(-) diff --git a/src/pytest_bdd/scenario.py b/src/pytest_bdd/scenario.py index 2058078..7a8f04e 100644 --- a/src/pytest_bdd/scenario.py +++ b/src/pytest_bdd/scenario.py @@ -18,6 +18,7 @@ import logging import os import re from collections.abc import Iterable, Iterator +from inspect import signature from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast import pytest @@ -177,6 +178,38 @@ def _execute_step_function( ) -> None: """Execute step function.""" __tracebackhide__ = True + + func_sig = signature(context.step_func) + converters = context.converters + + def _get_parsed_arguments(): + """Parse and convert step arguments.""" + parsed_args = context.parser.parse_arguments(step.name) + if parsed_args is None: + raise ValueError( + f"Unexpected `NoneType` returned from parse_arguments(...) in parser: {context.parser!r}" + ) + kwargs = {} + for arg, value in parsed_args.items(): + param = func_sig.parameters.get(arg) + if param: + if arg in converters: + value = converters[arg](value) + kwargs[arg] = value + return kwargs + + def _get_argument_values(kwargs): + """Get default values or request fixture values for missing arguments.""" + for arg in get_args(context.step_func): + if arg not in kwargs: + param = func_sig.parameters.get(arg) + if param: + if param.default != param.empty: + kwargs[arg] = param.default + else: + kwargs[arg] = request.getfixturevalue(arg) + return kwargs + kw = { "request": request, "feature": scenario.feature, @@ -188,37 +221,23 @@ def _execute_step_function( request.config.hook.pytest_bdd_before_step(**kw) - # Get the step argument values. - converters = context.converters - kwargs = {} - args = get_args(context.step_func) - try: - parsed_args = context.parser.parse_arguments(step.name) - assert parsed_args is not None, ( - f"Unexpected `NoneType` returned from " f"parse_arguments(...) in parser: {context.parser!r}" - ) + # Use internal methods without passing redundant arguments + kwargs = _get_parsed_arguments() - for arg, value in parsed_args.items(): - if arg in converters: - value = converters[arg](value) - kwargs[arg] = value - - if step.datatable is not None: + if "datatable" in func_sig.parameters and step.datatable is not None: kwargs["datatable"] = step.datatable.raw() - - if step.docstring is not None: + if "docstring" in func_sig.parameters and step.docstring is not None: kwargs["docstring"] = step.docstring - for arg in args: - if arg not in kwargs: - kwargs[arg] = request.getfixturevalue(arg) + kwargs = _get_argument_values(kwargs) kw["step_func_args"] = kwargs request.config.hook.pytest_bdd_before_step_call(**kw) - # Execute the step as if it was a pytest fixture, so that we can allow "yield" statements in it + return_value = call_fixture_func(fixturefunc=context.step_func, request=request, kwargs=kwargs) + except Exception as exception: request.config.hook.pytest_bdd_step_error(exception=exception, **kw) raise From 0224d68cc09c0fadedd859c44eab38f5eddbfd67 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 19 Nov 2024 18:03:47 +0000 Subject: [PATCH 04/32] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/pytest_bdd/scenario.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/pytest_bdd/scenario.py b/src/pytest_bdd/scenario.py index 7a8f04e..362c1fe 100644 --- a/src/pytest_bdd/scenario.py +++ b/src/pytest_bdd/scenario.py @@ -186,9 +186,7 @@ def _execute_step_function( """Parse and convert step arguments.""" parsed_args = context.parser.parse_arguments(step.name) if parsed_args is None: - raise ValueError( - f"Unexpected `NoneType` returned from parse_arguments(...) in parser: {context.parser!r}" - ) + raise ValueError(f"Unexpected `NoneType` returned from parse_arguments(...) in parser: {context.parser!r}") kwargs = {} for arg, value in parsed_args.items(): param = func_sig.parameters.get(arg) From b4532577c76610f368f9d8a0bded3f801c4992f8 Mon Sep 17 00:00:00 2001 From: jsa34 <31512041+jsa34@users.noreply.github.com> Date: Tue, 19 Nov 2024 19:09:21 +0100 Subject: [PATCH 05/32] Update scenario.py Add type hints --- src/pytest_bdd/scenario.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pytest_bdd/scenario.py b/src/pytest_bdd/scenario.py index 362c1fe..e04d41a 100644 --- a/src/pytest_bdd/scenario.py +++ b/src/pytest_bdd/scenario.py @@ -182,7 +182,7 @@ def _execute_step_function( func_sig = signature(context.step_func) converters = context.converters - def _get_parsed_arguments(): + def _get_parsed_arguments() -> dict: """Parse and convert step arguments.""" parsed_args = context.parser.parse_arguments(step.name) if parsed_args is None: @@ -196,7 +196,7 @@ def _execute_step_function( kwargs[arg] = value return kwargs - def _get_argument_values(kwargs): + def _get_argument_values(kwargs: dict) -> dict: """Get default values or request fixture values for missing arguments.""" for arg in get_args(context.step_func): if arg not in kwargs: From e0713d6a555abe0e55e5bc428f2e85e377889ca4 Mon Sep 17 00:00:00 2001 From: Jason Allen Date: Thu, 28 Nov 2024 12:23:21 +0000 Subject: [PATCH 06/32] Render docstrings and datatable cells with example table entries, just like step names currently are. --- src/pytest_bdd/parser.py | 69 +++++++++++++++++++++++++++++----- tests/feature/test_scenario.py | 33 +++++++++++++--- 2 files changed, 86 insertions(+), 16 deletions(-) diff --git a/src/pytest_bdd/parser.py b/src/pytest_bdd/parser.py index ff4a061..7629174 100644 --- a/src/pytest_bdd/parser.py +++ b/src/pytest_bdd/parser.py @@ -23,6 +23,27 @@ from .types import STEP_TYPE_BY_PARSER_KEYWORD STEP_PARAM_RE = re.compile(r"<(.+?)>") +def render_string(input_string: str, render_context: Mapping[str, Any]) -> str: + """ + Render the string with the given context, + but avoid replacing text inside angle brackets if context is missing. + + Args: + input_string (str): The string for which to render/replace params. + render_context (Mapping[str, Any]): The context for rendering the string. + + Returns: + str: The rendered string with parameters replaced only if they exist in the context. + """ + + def replacer(m: re.Match) -> str: + varname = m.group(1) + # If the context contains the variable, replace it. Otherwise, leave it unchanged. + return str(render_context.get(varname, f"<{varname}>")) + + return STEP_PARAM_RE.sub(replacer, input_string) + + def get_tag_names(tag_data: list[GherkinTag]) -> set[str]: """Extract tag names from tag data. @@ -191,13 +212,13 @@ class ScenarioTemplate: """ scenario_steps = [ Step( - name=step.render(context), + name=step.render_step_name(context), type=step.type, indent=step.indent, line_number=step.line_number, keyword=step.keyword, - datatable=step.datatable, - docstring=step.docstring, + datatable=step.render_datatable(context), + docstring=step.render_docstring(context), ) for step in self._steps ] @@ -308,8 +329,10 @@ class Step: """ return tuple(frozenset(STEP_PARAM_RE.findall(self.name))) - def render(self, context: Mapping[str, Any]) -> str: - """Render the step name with the given context, but avoid replacing text inside angle brackets if context is missing. + def render_step_name(self, context: Mapping[str, Any]) -> str: + """ + Render the step name with the given context, + but avoid replacing text inside angle brackets if context is missing. Args: context (Mapping[str, Any]): The context for rendering the step name. @@ -317,13 +340,39 @@ class Step: Returns: str: The rendered step name with parameters replaced only if they exist in the context. """ + return render_string(self.name, context) - def replacer(m: re.Match) -> str: - varname = m.group(1) - # If the context contains the variable, replace it. Otherwise, leave it unchanged. - return str(context.get(varname, f"<{varname}>")) + def render_datatable(self, context: Mapping[str, Any]) -> datatable | None: + """ + Render the datatable with the given context, + but avoid replacing text inside angle brackets if context is missing. - return STEP_PARAM_RE.sub(replacer, self.name) + Args: + context (Mapping[str, Any]): The context for rendering the datatable. + + Returns: + datatable: The rendered datatable with parameters replaced only if they exist in the context. + """ + if self.datatable: + rendered_datatable = self.datatable + for row in rendered_datatable.rows: + for cell in row.cells: + cell.value = render_string(cell.value, context) + return rendered_datatable + return None + + def render_docstring(self, context: Mapping[str, Any]) -> str | None: + """ + Render the docstring with the given context, + but avoid replacing text inside angle brackets if context is missing. + + Args: + context (Mapping[str, Any]): The context for rendering the docstring. + + Returns: + str: The rendered docstring with parameters replaced only if they exist in the context. + """ + return render_string(self.docstring, context) if self.docstring else None @dataclass(eq=False) diff --git a/tests/feature/test_scenario.py b/tests/feature/test_scenario.py index b8664e2..9e1d6f5 100644 --- a/tests/feature/test_scenario.py +++ b/tests/feature/test_scenario.py @@ -161,7 +161,7 @@ def test_angular_brackets_are_not_parsed(pytester): """ pytester.makefile( ".feature", - simple=""" + simple=''' Feature: Simple feature Scenario: Simple scenario Given I have a @@ -169,16 +169,24 @@ def test_angular_brackets_are_not_parsed(pytester): Scenario Outline: Outlined scenario Given I have a templated + When I have a templated datatable + | | + | example | + And I have a templated docstring + """ + This is a + """ Then pass Examples: - | foo | - | bar | - """, + | foo | data | doc | + | bar | table | string | + ''', ) pytester.makepyfile( """ - from pytest_bdd import scenarios, given, then, parsers + from pytest_bdd import scenarios, given, when, then, parsers + from pytest_bdd.utils import dump_obj scenarios("simple.feature") @@ -190,14 +198,27 @@ def test_angular_brackets_are_not_parsed(pytester): def _(foo): return "foo" + @when("I have a templated datatable") + def _(datatable): + return dump_obj(("datatable", datatable)) + + @when("I have a templated docstring") + def _(docstring): + return dump_obj(("docstring", docstring)) + @then("pass") def _(): pass """ ) - result = pytester.runpytest() + result = pytester.runpytest("-s") result.assert_outcomes(passed=2) + assert collect_dumped_objects(result) == [ + ("datatable", [["table"], ["example"]]), + ("docstring", "This is a string"), + ] + def test_multilanguage_support(pytester): """Test multilanguage support.""" From e28ea985764f9d7081a5fab977bdc3173ba03e92 Mon Sep 17 00:00:00 2001 From: Jason Allen Date: Thu, 28 Nov 2024 12:25:59 +0000 Subject: [PATCH 07/32] Make mypy happy --- src/pytest_bdd/parser.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pytest_bdd/parser.py b/src/pytest_bdd/parser.py index 7629174..b820921 100644 --- a/src/pytest_bdd/parser.py +++ b/src/pytest_bdd/parser.py @@ -342,7 +342,7 @@ class Step: """ return render_string(self.name, context) - def render_datatable(self, context: Mapping[str, Any]) -> datatable | None: + def render_datatable(self, context: Mapping[str, Any]) -> DataTable | None: """ Render the datatable with the given context, but avoid replacing text inside angle brackets if context is missing. @@ -351,7 +351,7 @@ class Step: context (Mapping[str, Any]): The context for rendering the datatable. Returns: - datatable: The rendered datatable with parameters replaced only if they exist in the context. + datatable (DataTable): The rendered datatable with parameters replaced only if they exist in the context. """ if self.datatable: rendered_datatable = self.datatable From e93097ecde03428e044a0273c140425cdad1c126 Mon Sep 17 00:00:00 2001 From: Jason Allen Date: Thu, 28 Nov 2024 12:55:48 +0000 Subject: [PATCH 08/32] Add example/documentation --- README.rst | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/README.rst b/README.rst index 7af8382..5fb1f86 100644 --- a/README.rst +++ b/README.rst @@ -513,6 +513,59 @@ Example: def should_have_left_cucumbers(cucumbers, left): assert cucumbers["start"] - cucumbers["eat"] == left + +Example parameters from example tables can not only be used in step names with angular brackets (e.g., ), +but also embedded directly within docstrings and datatables, allowing for dynamic substitution. +This provides added flexibility for scenarios that require complex setups or validations. + +Example: + +.. code-block:: gherkin + + # content of docstring_and_datatable_with_params.feature + + Feature: Docstring and Datatable with example parameters + Scenario Outline: Using parameters in docstrings and datatables + Given the following configuration: + """ + username: + password: + """ + When the user logs in + Then the response should contain: + | field | value | + | username | | + | logged_in | true | + + Examples: + | username | password | + | user1 | pass123 | + | user2 | 123secure | + +.. code-block:: python + + from pytest_bdd import scenarios, given, when, then + import json + + # Load scenarios from the feature file + scenarios("docstring_and_datatable_with_params.feature") + + + @given("the following configuration:") + def given_user_config(docstring): + print(docstring) + + + @when("the user logs in") + def user_logs_in(logged_in): + logged_in = True + + + @then("the response should contain:") + def response_should_contain(datatable): + assert datatable[1][1] in ["user1", "user2"] + + Rules ----- From 1ff5df38c0e2b7156a31a9e16109e44e8842fb09 Mon Sep 17 00:00:00 2001 From: Jason Allen Date: Thu, 28 Nov 2024 13:20:26 +0000 Subject: [PATCH 09/32] Apply the rendering to background steps, and test for these --- src/pytest_bdd/parser.py | 8 +-- tests/feature/test_scenario.py | 94 +++++++++++++++++++++++++++++----- 2 files changed, 84 insertions(+), 18 deletions(-) diff --git a/src/pytest_bdd/parser.py b/src/pytest_bdd/parser.py index b820921..e5d2506 100644 --- a/src/pytest_bdd/parser.py +++ b/src/pytest_bdd/parser.py @@ -210,6 +210,7 @@ class ScenarioTemplate: Returns: Scenario: A Scenario object with steps rendered based on the context. """ + base_steps = self.all_background_steps + self._steps scenario_steps = [ Step( name=step.render_step_name(context), @@ -220,15 +221,14 @@ class ScenarioTemplate: datatable=step.render_datatable(context), docstring=step.render_docstring(context), ) - for step in self._steps + for step in base_steps ] - steps = self.all_background_steps + scenario_steps return Scenario( feature=self.feature, keyword=self.keyword, - name=self.name, + name=render_string(self.name, context), line_number=self.line_number, - steps=steps, + steps=scenario_steps, tags=self.tags, description=self.description, rule=self.rule, diff --git a/tests/feature/test_scenario.py b/tests/feature/test_scenario.py index 9e1d6f5..09c8f42 100644 --- a/tests/feature/test_scenario.py +++ b/tests/feature/test_scenario.py @@ -161,7 +161,7 @@ def test_angular_brackets_are_not_parsed(pytester): """ pytester.makefile( ".feature", - simple=''' + simple=""" Feature: Simple feature Scenario: Simple scenario Given I have a @@ -169,24 +169,16 @@ def test_angular_brackets_are_not_parsed(pytester): Scenario Outline: Outlined scenario Given I have a templated - When I have a templated datatable - | | - | example | - And I have a templated docstring - """ - This is a - """ Then pass Examples: - | foo | data | doc | - | bar | table | string | - ''', + | foo | + | bar | + """, ) pytester.makepyfile( """ - from pytest_bdd import scenarios, given, when, then, parsers - from pytest_bdd.utils import dump_obj + from pytest_bdd import scenarios, given, then, parsers scenarios("simple.feature") @@ -198,23 +190,97 @@ def test_angular_brackets_are_not_parsed(pytester): def _(foo): return "foo" + @then("pass") + def _(): + pass + """ + ) + result = pytester.runpytest() + result.assert_outcomes(passed=2) + + +def test_example_params(pytester): + """Test example params are rendered where necessary: + * Step names + * Docstring + * Datatables + """ + pytester.makefile( + ".feature", + example_params=''' + Feature: Example params + Background: + Given I have a background + And my background has: + """ + Background + """ + + Scenario Outline: Outlined scenario + Given I have a templated + When I have a templated datatable + | | + | example | + And I have a templated docstring + """ + This is a + """ + Then pass + + Examples: + | background | foo | data | doc | + | parameter | bar | table | string | + ''', + ) + pytester.makepyfile( + """ + from pytest_bdd import scenarios, given, when, then, parsers + from pytest_bdd.utils import dump_obj + + scenarios("example_params.feature") + + + @given(parsers.parse("I have a background {background}")) + def _(background): + return dump_obj(("background", background)) + + + @given(parsers.parse("I have a templated {foo}")) + def _(foo): + return "foo" + + + @given("my background has:") + def _(docstring): + return dump_obj(("background_docstring", docstring)) + + + @given("I have a rule table:") + def _(datatable): + return dump_obj(("rule", datatable)) + + @when("I have a templated datatable") def _(datatable): return dump_obj(("datatable", datatable)) + @when("I have a templated docstring") def _(docstring): return dump_obj(("docstring", docstring)) + @then("pass") def _(): pass """ ) result = pytester.runpytest("-s") - result.assert_outcomes(passed=2) + result.assert_outcomes(passed=1) assert collect_dumped_objects(result) == [ + ("background", "parameter"), + ("background_docstring", "Background parameter"), ("datatable", [["table"], ["example"]]), ("docstring", "This is a string"), ] From c17348a170784759cb048cfa22a9d4ff9f4c544b Mon Sep 17 00:00:00 2001 From: jsa34 <31512041+jsa34@users.noreply.github.com> Date: Thu, 28 Nov 2024 16:27:41 +0000 Subject: [PATCH 10/32] Update README.rst Co-authored-by: Vianney GREMMEL --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 5fb1f86..9ce0d80 100644 --- a/README.rst +++ b/README.rst @@ -533,9 +533,9 @@ Example: """ When the user logs in Then the response should contain: - | field | value | + | field | value | | username | | - | logged_in | true | + | logged_in | true | Examples: | username | password | From f95563461ab86c7f228330411690d1df35fa189e Mon Sep 17 00:00:00 2001 From: Daara Shaw Date: Sat, 30 Nov 2024 09:59:05 +0000 Subject: [PATCH 11/32] Avoid pytest.mark.usefixtures call without arguments This will raise a warning in an upcoming change in pytest --- src/pytest_bdd/scenario.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/pytest_bdd/scenario.py b/src/pytest_bdd/scenario.py index ac8844c..b6f403b 100644 --- a/src/pytest_bdd/scenario.py +++ b/src/pytest_bdd/scenario.py @@ -271,9 +271,6 @@ def _get_scenario_decorator( [fn] = args func_args = get_args(fn) - # We need to tell pytest that the original function requires its fixtures, - # otherwise indirect fixtures would not work. - @pytest.mark.usefixtures(*func_args) def scenario_wrapper(request: FixtureRequest, _pytest_bdd_example: dict[str, str]) -> Any: __tracebackhide__ = True scenario = templated_scenario.render(_pytest_bdd_example) @@ -281,6 +278,11 @@ def _get_scenario_decorator( fixture_values = [request.getfixturevalue(arg) for arg in func_args] return fn(*fixture_values) + if func_args: + # We need to tell pytest that the original function requires its fixtures, + # otherwise indirect fixtures would not work. + scenario_wrapper = pytest.mark.usefixtures(*func_args)(scenario_wrapper) + example_parametrizations = collect_example_parametrizations(templated_scenario) if example_parametrizations is not None: # Parametrize the scenario outlines From a4bf8431e352d4fef6f16cba16297e695935b1b5 Mon Sep 17 00:00:00 2001 From: Daara Shaw Date: Sat, 30 Nov 2024 12:54:20 +0000 Subject: [PATCH 12/32] ignore attr-defined of scenario_wrapper --- src/pytest_bdd/scenario.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pytest_bdd/scenario.py b/src/pytest_bdd/scenario.py index b6f403b..f8ecb88 100644 --- a/src/pytest_bdd/scenario.py +++ b/src/pytest_bdd/scenario.py @@ -297,7 +297,7 @@ def _get_scenario_decorator( config.hook.pytest_bdd_apply_tag(tag=tag, function=scenario_wrapper) scenario_wrapper.__doc__ = f"{feature_name}: {scenario_name}" - scenario_wrapper.__scenario__ = templated_scenario + scenario_wrapper.__scenario__ = templated_scenario # type: ignore[attr-defined] return cast(Callable[P, T], scenario_wrapper) return decorator From 1e5595b37d1a8a29e264554770ab81fec70c1a7d Mon Sep 17 00:00:00 2001 From: Alessio Bogon <778703+youtux@users.noreply.github.com> Date: Sat, 30 Nov 2024 17:28:24 +0100 Subject: [PATCH 13/32] Add changelog entry --- CHANGES.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGES.rst b/CHANGES.rst index 1e8ae70..e5077f7 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -23,6 +23,7 @@ Removed Fixed +++++ +* Fixed an issue with the upcoming pytest release related to the use of ``@pytest.mark.usefixtures`` with an empty list. Security ++++++++ From ce4e296a56b0dc781f63893224f767760d019383 Mon Sep 17 00:00:00 2001 From: Alessio Bogon <778703+youtux@users.noreply.github.com> Date: Sat, 30 Nov 2024 19:22:12 +0100 Subject: [PATCH 14/32] Fix content in readme --- README.rst | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 9ce0d80..837a6d6 100644 --- a/README.rst +++ b/README.rst @@ -514,8 +514,7 @@ Example: assert cucumbers["start"] - cucumbers["eat"] == left -Example parameters from example tables can not only be used in step names with angular brackets (e.g., ), -but also embedded directly within docstrings and datatables, allowing for dynamic substitution. +Example parameters from example tables can not only be used in steps, but also embedded directly within docstrings and datatables, allowing for dynamic substitution. This provides added flexibility for scenarios that require complex setups or validations. Example: From be152c8b67023782c1dd1d4137a25ee601cf3087 Mon Sep 17 00:00:00 2001 From: Alessio Bogon <778703+youtux@users.noreply.github.com> Date: Sat, 30 Nov 2024 19:23:40 +0100 Subject: [PATCH 15/32] No need to use `Any`, we can use `object` --- src/pytest_bdd/parser.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pytest_bdd/parser.py b/src/pytest_bdd/parser.py index e5d2506..127d4c4 100644 --- a/src/pytest_bdd/parser.py +++ b/src/pytest_bdd/parser.py @@ -23,14 +23,14 @@ from .types import STEP_TYPE_BY_PARSER_KEYWORD STEP_PARAM_RE = re.compile(r"<(.+?)>") -def render_string(input_string: str, render_context: Mapping[str, Any]) -> str: +def render_string(input_string: str, render_context: Mapping[str, object]) -> str: """ Render the string with the given context, but avoid replacing text inside angle brackets if context is missing. Args: input_string (str): The string for which to render/replace params. - render_context (Mapping[str, Any]): The context for rendering the string. + render_context (Mapping[str, object]): The context for rendering the string. Returns: str: The rendered string with parameters replaced only if they exist in the context. From 67ab1f99fe26ef6a6243a3fb8f9603c0bfe0573b Mon Sep 17 00:00:00 2001 From: Alessio Bogon <778703+youtux@users.noreply.github.com> Date: Sat, 30 Nov 2024 19:26:15 +0100 Subject: [PATCH 16/32] Inline methods that don't need to exist --- src/pytest_bdd/parser.py | 30 ++---------------------------- 1 file changed, 2 insertions(+), 28 deletions(-) diff --git a/src/pytest_bdd/parser.py b/src/pytest_bdd/parser.py index 127d4c4..ad3dc17 100644 --- a/src/pytest_bdd/parser.py +++ b/src/pytest_bdd/parser.py @@ -213,13 +213,13 @@ class ScenarioTemplate: base_steps = self.all_background_steps + self._steps scenario_steps = [ Step( - name=step.render_step_name(context), + name=render_string(step.name, context), type=step.type, indent=step.indent, line_number=step.line_number, keyword=step.keyword, datatable=step.render_datatable(context), - docstring=step.render_docstring(context), + docstring=render_string(step.docstring, context) if step.docstring else None, ) for step in base_steps ] @@ -329,19 +329,6 @@ class Step: """ return tuple(frozenset(STEP_PARAM_RE.findall(self.name))) - def render_step_name(self, context: Mapping[str, Any]) -> str: - """ - Render the step name with the given context, - but avoid replacing text inside angle brackets if context is missing. - - Args: - context (Mapping[str, Any]): The context for rendering the step name. - - Returns: - str: The rendered step name with parameters replaced only if they exist in the context. - """ - return render_string(self.name, context) - def render_datatable(self, context: Mapping[str, Any]) -> DataTable | None: """ Render the datatable with the given context, @@ -361,19 +348,6 @@ class Step: return rendered_datatable return None - def render_docstring(self, context: Mapping[str, Any]) -> str | None: - """ - Render the docstring with the given context, - but avoid replacing text inside angle brackets if context is missing. - - Args: - context (Mapping[str, Any]): The context for rendering the docstring. - - Returns: - str: The rendered docstring with parameters replaced only if they exist in the context. - """ - return render_string(self.docstring, context) if self.docstring else None - @dataclass(eq=False) class Background: From 5b4e90e343252ef80eb2683a0d625e6ef73327d0 Mon Sep 17 00:00:00 2001 From: Alessio Bogon <778703+youtux@users.noreply.github.com> Date: Sat, 30 Nov 2024 19:29:42 +0100 Subject: [PATCH 17/32] Avoid mutation of objects, return a new one --- src/pytest_bdd/parser.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/pytest_bdd/parser.py b/src/pytest_bdd/parser.py index ad3dc17..d529d8c 100644 --- a/src/pytest_bdd/parser.py +++ b/src/pytest_bdd/parser.py @@ -1,5 +1,6 @@ from __future__ import annotations +import copy import os.path import re import textwrap @@ -218,7 +219,7 @@ class ScenarioTemplate: indent=step.indent, line_number=step.line_number, keyword=step.keyword, - datatable=step.render_datatable(context), + datatable=step.render_datatable(step.datatable, context) if step.datatable else None, docstring=render_string(step.docstring, context) if step.docstring else None, ) for step in base_steps @@ -329,24 +330,24 @@ class Step: """ return tuple(frozenset(STEP_PARAM_RE.findall(self.name))) - def render_datatable(self, context: Mapping[str, Any]) -> DataTable | None: + @staticmethod + def render_datatable(datatable: DataTable, context: Mapping[str, object]) -> DataTable: """ Render the datatable with the given context, but avoid replacing text inside angle brackets if context is missing. Args: + datatable (DataTable): The datatable to render. context (Mapping[str, Any]): The context for rendering the datatable. Returns: datatable (DataTable): The rendered datatable with parameters replaced only if they exist in the context. """ - if self.datatable: - rendered_datatable = self.datatable - for row in rendered_datatable.rows: - for cell in row.cells: - cell.value = render_string(cell.value, context) - return rendered_datatable - return None + rendered_datatable = copy.deepcopy(datatable) + for row in rendered_datatable.rows: + for cell in row.cells: + cell.value = render_string(cell.value, context) + return rendered_datatable @dataclass(eq=False) From 5a453c5dcbae2eed7830da75ca86997b4f08896f Mon Sep 17 00:00:00 2001 From: Alessio Bogon <778703+youtux@users.noreply.github.com> Date: Sat, 30 Nov 2024 19:35:17 +0100 Subject: [PATCH 18/32] Add changelog entry --- CHANGES.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGES.rst b/CHANGES.rst index e5077f7..023da04 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -24,6 +24,7 @@ Removed Fixed +++++ * Fixed an issue with the upcoming pytest release related to the use of ``@pytest.mark.usefixtures`` with an empty list. +* Render template variables in docstrings and datatable cells with example table entries, as we already do for steps definitions. Security ++++++++ From b2f101820f32d715162eca560128efe37037158f Mon Sep 17 00:00:00 2001 From: jsa34 <31512041+jsa34@users.noreply.github.com> Date: Sat, 30 Nov 2024 19:15:35 +0000 Subject: [PATCH 19/32] Rename STEP_PARAM_RE to remove step referencs The regex is no longer just for steps --- src/pytest_bdd/parser.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pytest_bdd/parser.py b/src/pytest_bdd/parser.py index d529d8c..df91a7d 100644 --- a/src/pytest_bdd/parser.py +++ b/src/pytest_bdd/parser.py @@ -21,7 +21,7 @@ from .gherkin_parser import Tag as GherkinTag from .gherkin_parser import get_gherkin_document from .types import STEP_TYPE_BY_PARSER_KEYWORD -STEP_PARAM_RE = re.compile(r"<(.+?)>") +PARAM_RE = re.compile(r"<(.+?)>") def render_string(input_string: str, render_context: Mapping[str, object]) -> str: @@ -42,7 +42,7 @@ def render_string(input_string: str, render_context: Mapping[str, object]) -> st # If the context contains the variable, replace it. Otherwise, leave it unchanged. return str(render_context.get(varname, f"<{varname}>")) - return STEP_PARAM_RE.sub(replacer, input_string) + return PARAM_RE.sub(replacer, input_string) def get_tag_names(tag_data: list[GherkinTag]) -> set[str]: From f68c3f5ce6ba9ffc3f98ceb6a47430485c807a5d Mon Sep 17 00:00:00 2001 From: jsa34 <31512041+jsa34@users.noreply.github.com> Date: Sat, 30 Nov 2024 19:19:29 +0000 Subject: [PATCH 20/32] Missed STEP_PARAM_RE -> PARAM_RE rename --- src/pytest_bdd/parser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pytest_bdd/parser.py b/src/pytest_bdd/parser.py index df91a7d..ebd7663 100644 --- a/src/pytest_bdd/parser.py +++ b/src/pytest_bdd/parser.py @@ -328,7 +328,7 @@ class Step: Returns: Tuple[str, ...]: A tuple of parameter names found in the step name. """ - return tuple(frozenset(STEP_PARAM_RE.findall(self.name))) + return tuple(frozenset(PARAM_RE.findall(self.name))) @staticmethod def render_datatable(datatable: DataTable, context: Mapping[str, object]) -> DataTable: From 3f42ef3b31f60c6b7c0089ac07460c0f3882133a Mon Sep 17 00:00:00 2001 From: Alessio Bogon <778703+youtux@users.noreply.github.com> Date: Sat, 30 Nov 2024 23:08:26 +0100 Subject: [PATCH 21/32] Remove unused method --- src/pytest_bdd/parser.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/pytest_bdd/parser.py b/src/pytest_bdd/parser.py index ebd7663..2ede93c 100644 --- a/src/pytest_bdd/parser.py +++ b/src/pytest_bdd/parser.py @@ -321,15 +321,6 @@ class Step: """ return f'{self.type.capitalize()} "{self.name}"' - @property - def params(self) -> tuple[str, ...]: - """Get the parameters in the step name. - - Returns: - Tuple[str, ...]: A tuple of parameter names found in the step name. - """ - return tuple(frozenset(PARAM_RE.findall(self.name))) - @staticmethod def render_datatable(datatable: DataTable, context: Mapping[str, object]) -> DataTable: """ From 4eb54fdc7a50eb9cc064dcc192b13c823f6b9f7b Mon Sep 17 00:00:00 2001 From: Alessio Bogon <778703+youtux@users.noreply.github.com> Date: Sat, 30 Nov 2024 23:54:06 +0100 Subject: [PATCH 22/32] Raise an error if a step defines reserved argument names This can cause headaches in the future, when users can't figure out why their step argument 'datatable' or 'docstring' does not get the value they expect --- src/pytest_bdd/exceptions.py | 4 +++ src/pytest_bdd/scenario.py | 47 +++++++++++++++++++------------ src/pytest_bdd/utils.py | 5 ++++ tests/datatable/test_datatable.py | 45 +++++++++++++++++++++++++++++ tests/steps/test_docstring.py | 45 +++++++++++++++++++++++++++++ 5 files changed, 128 insertions(+), 18 deletions(-) diff --git a/src/pytest_bdd/exceptions.py b/src/pytest_bdd/exceptions.py index 1baf617..b46a822 100644 --- a/src/pytest_bdd/exceptions.py +++ b/src/pytest_bdd/exceptions.py @@ -3,6 +3,10 @@ from __future__ import annotations +class StepImplementationError(Exception): + """Step implementation error.""" + + class ScenarioIsDecoratorOnly(Exception): """Scenario can be only used as decorator.""" diff --git a/src/pytest_bdd/scenario.py b/src/pytest_bdd/scenario.py index f8ecb88..b005f8a 100644 --- a/src/pytest_bdd/scenario.py +++ b/src/pytest_bdd/scenario.py @@ -28,7 +28,7 @@ from . import exceptions from .compat import getfixturedefs, inject_fixture from .feature import get_feature, get_features from .steps import StepFunctionContext, get_step_fixture_name -from .utils import CONFIG_STACK, get_args, get_caller_module_locals, get_caller_module_path +from .utils import CONFIG_STACK, get_args, get_caller_module_locals, get_caller_module_path, identity if TYPE_CHECKING: from _pytest.mark.structures import ParameterSet @@ -41,10 +41,13 @@ T = TypeVar("T") logger = logging.getLogger(__name__) - PYTHON_REPLACE_REGEX = re.compile(r"\W") ALPHA_REGEX = re.compile(r"^\d+_*") +STEP_ARGUMENT_DATATABLE = "datatable" +STEP_ARGUMENT_DOCSTRING = "docstring" +STEP_ARGUMENTS_RESERVED_NAMES = {STEP_ARGUMENT_DATATABLE, STEP_ARGUMENT_DOCSTRING} + def find_fixturedefs_for_step(step: Step, fixturemanager: FixtureManager, node: Node) -> Iterable[FixtureDef[Any]]: """Find the fixture defs that can parse a step.""" @@ -172,6 +175,27 @@ def get_step_function(request: FixtureRequest, step: Step) -> StepFunctionContex return None +def parse_step_arguments(step: Step, context: StepFunctionContext) -> dict[str, object] | None: + """Parse step arguments.""" + parsed_args = context.parser.parse_arguments(step.name) + + assert parsed_args is not None, ( + f"Unexpected `NoneType` returned from " f"parse_arguments(...) in parser: {context.parser!r}" + ) + + reserved_args = set(parsed_args.keys()) & STEP_ARGUMENTS_RESERVED_NAMES + if reserved_args: + reserved_arguments_str = ", ".join(repr(arg) for arg in reserved_args) + raise exceptions.StepImplementationError( + f"Step {step.name!r} defines argument names that are reserved: {reserved_arguments_str}. " + "Please use different names." + ) + + converted_args = {key: (context.converters.get(key, identity)(value)) for key, value in parsed_args.items()} + + return converted_args + + def _execute_step_function( request: FixtureRequest, scenario: Scenario, step: Step, context: StepFunctionContext ) -> None: @@ -185,30 +209,17 @@ def _execute_step_function( "step_func": context.step_func, "step_func_args": {}, } - request.config.hook.pytest_bdd_before_step(**kw) - - # Get the step argument values. - converters = context.converters - kwargs = {} args = get_args(context.step_func) try: - parsed_args = context.parser.parse_arguments(step.name) - assert parsed_args is not None, ( - f"Unexpected `NoneType` returned from " f"parse_arguments(...) in parser: {context.parser!r}" - ) - - for arg, value in parsed_args.items(): - if arg in converters: - value = converters[arg](value) - kwargs[arg] = value + kwargs = parse_step_arguments(step=step, context=context) if step.datatable is not None: - kwargs["datatable"] = step.datatable.raw() + kwargs[STEP_ARGUMENT_DATATABLE] = step.datatable.raw() if step.docstring is not None: - kwargs["docstring"] = step.docstring + kwargs[STEP_ARGUMENT_DOCSTRING] = step.docstring kwargs = {arg: kwargs[arg] if arg in kwargs else request.getfixturevalue(arg) for arg in args} diff --git a/src/pytest_bdd/utils.py b/src/pytest_bdd/utils.py index 1e9946c..56811eb 100644 --- a/src/pytest_bdd/utils.py +++ b/src/pytest_bdd/utils.py @@ -83,3 +83,8 @@ def setdefault(obj: object, name: str, default: T) -> T: except AttributeError: setattr(obj, name, default) return default + + +def identity(x: T) -> T: + """Return the argument.""" + return x diff --git a/tests/datatable/test_datatable.py b/tests/datatable/test_datatable.py index ddb4ee9..47a04d1 100644 --- a/tests/datatable/test_datatable.py +++ b/tests/datatable/test_datatable.py @@ -210,3 +210,48 @@ def test_steps_with_datatable_missing_argument_in_step(pytester): ) result = pytester.runpytest("-s") result.assert_outcomes(passed=1) + + +def test_datatable_step_argument_is_reserved_and_cannot_be_used(pytester): + pytester.makefile( + ".feature", + reserved_datatable_arg=textwrap.dedent( + """\ + Feature: Reserved datatable argument + + Scenario: Reserved datatable argument + Given this step has a {datatable} argument + Then the test fails + """ + ), + ) + + pytester.makepyfile( + textwrap.dedent( + """\ + from pytest_bdd import scenario, given, then, parsers + + @scenario("reserved_datatable_arg.feature", "Reserved datatable argument") + def test_datatable(): + pass + + + @given(parsers.parse("this step has a {datatable} argument")) + def _(datatable): + pass + + + @then("the test fails") + def _(): + pass + """ + ) + ) + + result = pytester.runpytest() + result.assert_outcomes(failed=1) + result.stdout.fnmatch_lines( + [ + "*Step 'this step has a {datatable} argument' defines argument names that are reserved: 'datatable'. Please use different names.*" + ] + ) diff --git a/tests/steps/test_docstring.py b/tests/steps/test_docstring.py index bfa5e52..81d63c2 100644 --- a/tests/steps/test_docstring.py +++ b/tests/steps/test_docstring.py @@ -193,3 +193,48 @@ def test_docstring_argument_in_step_impl_is_optional(pytester): ) result = pytester.runpytest("-s") result.assert_outcomes(passed=1) + + +def test_docstring_step_argument_is_reserved_and_cannot_be_used(pytester): + pytester.makefile( + ".feature", + reserved_docstring_arg=textwrap.dedent( + """\ + Feature: Reserved docstring argument + + Scenario: Reserved docstring argument + Given this step has a {docstring} argument + Then the test fails + """ + ), + ) + + pytester.makepyfile( + textwrap.dedent( + """\ + from pytest_bdd import scenario, given, then, parsers + + @scenario("reserved_docstring_arg.feature", "Reserved docstring argument") + def test_docstring(): + pass + + + @given(parsers.parse("this step has a {docstring} argument")) + def _(docstring): + pass + + + @then("the test fails") + def _(): + pass + """ + ) + ) + + result = pytester.runpytest() + result.assert_outcomes(failed=1) + result.stdout.fnmatch_lines( + [ + "*Step 'this step has a {docstring} argument' defines argument names that are reserved: 'docstring'. Please use different names.*" + ] + ) From 0914837a03f3e31c612e83aafbdc88e51e986907 Mon Sep 17 00:00:00 2001 From: Alessio Bogon <778703+youtux@users.noreply.github.com> Date: Sat, 30 Nov 2024 23:57:11 +0100 Subject: [PATCH 23/32] Add changelog entry --- CHANGES.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGES.rst b/CHANGES.rst index 023da04..c441165 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -14,6 +14,7 @@ Added Changed +++++++ +* Step arguments ``"datatable"`` and ``"docstring"`` are now reserved, and they can't be used as step argument names. Deprecated ++++++++++ From c3a49c24a84e0b2e48555f35cba60829b13722b8 Mon Sep 17 00:00:00 2001 From: Alessio Bogon <778703+youtux@users.noreply.github.com> Date: Sat, 30 Nov 2024 23:59:40 +0100 Subject: [PATCH 24/32] Fix return type --- src/pytest_bdd/scenario.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pytest_bdd/scenario.py b/src/pytest_bdd/scenario.py index b005f8a..bc8f34e 100644 --- a/src/pytest_bdd/scenario.py +++ b/src/pytest_bdd/scenario.py @@ -175,7 +175,7 @@ def get_step_function(request: FixtureRequest, step: Step) -> StepFunctionContex return None -def parse_step_arguments(step: Step, context: StepFunctionContext) -> dict[str, object] | None: +def parse_step_arguments(step: Step, context: StepFunctionContext) -> dict[str, object]: """Parse step arguments.""" parsed_args = context.parser.parse_arguments(step.name) From f5c3faa72a04ee41167a5061bd9a2c5d19b9d952 Mon Sep 17 00:00:00 2001 From: Alessio Bogon <778703+youtux@users.noreply.github.com> Date: Sun, 1 Dec 2024 09:38:45 +0100 Subject: [PATCH 25/32] Remove useless nested function --- src/pytest_bdd/scenario.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/pytest_bdd/scenario.py b/src/pytest_bdd/scenario.py index 0a3d2f5..ea7c189 100644 --- a/src/pytest_bdd/scenario.py +++ b/src/pytest_bdd/scenario.py @@ -205,12 +205,6 @@ def _execute_step_function( func_sig = signature(context.step_func) - def _get_parsed_arguments() -> dict: - """Parse and convert step arguments.""" - parsed_args = parse_step_arguments(step=step, context=context) - - return {k: v for k, v in parsed_args.items() if k in func_sig.parameters} - def _get_argument_values(kwargs: dict) -> dict: """Get default values or request fixture values for missing arguments.""" for arg in get_args(context.step_func): @@ -235,7 +229,8 @@ def _execute_step_function( try: # Use internal methods without passing redundant arguments - kwargs = _get_parsed_arguments() + parsed_args = parse_step_arguments(step=step, context=context) + kwargs = {k: v for k, v in parsed_args.items() if k in func_sig.parameters} if STEP_ARGUMENT_DATATABLE in func_sig.parameters and step.datatable is not None: kwargs[STEP_ARGUMENT_DATATABLE] = step.datatable.raw() From bf7971a8cc03fe03ab7ffe257e1f5cc19e6c7894 Mon Sep 17 00:00:00 2001 From: Alessio Bogon <778703+youtux@users.noreply.github.com> Date: Sun, 1 Dec 2024 10:43:39 +0100 Subject: [PATCH 26/32] Make sure we test what the "given" step receives Also, no need for this to use scenario outlines. --- tests/feature/test_scenario.py | 36 ++++++++++++---------------------- 1 file changed, 13 insertions(+), 23 deletions(-) diff --git a/tests/feature/test_scenario.py b/tests/feature/test_scenario.py index 7a2e442..fbdecae 100644 --- a/tests/feature/test_scenario.py +++ b/tests/feature/test_scenario.py @@ -368,44 +368,34 @@ def test_multilanguage_support(pytester): ] -def test_default_value_in_not_parsed(pytester): - """Test that angular brackets are not parsed for "Scenario"s. - - (They should be parsed only when used in "Scenario Outline") - - """ +def test_default_value_is_used_as_fallback(pytester): + """Test that the default value for a step implementation is only used as a fallback.""" pytester.makefile( ".feature", simple=""" Feature: Simple feature - Scenario: Simple scenario - Given a user with username - Then check username defaultuser + Scenario: Step using default arg + Given a user with default username - Scenario Outline: Outlined scenario - Given a user with username - Then check username - - Examples: - | username | - | user1 | + Scenario: Step using explicit value + Given a user with username "user1" """, ) pytester.makepyfile( """ from pytest_bdd import scenarios, given, then, parsers + from pytest_bdd.utils import dump_obj scenarios("simple.feature") - @given('a user with username', target_fixture="user") - @given(parsers.parse('a user with username {username}'), target_fixture="user") + @given('a user with default username', target_fixture="user") + @given(parsers.parse('a user with username "{username}"'), target_fixture="user") def create_user(username="defaultuser"): - return username + dump_obj(username) - @then(parsers.parse("check username {username}")) - def _(user, username): - assert user == username """ ) - result = pytester.runpytest() + result = pytester.runpytest("-s") result.assert_outcomes(passed=2) + + assert collect_dumped_objects(result) == ["defaultuser", "user1"] From 3752e405a3de0d50343ccfd9cc96ebcc1da09659 Mon Sep 17 00:00:00 2001 From: Alessio Bogon <778703+youtux@users.noreply.github.com> Date: Sun, 1 Dec 2024 10:47:10 +0100 Subject: [PATCH 27/32] Simplify implementation --- src/pytest_bdd/scenario.py | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/src/pytest_bdd/scenario.py b/src/pytest_bdd/scenario.py index ea7c189..9279cd9 100644 --- a/src/pytest_bdd/scenario.py +++ b/src/pytest_bdd/scenario.py @@ -205,18 +205,6 @@ def _execute_step_function( func_sig = signature(context.step_func) - def _get_argument_values(kwargs: dict) -> dict: - """Get default values or request fixture values for missing arguments.""" - for arg in get_args(context.step_func): - if arg not in kwargs: - param = func_sig.parameters.get(arg) - if param: - if param.default != param.empty: - kwargs[arg] = param.default - else: - kwargs[arg] = request.getfixturevalue(arg) - return kwargs - kw = { "request": request, "feature": scenario.feature, @@ -237,7 +225,8 @@ def _execute_step_function( if STEP_ARGUMENT_DOCSTRING in func_sig.parameters and step.docstring is not None: kwargs[STEP_ARGUMENT_DOCSTRING] = step.docstring - kwargs = _get_argument_values(kwargs) + # Fill the missing arguments requesting the fixture values + kwargs |= {arg: request.getfixturevalue(arg) for arg in get_args(context.step_func) if arg not in kwargs} kw["step_func_args"] = kwargs From 4602314d0315f8c3277890ccab7167ba3875352a Mon Sep 17 00:00:00 2001 From: Alessio Bogon <778703+youtux@users.noreply.github.com> Date: Sun, 1 Dec 2024 10:56:08 +0100 Subject: [PATCH 28/32] Add test --- tests/feature/test_scenario.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/feature/test_scenario.py b/tests/feature/test_scenario.py index 09c8f42..2c61964 100644 --- a/tests/feature/test_scenario.py +++ b/tests/feature/test_scenario.py @@ -286,6 +286,33 @@ def test_example_params(pytester): ] +def test_step_parser_argument_not_in_function_signature_does_not_fail(pytester): + """Test that if the step parser defines an argument, but step function does not accept it, + then it does not fail and the params is just not filled.""" + + pytester.makefile( + ".feature", + simple=""" + Feature: Simple feature + Scenario: Step with missing argument + Given a user with username "user1" + """, + ) + pytester.makepyfile( + """ + from pytest_bdd import scenarios, given, parsers + + scenarios("simple.feature") + + @given(parsers.parse('a user with username "{username}"')) + def create_user(): + pass + """ + ) + result = pytester.runpytest() + result.assert_outcomes(passed=1) + + def test_multilanguage_support(pytester): """Test multilanguage support.""" pytester.makefile( From 584b676c6d4dba0d85d4080245369dc0707c9551 Mon Sep 17 00:00:00 2001 From: Alessio Bogon <778703+youtux@users.noreply.github.com> Date: Sun, 1 Dec 2024 10:57:26 +0100 Subject: [PATCH 29/32] Explain why we do things --- src/pytest_bdd/scenario.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/pytest_bdd/scenario.py b/src/pytest_bdd/scenario.py index 9279cd9..ecddf69 100644 --- a/src/pytest_bdd/scenario.py +++ b/src/pytest_bdd/scenario.py @@ -218,6 +218,8 @@ def _execute_step_function( try: # Use internal methods without passing redundant arguments parsed_args = parse_step_arguments(step=step, context=context) + + # Filter out the arguments that are not in the function signature kwargs = {k: v for k, v in parsed_args.items() if k in func_sig.parameters} if STEP_ARGUMENT_DATATABLE in func_sig.parameters and step.datatable is not None: From 9d99f0eceb283254351ca89812cb4570b3ac7072 Mon Sep 17 00:00:00 2001 From: Alessio Bogon <778703+youtux@users.noreply.github.com> Date: Sun, 1 Dec 2024 10:57:38 +0100 Subject: [PATCH 30/32] Remove irrelevant comment --- src/pytest_bdd/scenario.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pytest_bdd/scenario.py b/src/pytest_bdd/scenario.py index ecddf69..38ded6d 100644 --- a/src/pytest_bdd/scenario.py +++ b/src/pytest_bdd/scenario.py @@ -216,7 +216,6 @@ def _execute_step_function( request.config.hook.pytest_bdd_before_step(**kw) try: - # Use internal methods without passing redundant arguments parsed_args = parse_step_arguments(step=step, context=context) # Filter out the arguments that are not in the function signature From 5e10d3430f94eb77ff47a8668dd5c8a7b002d84f Mon Sep 17 00:00:00 2001 From: Alessio Bogon <778703+youtux@users.noreply.github.com> Date: Sun, 1 Dec 2024 11:00:31 +0100 Subject: [PATCH 31/32] Change function name so that it's clearer what it does --- src/pytest_bdd/scenario.py | 8 +++++--- src/pytest_bdd/utils.py | 5 ++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/pytest_bdd/scenario.py b/src/pytest_bdd/scenario.py index 38ded6d..6a57f01 100644 --- a/src/pytest_bdd/scenario.py +++ b/src/pytest_bdd/scenario.py @@ -29,7 +29,7 @@ from . import exceptions from .compat import getfixturedefs, inject_fixture from .feature import get_feature, get_features from .steps import StepFunctionContext, get_step_fixture_name -from .utils import CONFIG_STACK, get_args, get_caller_module_locals, get_caller_module_path, identity +from .utils import CONFIG_STACK, get_caller_module_locals, get_caller_module_path, get_required_args, identity if TYPE_CHECKING: from _pytest.mark.structures import ParameterSet @@ -227,7 +227,9 @@ def _execute_step_function( kwargs[STEP_ARGUMENT_DOCSTRING] = step.docstring # Fill the missing arguments requesting the fixture values - kwargs |= {arg: request.getfixturevalue(arg) for arg in get_args(context.step_func) if arg not in kwargs} + kwargs |= { + arg: request.getfixturevalue(arg) for arg in get_required_args(context.step_func) if arg not in kwargs + } kw["step_func_args"] = kwargs @@ -287,7 +289,7 @@ def _get_scenario_decorator( "scenario function can only be used as a decorator. Refer to the documentation." ) [fn] = args - func_args = get_args(fn) + func_args = get_required_args(fn) def scenario_wrapper(request: FixtureRequest, _pytest_bdd_example: dict[str, str]) -> Any: __tracebackhide__ = True diff --git a/src/pytest_bdd/utils.py b/src/pytest_bdd/utils.py index 56811eb..a72c86d 100644 --- a/src/pytest_bdd/utils.py +++ b/src/pytest_bdd/utils.py @@ -20,13 +20,12 @@ T = TypeVar("T") CONFIG_STACK: list[Config] = [] -def get_args(func: Callable[..., Any]) -> list[str]: - """Get a list of argument names for a function. +def get_required_args(func: Callable[..., Any]) -> list[str]: + """Get a list of argument that are required for a function. :param func: The function to inspect. :return: A list of argument names. - :rtype: list """ params = signature(func).parameters.values() return [ From afec8b185dc71b358d7c9b0b255976f5b88fc97e Mon Sep 17 00:00:00 2001 From: Alessio Bogon <778703+youtux@users.noreply.github.com> Date: Sun, 1 Dec 2024 11:02:33 +0100 Subject: [PATCH 32/32] Add back comment that was relevant --- src/pytest_bdd/scenario.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/pytest_bdd/scenario.py b/src/pytest_bdd/scenario.py index 6a57f01..cb2a126 100644 --- a/src/pytest_bdd/scenario.py +++ b/src/pytest_bdd/scenario.py @@ -235,6 +235,8 @@ def _execute_step_function( request.config.hook.pytest_bdd_before_step_call(**kw) + # Execute the step as if it was a pytest fixture using `call_fixture_func`, + # so that we can allow "yield" statements in it return_value = call_fixture_func(fixturefunc=context.step_func, request=request, kwargs=kwargs) except Exception as exception: