From fe27f3cc7d5e2a07ec79f08713ad42b4c74b0ed6 Mon Sep 17 00:00:00 2001 From: Wouter van Ackooy Date: Mon, 20 May 2013 14:37:58 +0200 Subject: [PATCH 1/6] Fixed issue #306: Keywords and markers are now matched in a defined way. Also applied some pep8 formatting while fixing. --- _pytest/main.py | 12 +++++ _pytest/mark.py | 122 ++++++++++++++++++++++++++++++++----------- testing/test_mark.py | 3 +- 3 files changed, 104 insertions(+), 33 deletions(-) diff --git a/_pytest/main.py b/_pytest/main.py index 95b2359bc..a039cf221 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 = [] + #self.extrainit() @property @@ -307,6 +310,15 @@ class Node(object): chain.reverse() return chain + def listextrakeywords(self): + """ Return a list of all extra keywords in self and any parents.""" + extra_keywords = [] + item = self + while item is not None: + extra_keywords.extend(item.extra_keyword_matches) + item = item.parent + 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..d5981553b 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'.") + "where all names are substring-matched against test names" + "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' list, " + "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 = [] + for key, value in keywords.items(): + if isinstance(value, MarkInfo) or isinstance(value, MarkDecorator): + mymarks.append(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' list of + any item, as well as names directly assigned to test functions. + """ keywordexpr = keywordexpr.replace("-", "not ") - return eval(keywordexpr, {}, SubstringDict(colitem.keywords)) + mapped_names = [] + + # Add the names of the current item and any parent items + for item in colitem.listchain(): + if isinstance(item, pytest.Class) or isinstance(item, pytest.Function): + mapped_names.append(item.name) + + # Add the names added as extra keywords to current or parent items + for name in colitem.listextrakeywords(): + mapped_names.append(name) + + # Add the names attached to the current function through direct assignment + for name in colitem.function.func_dict: + mapped_names.append(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..68788ead4 100644 --- a/testing/test_mark.py +++ b/testing/test_mark.py @@ -382,7 +382,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 +400,7 @@ class TestKeywordSelection: def pytest_pycollect_makeitem(__multicall__, name): if name == "TestClass": item = __multicall__.execute() - item.keywords["xxx"] = True + item.extra_keyword_matches.append("xxx") return item """) reprec = testdir.inline_run(p.dirpath(), '-s', '-k', keyword) From 02511d1564aa963d0bc5dbd625296eb6e0c175ae Mon Sep 17 00:00:00 2001 From: Wouter van Ackooy Date: Wed, 22 May 2013 07:41:46 +0200 Subject: [PATCH 2/6] Added lost space. --- _pytest/mark.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/_pytest/mark.py b/_pytest/mark.py index d5981553b..5f3d880d5 100644 --- a/_pytest/mark.py +++ b/_pytest/mark.py @@ -13,7 +13,7 @@ def pytest_addoption(parser): 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" + "where all names are substring-matched against test names " "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'. " From 583c736f0c006642414dd9fab2ce607c677fbf91 Mon Sep 17 00:00:00 2001 From: Wouter van Ackooy Date: Thu, 23 May 2013 09:12:50 +0200 Subject: [PATCH 3/6] Added a test to check there is no matching on magic values. --- testing/test_mark.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/testing/test_mark.py b/testing/test_mark.py index 68788ead4..bbddf5476 100644 --- a/testing/test_mark.py +++ b/testing/test_mark.py @@ -439,3 +439,21 @@ 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 + assert len(dlist) == 1 + + assert_test_is_not_selected("__") + assert_test_is_not_selected("()") From 72afbbbd710ea2a3e984cedd159ad0d77bf45f1d Mon Sep 17 00:00:00 2001 From: Wouter van Ackooy Date: Thu, 23 May 2013 12:21:40 +0200 Subject: [PATCH 4/6] Added new test to check on matching markers to full test names, which was possible before. Also adjusted check on number of deselected tests. --- testing/test_mark.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/testing/test_mark.py b/testing/test_mark.py index bbddf5476..cc5b3290e 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 @@ -453,7 +471,8 @@ class TestKeywordSelection: passed, skipped, failed = reprec.countoutcomes() dlist = reprec.getcalls("pytest_deselected") assert passed + skipped + failed == 0 - assert len(dlist) == 1 + deselected_tests = dlist[0].items + assert len(deselected_tests) == 1 assert_test_is_not_selected("__") assert_test_is_not_selected("()") From 60906f7a46863416b67d329d45e39bcb01d5a28c Mon Sep 17 00:00:00 2001 From: Wouter van Ackooy Date: Mon, 27 May 2013 17:58:39 +0200 Subject: [PATCH 5/6] Issue 306: Use the names of all the parents in the chain for matching, except the Instance objects. --- _pytest/mark.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/_pytest/mark.py b/_pytest/mark.py index 5f3d880d5..67ff92817 100644 --- a/_pytest/mark.py +++ b/_pytest/mark.py @@ -130,7 +130,7 @@ def matchkeyword(colitem, keywordexpr): # Add the names of the current item and any parent items for item in colitem.listchain(): - if isinstance(item, pytest.Class) or isinstance(item, pytest.Function): + if not isinstance(item, pytest.Instance): mapped_names.append(item.name) # Add the names added as extra keywords to current or parent items From 212f4b4d64247a190ec25fd8c39f34dfa090e406 Mon Sep 17 00:00:00 2001 From: Wouter van Ackooy Date: Mon, 27 May 2013 18:14:35 +0200 Subject: [PATCH 6/6] Issue 306: Used a set for the extra_keywords, and used listchain for parent iteration. --- _pytest/main.py | 11 +++++------ _pytest/mark.py | 16 ++++++++-------- testing/test_mark.py | 2 +- 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/_pytest/main.py b/_pytest/main.py index a039cf221..9ef47faf9 100644 --- a/_pytest/main.py +++ b/_pytest/main.py @@ -217,7 +217,7 @@ class Node(object): self.keywords = NodeKeywords(self) #: allow adding of extra keywords to use for matching - self.extra_keyword_matches = [] + self.extra_keyword_matches = set() #self.extrainit() @@ -311,12 +311,11 @@ class Node(object): return chain def listextrakeywords(self): - """ Return a list of all extra keywords in self and any parents.""" - extra_keywords = [] + """ Return a set of all extra keywords in self and any parents.""" + extra_keywords = set() item = self - while item is not None: - extra_keywords.extend(item.extra_keyword_matches) - item = item.parent + for item in self.listchain(): + extra_keywords.update(item.extra_keyword_matches) return extra_keywords def listnames(self): diff --git a/_pytest/mark.py b/_pytest/mark.py index 67ff92817..eda74bff5 100644 --- a/_pytest/mark.py +++ b/_pytest/mark.py @@ -18,7 +18,7 @@ def pytest_addoption(parser): "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' list, " + "containing extra names in their 'extra_keyword_matches' set, " "as well as functions which have names assigned directly to them." ) @@ -87,10 +87,10 @@ class MarkMapping: :class:`MarkDecorator` items. """ def __init__(self, keywords): - mymarks = [] + mymarks = set() for key, value in keywords.items(): if isinstance(value, MarkInfo) or isinstance(value, MarkDecorator): - mymarks.append(key) + mymarks.add(key) self._mymarks = mymarks def __getitem__(self, markname): @@ -122,24 +122,24 @@ def matchkeyword(colitem, keywordexpr): 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' list of + 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 ") - mapped_names = [] + 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.append(item.name) + mapped_names.add(item.name) # Add the names added as extra keywords to current or parent items for name in colitem.listextrakeywords(): - mapped_names.append(name) + mapped_names.add(name) # Add the names attached to the current function through direct assignment for name in colitem.function.func_dict: - mapped_names.append(name) + mapped_names.add(name) return eval(keywordexpr, {}, KeywordMapping(mapped_names)) diff --git a/testing/test_mark.py b/testing/test_mark.py index cc5b3290e..3caf625b2 100644 --- a/testing/test_mark.py +++ b/testing/test_mark.py @@ -418,7 +418,7 @@ class TestKeywordSelection: def pytest_pycollect_makeitem(__multicall__, name): if name == "TestClass": item = __multicall__.execute() - item.extra_keyword_matches.append("xxx") + item.extra_keyword_matches.add("xxx") return item """) reprec = testdir.inline_run(p.dirpath(), '-s', '-k', keyword)