diff --git a/_pytest/main.py b/_pytest/main.py index 95b2359bc..9ef47faf9 100644 --- a/_pytest/main.py +++ b/_pytest/main.py @@ -216,6 +216,9 @@ class Node(object): #: keywords/markers collected from all scopes self.keywords = NodeKeywords(self) + #: allow adding of extra keywords to use for matching + self.extra_keyword_matches = set() + #self.extrainit() @property @@ -307,6 +310,14 @@ class Node(object): chain.reverse() return chain + def listextrakeywords(self): + """ Return a set of all extra keywords in self and any parents.""" + extra_keywords = set() + item = self + for item in self.listchain(): + extra_keywords.update(item.extra_keyword_matches) + return extra_keywords + def listnames(self): return [x.name for x in self.listchain()] diff --git a/_pytest/mark.py b/_pytest/mark.py index 7b6b2d00d..eda74bff5 100644 --- a/_pytest/mark.py +++ b/_pytest/mark.py @@ -1,44 +1,56 @@ """ generic mechanism for marking and selecting python functions. """ import pytest, py + def pytest_namespace(): return {'mark': MarkGenerator()} + def pytest_addoption(parser): group = parser.getgroup("general") - group._addoption('-k', + group._addoption( + '-k', action="store", dest="keyword", default='', metavar="EXPRESSION", help="only run tests which match the given substring expression. " "An expression is a python evaluatable expression " "where all names are substring-matched against test names " - "and keywords. Example: -k 'test_method or test_other' " - "matches all test functions whose name contains " - "'test_method' or 'test_other'.") + "and their parent classes. Example: -k 'test_method or test " + "other' matches all test functions and classes whose name " + "contains 'test_method' or 'test_other'. " + "Additionally keywords are matched to classes and functions " + "containing extra names in their 'extra_keyword_matches' set, " + "as well as functions which have names assigned directly to them." + ) - group._addoption("-m", + group._addoption( + "-m", action="store", dest="markexpr", default="", metavar="MARKEXPR", help="only run tests matching given mark expression. " "example: -m 'mark1 and not mark2'." - ) + ) - group.addoption("--markers", action="store_true", help= - "show markers (builtin, plugin and per-project ones).") + group.addoption( + "--markers", action="store_true", + help="show markers (builtin, plugin and per-project ones)." + ) parser.addini("markers", "markers for test functions", 'linelist') + def pytest_cmdline_main(config): if config.option.markers: config.pluginmanager.do_configure(config) tw = py.io.TerminalWriter() for line in config.getini("markers"): name, rest = line.split(":", 1) - tw.write("@pytest.mark.%s:" % name, bold=True) + tw.write("@pytest.mark.%s:" % name, bold=True) tw.line(rest) tw.line() config.pluginmanager.do_unconfigure(config) return 0 pytest_cmdline_main.tryfirst = True + def pytest_collection_modifyitems(items, config): keywordexpr = config.option.keyword matchexpr = config.option.markexpr @@ -67,32 +79,76 @@ def pytest_collection_modifyitems(items, config): config.hook.pytest_deselected(items=deselected) items[:] = remaining -class BoolDict: - def __init__(self, mydict): - self._mydict = mydict - def __getitem__(self, name): - return name in self._mydict -class SubstringDict: - def __init__(self, mydict): - self._mydict = mydict - def __getitem__(self, name): - for key in self._mydict: - if name in key: +class MarkMapping: + """Provides a local mapping for markers. + Only the marker names from the given :class:`NodeKeywords` will be mapped, + so the names are taken only from :class:`MarkInfo` or + :class:`MarkDecorator` items. + """ + def __init__(self, keywords): + mymarks = set() + for key, value in keywords.items(): + if isinstance(value, MarkInfo) or isinstance(value, MarkDecorator): + mymarks.add(key) + self._mymarks = mymarks + + def __getitem__(self, markname): + return markname in self._mymarks + + +class KeywordMapping: + """Provides a local mapping for keywords. + Given a list of names, map any substring of one of these names to True. + """ + def __init__(self, names): + self._names = names + + def __getitem__(self, subname): + for name in self._names: + if subname in name: return True return False -def matchmark(colitem, matchexpr): - return eval(matchexpr, {}, BoolDict(colitem.keywords)) + +def matchmark(colitem, markexpr): + """Tries to match on any marker names, attached to the given colitem.""" + return eval(markexpr, {}, MarkMapping(colitem.keywords)) + def matchkeyword(colitem, keywordexpr): + """Tries to match given keyword expression to given collector item. + + Will match on the name of colitem, including the names of its parents. + Only matches names of items which are either a :class:`Class` or a + :class:`Function`. + Additionally, matches on names in the 'extra_keyword_matches' set of + any item, as well as names directly assigned to test functions. + """ keywordexpr = keywordexpr.replace("-", "not ") - return eval(keywordexpr, {}, SubstringDict(colitem.keywords)) + mapped_names = set() + + # Add the names of the current item and any parent items + for item in colitem.listchain(): + if not isinstance(item, pytest.Instance): + mapped_names.add(item.name) + + # Add the names added as extra keywords to current or parent items + for name in colitem.listextrakeywords(): + mapped_names.add(name) + + # Add the names attached to the current function through direct assignment + for name in colitem.function.func_dict: + mapped_names.add(name) + + return eval(keywordexpr, {}, KeywordMapping(mapped_names)) + def pytest_configure(config): if config.option.strict: pytest.mark._config = config + class MarkGenerator: """ Factory for :class:`MarkDecorator` objects - exposed as a ``py.test.mark`` singleton instance. Example:: @@ -126,6 +182,7 @@ class MarkGenerator: if name not in self._markers: raise AttributeError("%r not a registered marker" % (name,)) + class MarkDecorator: """ A decorator for test functions and test classes. When applied it will create :class:`MarkInfo` objects which may be @@ -149,7 +206,7 @@ class MarkDecorator: def __repr__(self): d = self.__dict__.copy() name = d.pop('markname') - return "" %(name, d) + return "" % (name, d) def __call__(self, *args, **kwargs): """ if passed a single callable argument: decorate it with mark info. @@ -162,15 +219,17 @@ class MarkDecorator: if hasattr(func, 'pytestmark'): l = func.pytestmark if not isinstance(l, list): - func.pytestmark = [l, self] + func.pytestmark = [l, self] else: - l.append(self) + l.append(self) else: - func.pytestmark = [self] + func.pytestmark = [self] else: holder = getattr(func, self.markname, None) if holder is None: - holder = MarkInfo(self.markname, self.args, self.kwargs) + holder = MarkInfo( + self.markname, self.args, self.kwargs + ) setattr(func, self.markname, holder) else: holder.add(self.args, self.kwargs) @@ -180,6 +239,7 @@ class MarkDecorator: args = self.args + args return self.__class__(self.markname, args=args, kwargs=kw) + class MarkInfo: """ Marking object created by :class:`MarkDecorator` instances. """ def __init__(self, name, args, kwargs): @@ -193,7 +253,8 @@ class MarkInfo: def __repr__(self): return "" % ( - self.name, self.args, self.kwargs) + self.name, self.args, self.kwargs + ) def add(self, args, kwargs): """ add a MarkInfo with the given args and kwargs. """ @@ -205,4 +266,3 @@ class MarkInfo: """ yield MarkInfo objects each relating to a marking-call. """ for args, kwargs in self._arglist: yield MarkInfo(self.name, args, kwargs) - diff --git a/testing/test_mark.py b/testing/test_mark.py index 48ca5204e..3caf625b2 100644 --- a/testing/test_mark.py +++ b/testing/test_mark.py @@ -345,6 +345,24 @@ class TestFunctional: assert l[0].args == ("pos0",) assert l[1].args == ("pos1",) + def test_no_marker_match_on_unmarked_names(self, testdir): + p = testdir.makepyfile(""" + import pytest + @pytest.mark.shouldmatch + def test_marked(): + assert 1 + + def test_unmarked(): + assert 1 + """) + reprec = testdir.inline_run("-m", "test_unmarked", p) + passed, skipped, failed = reprec.listoutcomes() + assert len(passed) + len(skipped) + len(failed) == 0 + dlist = reprec.getcalls("pytest_deselected") + deselected_tests = dlist[0].items + assert len(deselected_tests) == 2 + + def test_keywords_at_node_level(self, testdir): p = testdir.makepyfile(""" import pytest @@ -382,7 +400,6 @@ class TestKeywordSelection: assert len(reprec.getcalls('pytest_deselected')) == 1 for keyword in ['test_one', 'est_on']: - #yield check, keyword, 'test_one' check(keyword, 'test_one') check('TestClass and test', 'test_method_one') @@ -401,7 +418,7 @@ class TestKeywordSelection: def pytest_pycollect_makeitem(__multicall__, name): if name == "TestClass": item = __multicall__.execute() - item.keywords["xxx"] = True + item.extra_keyword_matches.add("xxx") return item """) reprec = testdir.inline_run(p.dirpath(), '-s', '-k', keyword) @@ -440,3 +457,22 @@ class TestKeywordSelection: reprec = testdir.inline_run("-k", "mykeyword", p) passed, skipped, failed = reprec.countoutcomes() assert failed == 1 + + def test_no_magic_values(self, testdir): + """Make sure the tests do not match on magic values, + no double underscored values, like '__dict__', + and no instance values, like '()'. + """ + p = testdir.makepyfile(""" + def test_one(): assert 1 + """) + def assert_test_is_not_selected(keyword): + reprec = testdir.inline_run("-k", keyword, p) + passed, skipped, failed = reprec.countoutcomes() + dlist = reprec.getcalls("pytest_deselected") + assert passed + skipped + failed == 0 + deselected_tests = dlist[0].items + assert len(deselected_tests) == 1 + + assert_test_is_not_selected("__") + assert_test_is_not_selected("()")