Skip to content

Commit de8607d

Browse files
authored
Merge pull request #1921 from RonnyPfannschmidt/marked-value
introduce pytest.Marked as holder for marked parameter values
2 parents a122ae8 + e8a1b36 commit de8607d

File tree

6 files changed

+227
-102
lines changed

6 files changed

+227
-102
lines changed

CHANGELOG.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ New Features
1616
* ``pytest.raises`` now asserts that the error message matches a text or regex
1717
with the ``match`` keyword argument. Thanks `@Kriechi`_ for the PR.
1818

19+
* ``pytest.param`` can be used to declare test parameter sets with marks and test ids.
20+
Thanks `@RonnyPfannschmidt`_ for the PR.
21+
1922

2023
Changes
2124
-------

_pytest/mark.py

Lines changed: 62 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,72 @@
66
from operator import attrgetter
77
from .compat import imap
88

9+
910
def alias(name):
1011
return property(attrgetter(name), doc='alias for ' + name)
1112

1213

14+
class ParameterSet(namedtuple('ParameterSet', 'values, marks, id')):
15+
@classmethod
16+
def param(cls, *values, **kw):
17+
marks = kw.pop('marks', ())
18+
if isinstance(marks, MarkDecorator):
19+
marks = marks,
20+
else:
21+
assert isinstance(marks, (tuple, list, set))
22+
23+
def param_extract_id(id=None):
24+
return id
25+
26+
id = param_extract_id(**kw)
27+
return cls(values, marks, id)
28+
29+
@classmethod
30+
def extract_from(cls, parameterset, legacy_force_tuple=False):
31+
"""
32+
:param parameterset:
33+
a legacy style parameterset that may or may not be a tuple,
34+
and may or may not be wrapped into a mess of mark objects
35+
36+
:param legacy_force_tuple:
37+
enforce tuple wrapping so single argument tuple values
38+
don't get decomposed and break tests
39+
40+
"""
41+
42+
if isinstance(parameterset, cls):
43+
return parameterset
44+
if not isinstance(parameterset, MarkDecorator) and legacy_force_tuple:
45+
return cls.param(parameterset)
46+
47+
newmarks = []
48+
argval = parameterset
49+
while isinstance(argval, MarkDecorator):
50+
newmarks.append(MarkDecorator(Mark(
51+
argval.markname, argval.args[:-1], argval.kwargs)))
52+
argval = argval.args[-1]
53+
assert not isinstance(argval, ParameterSet)
54+
if legacy_force_tuple:
55+
argval = argval,
56+
57+
return cls(argval, marks=newmarks, id=None)
58+
59+
@property
60+
def deprecated_arg_dict(self):
61+
return dict((mark.name, mark) for mark in self.marks)
62+
63+
1364
class MarkerError(Exception):
1465

1566
"""Error in use of a pytest marker/attribute."""
1667

1768

69+
1870
def pytest_namespace():
19-
return {'mark': MarkGenerator()}
71+
return {
72+
'mark': MarkGenerator(),
73+
'param': ParameterSet.param,
74+
}
2075

2176

2277
def pytest_addoption(parser):
@@ -212,6 +267,7 @@ def istestfunc(func):
212267
return hasattr(func, "__call__") and \
213268
getattr(func, "__name__", "<lambda>") != "<lambda>"
214269

270+
215271
class MarkDecorator(object):
216272
""" A decorator for test functions and test classes. When applied
217273
it will create :class:`MarkInfo` objects which may be
@@ -257,8 +313,11 @@ def __init__(self, mark):
257313
def markname(self):
258314
return self.name # for backward-compat (2.4.1 had this attr)
259315

316+
def __eq__(self, other):
317+
return self.mark == other.mark
318+
260319
def __repr__(self):
261-
return "<MarkDecorator %r>" % self.mark
320+
return "<MarkDecorator %r>" % (self.mark,)
262321

263322
def __call__(self, *args, **kwargs):
264323
""" if passed a single callable argument: decorate it with mark info.
@@ -291,19 +350,7 @@ def __call__(self, *args, **kwargs):
291350
return self.__class__(self.mark.combined_with(mark))
292351

293352

294-
def extract_argvalue(maybe_marked_args):
295-
# TODO: incorrect mark data, the old code wanst able to collect lists
296-
# individual parametrized argument sets can be wrapped in a series
297-
# of markers in which case we unwrap the values and apply the mark
298-
# at Function init
299-
newmarks = {}
300-
argval = maybe_marked_args
301-
while isinstance(argval, MarkDecorator):
302-
newmark = MarkDecorator(Mark(
303-
argval.markname, argval.args[:-1], argval.kwargs))
304-
newmarks[newmark.name] = newmark
305-
argval = argval.args[-1]
306-
return argval, newmarks
353+
307354

308355

309356
class Mark(namedtuple('Mark', 'name, args, kwargs')):

_pytest/python.py

Lines changed: 36 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -788,36 +788,35 @@ def parametrize(self, argnames, argvalues, indirect=False, ids=None,
788788
to set a dynamic scope using test context or configuration.
789789
"""
790790
from _pytest.fixtures import scope2index
791-
from _pytest.mark import extract_argvalue
791+
from _pytest.mark import ParameterSet
792792
from py.io import saferepr
793793

794-
unwrapped_argvalues = []
795-
newkeywords = []
796-
for maybe_marked_args in argvalues:
797-
argval, newmarks = extract_argvalue(maybe_marked_args)
798-
unwrapped_argvalues.append(argval)
799-
newkeywords.append(newmarks)
800-
argvalues = unwrapped_argvalues
801-
802794
if not isinstance(argnames, (tuple, list)):
803795
argnames = [x.strip() for x in argnames.split(",") if x.strip()]
804-
if len(argnames) == 1:
805-
argvalues = [(val,) for val in argvalues]
806-
if not argvalues:
807-
argvalues = [(NOTSET,) * len(argnames)]
808-
# we passed a empty list to parameterize, skip that test
809-
#
796+
force_tuple = len(argnames) == 1
797+
else:
798+
force_tuple = False
799+
parameters = [
800+
ParameterSet.extract_from(x, legacy_force_tuple=force_tuple)
801+
for x in argvalues]
802+
del argvalues
803+
804+
805+
if not parameters:
810806
fs, lineno = getfslineno(self.function)
811-
newmark = pytest.mark.skip(
812-
reason="got empty parameter set %r, function %s at %s:%d" % (
813-
argnames, self.function.__name__, fs, lineno))
814-
newkeywords = [{newmark.markname: newmark}]
807+
reason = "got empty parameter set %r, function %s at %s:%d" % (
808+
argnames, self.function.__name__, fs, lineno)
809+
mark = pytest.mark.skip(reason=reason)
810+
parameters.append(ParameterSet(
811+
values=(NOTSET,) * len(argnames),
812+
marks=[mark],
813+
id=None,
814+
))
815815

816816
if scope is None:
817817
scope = _find_parametrized_scope(argnames, self._arg2fixturedefs, indirect)
818818

819-
scopenum = scope2index(
820-
scope, descr='call to {0}'.format(self.parametrize))
819+
scopenum = scope2index(scope, descr='call to {0}'.format(self.parametrize))
821820
valtypes = {}
822821
for arg in argnames:
823822
if arg not in self.fixturenames:
@@ -845,22 +844,22 @@ def parametrize(self, argnames, argvalues, indirect=False, ids=None,
845844
idfn = ids
846845
ids = None
847846
if ids:
848-
if len(ids) != len(argvalues):
849-
raise ValueError('%d tests specified with %d ids' %(
850-
len(argvalues), len(ids)))
847+
if len(ids) != len(parameters):
848+
raise ValueError('%d tests specified with %d ids' % (
849+
len(parameters), len(ids)))
851850
for id_value in ids:
852851
if id_value is not None and not isinstance(id_value, py.builtin._basestring):
853852
msg = 'ids must be list of strings, found: %s (type: %s)'
854853
raise ValueError(msg % (saferepr(id_value), type(id_value).__name__))
855-
ids = idmaker(argnames, argvalues, idfn, ids, self.config)
854+
ids = idmaker(argnames, parameters, idfn, ids, self.config)
856855
newcalls = []
857856
for callspec in self._calls or [CallSpec2(self)]:
858-
elements = zip(ids, argvalues, newkeywords, count())
859-
for a_id, valset, keywords, param_index in elements:
860-
assert len(valset) == len(argnames)
857+
elements = zip(ids, parameters, count())
858+
for a_id, param, param_index in elements:
859+
assert len(param.values) == len(argnames)
861860
newcallspec = callspec.copy(self)
862-
newcallspec.setmulti(valtypes, argnames, valset, a_id,
863-
keywords, scopenum, param_index)
861+
newcallspec.setmulti(valtypes, argnames, param.values, a_id,
862+
param.deprecated_arg_dict, scopenum, param_index)
864863
newcalls.append(newcallspec)
865864
self._calls = newcalls
866865

@@ -959,17 +958,19 @@ def _idval(val, argname, idx, idfn, config=None):
959958
return val.__name__
960959
return str(argname)+str(idx)
961960

962-
def _idvalset(idx, valset, argnames, idfn, ids, config=None):
961+
def _idvalset(idx, parameterset, argnames, idfn, ids, config=None):
962+
if parameterset.id is not None:
963+
return parameterset.id
963964
if ids is None or (idx >= len(ids) or ids[idx] is None):
964965
this_id = [_idval(val, argname, idx, idfn, config)
965-
for val, argname in zip(valset, argnames)]
966+
for val, argname in zip(parameterset.values, argnames)]
966967
return "-".join(this_id)
967968
else:
968969
return _escape_strings(ids[idx])
969970

970-
def idmaker(argnames, argvalues, idfn=None, ids=None, config=None):
971-
ids = [_idvalset(valindex, valset, argnames, idfn, ids, config)
972-
for valindex, valset in enumerate(argvalues)]
971+
def idmaker(argnames, parametersets, idfn=None, ids=None, config=None):
972+
ids = [_idvalset(valindex, parameterset, argnames, idfn, ids, config)
973+
for valindex, parameterset in enumerate(parametersets)]
973974
if len(set(ids)) != len(ids):
974975
# The ids are not unique
975976
duplicates = [testid for testid in ids if ids.count(testid) > 1]

doc/en/parametrize.rst

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -55,27 +55,27 @@ them in turn::
5555

5656
$ pytest
5757
======= test session starts ========
58-
platform linux -- Python 3.5.2, pytest-3.0.7, py-1.4.32, pluggy-0.4.0
58+
platform linux -- Python 3.5.2, pytest-3.0.3, py-1.4.31, pluggy-0.4.0
5959
rootdir: $REGENDOC_TMPDIR, inifile:
6060
collected 3 items
61-
61+
6262
test_expectation.py ..F
63-
63+
6464
======= FAILURES ========
6565
_______ test_eval[6*9-42] ________
66-
66+
6767
test_input = '6*9', expected = 42
68-
68+
6969
@pytest.mark.parametrize("test_input,expected", [
7070
("3+5", 8),
7171
("2+4", 6),
7272
("6*9", 42),
7373
])
7474
def test_eval(test_input, expected):
7575
> assert eval(test_input) == expected
76-
E AssertionError: assert 54 == 42
76+
E assert 54 == 42
7777
E + where 54 = eval('6*9')
78-
78+
7979
test_expectation.py:8: AssertionError
8080
======= 1 failed, 2 passed in 0.12 seconds ========
8181

@@ -94,21 +94,42 @@ for example with the builtin ``mark.xfail``::
9494
@pytest.mark.parametrize("test_input,expected", [
9595
("3+5", 8),
9696
("2+4", 6),
97-
pytest.mark.xfail(("6*9", 42)),
97+
pytest.param("6*9", 42,
98+
marks=pytest.mark.xfail),
9899
])
99100
def test_eval(test_input, expected):
100101
assert eval(test_input) == expected
101102

103+
.. note::
104+
105+
prior to version 3.1 the supported mechanism for marking values
106+
used the syntax::
107+
108+
import pytest
109+
@pytest.mark.parametrize("test_input,expected", [
110+
("3+5", 8),
111+
("2+4", 6),
112+
pytest.mark.xfail(("6*9", 42),),
113+
])
114+
def test_eval(test_input, expected):
115+
assert eval(test_input) == expected
116+
117+
118+
This was an initial hack to support the feature but soon was demonstrated to be incomplete,
119+
broken for passing functions or applying multiple marks with the same name but different parameters.
120+
The old syntax will be removed in pytest-4.0.
121+
122+
102123
Let's run this::
103124

104125
$ pytest
105126
======= test session starts ========
106-
platform linux -- Python 3.5.2, pytest-3.0.7, py-1.4.32, pluggy-0.4.0
127+
platform linux -- Python 3.5.2, pytest-3.0.3, py-1.4.31, pluggy-0.4.0
107128
rootdir: $REGENDOC_TMPDIR, inifile:
108129
collected 3 items
109-
130+
110131
test_expectation.py ..x
111-
132+
112133
======= 2 passed, 1 xfailed in 0.12 seconds ========
113134

114135
The one parameter set which caused a failure previously now
@@ -181,15 +202,15 @@ Let's also run with a stringinput that will lead to a failing test::
181202
F
182203
======= FAILURES ========
183204
_______ test_valid_string[!] ________
184-
205+
185206
stringinput = '!'
186-
207+
187208
def test_valid_string(stringinput):
188209
> assert stringinput.isalpha()
189-
E AssertionError: assert False
210+
E assert False
190211
E + where False = <built-in method isalpha of str object at 0xdeadbeef>()
191212
E + where <built-in method isalpha of str object at 0xdeadbeef> = '!'.isalpha
192-
213+
193214
test_strings.py:3: AssertionError
194215
1 failed in 0.12 seconds
195216

0 commit comments

Comments
 (0)