Skip to content

Commit dc850a6

Browse files
committed
Internal refactoring: we now use a more robust method to identify tests that are steps of the same test.
Now all methods used to get a unique test node id with or without removal of parameters are in steps_commons.py. Both parametrize and generator methods now use the same way to identify the tests that are steps belonging to the same test. This fixes some bugs that were happening on edge cases where several parameters had the same string id representation (or one was a substring of the other). Fixed #21 Added a corresponding test
1 parent 3939fba commit dc850a6

File tree

6 files changed

+341
-112
lines changed

6 files changed

+341
-112
lines changed

pytest_steps/steps_common.py

Lines changed: 214 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
import re
1+
# import re
22

33
try: # python 3+
44
from typing import Tuple
55
except ImportError:
66
pass
77

88

9-
def get_pytest_id(f):
9+
def create_pytest_param_str_id(f):
1010
# type: (...) -> str
1111
"""
1212
Returns an id that can be used as a pytest id, from an object.
@@ -20,95 +20,239 @@ def get_pytest_id(f):
2020
return str(f)
2121

2222

23-
def split_node_id(node_id # type: str
24-
):
25-
# type: (...) -> Tuple[str, str]
23+
# def get_pytest_node_str_id_approximate(pytest_node,
24+
# remove_params=None # type: List[str]
25+
# ):
26+
# # type: (...) -> str
27+
# """
28+
# Returns the unique id string associated with current parametrized pytest node, skipping parameters listed in
29+
# `remove_params`.
30+
#
31+
# Note: this only works if the id associated with the listed parameters to remove are obtained from the parameter
32+
# value using the 'str' function ; and if several parameters have the exact same string id it does not work.
33+
#
34+
# :param pytest_node:
35+
# :param remove_params:
36+
# :return:
37+
# """
38+
# if remove_params is None:
39+
# remove_params = []
40+
#
41+
# if len(remove_params) == 0:
42+
# return pytest_node.id
43+
# else:
44+
# # Unfortunately there seem to be no possibility in the pytest api to eliminate a named parameter from a node id,
45+
# # because the node ids are generated at collection time and the link between ids and parameter names are
46+
# # forgotten.
47+
# #
48+
# # The best we can do seems to be:
49+
# # unparametrized_node_id = node.parent.nodeid + '::' + node.function.__name__
50+
# # current_parametrization_id = node.callspec.id
51+
# #
52+
# # The goal would be to replace current_parametrization_id with a new one (w/o the selected params) from the
53+
# # callspec object. This object (a CallSpec2)
54+
# # - has a list of ids (in node.callspec._idlist)
55+
# # - has a dictionary of parameter names and values (in node.callspec.params)
56+
# # - But unfortunately there is no way to know which parameter name corresponds to which id (no order)
57+
# #
58+
# # Note: a good way to explore this part of pytest is to put a breakpoint in _pytest.python.Function init()
59+
#
60+
# # So we made the decision to rely only on string parsing, not objects
61+
# node_id_base, node_id_params = split_pytest_node_str_id(pytest_node.nodeid)
62+
#
63+
# # Create a new id from the current one, by "removing" the ids of the selected parameters
64+
# param_values_dct = get_pytest_node_current_param_values(pytest_node)
65+
# for p_name in remove_params:
66+
# if p_name not in param_values_dct:
67+
# raise ValueError("Parameter %s is not a valid parameter name in node %s"
68+
# "" % (p_name, pytest_node.nodeid))
69+
# else:
70+
# # Strong assumption: assume that the id will be str(param_value) of param_value.__name__
71+
# param_id = create_pytest_param_str_id(param_values_dct[p_name])
72+
#
73+
# if param_id in node_id_params:
74+
# node_id_params = remove_param_from_pytest_node_str_id(node_id_params, param_id)
75+
# else:
76+
# raise ValueError("Parameter value %s (for parameter %s) cannot be found in node %s"
77+
# "" % (param_id, p_name, pytest_node.nodeid))
78+
#
79+
# return node_id_base + node_id_params
80+
81+
82+
def remove_param_from_pytest_node_str_id(test_id, param_id_str):
2683
"""
27-
Splits a pytest node id into base and parametrization ids
84+
Returns a new test id where the step parameter is not present anymore.
2885
29-
:param node_id:
86+
:param test_id:
87+
:param param_id_str:
3088
:return:
3189
"""
32-
result = re.compile('(?P<base_node_id>.*)\[(?P<parametrization>.*)\]\Z').match(node_id)
33-
if result is None:
34-
raise ValueError("pytest node id does not match pytest pattern - cannot split it")
90+
# from math import isnan
91+
# if isnan(step_id):
92+
# return test_id
93+
new_id = test_id.replace('-' + param_id_str + '-', '-', 1)
94+
# only continue if previous replacement was not successful to avoid cases where the step id is identical to
95+
# another parameter
96+
if len(new_id) == len(test_id):
97+
new_id = test_id.replace('[' + param_id_str + '-', '[', 1)
98+
if len(new_id) == len(test_id):
99+
new_id = test_id.replace('-' + param_id_str + ']', ']', 1)
35100

36-
# Unpack
37-
unparametrized_node_id = result.group('base_node_id')
38-
current_parametrization_id = result.group('parametrization')
101+
return new_id
39102

40-
return unparametrized_node_id, current_parametrization_id
41103

104+
# def split_pytest_node_str_id(node_id_str # type: str
105+
# ):
106+
# # type: (...) -> Tuple[str, str]
107+
# """
108+
# Splits a pytest node id string into base and parametrization ids
109+
#
110+
# :param node_id_str:
111+
# :return:
112+
# """
113+
# result = re.compile('(?P<base_node_id>.*)\[(?P<parametrization>.*)\]\Z').match(node_id_str)
114+
# if result is None:
115+
# raise ValueError("pytest node id does not match pytest pattern - cannot split it")
116+
#
117+
# # Unpack
118+
# unparametrized_node_id = result.group('base_node_id')
119+
# current_parametrization_id = result.group('parametrization')
120+
#
121+
# return unparametrized_node_id, '[' + current_parametrization_id + ']'
42122

43-
def get_id(pytest_node,
44-
remove_params=None # type: List[str]
45-
):
46-
# type: (...) -> str
123+
124+
# class HashableDict(dict):
125+
# def __hash__(self):
126+
# return hash(tuple(sorted(self.items())))
127+
#
128+
#
129+
def get_pytest_node_hash_id(pytest_node,
130+
params_to_ignore=None):
131+
"""
132+
133+
:param pytest_node:
134+
:param ignore_params:
135+
:return:
136+
"""
137+
# Default value
138+
if params_to_ignore is None:
139+
params_to_ignore = []
140+
141+
# No need to remove parameters: the usual id can do the job
142+
if len(params_to_ignore) == 0:
143+
return pytest_node.callspec.id
144+
145+
# Method 0: use the string id and replace the params to ignore. Not reliable enough
146+
# id_without_steps = get_pytest_node_str_id_approximate(request.node, remove_params=(test_step_argname,))
147+
148+
# Method 1: rely on parameter indices to build the id
149+
# NOT APPROPRIATE: indices might not be always set (according to pytest comments in source)
150+
# AND indices do not represent unique values
151+
#
152+
# # Retrieve the current indice for all parameters
153+
# params_indices_dct = get_pytest_node_current_param_indices(pytest_node)
154+
#
155+
# # Use the same order to build the list of tuples to hash
156+
# tpl = tuple((p, params_indices_dct[p]) for p in get_pytest_node_current_param_values(pytest_node)
157+
# if p not in params_to_ignore)
158+
159+
# Method 2
160+
# create a hashable dictionary from the list of parameters AND fixtures VALUES
161+
# params = get_pytest_node_current_param_and_fixture_values(request, params_to_ignore={test_step_argname,
162+
# steps_data_holder_name, 'request'})
163+
# test_id_without_steps = HashableDict(params)
164+
# >> "too much", not necessary
165+
166+
# Method 3
167+
# Hash a tuple containing the parameter names with a hash of their value
168+
params_dct = get_pytest_node_current_param_values(pytest_node)
169+
l = []
170+
for p, v in params_dct.items():
171+
if p not in params_to_ignore:
172+
try:
173+
hash_o_v = hash(v)
174+
except TypeError:
175+
# not hashable, try at least the following for dictionary
176+
try:
177+
hash_o_v = hash(repr(sorted(v.items())))
178+
except AttributeError:
179+
raise TypeError("Unable to hash test parameter '%s'. Hashable parameters are required to use steps "
180+
"reliably." % v)
181+
l.append((p, hash_o_v))
182+
183+
# Hash
184+
return hash(tuple(l))
185+
186+
187+
# def get_pytest_node_current_param_indices(pytest_node):
188+
# """
189+
# Returns a dictionary containing all parameter indices in the parameter matrix.
190+
# Problem: these indices do not represent unique parameter values !
191+
#
192+
# Indeed if you have a parameter with two values 'a' and 'b', and use also a second parameter with values 1 and 2,
193+
# then both indices will go from 0 to 3... at least in pytest 3.4.2
194+
#
195+
# :param pytest_node:
196+
# :return:
197+
# """
198+
# return pytest_node.callspec.indices
199+
200+
201+
def get_pytest_node_current_param_values(pytest_node):
202+
"""
203+
Returns a dictionary containing all parameters and their values for the given call.
204+
Like `get_pytest_node_current_param_and_fixture_values` it contains all direct parameters
205+
(@pytest.mark.parametrize), but it contains no fixture - instead it contains the fixture *parameters*, and only
206+
for parametrized fixtures.
207+
208+
Note: it seems that it is the same than `request.node.funcargs` pytest_node.funcargnames
209+
210+
:param pytest_node:
211+
:return:
47212
"""
48-
Returns the unique id associated with current parametrized pytest node, skipping parameters listed in remove_params
213+
return pytest_node.callspec.params
49214

50-
Note: this only works if the id associated with the listed parameters are obtained from the parameter
51-
value using the 'str' function.
215+
216+
def get_pytest_node_current_param_and_fixture_values(request,
217+
params_to_ignore=None):
218+
"""
219+
Returns a dictionary containing all fixtures and parameters values available in a given test `request`.
220+
221+
As opposed to `get_pytest_node_current_param_values`, this contains fixture VALUES and non-parametrized fixtures,
222+
whereas `get_pytest_node_current_param_values` only contains the parameters.
52223
53224
:param request:
54-
:param remove_params:
225+
:param params_to_ignore: an iterable of parameter or fixture names to ignore in the returned dictionary
55226
:return:
56227
"""
57-
if remove_params is None:
58-
remove_params = []
228+
# Default value
229+
if params_to_ignore is None:
230+
params_to_ignore = []
59231

60-
if len(remove_params) == 0:
61-
return pytest_node.id
62-
else:
63-
# Unfortunately there seem to be no possibility in the pytest api to eliminate a named parameter from a node id,
64-
# because the node ids are generated at collection time and the link between ids and parameter names are
65-
# forgotten.
66-
#
67-
# The best we can do seems to be:
68-
# unparametrized_node_id = node.parent.nodeid + '::' + node.function.__name__
69-
# current_parametrization_id = node.callspec.id
70-
#
71-
# The goal would be to replace current_parametrization_id with a new one (w/o the selected params) from the
72-
# callspec object. This object (a CallSpec2)
73-
# - has a list of ids (in node.callspec._idlist)
74-
# - has a dictionary of parameter names and values (in node.callspec.params)
75-
# - But unfortunately there is no way to know which parameter name corresponds to which id (no order)
76-
#
77-
# Note: a good way to explore this part of pytest is to put a breakpoint in _pytest.python.Function init()
78-
79-
# So we made the decision to rely only on string parsing, not objects
80-
node_id_base, node_id_params = split_node_id(pytest_node.nodeid)
81-
82-
# Create a new id from the current one, by "removing" the ids of the selected parameters
83-
for p_name in remove_params:
84-
if p_name not in pytest_node.callspec.params:
85-
raise ValueError("Parameter %s is not a valid parameter name in node %s"
86-
"" % (p_name, pytest_node.nodeid))
87-
else:
88-
# Strong assumption: assume that the id will be str(param_value) of param_value.__name__
89-
param_id = get_pytest_id(pytest_node.callspec.params[p_name])
90-
if param_id in node_id_params:
91-
node_id_params = node_id_params.replace(param_id, '*')
92-
else:
93-
raise ValueError("Parameter value %s (for parameter %s) cannot be found in node %s"
94-
"" % (param_id, p_name, pytest_node.nodeid))
95-
96-
return node_id_base + '[' + node_id_params + ']'
97-
98-
99-
def get_fixture_value(request, fixture_name):
232+
# List the values of all the test function parameters that matter
233+
kwargs = {argname: get_fixture_or_param_value(request, argname)
234+
for argname in request.funcargnames
235+
if argname not in params_to_ignore}
236+
237+
return kwargs
238+
239+
240+
def get_fixture_or_param_value(request, fixture_or_param_name):
100241
"""
101-
Returns the value associated with fixture named `fixture_name`, in provided request context.
102-
This is just an easy way to use `getfixturevalue` or `getfuncargvalue` according to whichever is availabl in
103-
current pytest version
242+
Returns the value associated with parameter or fixture named `fixture_name`, in provided request context.
243+
This is just an easy way to use `getfixturevalue` or `getfuncargvalue` according to whichever is available in
244+
current pytest version.
245+
246+
Note: it seems that this is the same than `request.node.callspec.params[fixture_or_param_name]` but maybe it is
247+
less 'internal' as an api ?
104248
105249
:param request:
106-
:param fixture_name:
250+
:param fixture_or_param_name:
107251
:return:
108252
"""
109253
try:
110254
# Pytest 4+ or latest 3.x (to avoid the deprecated warning)
111-
return request.getfixturevalue(fixture_name)
255+
return request.getfixturevalue(fixture_or_param_name)
112256
except AttributeError:
113257
# Pytest 3-
114-
return request.getfuncargvalue(fixture_name)
258+
return request.getfuncargvalue(fixture_or_param_name)

pytest_steps/steps_generator.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323

2424
import pytest
2525

26-
from pytest_steps.steps_common import get_pytest_id, get_id
26+
from pytest_steps.steps_common import create_pytest_param_str_id, get_pytest_node_hash_id
2727
from pytest_steps.decorator_hack import my_decorate
2828

2929

@@ -365,7 +365,8 @@ def get_execution_monitor(self, pytest_node, *args, **kwargs):
365365
:return:
366366
"""
367367
# Get the unique id that is shared between the steps of the same execution, by removing the step parameter
368-
id_without_steps = get_id(pytest_node, remove_params=(GENERATOR_MODE_STEP_ARGNAME,))
368+
# TODO also discard all parametrized fixtures that are @one_per_step
369+
id_without_steps = get_pytest_node_hash_id(pytest_node, params_to_ignore=(GENERATOR_MODE_STEP_ARGNAME,))
369370

370371
if id_without_steps not in self:
371372
# First time we call the function with this combination of parameters
@@ -415,7 +416,7 @@ def steps_decorator(test_func):
415416

416417
# ------CORE -------
417418
# Transform the steps into ids if needed
418-
step_ids = [get_pytest_id(f) for f in steps]
419+
step_ids = [create_pytest_param_str_id(f) for f in steps]
419420

420421
# Create the container that will hold all execution monitors for this function
421422
all_monitors = StepMonitorsContainer(test_func, step_ids)

pytest_steps/steps_harvest.py

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from pytest_steps.steps import TEST_STEP_ARGNAME_DEFAULT
77
from pytest_steps.steps_generator import GENERATOR_MODE_STEP_ARGNAME
8+
from steps_common import remove_param_from_pytest_node_str_id
89

910
try: # python 3.5+
1011
from typing import Union, Iterable, Mapping, Any
@@ -108,15 +109,7 @@ def remove_step_from_test_id(test_id, step_id):
108109
# from math import isnan
109110
# if isnan(step_id):
110111
# return test_id
111-
new_id = test_id.replace('-' + step_id + '-', '-')
112-
# only continue if previous replacement was not successful to avoid cases where the step id is identical to
113-
# another parameter
114-
if len(new_id) == len(test_id):
115-
new_id = test_id.replace('[' + step_id + '-', '[')
116-
if len(new_id) == len(test_id):
117-
new_id = test_id.replace('-' + step_id + ']', ']')
118-
119-
return new_id
112+
return remove_param_from_pytest_node_str_id(test_id, step_id)
120113

121114

122115
def get_all_pytest_param_names_except_step_id(session,

0 commit comments

Comments
 (0)