Skip to content

Commit

Permalink
Merge branch 'master' into test-389
Browse files Browse the repository at this point in the history
  • Loading branch information
jsa34 authored Dec 1, 2024
2 parents f94fad2 + d527a67 commit 07ca9ef
Show file tree
Hide file tree
Showing 9 changed files with 397 additions and 58 deletions.
3 changes: 3 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
++++++++++
Expand All @@ -23,6 +24,8 @@ 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
++++++++
Expand Down
52 changes: 52 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,58 @@ 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 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:

.. 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: <username>
password: <password>
"""
When the user logs in
Then the response should contain:
| field | value |
| username | <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
-----

Expand Down
4 changes: 4 additions & 0 deletions src/pytest_bdd/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
from __future__ import annotations


class StepImplementationError(Exception):
"""Step implementation error."""


class ScenarioIsDecoratorOnly(Exception):
"""Scenario can be only used as decorator."""

Expand Down
69 changes: 42 additions & 27 deletions src/pytest_bdd/parser.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import copy
import os.path
import re
import textwrap
Expand All @@ -20,7 +21,28 @@
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:
"""
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, object]): 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 PARAM_RE.sub(replacer, input_string)


def get_tag_names(tag_data: list[GherkinTag]) -> set[str]:
Expand Down Expand Up @@ -189,25 +211,25 @@ 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(context),
name=render_string(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(step.datatable, context) if step.datatable else None,
docstring=render_string(step.docstring, context) if step.docstring else None,
)
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,
Expand Down Expand Up @@ -299,31 +321,24 @@ 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.
@staticmethod
def render_datatable(datatable: DataTable, context: Mapping[str, object]) -> DataTable:
"""
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.
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 step name.
datatable (DataTable): The datatable to render.
context (Mapping[str, Any]): The context for rendering the datatable.
Returns:
str: The rendered step name 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.
"""

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}>"))

return STEP_PARAM_RE.sub(replacer, self.name)
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)
Expand Down
80 changes: 52 additions & 28 deletions src/pytest_bdd/scenario.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -28,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
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
Expand All @@ -41,10 +42,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."""
Expand Down Expand Up @@ -172,11 +176,35 @@ def get_step_function(request: FixtureRequest, step: Step) -> StepFunctionContex
return None


def parse_step_arguments(step: Step, context: StepFunctionContext) -> dict[str, object]:
"""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:
"""Execute step function."""
__tracebackhide__ = True

func_sig = signature(context.step_func)

kw = {
"request": request,
"feature": scenario.feature,
Expand All @@ -185,38 +213,32 @@ 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}"
)
parsed_args = parse_step_arguments(step=step, context=context)

for arg, value in parsed_args.items():
if arg in converters:
value = converters[arg](value)
kwargs[arg] = value
# 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.datatable is not None:
kwargs["datatable"] = step.datatable.raw()
if STEP_ARGUMENT_DATATABLE in func_sig.parameters and step.datatable is not None:
kwargs[STEP_ARGUMENT_DATATABLE] = step.datatable.raw()
if STEP_ARGUMENT_DOCSTRING in func_sig.parameters and step.docstring is not None:
kwargs[STEP_ARGUMENT_DOCSTRING] = step.docstring

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}
# Fill the missing arguments requesting the fixture values
kwargs |= {
arg: request.getfixturevalue(arg) for arg in get_required_args(context.step_func) if arg not in 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

# 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:
request.config.hook.pytest_bdd_step_error(exception=exception, **kw)
raise
Expand Down Expand Up @@ -269,18 +291,20 @@ 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)

# 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)
_execute_scenario(feature, scenario, request)
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
Expand All @@ -295,7 +319,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
Expand Down
10 changes: 7 additions & 3 deletions src/pytest_bdd/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand Down Expand Up @@ -83,3 +82,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
Loading

0 comments on commit 07ca9ef

Please sign in to comment.