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 08a53c3c..f228332f 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 b8664e21..bf7c46f1 100644 --- a/tests/feature/test_scenario.py +++ b/tests/feature/test_scenario.py @@ -279,3 +279,46 @@ def _(): ("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 2058078f..7a8f04e5 100644 --- a/src/pytest_bdd/scenario.py +++ b/src/pytest_bdd/scenario.py @@ -18,6 +18,7 @@ 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}" - ) - - for arg, value in parsed_args.items(): - if arg in converters: - value = converters[arg](value) - kwargs[arg] = value + # Use internal methods without passing redundant arguments + kwargs = _get_parsed_arguments() - 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 7a8f04e5..362c1fea 100644 --- a/src/pytest_bdd/scenario.py +++ b/src/pytest_bdd/scenario.py @@ -186,9 +186,7 @@ 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}" - ) + 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 362c1fea..e04d41aa 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 _get_parsed_arguments(): 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 ff4a0619..7629174a 100644 --- a/src/pytest_bdd/parser.py +++ b/src/pytest_bdd/parser.py @@ -23,6 +23,27 @@ 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 @@ def render(self, context: Mapping[str, Any]) -> Scenario: """ 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 @@ def params(self) -> tuple[str, ...]: """ 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 @@ def render(self, context: Mapping[str, Any]) -> str: 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, + but avoid replacing text inside angle brackets if context is missing. + + 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. - 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}>")) + Args: + context (Mapping[str, Any]): The context for rendering the docstring. - return STEP_PARAM_RE.sub(replacer, self.name) + 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 b8664e21..9e1d6f58 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 _(): 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 7629174a..b8209210 100644 --- a/src/pytest_bdd/parser.py +++ b/src/pytest_bdd/parser.py @@ -342,7 +342,7 @@ def render_step_name(self, context: Mapping[str, Any]) -> str: """ 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 @@ def render_datatable(self, context: Mapping[str, Any]) -> datatable | None: 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 7af83821..5fb1f863 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 b8209210..e5d2506e 100644 --- a/src/pytest_bdd/parser.py +++ b/src/pytest_bdd/parser.py @@ -210,6 +210,7 @@ def render(self, context: Mapping[str, Any]) -> Scenario: 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 @@ def render(self, context: Mapping[str, Any]) -> Scenario: 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 9e1d6f58..09c8f42c 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 _(): 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 5fb1f863..9ce0d80b 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 ac8844c5..b6f403b1 100644 --- a/src/pytest_bdd/scenario.py +++ b/src/pytest_bdd/scenario.py @@ -271,9 +271,6 @@ def decorator(*args: Callable[P, T]) -> Callable[P, T]: [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 scenario_wrapper(request: FixtureRequest, _pytest_bdd_example: dict[str, str 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 b6f403b1..f8ecb88d 100644 --- a/src/pytest_bdd/scenario.py +++ b/src/pytest_bdd/scenario.py @@ -297,7 +297,7 @@ def scenario_wrapper(request: FixtureRequest, _pytest_bdd_example: dict[str, str 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 1e8ae705..e5077f78 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 9ce0d80b..837a6d60 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 e5d2506e..127d4c49 100644 --- a/src/pytest_bdd/parser.py +++ b/src/pytest_bdd/parser.py @@ -23,14 +23,14 @@ 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 127d4c49..ad3dc171 100644 --- a/src/pytest_bdd/parser.py +++ b/src/pytest_bdd/parser.py @@ -213,13 +213,13 @@ def render(self, context: Mapping[str, Any]) -> Scenario: 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 @@ def params(self) -> tuple[str, ...]: """ 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 @@ def render_datatable(self, context: Mapping[str, Any]) -> DataTable | None: 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 ad3dc171..d529d8c4 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 @@ def render(self, context: Mapping[str, Any]) -> Scenario: 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 @@ def params(self) -> tuple[str, ...]: """ 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 e5077f78..023da040 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 d529d8c4..df91a7d9 100644 --- a/src/pytest_bdd/parser.py +++ b/src/pytest_bdd/parser.py @@ -21,7 +21,7 @@ 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 replacer(m: re.Match) -> str: # 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 df91a7d9..ebd7663b 100644 --- a/src/pytest_bdd/parser.py +++ b/src/pytest_bdd/parser.py @@ -328,7 +328,7 @@ def params(self) -> tuple[str, ...]: 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 ebd7663b..2ede93ca 100644 --- a/src/pytest_bdd/parser.py +++ b/src/pytest_bdd/parser.py @@ -321,15 +321,6 @@ def __str__(self) -> str: """ 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 1baf617e..b46a8221 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 f8ecb88d..b005f8a2 100644 --- a/src/pytest_bdd/scenario.py +++ b/src/pytest_bdd/scenario.py @@ -28,7 +28,7 @@ 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 @@ 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 1e9946c4..56811ebe 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 ddb4ee94..47a04d11 100644 --- a/tests/datatable/test_datatable.py +++ b/tests/datatable/test_datatable.py @@ -210,3 +210,48 @@ def test_datatable(): ) 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 bfa5e524..81d63c2b 100644 --- a/tests/steps/test_docstring.py +++ b/tests/steps/test_docstring.py @@ -193,3 +193,48 @@ def _(): ) 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 023da040..c441165b 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 b005f8a2..bc8f34ee 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 0a3d2f5c..ea7c189a 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 _get_argument_values(kwargs: dict) -> dict: 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 7a2e4428..fbdecae6 100644 --- a/tests/feature/test_scenario.py +++ b/tests/feature/test_scenario.py @@ -368,44 +368,34 @@ def _(): ] -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 ea7c189a..9279cd94 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 _get_argument_values(kwargs: dict) -> dict: 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 09c8f42c..2c619640 100644 --- a/tests/feature/test_scenario.py +++ b/tests/feature/test_scenario.py @@ -286,6 +286,33 @@ def _(): ] +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 9279cd94..ecddf69d 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 ecddf69d..38ded6d5 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 38ded6d5..6a57f011 100644 --- a/src/pytest_bdd/scenario.py +++ b/src/pytest_bdd/scenario.py @@ -29,7 +29,7 @@ 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 decorator(*args: Callable[P, T]) -> Callable[P, T]: "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 56811ebe..a72c86df 100644 --- a/src/pytest_bdd/utils.py +++ b/src/pytest_bdd/utils.py @@ -20,13 +20,12 @@ 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 6a57f011..cb2a126f 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: