Skip to content

Commit 34f68dc

Browse files
committed
support async scenarios
1 parent 0f02d56 commit 34f68dc

File tree

9 files changed

+454
-310
lines changed

9 files changed

+454
-310
lines changed

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
[build-system]
2+
requires = ["setuptools", "wheel", "unasync~=0.5.0"]
3+
build-backend = "setuptools.build_meta"
4+
15
[tool.black]
26
line-length = 120
37
target-version = ['py36', 'py37', 'py38']

pytest_bdd/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
"""pytest-bdd public API."""
22

33
from pytest_bdd.steps import given, when, then
4-
from pytest_bdd.scenario import scenario, scenarios
4+
from pytest_bdd.scenario import scenario, scenarios, async_scenario, async_scenarios
55

66
__version__ = "4.0.2"
77

8-
__all__ = [given.__name__, when.__name__, then.__name__, scenario.__name__, scenarios.__name__]
8+
__all__ = ["given", "when", "then", "scenario", "scenarios", "async_scenario", "async_scenarios"]

pytest_bdd/_async/__init__.py

Whitespace-only changes.

pytest_bdd/_async/scenario.py

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
"""Scenario implementation.
2+
3+
The pytest will collect the test case and the steps will be executed
4+
line by line.
5+
6+
Example:
7+
8+
test_publish_article = scenario(
9+
feature_name="publish_article.feature",
10+
scenario_name="Publishing the article",
11+
)
12+
"""
13+
import collections
14+
import os
15+
import re
16+
17+
import pytest
18+
from _pytest.fixtures import FixtureLookupError
19+
20+
from .. import exceptions
21+
from ..feature import get_feature, get_features
22+
from ..steps import get_step_fixture_name, inject_fixture
23+
from ..utils import CONFIG_STACK, get_args, get_caller_module_locals, get_caller_module_path
24+
25+
PYTHON_REPLACE_REGEX = re.compile(r"\W")
26+
ALPHA_REGEX = re.compile(r"^\d+_*")
27+
28+
29+
def find_argumented_step_fixture_name(name, type_, fixturemanager, request=None):
30+
"""Find argumented step fixture name."""
31+
# happens to be that _arg2fixturedefs is changed during the iteration so we use a copy
32+
for fixturename, fixturedefs in list(fixturemanager._arg2fixturedefs.items()):
33+
for fixturedef in fixturedefs:
34+
parser = getattr(fixturedef.func, "parser", None)
35+
if parser is None:
36+
continue
37+
match = parser.is_matching(name)
38+
if not match:
39+
continue
40+
41+
converters = getattr(fixturedef.func, "converters", {})
42+
for arg, value in parser.parse_arguments(name).items():
43+
if arg in converters:
44+
value = converters[arg](value)
45+
if request:
46+
inject_fixture(request, arg, value)
47+
parser_name = get_step_fixture_name(parser.name, type_)
48+
if request:
49+
try:
50+
request.getfixturevalue(parser_name)
51+
except FixtureLookupError:
52+
continue
53+
return parser_name
54+
55+
56+
def _find_step_function(request, step, scenario):
57+
"""Match the step defined by the regular expression pattern.
58+
59+
:param request: PyTest request object.
60+
:param step: Step.
61+
:param scenario: Scenario.
62+
63+
:return: Function of the step.
64+
:rtype: function
65+
"""
66+
name = step.name
67+
try:
68+
# Simple case where no parser is used for the step
69+
return request.getfixturevalue(get_step_fixture_name(name, step.type))
70+
except FixtureLookupError:
71+
try:
72+
# Could not find a fixture with the same name, let's see if there is a parser involved
73+
name = find_argumented_step_fixture_name(name, step.type, request._fixturemanager, request)
74+
if name:
75+
return request.getfixturevalue(name)
76+
raise
77+
except FixtureLookupError:
78+
raise exceptions.StepDefinitionNotFoundError(
79+
f"Step definition is not found: {step}. "
80+
f'Line {step.line_number} in scenario "{scenario.name}" in the feature "{scenario.feature.filename}"'
81+
)
82+
83+
84+
async def _execute_step_function(request, scenario, step, step_func):
85+
"""Execute step function.
86+
87+
:param request: PyTest request.
88+
:param scenario: Scenario.
89+
:param step: Step.
90+
:param function step_func: Step function.
91+
:param example: Example table.
92+
"""
93+
kw = dict(request=request, feature=scenario.feature, scenario=scenario, step=step, step_func=step_func)
94+
95+
request.config.hook.pytest_bdd_before_step(**kw)
96+
97+
kw["step_func_args"] = {}
98+
try:
99+
# Get the step argument values.
100+
kwargs = {arg: request.getfixturevalue(arg) for arg in get_args(step_func)}
101+
kw["step_func_args"] = kwargs
102+
103+
request.config.hook.pytest_bdd_before_step_call(**kw)
104+
target_fixture = getattr(step_func, "target_fixture", None)
105+
# Execute the step.
106+
return_value = await step_func(**kwargs)
107+
if target_fixture:
108+
inject_fixture(request, target_fixture, return_value)
109+
110+
request.config.hook.pytest_bdd_after_step(**kw)
111+
except Exception as exception:
112+
request.config.hook.pytest_bdd_step_error(exception=exception, **kw)
113+
raise
114+
115+
116+
async def _execute_scenario(feature, scenario, request):
117+
"""Execute the scenario.
118+
119+
:param feature: Feature.
120+
:param scenario: Scenario.
121+
:param request: request.
122+
:param encoding: Encoding.
123+
"""
124+
request.config.hook.pytest_bdd_before_scenario(request=request, feature=feature, scenario=scenario)
125+
126+
try:
127+
# Execute scenario steps
128+
for step in scenario.steps:
129+
try:
130+
step_func = _find_step_function(request, step, scenario)
131+
except exceptions.StepDefinitionNotFoundError as exception:
132+
request.config.hook.pytest_bdd_step_func_lookup_error(
133+
request=request, feature=feature, scenario=scenario, step=step, exception=exception
134+
)
135+
raise
136+
await _execute_step_function(request, scenario, step, step_func)
137+
finally:
138+
request.config.hook.pytest_bdd_after_scenario(request=request, feature=feature, scenario=scenario)
139+
140+
141+
FakeRequest = collections.namedtuple("FakeRequest", ["module"])
142+
143+
144+
def _get_scenario_decorator(feature, feature_name, scenario, scenario_name):
145+
# HACK: Ideally we would use `def decorator(fn)`, but we want to return a custom exception
146+
# when the decorator is misused.
147+
# Pytest inspect the signature to determine the required fixtures, and in that case it would look
148+
# for a fixture called "fn" that doesn't exist (if it exists then it's even worse).
149+
# It will error with a "fixture 'fn' not found" message instead.
150+
# We can avoid this hack by using a pytest hook and check for misuse instead.
151+
def decorator(*args):
152+
if not args:
153+
raise exceptions.ScenarioIsDecoratorOnly(
154+
"scenario function can only be used as a decorator. Refer to the documentation."
155+
)
156+
[fn] = args
157+
args = get_args(fn)
158+
function_args = list(args)
159+
for arg in scenario.get_example_params():
160+
if arg not in function_args:
161+
function_args.append(arg)
162+
163+
@pytest.mark.usefixtures(*function_args)
164+
async def scenario_wrapper(request):
165+
await _execute_scenario(feature, scenario, request)
166+
return await fn(*[request.getfixturevalue(arg) for arg in args])
167+
168+
for param_set in scenario.get_params():
169+
if param_set:
170+
scenario_wrapper = pytest.mark.parametrize(*param_set)(scenario_wrapper)
171+
for tag in scenario.tags.union(feature.tags):
172+
config = CONFIG_STACK[-1]
173+
config.hook.pytest_bdd_apply_tag(tag=tag, function=scenario_wrapper)
174+
175+
scenario_wrapper.__doc__ = f"{feature_name}: {scenario_name}"
176+
scenario_wrapper.__scenario__ = scenario
177+
scenario.test_function = scenario_wrapper
178+
return scenario_wrapper
179+
180+
return decorator
181+
182+
183+
def scenario(feature_name, scenario_name, encoding="utf-8", example_converters=None, features_base_dir=None):
184+
"""Scenario decorator.
185+
186+
:param str feature_name: Feature file name. Absolute or relative to the configured feature base path.
187+
:param str scenario_name: Scenario name.
188+
:param str encoding: Feature file encoding.
189+
:param dict example_converters: optional `dict` of example converter function, where key is the name of the
190+
example parameter, and value is the converter function.
191+
"""
192+
193+
scenario_name = str(scenario_name)
194+
caller_module_path = get_caller_module_path()
195+
196+
# Get the feature
197+
if features_base_dir is None:
198+
features_base_dir = get_features_base_dir(caller_module_path)
199+
feature = get_feature(features_base_dir, feature_name, encoding=encoding)
200+
201+
# Get the scenario
202+
try:
203+
scenario = feature.scenarios[scenario_name]
204+
except KeyError:
205+
feature_name = feature.name or "[Empty]"
206+
raise exceptions.ScenarioNotFound(
207+
f'Scenario "{scenario_name}" in feature "{feature_name}" in {feature.filename} is not found.'
208+
)
209+
210+
scenario.example_converters = example_converters
211+
212+
# Validate the scenario
213+
scenario.validate()
214+
215+
return _get_scenario_decorator(
216+
feature=feature, feature_name=feature_name, scenario=scenario, scenario_name=scenario_name
217+
)
218+
219+
220+
def get_features_base_dir(caller_module_path):
221+
default_base_dir = os.path.dirname(caller_module_path)
222+
return get_from_ini("bdd_features_base_dir", default_base_dir)
223+
224+
225+
def get_from_ini(key, default):
226+
"""Get value from ini config. Return default if value has not been set.
227+
228+
Use if the default value is dynamic. Otherwise set default on addini call.
229+
"""
230+
config = CONFIG_STACK[-1]
231+
value = config.getini(key)
232+
return value if value != "" else default
233+
234+
235+
def make_python_name(string):
236+
"""Make python attribute name out of a given string."""
237+
string = re.sub(PYTHON_REPLACE_REGEX, "", string.replace(" ", "_"))
238+
return re.sub(ALPHA_REGEX, "", string).lower()
239+
240+
241+
def make_python_docstring(string):
242+
"""Make a python docstring literal out of a given string."""
243+
return '"""{}."""'.format(string.replace('"""', '\\"\\"\\"'))
244+
245+
246+
def make_string_literal(string):
247+
"""Make python string literal out of a given string."""
248+
return "'{}'".format(string.replace("'", "\\'"))
249+
250+
251+
def get_python_name_generator(name):
252+
"""Generate a sequence of suitable python names out of given arbitrary string name."""
253+
python_name = make_python_name(name)
254+
suffix = ""
255+
index = 0
256+
257+
def get_name():
258+
return f"test_{python_name}{suffix}"
259+
260+
while True:
261+
yield get_name()
262+
index += 1
263+
suffix = f"_{index}"
264+
265+
266+
def scenarios(*feature_paths, **kwargs):
267+
"""Parse features from the paths and put all found scenarios in the caller module.
268+
269+
:param *feature_paths: feature file paths to use for scenarios
270+
"""
271+
caller_locals = get_caller_module_locals()
272+
caller_path = get_caller_module_path()
273+
274+
features_base_dir = kwargs.get("features_base_dir")
275+
if features_base_dir is None:
276+
features_base_dir = get_features_base_dir(caller_path)
277+
278+
abs_feature_paths = []
279+
for path in feature_paths:
280+
if not os.path.isabs(path):
281+
path = os.path.abspath(os.path.join(features_base_dir, path))
282+
abs_feature_paths.append(path)
283+
found = False
284+
285+
module_scenarios = frozenset(
286+
(attr.__scenario__.feature.filename, attr.__scenario__.name)
287+
for name, attr in caller_locals.items()
288+
if hasattr(attr, "__scenario__")
289+
)
290+
291+
for feature in get_features(abs_feature_paths):
292+
for scenario_name, scenario_object in feature.scenarios.items():
293+
# skip already bound scenarios
294+
if (scenario_object.feature.filename, scenario_name) not in module_scenarios:
295+
296+
@scenario(feature.filename, scenario_name, **kwargs)
297+
async def _scenario():
298+
pass # pragma: no cover
299+
300+
for test_name in get_python_name_generator(scenario_name):
301+
if test_name not in caller_locals:
302+
# found an unique test name
303+
caller_locals[test_name] = _scenario
304+
break
305+
found = True
306+
if not found:
307+
raise exceptions.NoScenariosFound(abs_feature_paths)

0 commit comments

Comments
 (0)