Skip to content

Commit 2226128

Browse files
authored
Remove code that rewrites code (#309)
* Stop testing for old pytest and python versions * Switch from pytest-pep8 to pycodestyle. Pytest-pep8 received the last update in 2014, and it is now not working with pytest >= 4.5 * Explicitly state used markers * Remove supposedly useless test * Fix pytest missing markers definitions * Fix pytest missing markers definitions * Fix wrong command line usage * Remove compatibility with ancient pytest * Be more lenient when checking for failed test string * Remove dead code
1 parent 467e73d commit 2226128

File tree

5 files changed

+78
-166
lines changed

5 files changed

+78
-166
lines changed

pytest_bdd/plugin.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,7 @@
1313
def pytest_addhooks(pluginmanager):
1414
"""Register plugin hooks."""
1515
from pytest_bdd import hooks
16-
try:
17-
# pytest >= 2.8
18-
pluginmanager.add_hookspecs(hooks)
19-
except AttributeError:
20-
# pytest < 2.8
21-
pluginmanager.addhooks(hooks)
16+
pluginmanager.add_hookspecs(hooks)
2217

2318

2419
@given('trace')
@@ -92,3 +87,20 @@ def pytest_cmdline_main(config):
9287
def pytest_bdd_apply_tag(tag, function):
9388
mark = getattr(pytest.mark, tag)
9489
return mark(function)
90+
91+
92+
@pytest.mark.tryfirst
93+
def pytest_collection_modifyitems(session, config, items):
94+
"""Re-order items using the creation counter as fallback.
95+
96+
Pytest has troubles to correctly order the test items for python < 3.6.
97+
For this reason, we have to apply some better ordering for pytest_bdd scenario-decorated test functions.
98+
99+
This is not needed for python 3.6+, but this logic is safe to apply in that case as well.
100+
"""
101+
# TODO: Try to only re-sort the items that have __pytest_bdd_counter__, and not the others,
102+
# since there may be other hooks that are executed before this and that want to reorder item as well
103+
def item_key(item):
104+
pytest_bdd_counter = getattr(item.function, '__pytest_bdd_counter__', 0)
105+
return (item.reportinfo()[:2], pytest_bdd_counter)
106+
items.sort(key=item_key)

pytest_bdd/scenario.py

Lines changed: 40 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -20,39 +20,31 @@
2020
from _pytest import fixtures as pytest_fixtures
2121
except ImportError:
2222
from _pytest import python as pytest_fixtures
23-
import six
2423

2524
from . import exceptions
2625
from .feature import (
2726
Feature,
28-
force_encode,
2927
force_unicode,
3028
get_features,
3129
)
3230
from .steps import (
33-
execute,
34-
get_caller_function,
3531
get_caller_module,
3632
get_step_fixture_name,
3733
inject_fixture,
38-
recreate_function,
3934
)
4035
from .types import GIVEN
4136
from .utils import CONFIG_STACK, get_args
4237

43-
if six.PY3: # pragma: no cover
44-
import runpy
45-
46-
def execfile(filename, init_globals):
47-
"""Execute given file as a python script in given globals environment."""
48-
result = runpy.run_path(filename, init_globals=init_globals)
49-
init_globals.update(result)
50-
5138

5239
PYTHON_REPLACE_REGEX = re.compile(r"\W")
5340
ALPHA_REGEX = re.compile(r"^\d+_*")
5441

5542

43+
# We have to keep track of the invocation of @scenario() so that we can reorder test item accordingly.
44+
# In python 3.6+ this is no longer necessary, as the order is automatically retained.
45+
_py2_scenario_creation_counter = 0
46+
47+
5648
def find_argumented_step_fixture_name(name, type_, fixturemanager, request=None):
5749
"""Find argumented step fixture name."""
5850
# happens to be that _arg2fixturedefs is changed during the iteration so we use a copy
@@ -88,9 +80,11 @@ def _find_step_function(request, step, scenario, encoding):
8880
"""
8981
name = step.name
9082
try:
83+
# Simple case where no parser is used for the step
9184
return request.getfixturevalue(get_step_fixture_name(name, step.type, encoding))
9285
except pytest_fixtures.FixtureLookupError:
9386
try:
87+
# Could not find a fixture with the same name, let's see if there is a parser involved
9488
name = find_argumented_step_fixture_name(name, step.type, request._fixturemanager, request)
9589
if name:
9690
return request.getfixturevalue(name)
@@ -204,63 +198,53 @@ def _execute_scenario(feature, scenario, request, encoding):
204198
FakeRequest = collections.namedtuple("FakeRequest", ["module"])
205199

206200

207-
def _get_scenario_decorator(feature, feature_name, scenario, scenario_name, caller_module, caller_function, encoding):
208-
"""Get scenario decorator."""
209-
g = locals()
210-
g["_execute_scenario"] = _execute_scenario
201+
def _get_scenario_decorator(feature, feature_name, scenario, scenario_name, encoding):
202+
global _py2_scenario_creation_counter
211203

212-
scenario_name = force_encode(scenario_name, encoding)
204+
counter = _py2_scenario_creation_counter
205+
_py2_scenario_creation_counter += 1
213206

214-
def decorator(_pytestbdd_function):
215-
if isinstance(_pytestbdd_function, pytest_fixtures.FixtureRequest):
207+
# HACK: Ideally we would use `def decorator(fn)`, but we want to return a custom exception
208+
# when the decorator is misused.
209+
# Pytest inspect the signature to determine the required fixtures, and in that case it would look
210+
# for a fixture called "fn" that doesn't exist (if it exists then it's even worse).
211+
# It will error with a "fixture 'fn' not found" message instead.
212+
# We can avoid this hack by using a pytest hook and check for misuse instead.
213+
def decorator(*args):
214+
if not args:
216215
raise exceptions.ScenarioIsDecoratorOnly(
217216
"scenario function can only be used as a decorator. Refer to the documentation.",
218217
)
219-
220-
g.update(locals())
221-
222-
args = get_args(_pytestbdd_function)
218+
[fn] = args
219+
args = get_args(fn)
223220
function_args = list(args)
224221
for arg in scenario.get_example_params():
225222
if arg not in function_args:
226223
function_args.append(arg)
227-
if "request" not in function_args:
228-
function_args.append("request")
229224

230-
code = """def {name}({function_args}):
225+
@pytest.mark.usefixtures(*function_args)
226+
def scenario_wrapper(request):
231227
_execute_scenario(feature, scenario, request, encoding)
232-
_pytestbdd_function({args})""".format(
233-
name=_pytestbdd_function.__name__,
234-
function_args=", ".join(function_args),
235-
args=", ".join(args))
236-
237-
execute(code, g)
238-
239-
_scenario = recreate_function(
240-
g[_pytestbdd_function.__name__],
241-
module=caller_module,
242-
firstlineno=caller_function.f_lineno,
243-
)
228+
return fn(*[request.getfixturevalue(arg) for arg in args])
244229

245230
for param_set in scenario.get_params():
246231
if param_set:
247-
_scenario = pytest.mark.parametrize(*param_set)(_scenario)
248-
232+
scenario_wrapper = pytest.mark.parametrize(*param_set)(scenario_wrapper)
249233
for tag in scenario.tags.union(feature.tags):
250234
config = CONFIG_STACK[-1]
251-
config.hook.pytest_bdd_apply_tag(tag=tag, function=_scenario)
235+
config.hook.pytest_bdd_apply_tag(tag=tag, function=scenario_wrapper)
252236

253-
_scenario.__doc__ = "{feature_name}: {scenario_name}".format(
237+
scenario_wrapper.__doc__ = u"{feature_name}: {scenario_name}".format(
254238
feature_name=feature_name, scenario_name=scenario_name)
255-
_scenario.__scenario__ = scenario
256-
scenario.test_function = _scenario
257-
return _scenario
258-
259-
return recreate_function(decorator, module=caller_module, firstlineno=caller_function.f_lineno)
239+
scenario_wrapper.__scenario__ = scenario
240+
scenario_wrapper.__pytest_bdd_counter__ = counter
241+
scenario.test_function = scenario_wrapper
242+
return scenario_wrapper
243+
return decorator
260244

261245

262246
def scenario(feature_name, scenario_name, encoding="utf-8", example_converters=None,
263-
caller_module=None, caller_function=None, features_base_dir=None, strict_gherkin=None):
247+
caller_module=None, features_base_dir=None, strict_gherkin=None):
264248
"""Scenario decorator.
265249
266250
:param str feature_name: Feature file name. Absolute or relative to the configured feature base path.
@@ -269,9 +253,9 @@ def scenario(feature_name, scenario_name, encoding="utf-8", example_converters=N
269253
:param dict example_converters: optional `dict` of example converter function, where key is the name of the
270254
example parameter, and value is the converter function.
271255
"""
256+
272257
scenario_name = force_unicode(scenario_name, encoding)
273258
caller_module = caller_module or get_caller_module()
274-
caller_function = caller_function or get_caller_function()
275259

276260
# Get the feature
277261
if features_base_dir is None:
@@ -280,7 +264,7 @@ def scenario(feature_name, scenario_name, encoding="utf-8", example_converters=N
280264
strict_gherkin = get_strict_gherkin()
281265
feature = Feature.get_feature(features_base_dir, feature_name, encoding=encoding, strict_gherkin=strict_gherkin)
282266

283-
# Get the sc_enario
267+
# Get the scenario
284268
try:
285269
scenario = feature.scenarios[scenario_name]
286270
except KeyError:
@@ -298,13 +282,11 @@ def scenario(feature_name, scenario_name, encoding="utf-8", example_converters=N
298282
scenario.validate()
299283

300284
return _get_scenario_decorator(
301-
feature,
302-
feature_name,
303-
scenario,
304-
scenario_name,
305-
caller_module,
306-
caller_function,
307-
encoding,
285+
feature=feature,
286+
feature_name=feature_name,
287+
scenario=scenario,
288+
scenario_name=scenario_name,
289+
encoding=encoding,
308290
)
309291

310292

@@ -375,7 +357,6 @@ def scenarios(*feature_paths, **kwargs):
375357
(attr.__scenario__.feature.filename, attr.__scenario__.name)
376358
for name, attr in module.__dict__.items() if hasattr(attr, '__scenario__'))
377359

378-
index = 10
379360
for feature in get_features(abs_feature_paths, strict_gherkin=strict_gherkin):
380361
for scenario_name, scenario_object in feature.scenarios.items():
381362
# skip already bound scenarios
@@ -386,9 +367,6 @@ def _scenario():
386367
for test_name in get_python_name_generator(scenario_name):
387368
if test_name not in module.__dict__:
388369
# found an unique test name
389-
# recreate function to set line number
390-
_scenario = recreate_function(_scenario, module=module, firstlineno=index * 4)
391-
index += 1
392370
module.__dict__[test_name] = _scenario
393371
break
394372
found = True

pytest_bdd/steps.py

Lines changed: 2 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ def article(author):
3232
"""
3333

3434
from __future__ import absolute_import
35-
from types import CodeType
3635
import inspect
3736
import sys
3837

@@ -41,7 +40,6 @@ def article(author):
4140
from _pytest import fixtures as pytest_fixtures
4241
except ImportError:
4342
from _pytest import python as pytest_fixtures
44-
import six
4543

4644
from .feature import parse_line, force_encode
4745
from .types import GIVEN, WHEN, THEN
@@ -90,7 +88,7 @@ def step_func(request):
9088
func = pytest.fixture(scope=scope)(lambda: step_func)
9189
func.__doc__ = 'Alias for the "{0}" fixture.'.format(fixture)
9290
_, name = parse_line(name)
93-
contribute_to_module(module, get_step_fixture_name(name, GIVEN), func)
91+
setattr(module, get_step_fixture_name(name, GIVEN), func)
9492
return _not_a_fixture_decorator
9593

9694
return _step_decorator(GIVEN, name, converters=converters, scope=scope, target_fixture=target_fixture)
@@ -185,86 +183,12 @@ def lazy_step_func():
185183
step_func.converters = lazy_step_func.converters = converters
186184

187185
lazy_step_func = pytest.fixture(scope=scope)(lazy_step_func)
188-
contribute_to_module(
189-
module=get_caller_module(),
190-
name=get_step_fixture_name(parsed_step_name, step_type),
191-
func=lazy_step_func,
192-
)
193-
186+
setattr(get_caller_module(), get_step_fixture_name(parsed_step_name, step_type), lazy_step_func)
194187
return func
195188

196189
return decorator
197190

198191

199-
def recreate_function(func, module=None, name=None, add_args=[], firstlineno=None):
200-
"""Recreate a function, replacing some info.
201-
202-
:param func: Function object.
203-
:param module: Module to contribute to.
204-
:param add_args: Additional arguments to add to function.
205-
206-
:return: Function copy.
207-
"""
208-
def get_code(func):
209-
return func.__code__ if six.PY3 else func.func_code
210-
211-
def set_code(func, code):
212-
if six.PY3:
213-
func.__code__ = code
214-
else:
215-
func.func_code = code
216-
217-
argnames = [
218-
"co_argcount", "co_nlocals", "co_stacksize", "co_flags", "co_code", "co_consts", "co_names",
219-
"co_varnames", "co_filename", "co_name", "co_firstlineno", "co_lnotab", "co_freevars", "co_cellvars",
220-
]
221-
if six.PY3:
222-
argnames.insert(1, "co_kwonlyargcount")
223-
if sys.version_info.minor >= 8:
224-
argnames.insert(1, "co_posonlyargcount")
225-
226-
for arg in get_args(func):
227-
if arg in add_args:
228-
add_args.remove(arg)
229-
230-
args = []
231-
code = get_code(func)
232-
for arg in argnames:
233-
if module is not None and arg == "co_filename":
234-
args.append(module.__file__)
235-
elif name is not None and arg == "co_name":
236-
args.append(name)
237-
elif arg == "co_argcount":
238-
args.append(getattr(code, arg) + len(add_args))
239-
elif arg == "co_varnames":
240-
co_varnames = getattr(code, arg)
241-
args.append(co_varnames[:code.co_argcount] + tuple(add_args) + co_varnames[code.co_argcount:])
242-
elif arg == "co_firstlineno":
243-
args.append(firstlineno if firstlineno else 1)
244-
else:
245-
args.append(getattr(code, arg))
246-
247-
set_code(func, CodeType(*args))
248-
if name is not None:
249-
func.__name__ = name
250-
return func
251-
252-
253-
def contribute_to_module(module, name, func):
254-
"""Contribute a function to a module.
255-
256-
:param module: Module to contribute to.
257-
:param name: Attribute name.
258-
:param func: Function object.
259-
260-
:return: New function copy contributed to the module
261-
"""
262-
name = force_encode(name)
263-
func = recreate_function(func, module=module)
264-
setattr(module, name, func)
265-
return func
266-
267-
268192
def get_caller_module(depth=2):
269193
"""Return the module of the caller."""
270194
frame = sys._getframe(depth)
@@ -274,16 +198,6 @@ def get_caller_module(depth=2):
274198
return module
275199

276200

277-
def get_caller_function(depth=2):
278-
"""Return caller function."""
279-
return sys._getframe(depth)
280-
281-
282-
def execute(code, g):
283-
"""Execute given code in given globals environment."""
284-
exec(code, g)
285-
286-
287201
def inject_fixture(request, arg, value):
288202
"""Inject fixture into pytest fixture request.
289203

tests/feature/test_scenario.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,19 @@ def test2():
5757
test2(request)
5858

5959

60-
def test_scenario_not_decorator(request):
60+
def test_scenario_not_decorator(testdir):
6161
"""Test scenario function is used not as decorator."""
62-
func = scenario(
63-
'comments.feature',
64-
'Strings that are not comments')
62+
testdir.makefile('.feature', foo="""
63+
Scenario: Foo
64+
Given I have a bar
65+
""")
66+
testdir.makepyfile("""
67+
from pytest_bdd import scenario
68+
69+
test_foo = scenario('foo.feature', 'Foo')
70+
""")
71+
72+
result = testdir.runpytest()
6573

66-
with pytest.raises(exceptions.ScenarioIsDecoratorOnly):
67-
func(request)
74+
result.assert_outcomes(failed=1)
75+
result.stdout.fnmatch_lines("*ScenarioIsDecoratorOnly: scenario function can only be used as a decorator*")

0 commit comments

Comments
 (0)