Merge pull request #3317 from RonnyPfannschmidt/marker-pristine-node-storage
introduce a distinct searchable non-broken storage for markers
This commit is contained in:
commit
715337011b
|
@ -32,7 +32,8 @@ RESULT_LOG = (
|
||||||
)
|
)
|
||||||
|
|
||||||
MARK_INFO_ATTRIBUTE = RemovedInPytest4Warning(
|
MARK_INFO_ATTRIBUTE = RemovedInPytest4Warning(
|
||||||
"MarkInfo objects are deprecated as they contain the merged marks"
|
"MarkInfo objects are deprecated as they contain the merged marks.\n"
|
||||||
|
"Please use node.iter_markers to iterate over markers correctly"
|
||||||
)
|
)
|
||||||
|
|
||||||
MARK_PARAMETERSET_UNPACKING = RemovedInPytest4Warning(
|
MARK_PARAMETERSET_UNPACKING = RemovedInPytest4Warning(
|
||||||
|
|
|
@ -5,6 +5,7 @@ import inspect
|
||||||
import sys
|
import sys
|
||||||
import warnings
|
import warnings
|
||||||
from collections import OrderedDict, deque, defaultdict
|
from collections import OrderedDict, deque, defaultdict
|
||||||
|
from more_itertools import flatten
|
||||||
|
|
||||||
import attr
|
import attr
|
||||||
import py
|
import py
|
||||||
|
@ -371,10 +372,7 @@ class FixtureRequest(FuncargnamesCompatAttr):
|
||||||
:arg marker: a :py:class:`_pytest.mark.MarkDecorator` object
|
:arg marker: a :py:class:`_pytest.mark.MarkDecorator` object
|
||||||
created by a call to ``pytest.mark.NAME(...)``.
|
created by a call to ``pytest.mark.NAME(...)``.
|
||||||
"""
|
"""
|
||||||
try:
|
self.node.add_marker(marker)
|
||||||
self.node.keywords[marker.markname] = marker
|
|
||||||
except AttributeError:
|
|
||||||
raise ValueError(marker)
|
|
||||||
|
|
||||||
def raiseerror(self, msg):
|
def raiseerror(self, msg):
|
||||||
""" raise a FixtureLookupError with the given message. """
|
""" raise a FixtureLookupError with the given message. """
|
||||||
|
@ -985,10 +983,9 @@ class FixtureManager(object):
|
||||||
argnames = getfuncargnames(func, cls=cls)
|
argnames = getfuncargnames(func, cls=cls)
|
||||||
else:
|
else:
|
||||||
argnames = ()
|
argnames = ()
|
||||||
usefixtures = getattr(func, "usefixtures", None)
|
usefixtures = flatten(mark.args for mark in node.iter_markers() if mark.name == "usefixtures")
|
||||||
initialnames = argnames
|
initialnames = argnames
|
||||||
if usefixtures is not None:
|
initialnames = tuple(usefixtures) + initialnames
|
||||||
initialnames = usefixtures.args + initialnames
|
|
||||||
fm = node.session._fixturemanager
|
fm = node.session._fixturemanager
|
||||||
names_closure, arg2fixturedefs = fm.getfixtureclosure(initialnames,
|
names_closure, arg2fixturedefs = fm.getfixtureclosure(initialnames,
|
||||||
node)
|
node)
|
||||||
|
@ -1070,6 +1067,8 @@ class FixtureManager(object):
|
||||||
fixturedef = faclist[-1]
|
fixturedef = faclist[-1]
|
||||||
if fixturedef.params is not None:
|
if fixturedef.params is not None:
|
||||||
parametrize_func = getattr(metafunc.function, 'parametrize', None)
|
parametrize_func = getattr(metafunc.function, 'parametrize', None)
|
||||||
|
if parametrize_func is not None:
|
||||||
|
parametrize_func = parametrize_func.combined
|
||||||
func_params = getattr(parametrize_func, 'args', [[None]])
|
func_params = getattr(parametrize_func, 'args', [[None]])
|
||||||
func_kwargs = getattr(parametrize_func, 'kwargs', {})
|
func_kwargs = getattr(parametrize_func, 'kwargs', {})
|
||||||
# skip directly parametrized arguments
|
# skip directly parametrized arguments
|
||||||
|
|
|
@ -4,7 +4,6 @@ import sys
|
||||||
import platform
|
import platform
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
from . import MarkDecorator, MarkInfo
|
|
||||||
from ..outcomes import fail, TEST_OUTCOME
|
from ..outcomes import fail, TEST_OUTCOME
|
||||||
|
|
||||||
|
|
||||||
|
@ -28,22 +27,15 @@ class MarkEvaluator(object):
|
||||||
self._mark_name = name
|
self._mark_name = name
|
||||||
|
|
||||||
def __bool__(self):
|
def __bool__(self):
|
||||||
self._marks = self._get_marks()
|
# dont cache here to prevent staleness
|
||||||
return bool(self._marks)
|
return bool(self._get_marks())
|
||||||
__nonzero__ = __bool__
|
__nonzero__ = __bool__
|
||||||
|
|
||||||
def wasvalid(self):
|
def wasvalid(self):
|
||||||
return not hasattr(self, 'exc')
|
return not hasattr(self, 'exc')
|
||||||
|
|
||||||
def _get_marks(self):
|
def _get_marks(self):
|
||||||
|
return [x for x in self.item.iter_markers() if x.name == self._mark_name]
|
||||||
keyword = self.item.keywords.get(self._mark_name)
|
|
||||||
if isinstance(keyword, MarkDecorator):
|
|
||||||
return [keyword.mark]
|
|
||||||
elif isinstance(keyword, MarkInfo):
|
|
||||||
return [x.combined for x in keyword]
|
|
||||||
else:
|
|
||||||
return []
|
|
||||||
|
|
||||||
def invalidraise(self, exc):
|
def invalidraise(self, exc):
|
||||||
raises = self.get('raises')
|
raises = self.get('raises')
|
||||||
|
|
|
@ -4,9 +4,10 @@ from operator import attrgetter
|
||||||
import inspect
|
import inspect
|
||||||
|
|
||||||
import attr
|
import attr
|
||||||
from ..deprecated import MARK_PARAMETERSET_UNPACKING
|
|
||||||
|
from ..deprecated import MARK_PARAMETERSET_UNPACKING, MARK_INFO_ATTRIBUTE
|
||||||
from ..compat import NOTSET, getfslineno
|
from ..compat import NOTSET, getfslineno
|
||||||
from six.moves import map
|
from six.moves import map, reduce
|
||||||
|
|
||||||
|
|
||||||
EMPTY_PARAMETERSET_OPTION = "empty_parameter_set_mark"
|
EMPTY_PARAMETERSET_OPTION = "empty_parameter_set_mark"
|
||||||
|
@ -113,11 +114,21 @@ class ParameterSet(namedtuple('ParameterSet', 'values, marks, id')):
|
||||||
|
|
||||||
@attr.s(frozen=True)
|
@attr.s(frozen=True)
|
||||||
class Mark(object):
|
class Mark(object):
|
||||||
name = attr.ib()
|
#: name of the mark
|
||||||
args = attr.ib()
|
name = attr.ib(type=str)
|
||||||
kwargs = attr.ib()
|
#: positional arguments of the mark decorator
|
||||||
|
args = attr.ib(type="List[object]")
|
||||||
|
#: keyword arguments of the mark decorator
|
||||||
|
kwargs = attr.ib(type="Dict[str, object]")
|
||||||
|
|
||||||
def combined_with(self, other):
|
def combined_with(self, other):
|
||||||
|
"""
|
||||||
|
:param other: the mark to combine with
|
||||||
|
:type other: Mark
|
||||||
|
:rtype: Mark
|
||||||
|
|
||||||
|
combines by appending aargs and merging the mappings
|
||||||
|
"""
|
||||||
assert self.name == other.name
|
assert self.name == other.name
|
||||||
return Mark(
|
return Mark(
|
||||||
self.name, self.args + other.args,
|
self.name, self.args + other.args,
|
||||||
|
@ -233,7 +244,7 @@ def store_legacy_markinfo(func, mark):
|
||||||
raise TypeError("got {mark!r} instead of a Mark".format(mark=mark))
|
raise TypeError("got {mark!r} instead of a Mark".format(mark=mark))
|
||||||
holder = getattr(func, mark.name, None)
|
holder = getattr(func, mark.name, None)
|
||||||
if holder is None:
|
if holder is None:
|
||||||
holder = MarkInfo(mark)
|
holder = MarkInfo.for_mark(mark)
|
||||||
setattr(func, mark.name, holder)
|
setattr(func, mark.name, holder)
|
||||||
else:
|
else:
|
||||||
holder.add_mark(mark)
|
holder.add_mark(mark)
|
||||||
|
@ -260,23 +271,29 @@ def _marked(func, mark):
|
||||||
invoked more than once.
|
invoked more than once.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
func_mark = getattr(func, mark.name)
|
func_mark = getattr(func, getattr(mark, 'combined', mark).name)
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
return False
|
return False
|
||||||
return mark.args == func_mark.args and mark.kwargs == func_mark.kwargs
|
return any(mark == info.combined for info in func_mark)
|
||||||
|
|
||||||
|
|
||||||
|
@attr.s
|
||||||
class MarkInfo(object):
|
class MarkInfo(object):
|
||||||
""" Marking object created by :class:`MarkDecorator` instances. """
|
""" Marking object created by :class:`MarkDecorator` instances. """
|
||||||
|
|
||||||
def __init__(self, mark):
|
_marks = attr.ib()
|
||||||
assert isinstance(mark, Mark), repr(mark)
|
combined = attr.ib(
|
||||||
self.combined = mark
|
repr=False,
|
||||||
self._marks = [mark]
|
default=attr.Factory(lambda self: reduce(Mark.combined_with, self._marks),
|
||||||
|
takes_self=True))
|
||||||
|
|
||||||
name = alias('combined.name')
|
name = alias('combined.name', warning=MARK_INFO_ATTRIBUTE)
|
||||||
args = alias('combined.args')
|
args = alias('combined.args', warning=MARK_INFO_ATTRIBUTE)
|
||||||
kwargs = alias('combined.kwargs')
|
kwargs = alias('combined.kwargs', warning=MARK_INFO_ATTRIBUTE)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def for_mark(cls, mark):
|
||||||
|
return cls([mark])
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return "<MarkInfo {0!r}>".format(self.combined)
|
return "<MarkInfo {0!r}>".format(self.combined)
|
||||||
|
@ -288,7 +305,7 @@ class MarkInfo(object):
|
||||||
|
|
||||||
def __iter__(self):
|
def __iter__(self):
|
||||||
""" yield MarkInfo objects each relating to a marking-call. """
|
""" yield MarkInfo objects each relating to a marking-call. """
|
||||||
return map(MarkInfo, self._marks)
|
return map(MarkInfo.for_mark, self._marks)
|
||||||
|
|
||||||
|
|
||||||
class MarkGenerator(object):
|
class MarkGenerator(object):
|
||||||
|
@ -365,3 +382,33 @@ class NodeKeywords(MappingMixin):
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return "<NodeKeywords for node %s>" % (self.node, )
|
return "<NodeKeywords for node %s>" % (self.node, )
|
||||||
|
|
||||||
|
|
||||||
|
@attr.s(cmp=False, hash=False)
|
||||||
|
class NodeMarkers(object):
|
||||||
|
"""
|
||||||
|
internal strucutre for storing marks belongong to a node
|
||||||
|
|
||||||
|
..warning::
|
||||||
|
|
||||||
|
unstable api
|
||||||
|
|
||||||
|
"""
|
||||||
|
own_markers = attr.ib(default=attr.Factory(list))
|
||||||
|
|
||||||
|
def update(self, add_markers):
|
||||||
|
"""update the own markers
|
||||||
|
"""
|
||||||
|
self.own_markers.extend(add_markers)
|
||||||
|
|
||||||
|
def find(self, name):
|
||||||
|
"""
|
||||||
|
find markers in own nodes or parent nodes
|
||||||
|
needs a better place
|
||||||
|
"""
|
||||||
|
for mark in self.own_markers:
|
||||||
|
if mark.name == name:
|
||||||
|
yield mark
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
return iter(self.own_markers)
|
||||||
|
|
|
@ -8,7 +8,7 @@ import attr
|
||||||
import _pytest
|
import _pytest
|
||||||
import _pytest._code
|
import _pytest._code
|
||||||
|
|
||||||
from _pytest.mark.structures import NodeKeywords
|
from _pytest.mark.structures import NodeKeywords, MarkInfo
|
||||||
|
|
||||||
SEP = "/"
|
SEP = "/"
|
||||||
|
|
||||||
|
@ -90,6 +90,9 @@ class Node(object):
|
||||||
#: keywords/markers collected from all scopes
|
#: keywords/markers collected from all scopes
|
||||||
self.keywords = NodeKeywords(self)
|
self.keywords = NodeKeywords(self)
|
||||||
|
|
||||||
|
#: the marker objects belonging to this node
|
||||||
|
self.own_markers = []
|
||||||
|
|
||||||
#: allow adding of extra keywords to use for matching
|
#: allow adding of extra keywords to use for matching
|
||||||
self.extra_keyword_matches = set()
|
self.extra_keyword_matches = set()
|
||||||
|
|
||||||
|
@ -178,15 +181,34 @@ class Node(object):
|
||||||
elif not isinstance(marker, MarkDecorator):
|
elif not isinstance(marker, MarkDecorator):
|
||||||
raise ValueError("is not a string or pytest.mark.* Marker")
|
raise ValueError("is not a string or pytest.mark.* Marker")
|
||||||
self.keywords[marker.name] = marker
|
self.keywords[marker.name] = marker
|
||||||
|
self.own_markers.append(marker)
|
||||||
|
|
||||||
|
def iter_markers(self):
|
||||||
|
"""
|
||||||
|
iterate over all markers of the node
|
||||||
|
"""
|
||||||
|
return (x[1] for x in self.iter_markers_with_node())
|
||||||
|
|
||||||
|
def iter_markers_with_node(self):
|
||||||
|
"""
|
||||||
|
iterate over all markers of the node
|
||||||
|
returns sequence of tuples (node, mark)
|
||||||
|
"""
|
||||||
|
for node in reversed(self.listchain()):
|
||||||
|
for mark in node.own_markers:
|
||||||
|
yield node, mark
|
||||||
|
|
||||||
def get_marker(self, name):
|
def get_marker(self, name):
|
||||||
""" get a marker object from this node or None if
|
""" get a marker object from this node or None if
|
||||||
the node doesn't have a marker with that name. """
|
the node doesn't have a marker with that name.
|
||||||
val = self.keywords.get(name, None)
|
|
||||||
if val is not None:
|
..warning::
|
||||||
from _pytest.mark import MarkInfo, MarkDecorator
|
|
||||||
if isinstance(val, (MarkDecorator, MarkInfo)):
|
deprecated
|
||||||
return val
|
"""
|
||||||
|
markers = [x for x in self.iter_markers() if x.name == name]
|
||||||
|
if markers:
|
||||||
|
return MarkInfo(markers)
|
||||||
|
|
||||||
def listextrakeywords(self):
|
def listextrakeywords(self):
|
||||||
""" Return a set of all extra keywords in self and any parents."""
|
""" Return a set of all extra keywords in self and any parents."""
|
||||||
|
|
|
@ -28,7 +28,7 @@ from _pytest.compat import (
|
||||||
safe_str, getlocation, enum,
|
safe_str, getlocation, enum,
|
||||||
)
|
)
|
||||||
from _pytest.outcomes import fail
|
from _pytest.outcomes import fail
|
||||||
from _pytest.mark.structures import transfer_markers
|
from _pytest.mark.structures import transfer_markers, get_unpacked_marks
|
||||||
|
|
||||||
|
|
||||||
# relative paths that we use to filter traceback entries from appearing to the user;
|
# relative paths that we use to filter traceback entries from appearing to the user;
|
||||||
|
@ -117,12 +117,9 @@ def pytest_generate_tests(metafunc):
|
||||||
if hasattr(metafunc.function, attr):
|
if hasattr(metafunc.function, attr):
|
||||||
msg = "{0} has '{1}', spelling should be 'parametrize'"
|
msg = "{0} has '{1}', spelling should be 'parametrize'"
|
||||||
raise MarkerError(msg.format(metafunc.function.__name__, attr))
|
raise MarkerError(msg.format(metafunc.function.__name__, attr))
|
||||||
try:
|
for marker in metafunc.definition.iter_markers():
|
||||||
markers = metafunc.function.parametrize
|
if marker.name == 'parametrize':
|
||||||
except AttributeError:
|
metafunc.parametrize(*marker.args, **marker.kwargs)
|
||||||
return
|
|
||||||
for marker in markers:
|
|
||||||
metafunc.parametrize(*marker.args, **marker.kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
def pytest_configure(config):
|
def pytest_configure(config):
|
||||||
|
@ -212,11 +209,20 @@ class PyobjContext(object):
|
||||||
|
|
||||||
|
|
||||||
class PyobjMixin(PyobjContext):
|
class PyobjMixin(PyobjContext):
|
||||||
|
_ALLOW_MARKERS = True
|
||||||
|
|
||||||
|
def __init__(self, *k, **kw):
|
||||||
|
super(PyobjMixin, self).__init__(*k, **kw)
|
||||||
|
|
||||||
def obj():
|
def obj():
|
||||||
def fget(self):
|
def fget(self):
|
||||||
obj = getattr(self, '_obj', None)
|
obj = getattr(self, '_obj', None)
|
||||||
if obj is None:
|
if obj is None:
|
||||||
self._obj = obj = self._getobj()
|
self._obj = obj = self._getobj()
|
||||||
|
# XXX evil hack
|
||||||
|
# used to avoid Instance collector marker duplication
|
||||||
|
if self._ALLOW_MARKERS:
|
||||||
|
self.own_markers.extend(get_unpacked_marks(self.obj))
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
def fset(self, value):
|
def fset(self, value):
|
||||||
|
@ -363,9 +369,15 @@ class PyCollector(PyobjMixin, nodes.Collector):
|
||||||
cls = clscol and clscol.obj or None
|
cls = clscol and clscol.obj or None
|
||||||
transfer_markers(funcobj, cls, module)
|
transfer_markers(funcobj, cls, module)
|
||||||
fm = self.session._fixturemanager
|
fm = self.session._fixturemanager
|
||||||
fixtureinfo = fm.getfixtureinfo(self, funcobj, cls)
|
|
||||||
metafunc = Metafunc(funcobj, fixtureinfo, self.config,
|
definition = FunctionDefinition(
|
||||||
cls=cls, module=module)
|
name=name,
|
||||||
|
parent=self,
|
||||||
|
callobj=funcobj,
|
||||||
|
)
|
||||||
|
fixtureinfo = fm.getfixtureinfo(definition, funcobj, cls)
|
||||||
|
|
||||||
|
metafunc = Metafunc(definition, fixtureinfo, self.config, cls=cls, module=module)
|
||||||
methods = []
|
methods = []
|
||||||
if hasattr(module, "pytest_generate_tests"):
|
if hasattr(module, "pytest_generate_tests"):
|
||||||
methods.append(module.pytest_generate_tests)
|
methods.append(module.pytest_generate_tests)
|
||||||
|
@ -524,6 +536,11 @@ class Class(PyCollector):
|
||||||
|
|
||||||
|
|
||||||
class Instance(PyCollector):
|
class Instance(PyCollector):
|
||||||
|
_ALLOW_MARKERS = False # hack, destroy later
|
||||||
|
# instances share the object with their parents in a way
|
||||||
|
# that duplicates markers instances if not taken out
|
||||||
|
# can be removed at node strucutre reorganization time
|
||||||
|
|
||||||
def _getobj(self):
|
def _getobj(self):
|
||||||
return self.parent.obj()
|
return self.parent.obj()
|
||||||
|
|
||||||
|
@ -723,15 +740,17 @@ class Metafunc(fixtures.FuncargnamesCompatAttr):
|
||||||
test function is defined.
|
test function is defined.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, function, fixtureinfo, config, cls=None, module=None):
|
def __init__(self, definition, fixtureinfo, config, cls=None, module=None):
|
||||||
#: access to the :class:`_pytest.config.Config` object for the test session
|
#: access to the :class:`_pytest.config.Config` object for the test session
|
||||||
|
assert isinstance(definition, FunctionDefinition) or type(definition).__name__ == "DefinitionMock"
|
||||||
|
self.definition = definition
|
||||||
self.config = config
|
self.config = config
|
||||||
|
|
||||||
#: the module object where the test function is defined in.
|
#: the module object where the test function is defined in.
|
||||||
self.module = module
|
self.module = module
|
||||||
|
|
||||||
#: underlying python test function
|
#: underlying python test function
|
||||||
self.function = function
|
self.function = definition.obj
|
||||||
|
|
||||||
#: set of fixture names required by the test function
|
#: set of fixture names required by the test function
|
||||||
self.fixturenames = fixtureinfo.names_closure
|
self.fixturenames = fixtureinfo.names_closure
|
||||||
|
@ -1103,6 +1122,8 @@ class Function(FunctionMixin, nodes.Item, fixtures.FuncargnamesCompatAttr):
|
||||||
Python test function.
|
Python test function.
|
||||||
"""
|
"""
|
||||||
_genid = None
|
_genid = None
|
||||||
|
# disable since functions handle it themselfes
|
||||||
|
_ALLOW_MARKERS = False
|
||||||
|
|
||||||
def __init__(self, name, parent, args=None, config=None,
|
def __init__(self, name, parent, args=None, config=None,
|
||||||
callspec=None, callobj=NOTSET, keywords=None, session=None,
|
callspec=None, callobj=NOTSET, keywords=None, session=None,
|
||||||
|
@ -1114,6 +1135,7 @@ class Function(FunctionMixin, nodes.Item, fixtures.FuncargnamesCompatAttr):
|
||||||
self.obj = callobj
|
self.obj = callobj
|
||||||
|
|
||||||
self.keywords.update(self.obj.__dict__)
|
self.keywords.update(self.obj.__dict__)
|
||||||
|
self.own_markers.extend(get_unpacked_marks(self.obj))
|
||||||
if callspec:
|
if callspec:
|
||||||
self.callspec = callspec
|
self.callspec = callspec
|
||||||
# this is total hostile and a mess
|
# this is total hostile and a mess
|
||||||
|
@ -1123,6 +1145,7 @@ class Function(FunctionMixin, nodes.Item, fixtures.FuncargnamesCompatAttr):
|
||||||
# feel free to cry, this was broken for years before
|
# feel free to cry, this was broken for years before
|
||||||
# and keywords cant fix it per design
|
# and keywords cant fix it per design
|
||||||
self.keywords[mark.name] = mark
|
self.keywords[mark.name] = mark
|
||||||
|
self.own_markers.extend(callspec.marks)
|
||||||
if keywords:
|
if keywords:
|
||||||
self.keywords.update(keywords)
|
self.keywords.update(keywords)
|
||||||
|
|
||||||
|
@ -1181,3 +1204,15 @@ class Function(FunctionMixin, nodes.Item, fixtures.FuncargnamesCompatAttr):
|
||||||
def setup(self):
|
def setup(self):
|
||||||
super(Function, self).setup()
|
super(Function, self).setup()
|
||||||
fixtures.fillfixtures(self)
|
fixtures.fillfixtures(self)
|
||||||
|
|
||||||
|
|
||||||
|
class FunctionDefinition(Function):
|
||||||
|
"""
|
||||||
|
internal hack until we get actual definition nodes instead of the
|
||||||
|
crappy metafunc hack
|
||||||
|
"""
|
||||||
|
|
||||||
|
def runtest(self):
|
||||||
|
raise RuntimeError("function definitions are not supposed to be used")
|
||||||
|
|
||||||
|
setup = runtest
|
||||||
|
|
|
@ -2,7 +2,6 @@
|
||||||
from __future__ import absolute_import, division, print_function
|
from __future__ import absolute_import, division, print_function
|
||||||
|
|
||||||
from _pytest.config import hookimpl
|
from _pytest.config import hookimpl
|
||||||
from _pytest.mark import MarkInfo, MarkDecorator
|
|
||||||
from _pytest.mark.evaluate import MarkEvaluator
|
from _pytest.mark.evaluate import MarkEvaluator
|
||||||
from _pytest.outcomes import fail, skip, xfail
|
from _pytest.outcomes import fail, skip, xfail
|
||||||
|
|
||||||
|
@ -60,15 +59,14 @@ def pytest_configure(config):
|
||||||
def pytest_runtest_setup(item):
|
def pytest_runtest_setup(item):
|
||||||
# Check if skip or skipif are specified as pytest marks
|
# Check if skip or skipif are specified as pytest marks
|
||||||
item._skipped_by_mark = False
|
item._skipped_by_mark = False
|
||||||
skipif_info = item.keywords.get('skipif')
|
eval_skipif = MarkEvaluator(item, 'skipif')
|
||||||
if isinstance(skipif_info, (MarkInfo, MarkDecorator)):
|
if eval_skipif.istrue():
|
||||||
eval_skipif = MarkEvaluator(item, 'skipif')
|
item._skipped_by_mark = True
|
||||||
if eval_skipif.istrue():
|
skip(eval_skipif.getexplanation())
|
||||||
item._skipped_by_mark = True
|
|
||||||
skip(eval_skipif.getexplanation())
|
|
||||||
|
|
||||||
skip_info = item.keywords.get('skip')
|
for skip_info in item.iter_markers():
|
||||||
if isinstance(skip_info, (MarkInfo, MarkDecorator)):
|
if skip_info.name != 'skip':
|
||||||
|
continue
|
||||||
item._skipped_by_mark = True
|
item._skipped_by_mark = True
|
||||||
if 'reason' in skip_info.kwargs:
|
if 'reason' in skip_info.kwargs:
|
||||||
skip(skip_info.kwargs['reason'])
|
skip(skip_info.kwargs['reason'])
|
||||||
|
|
|
@ -60,10 +60,10 @@ def catch_warnings_for_item(item):
|
||||||
for arg in inifilters:
|
for arg in inifilters:
|
||||||
_setoption(warnings, arg)
|
_setoption(warnings, arg)
|
||||||
|
|
||||||
mark = item.get_marker('filterwarnings')
|
for mark in item.iter_markers():
|
||||||
if mark:
|
if mark.name == 'filterwarnings':
|
||||||
for arg in mark.args:
|
for arg in mark.args:
|
||||||
warnings._setoption(arg)
|
warnings._setoption(arg)
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
introduce correct per node mark handling and deprecate the always incorrect existing mark handling
|
|
@ -330,11 +330,10 @@ specifies via named environments::
|
||||||
"env(name): mark test to run only on named environment")
|
"env(name): mark test to run only on named environment")
|
||||||
|
|
||||||
def pytest_runtest_setup(item):
|
def pytest_runtest_setup(item):
|
||||||
envmarker = item.get_marker("env")
|
envnames = [mark.args[0] for mark in item.iter_markers() if mark.name == "env"]
|
||||||
if envmarker is not None:
|
if envnames:
|
||||||
envname = envmarker.args[0]
|
if item.config.getoption("-E") not in envnames:
|
||||||
if envname != item.config.getoption("-E"):
|
pytest.skip("test requires env in %r" % envnames)
|
||||||
pytest.skip("test requires env %r" % envname)
|
|
||||||
|
|
||||||
A test file using this local plugin::
|
A test file using this local plugin::
|
||||||
|
|
||||||
|
@ -403,10 +402,9 @@ Below is the config file that will be used in the next examples::
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
def pytest_runtest_setup(item):
|
def pytest_runtest_setup(item):
|
||||||
marker = item.get_marker('my_marker')
|
for marker in item.iter_markers():
|
||||||
if marker is not None:
|
if marker.name == 'my_marker':
|
||||||
for info in marker:
|
print(marker)
|
||||||
print('Marker info name={} args={} kwars={}'.format(info.name, info.args, info.kwargs))
|
|
||||||
sys.stdout.flush()
|
sys.stdout.flush()
|
||||||
|
|
||||||
A custom marker can have its argument set, i.e. ``args`` and ``kwargs`` properties, defined by either invoking it as a callable or using ``pytest.mark.MARKER_NAME.with_args``. These two methods achieve the same effect most of the time.
|
A custom marker can have its argument set, i.e. ``args`` and ``kwargs`` properties, defined by either invoking it as a callable or using ``pytest.mark.MARKER_NAME.with_args``. These two methods achieve the same effect most of the time.
|
||||||
|
@ -426,7 +424,7 @@ However, if there is a callable as the single positional argument with no keywor
|
||||||
The output is as follows::
|
The output is as follows::
|
||||||
|
|
||||||
$ pytest -q -s
|
$ pytest -q -s
|
||||||
Marker info name=my_marker args=(<function hello_world at 0xdeadbeef>,) kwars={}
|
Mark(name='my_marker', args=(<function hello_world at 0xdeadbeef>,), kwargs={})
|
||||||
.
|
.
|
||||||
1 passed in 0.12 seconds
|
1 passed in 0.12 seconds
|
||||||
|
|
||||||
|
@ -460,10 +458,9 @@ test function. From a conftest file we can read it like this::
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
def pytest_runtest_setup(item):
|
def pytest_runtest_setup(item):
|
||||||
g = item.get_marker("glob")
|
for mark in item.iter_markers():
|
||||||
if g is not None:
|
if mark.name == 'glob':
|
||||||
for info in g:
|
print ("glob args=%s kwargs=%s" %(mark.args, mark.kwargs))
|
||||||
print ("glob args=%s kwargs=%s" %(info.args, info.kwargs))
|
|
||||||
sys.stdout.flush()
|
sys.stdout.flush()
|
||||||
|
|
||||||
Let's run this without capturing output and see what we get::
|
Let's run this without capturing output and see what we get::
|
||||||
|
@ -494,11 +491,10 @@ for your particular platform, you could use the following plugin::
|
||||||
ALL = set("darwin linux win32".split())
|
ALL = set("darwin linux win32".split())
|
||||||
|
|
||||||
def pytest_runtest_setup(item):
|
def pytest_runtest_setup(item):
|
||||||
if isinstance(item, item.Function):
|
supported_platforms = ALL.intersection(mark.name for mark in item.iter_markers())
|
||||||
plat = sys.platform
|
plat = sys.platform
|
||||||
if not item.get_marker(plat):
|
if supported_platforms and plat not in supported_platforms:
|
||||||
if ALL.intersection(item.keywords):
|
pytest.skip("cannot run on platform %s" % (plat))
|
||||||
pytest.skip("cannot run on platform %s" %(plat))
|
|
||||||
|
|
||||||
then tests will be skipped if they were specified for a different platform.
|
then tests will be skipped if they were specified for a different platform.
|
||||||
Let's do a little test file to show how this looks like::
|
Let's do a little test file to show how this looks like::
|
||||||
|
@ -532,7 +528,7 @@ then you will see two tests skipped and two executed tests as expected::
|
||||||
|
|
||||||
test_plat.py s.s. [100%]
|
test_plat.py s.s. [100%]
|
||||||
========================= short test summary info ==========================
|
========================= short test summary info ==========================
|
||||||
SKIP [2] $REGENDOC_TMPDIR/conftest.py:13: cannot run on platform linux
|
SKIP [2] $REGENDOC_TMPDIR/conftest.py:12: cannot run on platform linux
|
||||||
|
|
||||||
=================== 2 passed, 2 skipped in 0.12 seconds ====================
|
=================== 2 passed, 2 skipped in 0.12 seconds ====================
|
||||||
|
|
||||||
|
|
|
@ -389,7 +389,7 @@ Now we can profile which test functions execute the slowest::
|
||||||
========================= slowest 3 test durations =========================
|
========================= slowest 3 test durations =========================
|
||||||
0.30s call test_some_are_slow.py::test_funcslow2
|
0.30s call test_some_are_slow.py::test_funcslow2
|
||||||
0.20s call test_some_are_slow.py::test_funcslow1
|
0.20s call test_some_are_slow.py::test_funcslow1
|
||||||
0.16s call test_some_are_slow.py::test_funcfast
|
0.10s call test_some_are_slow.py::test_funcfast
|
||||||
========================= 3 passed in 0.12 seconds =========================
|
========================= 3 passed in 0.12 seconds =========================
|
||||||
|
|
||||||
incremental testing - test steps
|
incremental testing - test steps
|
||||||
|
|
|
@ -26,3 +26,33 @@ which also serve as documentation.
|
||||||
:ref:`fixtures <fixtures>`.
|
:ref:`fixtures <fixtures>`.
|
||||||
|
|
||||||
|
|
||||||
|
.. currentmodule:: _pytest.mark.structures
|
||||||
|
.. autoclass:: Mark
|
||||||
|
:members:
|
||||||
|
:noindex:
|
||||||
|
|
||||||
|
|
||||||
|
.. `marker-iteration`
|
||||||
|
|
||||||
|
Marker iteration
|
||||||
|
=================
|
||||||
|
|
||||||
|
.. versionadded:: 3.6
|
||||||
|
|
||||||
|
pytest's marker implementation traditionally worked by simply updating the ``__dict__`` attribute of functions to add markers, in a cumulative manner. As a result of the this, markers would unintendely be passed along class hierarchies in surprising ways plus the API for retriving them was inconsistent, as markers from parameterization would be stored differently than markers applied using the ``@pytest.mark`` decorator and markers added via ``node.add_marker``.
|
||||||
|
|
||||||
|
This state of things made it technically next to impossible to use data from markers correctly without having a deep understanding of the internals, leading to subtle and hard to understand bugs in more advanced usages.
|
||||||
|
|
||||||
|
Depending on how a marker got declared/changed one would get either a ``MarkerInfo`` which might contain markers from sibling classes,
|
||||||
|
``MarkDecorators`` when marks came from parameterization or from a ``node.add_marker`` call, discarding prior marks. Also ``MarkerInfo`` acts like a single mark, when it in fact repressents a merged view on multiple marks with the same name.
|
||||||
|
|
||||||
|
On top of that markers where not accessible the same way for modules, classes, and functions/methods,
|
||||||
|
in fact, markers where only accessible in functions, even if they where declared on classes/modules.
|
||||||
|
|
||||||
|
A new API to access markers has been introduced in pytest 3.6 in order to solve the problems with the initial design, providing :func:`_pytest.nodes.Node.iter_markers` method to iterate over markers in a consistent manner and reworking the internals, which solved great deal of problems with the initial design.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
in a future major relase of pytest we will introduce class based markers,
|
||||||
|
at which points markers will no longer be limited to instances of :py:class:`Mark`
|
||||||
|
|
||||||
|
|
|
@ -94,6 +94,8 @@ Marks can be used apply meta data to *test functions* (but not fixtures), which
|
||||||
fixtures or plugins.
|
fixtures or plugins.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.. _`pytest.mark.filterwarnings ref`:
|
.. _`pytest.mark.filterwarnings ref`:
|
||||||
|
|
||||||
pytest.mark.filterwarnings
|
pytest.mark.filterwarnings
|
||||||
|
@ -200,9 +202,9 @@ For example:
|
||||||
def test_function():
|
def test_function():
|
||||||
...
|
...
|
||||||
|
|
||||||
Will create and attach a :class:`MarkInfo <_pytest.mark.MarkInfo>` object to the collected
|
Will create and attach a :class:`Mark <_pytest.mark.structures.Mark>` object to the collected
|
||||||
:class:`Item <_pytest.nodes.Item>`, which can then be accessed by fixtures or hooks with
|
:class:`Item <_pytest.nodes.Item>`, which can then be accessed by fixtures or hooks with
|
||||||
:meth:`Node.get_marker <_pytest.nodes.Node.get_marker>`. The ``mark`` object will have the following attributes:
|
:meth:`Node.iter_markers <_pytest.nodes.Node.iter_markers>`. The ``mark`` object will have the following attributes:
|
||||||
|
|
||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|
||||||
|
@ -685,18 +687,28 @@ MarkDecorator
|
||||||
.. autoclass:: _pytest.mark.MarkDecorator
|
.. autoclass:: _pytest.mark.MarkDecorator
|
||||||
:members:
|
:members:
|
||||||
|
|
||||||
|
|
||||||
MarkGenerator
|
MarkGenerator
|
||||||
~~~~~~~~~~~~~
|
~~~~~~~~~~~~~
|
||||||
|
|
||||||
.. autoclass:: _pytest.mark.MarkGenerator
|
.. autoclass:: _pytest.mark.MarkGenerator
|
||||||
:members:
|
:members:
|
||||||
|
|
||||||
|
|
||||||
MarkInfo
|
MarkInfo
|
||||||
~~~~~~~~
|
~~~~~~~~
|
||||||
|
|
||||||
.. autoclass:: _pytest.mark.MarkInfo
|
.. autoclass:: _pytest.mark.MarkInfo
|
||||||
:members:
|
:members:
|
||||||
|
|
||||||
|
|
||||||
|
Mark
|
||||||
|
~~~~
|
||||||
|
|
||||||
|
.. autoclass:: _pytest.mark.structures.Mark
|
||||||
|
:members:
|
||||||
|
|
||||||
|
|
||||||
Metafunc
|
Metafunc
|
||||||
~~~~~~~~
|
~~~~~~~~
|
||||||
|
|
||||||
|
|
|
@ -260,10 +260,10 @@ Alternatively, you can integrate this functionality with custom markers:
|
||||||
|
|
||||||
def pytest_collection_modifyitems(session, config, items):
|
def pytest_collection_modifyitems(session, config, items):
|
||||||
for item in items:
|
for item in items:
|
||||||
marker = item.get_marker('test_id')
|
for marker in item.iter_markers():
|
||||||
if marker is not None:
|
if marker.name == 'test_id':
|
||||||
test_id = marker.args[0]
|
test_id = marker.args[0]
|
||||||
item.user_properties.append(('test_id', test_id))
|
item.user_properties.append(('test_id', test_id))
|
||||||
|
|
||||||
And in your tests:
|
And in your tests:
|
||||||
|
|
||||||
|
|
|
@ -1781,6 +1781,8 @@ class TestAutouseManagement(object):
|
||||||
import pytest
|
import pytest
|
||||||
values = []
|
values = []
|
||||||
def pytest_generate_tests(metafunc):
|
def pytest_generate_tests(metafunc):
|
||||||
|
if metafunc.cls is None:
|
||||||
|
assert metafunc.function is test_finish
|
||||||
if metafunc.cls is not None:
|
if metafunc.cls is not None:
|
||||||
metafunc.parametrize("item", [1,2], scope="class")
|
metafunc.parametrize("item", [1,2], scope="class")
|
||||||
class TestClass(object):
|
class TestClass(object):
|
||||||
|
@ -1798,7 +1800,7 @@ class TestAutouseManagement(object):
|
||||||
assert values == ["setup-1", "step1-1", "step2-1", "teardown-1",
|
assert values == ["setup-1", "step1-1", "step2-1", "teardown-1",
|
||||||
"setup-2", "step1-2", "step2-2", "teardown-2",]
|
"setup-2", "step1-2", "step2-2", "teardown-2",]
|
||||||
""")
|
""")
|
||||||
reprec = testdir.inline_run()
|
reprec = testdir.inline_run('-s')
|
||||||
reprec.assertoutcome(passed=5)
|
reprec.assertoutcome(passed=5)
|
||||||
|
|
||||||
def test_ordering_autouse_before_explicit(self, testdir):
|
def test_ordering_autouse_before_explicit(self, testdir):
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
import attr
|
||||||
import _pytest._code
|
import _pytest._code
|
||||||
import py
|
import py
|
||||||
import pytest
|
import pytest
|
||||||
|
@ -24,13 +24,19 @@ class TestMetafunc(object):
|
||||||
def __init__(self, names):
|
def __init__(self, names):
|
||||||
self.names_closure = names
|
self.names_closure = names
|
||||||
|
|
||||||
|
@attr.s
|
||||||
|
class DefinitionMock(object):
|
||||||
|
obj = attr.ib()
|
||||||
|
|
||||||
names = fixtures.getfuncargnames(func)
|
names = fixtures.getfuncargnames(func)
|
||||||
fixtureinfo = FixtureInfo(names)
|
fixtureinfo = FixtureInfo(names)
|
||||||
return python.Metafunc(func, fixtureinfo, config)
|
definition = DefinitionMock(func)
|
||||||
|
return python.Metafunc(definition, fixtureinfo, config)
|
||||||
|
|
||||||
def test_no_funcargs(self, testdir):
|
def test_no_funcargs(self, testdir):
|
||||||
def function():
|
def function():
|
||||||
pass
|
pass
|
||||||
|
|
||||||
metafunc = self.Metafunc(function)
|
metafunc = self.Metafunc(function)
|
||||||
assert not metafunc.fixturenames
|
assert not metafunc.fixturenames
|
||||||
repr(metafunc._calls)
|
repr(metafunc._calls)
|
||||||
|
|
|
@ -8,11 +8,13 @@ from _pytest.mark import (
|
||||||
EMPTY_PARAMETERSET_OPTION,
|
EMPTY_PARAMETERSET_OPTION,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ignore_markinfo = pytest.mark.filterwarnings('ignore:MarkInfo objects:_pytest.deprecated.RemovedInPytest4Warning')
|
||||||
|
|
||||||
|
|
||||||
class TestMark(object):
|
class TestMark(object):
|
||||||
def test_markinfo_repr(self):
|
def test_markinfo_repr(self):
|
||||||
from _pytest.mark import MarkInfo, Mark
|
from _pytest.mark import MarkInfo, Mark
|
||||||
m = MarkInfo(Mark("hello", (1, 2), {}))
|
m = MarkInfo.for_mark(Mark("hello", (1, 2), {}))
|
||||||
repr(m)
|
repr(m)
|
||||||
|
|
||||||
@pytest.mark.parametrize('attr', ['mark', 'param'])
|
@pytest.mark.parametrize('attr', ['mark', 'param'])
|
||||||
|
@ -51,6 +53,7 @@ class TestMark(object):
|
||||||
mark.hello(f)
|
mark.hello(f)
|
||||||
assert f.hello
|
assert f.hello
|
||||||
|
|
||||||
|
@ignore_markinfo
|
||||||
def test_pytest_mark_keywords(self):
|
def test_pytest_mark_keywords(self):
|
||||||
mark = Mark()
|
mark = Mark()
|
||||||
|
|
||||||
|
@ -62,6 +65,7 @@ class TestMark(object):
|
||||||
assert f.world.kwargs['x'] == 3
|
assert f.world.kwargs['x'] == 3
|
||||||
assert f.world.kwargs['y'] == 4
|
assert f.world.kwargs['y'] == 4
|
||||||
|
|
||||||
|
@ignore_markinfo
|
||||||
def test_apply_multiple_and_merge(self):
|
def test_apply_multiple_and_merge(self):
|
||||||
mark = Mark()
|
mark = Mark()
|
||||||
|
|
||||||
|
@ -78,6 +82,7 @@ class TestMark(object):
|
||||||
assert f.world.kwargs['y'] == 1
|
assert f.world.kwargs['y'] == 1
|
||||||
assert len(f.world.args) == 0
|
assert len(f.world.args) == 0
|
||||||
|
|
||||||
|
@ignore_markinfo
|
||||||
def test_pytest_mark_positional(self):
|
def test_pytest_mark_positional(self):
|
||||||
mark = Mark()
|
mark = Mark()
|
||||||
|
|
||||||
|
@ -88,6 +93,7 @@ class TestMark(object):
|
||||||
assert f.world.args[0] == "hello"
|
assert f.world.args[0] == "hello"
|
||||||
mark.world("world")(f)
|
mark.world("world")(f)
|
||||||
|
|
||||||
|
@ignore_markinfo
|
||||||
def test_pytest_mark_positional_func_and_keyword(self):
|
def test_pytest_mark_positional_func_and_keyword(self):
|
||||||
mark = Mark()
|
mark = Mark()
|
||||||
|
|
||||||
|
@ -103,6 +109,7 @@ class TestMark(object):
|
||||||
assert g.world.args[0] is f
|
assert g.world.args[0] is f
|
||||||
assert g.world.kwargs["omega"] == "hello"
|
assert g.world.kwargs["omega"] == "hello"
|
||||||
|
|
||||||
|
@ignore_markinfo
|
||||||
def test_pytest_mark_reuse(self):
|
def test_pytest_mark_reuse(self):
|
||||||
mark = Mark()
|
mark = Mark()
|
||||||
|
|
||||||
|
@ -484,6 +491,7 @@ class TestFunctional(object):
|
||||||
assert 'hello' in keywords
|
assert 'hello' in keywords
|
||||||
assert 'world' in keywords
|
assert 'world' in keywords
|
||||||
|
|
||||||
|
@ignore_markinfo
|
||||||
def test_merging_markers(self, testdir):
|
def test_merging_markers(self, testdir):
|
||||||
p = testdir.makepyfile("""
|
p = testdir.makepyfile("""
|
||||||
import pytest
|
import pytest
|
||||||
|
@ -509,7 +517,6 @@ class TestFunctional(object):
|
||||||
assert values[1].args == ()
|
assert values[1].args == ()
|
||||||
assert values[2].args == ("pos1", )
|
assert values[2].args == ("pos1", )
|
||||||
|
|
||||||
@pytest.mark.xfail(reason='unfixed')
|
|
||||||
def test_merging_markers_deep(self, testdir):
|
def test_merging_markers_deep(self, testdir):
|
||||||
# issue 199 - propagate markers into nested classes
|
# issue 199 - propagate markers into nested classes
|
||||||
p = testdir.makepyfile("""
|
p = testdir.makepyfile("""
|
||||||
|
@ -526,7 +533,7 @@ class TestFunctional(object):
|
||||||
items, rec = testdir.inline_genitems(p)
|
items, rec = testdir.inline_genitems(p)
|
||||||
for item in items:
|
for item in items:
|
||||||
print(item, item.keywords)
|
print(item, item.keywords)
|
||||||
assert 'a' in item.keywords
|
assert [x for x in item.iter_markers() if x.name == 'a']
|
||||||
|
|
||||||
def test_mark_decorator_subclass_does_not_propagate_to_base(self, testdir):
|
def test_mark_decorator_subclass_does_not_propagate_to_base(self, testdir):
|
||||||
p = testdir.makepyfile("""
|
p = testdir.makepyfile("""
|
||||||
|
@ -622,6 +629,7 @@ class TestFunctional(object):
|
||||||
"keyword: *hello*"
|
"keyword: *hello*"
|
||||||
])
|
])
|
||||||
|
|
||||||
|
@ignore_markinfo
|
||||||
def test_merging_markers_two_functions(self, testdir):
|
def test_merging_markers_two_functions(self, testdir):
|
||||||
p = testdir.makepyfile("""
|
p = testdir.makepyfile("""
|
||||||
import pytest
|
import pytest
|
||||||
|
@ -676,6 +684,7 @@ class TestFunctional(object):
|
||||||
reprec = testdir.inline_run()
|
reprec = testdir.inline_run()
|
||||||
reprec.assertoutcome(passed=1)
|
reprec.assertoutcome(passed=1)
|
||||||
|
|
||||||
|
@ignore_markinfo
|
||||||
def test_keyword_added_for_session(self, testdir):
|
def test_keyword_added_for_session(self, testdir):
|
||||||
testdir.makeconftest("""
|
testdir.makeconftest("""
|
||||||
import pytest
|
import pytest
|
||||||
|
@ -715,8 +724,8 @@ class TestFunctional(object):
|
||||||
if isinstance(v, MarkInfo)])
|
if isinstance(v, MarkInfo)])
|
||||||
assert marker_names == set(expected_markers)
|
assert marker_names == set(expected_markers)
|
||||||
|
|
||||||
@pytest.mark.xfail(reason='callspec2.setmulti misuses keywords')
|
|
||||||
@pytest.mark.issue1540
|
@pytest.mark.issue1540
|
||||||
|
@pytest.mark.filterwarnings("ignore")
|
||||||
def test_mark_from_parameters(self, testdir):
|
def test_mark_from_parameters(self, testdir):
|
||||||
testdir.makepyfile("""
|
testdir.makepyfile("""
|
||||||
import pytest
|
import pytest
|
||||||
|
|
Loading…
Reference in New Issue