From 91b6f2bda8668e0f74190ed223a9eec01d09a0a7 Mon Sep 17 00:00:00 2001 From: holger krekel Date: Mon, 25 Jun 2012 17:35:33 +0200 Subject: [PATCH] mid-scale refactoring to make request API available directly on items. This commit was slightly tricky because i want to backward compatibility especially for the oejskit plugin which uses Funcarg-filling for non-Function objects. --- CHANGELOG | 16 +- _pytest/__init__.py | 2 +- _pytest/capture.py | 6 +- _pytest/main.py | 177 ++++++++++++++- _pytest/pytester.py | 2 +- _pytest/python.py | 449 +++++++++++++++----------------------- _pytest/resultlog.py | 4 +- _pytest/tmpdir.py | 6 +- doc/en/conf.py | 9 +- doc/en/funcargs.txt | 69 +++--- doc/en/plugins.txt | 52 +++-- setup.py | 2 +- testing/test_assertion.py | 46 ++-- testing/test_python.py | 81 ++++++- testing/test_tmpdir.py | 5 +- tox.ini | 2 +- 16 files changed, 529 insertions(+), 399 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 6701d7478..a6f487f6a 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,19 @@ -Changes between 2.2.4 and 2.2.5.dev +Changes between 2.2.4 and 2.3.0.dev ----------------------------------- +- merge FuncargRequest and Item API such that funcarg-functionality + is now natively available on the "item" object passed to the various + pytest_runtest hooks. This allows more sensitive behaviour + of e.g. the pytest-django plugin which previously had no full + access to all instantiated funcargs. + This internal API re-organisation is a fully backward compatible + change: existing factories accepting a "request" object will + get a Function "item" object which carries the same API. In fact, + the FuncargRequest API (or rather then a ResourceRequestAPI) + could be available for all collection and item nodes but this is + left for later consideration because it would render the documentation + invalid and the "funcarg" naming sounds odd in context of + directory, file, class, etc. nodes. - catch unicode-issues when writing failure representations to terminal to prevent the whole session from crashing - fix xfail/skip confusion: a skip-mark or an imperative pytest.skip @@ -23,6 +36,7 @@ Changes between 2.2.4 and 2.2.5.dev pytest-django, trial and unittest integration. - reporting refinements: + - pytest_report_header now receives a "startdir" so that you can use startdir.bestrelpath(yourpath) to show nice relative path diff --git a/_pytest/__init__.py b/_pytest/__init__.py index 7f23ddf30..9405eb038 100644 --- a/_pytest/__init__.py +++ b/_pytest/__init__.py @@ -1,2 +1,2 @@ # -__version__ = '2.2.5.dev4' +__version__ = '2.3.0.dev1' diff --git a/_pytest/capture.py b/_pytest/capture.py index b59e7b2a9..ea908da91 100644 --- a/_pytest/capture.py +++ b/_pytest/capture.py @@ -119,7 +119,7 @@ class CaptureManager: return "", "" def activate_funcargs(self, pyfuncitem): - if hasattr(pyfuncitem, 'funcargs'): + if pyfuncitem.funcargs: for name, capfuncarg in pyfuncitem.funcargs.items(): if name in ('capsys', 'capfd'): assert not hasattr(self, '_capturing_funcarg') @@ -186,7 +186,7 @@ def pytest_funcarg__capsys(request): captured output available via ``capsys.readouterr()`` method calls which return a ``(out, err)`` tuple. """ - if "capfd" in request._funcargs: + if "capfd" in request.funcargs: raise request.LookupError(error_capsysfderror) return CaptureFuncarg(py.io.StdCapture) @@ -195,7 +195,7 @@ def pytest_funcarg__capfd(request): captured output available via ``capsys.readouterr()`` method calls which return a ``(out, err)`` tuple. """ - if "capsys" in request._funcargs: + if "capsys" in request.funcargs: raise request.LookupError(error_capsysfderror) if not hasattr(os, 'dup'): pytest.skip("capfd funcarg needs os.dup") diff --git a/_pytest/main.py b/_pytest/main.py index e7cdc1454..89b6352f1 100644 --- a/_pytest/main.py +++ b/_pytest/main.py @@ -3,6 +3,8 @@ import py import pytest, _pytest import os, sys, imp +from _pytest.monkeypatch import monkeypatch + tracebackcutdir = py.path.local(_pytest.__file__).dirpath() # exitcodes for the command line @@ -144,33 +146,162 @@ class HookProxy: def compatproperty(name): def fget(self): + # deprecated - use pytest.name return getattr(pytest, name) - return property(fget, None, None, - "deprecated attribute %r, use pytest.%s" % (name, name)) + return property(fget) + +def pyobj_property(name): + def get(self): + node = self.getparent(getattr(pytest, name)) + if node is not None: + return node.obj + doc = "python %s object this node was collected from (can be None)." % ( + name.lower(),) + return property(get, None, None, doc) + +class Request(object): + _argprefix = "pytest_funcarg__" + + class LookupError(LookupError): + """ error while performing funcarg factory lookup. """ + + def _initattr(self): + self._name2factory = {} + self._currentarg = None + + @property + def _plugins(self): + extra = [obj for obj in (self.module, self.instance) if obj] + return self.getplugins() + extra + + def _getscopeitem(self, scope): + if scope == "function": + return self + elif scope == "session": + return None + elif scope == "class": + x = self.getparent(pytest.Class) + if x is not None: + return x + scope = "module" + if scope == "module": + return self.getparent(pytest.Module) + raise ValueError("unknown scope %r" %(scope,)) + + def getfuncargvalue(self, argname): + """ Retrieve a named function argument value. + + This function looks up a matching factory and invokes + it to obtain the return value. The factory receives + the same request object and can itself perform recursive + calls to this method, effectively allowing to make use of + multiple other funcarg values or to decorate values from + other name-matching factories. + """ + try: + return self.funcargs[argname] + except KeyError: + pass + except TypeError: + self.funcargs = getattr(self, "_funcargs", {}) + if argname not in self._name2factory: + self._name2factory[argname] = self.config.pluginmanager.listattr( + plugins=self._plugins, + attrname=self._argprefix + str(argname) + ) + #else: we are called recursively + if not self._name2factory[argname]: + self._raiselookupfailed(argname) + funcargfactory = self._name2factory[argname].pop() + oldarg = self._currentarg + mp = monkeypatch() + mp.setattr(self, '_currentarg', argname) + try: + param = self.callspec.getparam(argname) + except (AttributeError, ValueError): + pass + else: + mp.setattr(self, 'param', param, raising=False) + try: + self.funcargs[argname] = res = funcargfactory(self) + finally: + mp.undo() + return res + + def addfinalizer(self, finalizer): + """ add a no-args finalizer function to be called when the underlying + node is torn down.""" + self.session._setupstate.addfinalizer(finalizer, self) + + def cached_setup(self, setup, teardown=None, + scope="module", extrakey=None): + """ Return a cached testing resource created by ``setup`` & + detroyed by a respective ``teardown(resource)`` call. + + :arg teardown: function receiving a previously setup resource. + :arg setup: a no-argument function creating a resource. + :arg scope: a string value out of ``function``, ``class``, ``module`` + or ``session`` indicating the caching lifecycle of the resource. + :arg extrakey: added to internal caching key. + """ + if not hasattr(self.config, '_setupcache'): + self.config._setupcache = {} # XXX weakref? + colitem = self._getscopeitem(scope) + cachekey = (self._currentarg, colitem, extrakey) + cache = self.config._setupcache + try: + val = cache[cachekey] + except KeyError: + val = setup() + cache[cachekey] = val + if teardown is not None: + def finalizer(): + del cache[cachekey] + teardown(val) + self.session._setupstate.addfinalizer(finalizer, colitem) + return val + + def _raiselookupfailed(self, argname): + available = [] + for plugin in self._plugins: + for name in vars(plugin): + if name.startswith(self._argprefix): + name = name[len(self._argprefix):] + if name not in available: + available.append(name) + fspath, lineno, msg = self.reportinfo() + msg = "LookupError: no factory found for function argument %r" % (argname,) + msg += "\n available funcargs: %s" %(", ".join(available),) + msg += "\n use 'py.test --funcargs [testpath]' for help on them." + raise self.LookupError(msg) class Node(object): - """ base class for all Nodes in the collection tree. + """ base class for Collector and Item the test collection tree. Collector subclasses have children, Items are terminal nodes.""" def __init__(self, name, parent=None, config=None, session=None): - #: a unique name with the scope of the parent + #: a unique name within the scope of the parent node self.name = name #: the parent collector node. self.parent = parent - #: the test config object + #: the pytest config object self.config = config or parent.config - #: the collection this node is part of + #: the session this node is part of self.session = session or parent.session - #: filesystem path where this node was collected from + #: filesystem path where this node was collected from (can be None) self.fspath = getattr(parent, 'fspath', None) - self.ihook = self.session.gethookproxy(self.fspath) + + #: keywords on this node (node name is always contained) self.keywords = {self.name: True} + #: fspath sensitive hook proxy used to call pytest hooks + self.ihook = self.session.gethookproxy(self.fspath) + Module = compatproperty("Module") Class = compatproperty("Class") Instance = compatproperty("Instance") @@ -178,6 +309,11 @@ class Node(object): File = compatproperty("File") Item = compatproperty("Item") + module = pyobj_property("Module") + cls = pyobj_property("Class") + instance = pyobj_property("Instance") + + def _getcustomclass(self, name): cls = getattr(self, name) if cls != getattr(pytest, name): @@ -193,12 +329,14 @@ class Node(object): # methods for ordering nodes @property def nodeid(self): + """ a ::-separated string denoting its collection tree address. """ try: return self._nodeid except AttributeError: self._nodeid = x = self._makeid() return x + def _makeid(self): return self.parent.nodeid + "::" + self.name @@ -338,15 +476,36 @@ class FSCollector(Collector): class File(FSCollector): """ base class for collecting tests from a file. """ -class Item(Node): +class Item(Node, Request): """ a basic test invocation item. Note that for a single function there might be multiple test invocation items. """ nextitem = None + def __init__(self, name, parent=None, config=None, session=None): + super(Item, self).__init__(name, parent, config, session) + self._initattr() + self.funcargs = None # later set to a dict from fillfuncargs() or + # from getfuncargvalue(). Setting it to + # None prevents users from performing + # "name in item.funcargs" checks too early. + def reportinfo(self): return self.fspath, None, "" + def applymarker(self, marker): + """ Apply a marker to this item. This method is + useful if you have several parametrized function + and want to mark a single one of them. + + :arg marker: a :py:class:`_pytest.mark.MarkDecorator` object + created by a call to ``py.test.mark.NAME(...)``. + """ + if not isinstance(marker, pytest.mark.XYZ.__class__): + raise ValueError("%r is not a py.test.mark.* object") + self.keywords[marker.markname] = marker + + @property def location(self): try: diff --git a/_pytest/pytester.py b/_pytest/pytester.py index a7aee998f..2edc04ec4 100644 --- a/_pytest/pytester.py +++ b/_pytest/pytester.py @@ -318,7 +318,7 @@ class TmpTestdir: # used from runner functional tests item = self.getitem(source) # the test class where we are called from wants to provide the runner - testclassinstance = py.builtin._getimself(self.request.function) + testclassinstance = self.request.instance runner = testclassinstance.getrunner() return runner(item) diff --git a/_pytest/python.py b/_pytest/python.py index 3f1924f44..3cb6f4e33 100644 --- a/_pytest/python.py +++ b/_pytest/python.py @@ -4,11 +4,27 @@ import inspect import sys import pytest from py._code.code import TerminalRepr -from _pytest.monkeypatch import monkeypatch +from _pytest.main import Request, Item import _pytest cutdir = py.path.local(_pytest.__file__).dirpath() +def cached_property(f): + """returns a cached property that is calculated by function f. + taken from http://code.activestate.com/recipes/576563-cached-property/""" + def get(self): + try: + return self._property_cache[f] + except AttributeError: + self._property_cache = {} + x = self._property_cache[f] = f(self) + return x + except KeyError: + x = self._property_cache[f] = f(self) + return x + return property(get) + + def pytest_addoption(parser): group = parser.getgroup("general") group.addoption('--funcargs', @@ -60,13 +76,21 @@ def pytest_funcarg__pytestconfig(request): """ the pytest config object with access to command line opts.""" return request.config + def pytest_pyfunc_call(__multicall__, pyfuncitem): if not __multicall__.execute(): testfunction = pyfuncitem.obj if pyfuncitem._isyieldedfunction(): testfunction(*pyfuncitem._args) else: - funcargs = pyfuncitem.funcargs + try: + funcargnames = pyfuncitem.funcargnames + except AttributeError: + funcargs = pyfuncitem.funcargs + else: + funcargs = {} + for name in funcargnames: + funcargs[name] = pyfuncitem.funcargs[name] testfunction(**funcargs) def pytest_collect_file(path, parent): @@ -110,6 +134,7 @@ def is_generator(func): return False class PyobjMixin(object): + def obj(): def fget(self): try: @@ -232,12 +257,15 @@ class PyCollectorMixin(PyobjMixin, pytest.Collector): gentesthook.pcall(plugins, metafunc=metafunc) Function = self._getcustomclass("Function") if not metafunc._calls: - return Function(name, parent=self) + return Function(name, parent=self, + funcargnames=metafunc.funcargnames) l = [] for callspec in metafunc._calls: subname = "%s[%s]" %(name, callspec.id) function = Function(name=subname, parent=self, - callspec=callspec, callobj=funcobj, keywords={callspec.id:True}) + callspec=callspec, callobj=funcobj, + funcargnames=metafunc.funcargnames, + keywords={callspec.id:True}) l.append(function) return l @@ -256,6 +284,7 @@ def transfer_markers(funcobj, cls, mod): pytestmark(funcobj) class Module(pytest.File, PyCollectorMixin): + """ Collector for test classes and functions. """ def _getobj(self): return self._memoizedcall('_obj', self._importtestmodule) @@ -303,7 +332,7 @@ class Module(pytest.File, PyCollectorMixin): self.obj.teardown_module() class Class(PyCollectorMixin, pytest.Collector): - + """ Collector for test methods. """ def collect(self): return [self._getcustomclass("Instance")(name="()", parent=self)] @@ -373,7 +402,7 @@ class FunctionMixin(PyobjMixin): excinfo.traceback = ntraceback.filter() def _repr_failure_py(self, excinfo, style="long"): - if excinfo.errisinstance(FuncargRequest.LookupError): + if excinfo.errisinstance(Request.LookupError): fspath, lineno, msg = self.reportinfo() lines, _ = inspect.getsourcelines(self.obj) for i, line in enumerate(lines): @@ -445,77 +474,6 @@ class Generator(FunctionMixin, PyCollectorMixin, pytest.Collector): return name, call, args -# -# Test Items -# -_dummy = object() -class Function(FunctionMixin, pytest.Item): - """ a Function Item is responsible for setting up - and executing a Python callable test object. - """ - _genid = None - def __init__(self, name, parent=None, args=None, config=None, - callspec=None, callobj=_dummy, keywords=None, session=None): - super(Function, self).__init__(name, parent, - config=config, session=session) - self._args = args - if self._isyieldedfunction(): - assert not callspec, ( - "yielded functions (deprecated) cannot have funcargs") - else: - if callspec is not None: - self.callspec = callspec - self.funcargs = callspec.funcargs or {} - self._genid = callspec.id - if hasattr(callspec, "param"): - self._requestparam = callspec.param - else: - self.funcargs = {} - if callobj is not _dummy: - self._obj = callobj - self.function = getattr(self.obj, 'im_func', self.obj) - self.keywords.update(py.builtin._getfuncdict(self.obj) or {}) - if keywords: - self.keywords.update(keywords) - - def _getobj(self): - name = self.name - i = name.find("[") # parametrization - if i != -1: - name = name[:i] - return getattr(self.parent.obj, name) - - def _isyieldedfunction(self): - return self._args is not None - - def runtest(self): - """ execute the underlying test function. """ - self.ihook.pytest_pyfunc_call(pyfuncitem=self) - - def setup(self): - super(Function, self).setup() - if hasattr(self, 'funcargs'): - fillfuncargs(self) - - def __eq__(self, other): - try: - return (self.name == other.name and - self._args == other._args and - self.parent == other.parent and - self.obj == other.obj and - getattr(self, '_genid', None) == - getattr(other, '_genid', None) - ) - except AttributeError: - pass - return False - - def __ne__(self, other): - return not self == other - - def __hash__(self): - return hash((self.parent, self.name)) - def hasinit(obj): init = getattr(obj, '__init__', None) if init: @@ -535,10 +493,20 @@ def getfuncargnames(function, startindex=None): return argnames[startindex:-numdefaults] return argnames[startindex:] -def fillfuncargs(function): +def fillfuncargs(node): """ fill missing funcargs. """ - request = FuncargRequest(pyfuncitem=function) - request._fillfuncargs() + if not isinstance(node, Function): + node = FuncargRequest(pyfuncitem=node) + if node.funcargs is None: + node.funcargs = getattr(node, "_funcargs", {}) + if not isinstance(node, Function) or not node._isyieldedfunction(): + try: + funcargnames = node.funcargnames + except AttributeError: + funcargnames = getfuncargnames(node.function) + if funcargnames: + for argname in funcargnames: + node.getfuncargvalue(argname) _notexists = object() @@ -697,195 +665,6 @@ class IDMaker: l.append(str(val)) return "-".join(l) -class FuncargRequest: - """ A request for function arguments from a test function. - - Note that there is an optional ``param`` attribute in case - there was an invocation to metafunc.addcall(param=...). - If no such call was done in a ``pytest_generate_tests`` - hook, the attribute will not be present. - """ - _argprefix = "pytest_funcarg__" - _argname = None - - class LookupError(LookupError): - """ error on performing funcarg request. """ - - def __init__(self, pyfuncitem): - self._pyfuncitem = pyfuncitem - if hasattr(pyfuncitem, '_requestparam'): - self.param = pyfuncitem._requestparam - extra = [obj for obj in (self.module, self.instance) if obj] - self._plugins = pyfuncitem.getplugins() + extra - self._funcargs = self._pyfuncitem.funcargs.copy() - self._name2factory = {} - self._currentarg = None - - @property - def function(self): - """ function object of the test invocation. """ - return self._pyfuncitem.obj - - @property - def keywords(self): - """ keywords of the test function item. - - .. versionadded:: 2.0 - """ - return self._pyfuncitem.keywords - - @property - def module(self): - """ module where the test function was collected. """ - return self._pyfuncitem.getparent(pytest.Module).obj - - @property - def cls(self): - """ class (can be None) where the test function was collected. """ - clscol = self._pyfuncitem.getparent(pytest.Class) - if clscol: - return clscol.obj - @property - def instance(self): - """ instance (can be None) on which test function was collected. """ - return py.builtin._getimself(self.function) - - @property - def config(self): - """ the pytest config object associated with this request. """ - return self._pyfuncitem.config - - @property - def fspath(self): - """ the file system path of the test module which collected this test. """ - return self._pyfuncitem.fspath - - def _fillfuncargs(self): - argnames = getfuncargnames(self.function) - if argnames: - assert not getattr(self._pyfuncitem, '_args', None), ( - "yielded functions cannot have funcargs") - for argname in argnames: - if argname not in self._pyfuncitem.funcargs: - self._pyfuncitem.funcargs[argname] = self.getfuncargvalue(argname) - - - def applymarker(self, marker): - """ Apply a marker to a single test function invocation. - This method is useful if you don't want to have a keyword/marker - on all function invocations. - - :arg marker: a :py:class:`_pytest.mark.MarkDecorator` object - created by a call to ``py.test.mark.NAME(...)``. - """ - if not isinstance(marker, py.test.mark.XYZ.__class__): - raise ValueError("%r is not a py.test.mark.* object") - self._pyfuncitem.keywords[marker.markname] = marker - - def cached_setup(self, setup, teardown=None, scope="module", extrakey=None): - """ Return a testing resource managed by ``setup`` & - ``teardown`` calls. ``scope`` and ``extrakey`` determine when the - ``teardown`` function will be called so that subsequent calls to - ``setup`` would recreate the resource. - - :arg teardown: function receiving a previously setup resource. - :arg setup: a no-argument function creating a resource. - :arg scope: a string value out of ``function``, ``class``, ``module`` - or ``session`` indicating the caching lifecycle of the resource. - :arg extrakey: added to internal caching key of (funcargname, scope). - """ - if not hasattr(self.config, '_setupcache'): - self.config._setupcache = {} # XXX weakref? - cachekey = (self._currentarg, self._getscopeitem(scope), extrakey) - cache = self.config._setupcache - try: - val = cache[cachekey] - except KeyError: - val = setup() - cache[cachekey] = val - if teardown is not None: - def finalizer(): - del cache[cachekey] - teardown(val) - self._addfinalizer(finalizer, scope=scope) - return val - - def getfuncargvalue(self, argname): - """ Retrieve a function argument by name for this test - function invocation. This allows one function argument factory - to call another function argument factory. If there are two - funcarg factories for the same test function argument the first - factory may use ``getfuncargvalue`` to call the second one and - do something additional with the resource. - """ - try: - return self._funcargs[argname] - except KeyError: - pass - if argname not in self._name2factory: - self._name2factory[argname] = self.config.pluginmanager.listattr( - plugins=self._plugins, - attrname=self._argprefix + str(argname) - ) - #else: we are called recursively - if not self._name2factory[argname]: - self._raiselookupfailed(argname) - funcargfactory = self._name2factory[argname].pop() - oldarg = self._currentarg - mp = monkeypatch() - mp.setattr(self, '_currentarg', argname) - try: - param = self._pyfuncitem.callspec.getparam(argname) - except (AttributeError, ValueError): - pass - else: - mp.setattr(self, 'param', param, raising=False) - try: - self._funcargs[argname] = res = funcargfactory(request=self) - finally: - mp.undo() - return res - - def _getscopeitem(self, scope): - if scope == "function": - return self._pyfuncitem - elif scope == "session": - return None - elif scope == "class": - x = self._pyfuncitem.getparent(pytest.Class) - if x is not None: - return x - scope = "module" - if scope == "module": - return self._pyfuncitem.getparent(pytest.Module) - raise ValueError("unknown finalization scope %r" %(scope,)) - - def addfinalizer(self, finalizer): - """add finalizer function to be called after test function - finished execution. """ - self._addfinalizer(finalizer, scope="function") - - def _addfinalizer(self, finalizer, scope): - colitem = self._getscopeitem(scope) - self._pyfuncitem.session._setupstate.addfinalizer( - finalizer=finalizer, colitem=colitem) - - def __repr__(self): - return "" %(self._pyfuncitem) - - def _raiselookupfailed(self, argname): - available = [] - for plugin in self._plugins: - for name in vars(plugin): - if name.startswith(self._argprefix): - name = name[len(self._argprefix):] - if name not in available: - available.append(name) - fspath, lineno, msg = self._pyfuncitem.reportinfo() - msg = "LookupError: no factory found for function argument %r" % (argname,) - msg += "\n available funcargs: %s" %(", ".join(available),) - msg += "\n use 'py.test --funcargs [testpath]' for help on them." - raise self.LookupError(msg) def showfuncargs(config): from _pytest.main import wrap_session @@ -903,8 +682,8 @@ def _showfuncargs_main(config, session): for plugin in plugins: available = [] for name, factory in vars(plugin).items(): - if name.startswith(FuncargRequest._argprefix): - name = name[len(FuncargRequest._argprefix):] + if name.startswith(Request._argprefix): + name = name[len(Request._argprefix):] if name not in available: available.append([name, factory]) if available: @@ -1009,3 +788,131 @@ class RaisesContext(object): self.excinfo.__init__(tp) return issubclass(self.excinfo.type, self.ExpectedException) +# +# the basic py.test Function item +# +_dummy = object() +class Function(FunctionMixin, pytest.Item): + """ a Function Item is responsible for setting up and executing a + Python test function. + """ + _genid = None + def __init__(self, name, parent=None, args=None, config=None, + callspec=None, callobj=_dummy, keywords=None, + session=None, funcargnames=()): + super(Function, self).__init__(name, parent, config=config, + session=session) + self.funcargnames = funcargnames + self._args = args + if self._isyieldedfunction(): + assert not callspec, ( + "yielded functions (deprecated) cannot have funcargs") + else: + if callspec is not None: + self.callspec = callspec + self._funcargs = callspec.funcargs or {} + self._genid = callspec.id + if hasattr(callspec, "param"): + self.param = callspec.param + if callobj is not _dummy: + self._obj = callobj + + self.keywords.update(py.builtin._getfuncdict(self.obj) or {}) + if keywords: + self.keywords.update(keywords) + + @property + def function(self): + "underlying python 'function' object" + return getattr(self.obj, 'im_func', self.obj) + + def _getobj(self): + name = self.name + i = name.find("[") # parametrization + if i != -1: + name = name[:i] + return getattr(self.parent.obj, name) + + @property + def _pyfuncitem(self): + "(compatonly) for code expecting pytest-2.2 style request objects" + return self + + def _isyieldedfunction(self): + return getattr(self, "_args", None) is not None + + def runtest(self): + """ execute the underlying test function. """ + self.ihook.pytest_pyfunc_call(pyfuncitem=self) + + def setup(self): + super(Function, self).setup() + fillfuncargs(self) + + def __eq__(self, other): + try: + return (self.name == other.name and + self._args == other._args and + self.parent == other.parent and + self.obj == other.obj and + getattr(self, '_genid', None) == + getattr(other, '_genid', None) + ) + except AttributeError: + pass + return False + + def __ne__(self, other): + return not self == other + + def __hash__(self): + return hash((self.parent, self.name)) + + +def itemapi_property(name, set=False): + prop = getattr(Function, name, None) + doc = getattr(prop, "__doc__", None) + def get(self): + return getattr(self._pyfuncitem, name) + if set: + def set(self, value): + setattr(self._pyfuncitem, name, value) + else: + set = None + return property(get, set, None, doc) + + +class FuncargRequest(Request): + """ (deprecated) helper interactions with a test function invocation. + + Note that there is an optional ``param`` attribute in case + there was an invocation to metafunc.addcall(param=...). + If no such call was done in a ``pytest_generate_tests`` + hook, the attribute will not be present. + """ + def __init__(self, pyfuncitem): + self._pyfuncitem = pyfuncitem + Request._initattr(self) + self.getplugins = self._pyfuncitem.getplugins + self.reportinfo = self._pyfuncitem.reportinfo + try: + self.param = self._pyfuncitem.param + except AttributeError: + pass + + def __repr__(self): + return "" % (self._pyfuncitem.name) + + _getscopeitem = itemapi_property("_getscopeitem") + funcargs = itemapi_property("funcargs", set=True) + keywords = itemapi_property("keywords") + module = itemapi_property("module") + cls = itemapi_property("cls") + instance = itemapi_property("instance") + config = itemapi_property("config") + session = itemapi_property("session") + fspath = itemapi_property("fspath") + applymarker = itemapi_property("applymarker") + @property + def function(self): + return self._pyfuncitem.obj diff --git a/_pytest/resultlog.py b/_pytest/resultlog.py index 94ac67a7d..892a880b3 100644 --- a/_pytest/resultlog.py +++ b/_pytest/resultlog.py @@ -1,4 +1,6 @@ -""" (disabled by default) create result information in a plain text file. """ +""" log machine-parseable test session result information in a plain +text file. +""" import py diff --git a/_pytest/tmpdir.py b/_pytest/tmpdir.py index c67f4389e..0d0cf4bf3 100644 --- a/_pytest/tmpdir.py +++ b/_pytest/tmpdir.py @@ -54,15 +54,15 @@ def pytest_configure(config): mp.setattr(config, '_tmpdirhandler', t, raising=False) mp.setattr(pytest, 'ensuretemp', t.ensuretemp, raising=False) -def pytest_funcarg__tmpdir(request): +def pytest_funcarg__tmpdir(item): """return a temporary directory path object which is unique to each test function invocation, created as a sub directory of the base temporary directory. The returned object is a `py.path.local`_ path object. """ - name = request._pyfuncitem.name + name = item.name name = py.std.re.sub("[\W]", "_", name) - x = request.config._tmpdirhandler.mktemp(name, numbered=True) + x = item.config._tmpdirhandler.mktemp(name, numbered=True) return x diff --git a/doc/en/conf.py b/doc/en/conf.py index 58705744d..85a68c4e6 100644 --- a/doc/en/conf.py +++ b/doc/en/conf.py @@ -17,7 +17,7 @@ # # The full version, including alpha/beta/rc tags. # The short X.Y version. -version = release = "2.2.4.3" +version = release = "2.3.0.dev1" import sys, os @@ -26,6 +26,8 @@ import sys, os # documentation root, use os.path.abspath to make it absolute, like shown here. #sys.path.insert(0, os.path.abspath('.')) +autodoc_member_order = "bysource" + # -- General configuration ----------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. @@ -53,6 +55,7 @@ project = u'pytest' copyright = u'2011, holger krekel et alii' + # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. #language = None @@ -78,7 +81,7 @@ exclude_patterns = ['links.inc', '_build', 'naming20.txt', 'test/*', # If true, the current module name will be prepended to all description # unit titles (such as .. function::). -#add_module_names = True +add_module_names = False # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. @@ -87,6 +90,8 @@ exclude_patterns = ['links.inc', '_build', 'naming20.txt', 'test/*', # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' + + # A list of ignored prefixes for module index sorting. #modindex_common_prefix = [] diff --git a/doc/en/funcargs.txt b/doc/en/funcargs.txt index 1103e9402..0f2bdc8cf 100644 --- a/doc/en/funcargs.txt +++ b/doc/en/funcargs.txt @@ -11,26 +11,27 @@ Injecting objects into test functions (funcargs) Dependency injection through function arguments ================================================= -py.test lets you inject objects into test functions and precisely -control their life cycle in relation to the test execution. It is -also possible to run a test function multiple times with different objects. +py.test lets you inject objects into test invocations and precisely +control their life cycle in relation to the overall test execution. Moreover, +you can run a test function multiple times injecting different objects. The basic mechanism for injecting objects is also called the *funcarg mechanism* because objects are ultimately injected by calling a test function with it as an argument. Unlike the classical xUnit approach *funcargs* relate more to `Dependency Injection`_ because they help to de-couple test code from objects required for -them to execute. +them to execute. At test writing time you do not need to care for the +details of how your required resources are constructed or if they +live through a function, class, module or session scope. .. _`Dependency injection`: http://en.wikipedia.org/wiki/Dependency_injection To create a value with which to call a test function a factory function is called which gets full access to the test function context and can register finalizers or invoke lifecycle-caching helpers. The factory -can be implemented in same test class or test module, or in a -per-directory ``conftest.py`` file or even in an external plugin. This -allows full de-coupling of test code and objects needed for test -execution. +can be implemented in same test class or test module, in a +per-directory ``conftest.py`` file or in an external plugin. This +allows total de-coupling of test and setup code. A test function may be invoked multiple times in which case we speak of :ref:`parametrized testing `. This can be @@ -38,7 +39,7 @@ very useful if you want to test e.g. against different database backends or with multiple numerical arguments sets and want to reuse the same set of test functions. -py.test comes with :ref:`builtinfuncargs` and there are some refined usages in the examples section. +py.test comes with some :ref:`builtinfuncargs` and there are some refined usages in the examples section. .. _funcarg: @@ -55,10 +56,8 @@ Let's look at a simple self-contained test module:: assert myfuncarg == 17 This test function needs an injected object named ``myfuncarg``. -py.test will discover and call the factory named -``pytest_funcarg__myfuncarg`` within the same module in this case. - -Running the test looks like this:: +py.test will automatically discover and call the ``pytest_funcarg__myfuncarg`` +factory. Running the test looks like this:: $ py.test test_simplefactory.py =========================== test session starts ============================ @@ -79,9 +78,9 @@ Running the test looks like this:: test_simplefactory.py:5: AssertionError ========================= 1 failed in 0.01 seconds ========================= -This means that indeed the test function was called with a ``myfuncarg`` -argument value of ``42`` and the assert fails. Here is how py.test -comes to call the test function this way: +This shows that the test function was called with a ``myfuncarg`` +argument value of ``42`` and the assert fails as expected. Here is +how py.test comes to call the test function this way: 1. py.test :ref:`finds ` the ``test_function`` because of the ``test_`` prefix. The test function needs a function argument @@ -99,13 +98,22 @@ Note that if you misspell a function argument or want to use one that isn't available, you'll see an error with a list of available function arguments. -You can always issue:: +.. Note:: - py.test --funcargs test_simplefactory.py + You can always issue:: -to see available function arguments (which you can also -think of as "resources"). + py.test --funcargs test_simplefactory.py + to see available function arguments. + +The request object passed to factories +----------------------------------------- + +Each funcarg factory receives a :py:class:`~_pytest.main.Request` object which +provides methods to manage caching and finalization in the context of the +test invocation as well as several attributes of the the underlying test item. In fact, as of version pytest-2.3, the request API is implemented on all Item +objects and therefore the request object has general :py:class:`Node attributes and methods <_pytest.main.Node>` attributes. This is a backward compatible +change so no changes are neccessary for pre-2.3 funcarg factories. .. _`parametrizing tests, generalized`: http://tetamap.wordpress.com/2009/05/13/parametrizing-python-tests-generalized/ @@ -116,27 +124,6 @@ think of as "resources"). .. _`funcarg factory`: .. _factory: -The funcarg **request** object -============================================= - -Each funcarg factory receives a **request** object tied to a specific test -function call. A request object is passed to a funcarg factory and provides -access to test configuration and context: - -.. autoclass:: _pytest.python.FuncargRequest() - :members: function,cls,module,keywords,config - -.. _`useful caching and finalization helpers`: - -.. automethod:: FuncargRequest.addfinalizer - -.. automethod:: FuncargRequest.cached_setup - -.. automethod:: FuncargRequest.applymarker - -.. automethod:: FuncargRequest.getfuncargvalue - - .. _`test generators`: .. _`parametrizing-tests`: .. _`parametrized test functions`: diff --git a/doc/en/plugins.txt b/doc/en/plugins.txt index 1425edfc3..719404192 100644 --- a/doc/en/plugins.txt +++ b/doc/en/plugins.txt @@ -296,7 +296,6 @@ into interactive debugging when a test failure occurs. The :py:mod:`_pytest.terminal` reported specifically uses the reporting hook to print information about a test run. - Collection hooks ------------------------------ @@ -327,37 +326,44 @@ test execution: .. autofunction: pytest_runtest_logreport -Reference of important objects involved in hooks +Reference of objects involved in hooks =========================================================== -.. autoclass:: _pytest.config.Config +.. autoclass:: _pytest.main.Request() :members: -.. autoclass:: _pytest.config.Parser +.. autoclass:: _pytest.config.Config() :members: -.. autoclass:: _pytest.main.Node(name, parent) +.. autoclass:: _pytest.config.Parser() :members: -.. - .. autoclass:: _pytest.main.File(fspath, parent) - :members: - - .. autoclass:: _pytest.main.Item(name, parent) - :members: - - .. autoclass:: _pytest.python.Module(name, parent) - :members: - - .. autoclass:: _pytest.python.Class(name, parent) - :members: - - .. autoclass:: _pytest.python.Function(name, parent) - :members: - -.. autoclass:: _pytest.runner.CallInfo +.. autoclass:: _pytest.main.Node() :members: -.. autoclass:: _pytest.runner.TestReport +.. autoclass:: _pytest.main.Collector() + :members: + :show-inheritance: + +.. autoclass:: _pytest.main.Item() + :members: + :show-inheritance: + +.. autoclass:: _pytest.python.Module() + :members: + :show-inheritance: + +.. autoclass:: _pytest.python.Class() + :members: + :show-inheritance: + +.. autoclass:: _pytest.python.Function() + :members: + :show-inheritance: + +.. autoclass:: _pytest.runner.CallInfo() + :members: + +.. autoclass:: _pytest.runner.TestReport() :members: diff --git a/setup.py b/setup.py index 161e5ca0c..fee8f8f55 100644 --- a/setup.py +++ b/setup.py @@ -24,7 +24,7 @@ def main(): name='pytest', description='py.test: simple powerful testing with Python', long_description = long_description, - version='2.2.5.dev4', + version='2.3.0.dev1', url='http://pytest.org', license='MIT license', platforms=['unix', 'linux', 'osx', 'cygwin', 'win32'], diff --git a/testing/test_assertion.py b/testing/test_assertion.py index 75b06943a..e67e010f7 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -12,33 +12,25 @@ def interpret(expr): class TestBinReprIntegration: pytestmark = needsnewassert - def pytest_funcarg__hook(self, request): - class MockHook(object): - def __init__(self): - self.called = False - self.args = tuple() - self.kwargs = dict() - - def __call__(self, op, left, right): - self.called = True - self.op = op - self.left = left - self.right = right - mockhook = MockHook() - monkeypatch = request.getfuncargvalue("monkeypatch") - monkeypatch.setattr(util, '_reprcompare', mockhook) - return mockhook - - def test_pytest_assertrepr_compare_called(self, hook): - interpret('assert 0 == 1') - assert hook.called - - - def test_pytest_assertrepr_compare_args(self, hook): - interpret('assert [0, 1] == [0, 2]') - assert hook.op == '==' - assert hook.left == [0, 1] - assert hook.right == [0, 2] + def test_pytest_assertrepr_compare_called(self, testdir): + testdir.makeconftest(""" + l = [] + def pytest_assertrepr_compare(op, left, right): + l.append((op, left, right)) + def pytest_funcarg__l(request): + return l + """) + testdir.makepyfile(""" + def test_hello(): + assert 0 == 1 + def test_check(l): + assert l == [("==", 0, 1)] + """) + result = testdir.runpytest("-v") + result.stdout.fnmatch_lines([ + "*test_hello*FAIL*", + "*test_check*PASS*", + ]) def callequal(left, right): return plugin.pytest_assertrepr_compare('==', left, right) diff --git a/testing/test_python.py b/testing/test_python.py index 2d0dc3622..3195fd632 100644 --- a/testing/test_python.py +++ b/testing/test_python.py @@ -690,8 +690,8 @@ class TestRequest: assert val2 == 2 val2 = req.getfuncargvalue("other") # see about caching assert val2 == 2 - req._fillfuncargs() - assert item.funcargs == {'something': 1} + pytest._fillfuncargs(item) + assert item.funcargs == {'something': 1, "other": 2} def test_request_addfinalizer(self, testdir): item = testdir.getitem(""" @@ -700,9 +700,8 @@ class TestRequest: request.addfinalizer(lambda: teardownlist.append(1)) def test_func(something): pass """) - req = funcargs.FuncargRequest(item) - req._pyfuncitem.session._setupstate.prepare(item) # XXX - req._fillfuncargs() + item.session._setupstate.prepare(item) + pytest._fillfuncargs(item) # successively check finalization calls teardownlist = item.getparent(pytest.Module).obj.teardownlist ss = item.session._setupstate @@ -799,7 +798,8 @@ class TestRequestCachedSetup: req3 = funcargs.FuncargRequest(item3) ret3a = req3.cached_setup(setup, scope="class") ret3b = req3.cached_setup(setup, scope="class") - assert ret3a == ret3b == "hello2" + assert ret3a == "hello2" + assert ret3b == "hello2" req4 = funcargs.FuncargRequest(item4) ret4 = req4.cached_setup(setup, scope="class") assert ret4 == ret3a @@ -830,11 +830,12 @@ class TestRequestCachedSetup: ret1 = req1.cached_setup(setup, teardown, scope="function") assert l == ['setup'] # artificial call of finalizer - req1._pyfuncitem.session._setupstate._callfinalizers(item1) + setupstate = req1._pyfuncitem.session._setupstate + setupstate._callfinalizers(item1) assert l == ["setup", "teardown"] ret2 = req1.cached_setup(setup, teardown, scope="function") assert l == ["setup", "teardown", "setup"] - req1._pyfuncitem.session._setupstate._callfinalizers(item1) + setupstate._callfinalizers(item1) assert l == ["setup", "teardown", "setup", "teardown"] def test_request_cached_setup_two_args(self, testdir): @@ -1092,9 +1093,9 @@ class TestMetafuncFunctional: def pytest_generate_tests(metafunc): metafunc.addcall(param=metafunc) - def pytest_funcarg__metafunc(request): - assert request._pyfuncitem._genid == "0" - return request.param + def pytest_funcarg__metafunc(item): + assert item._genid == "0" + return item.param def test_function(metafunc, pytestconfig): assert metafunc.config == pytestconfig @@ -1588,3 +1589,61 @@ def test_issue117_sessionscopeteardown(testdir): "*3/x*", "*ZeroDivisionError*", ]) + +class TestRequestAPI: + def test_addfinalizer_cachedsetup_getfuncargvalue(self, testdir): + testdir.makeconftest(""" + l = [] + def pytest_runtest_setup(item): + item.addfinalizer(lambda: l.append(1)) + l2 = item.getfuncargvalue("l") + assert l2 is l + item.cached_setup(lambda: l.append(2), lambda val: l.append(3), + scope="function") + def pytest_funcarg__l(request): + return l + """) + testdir.makepyfile(""" + def test_hello(): + pass + def test_hello2(l): + assert l == [2, 3, 1, 2] + """) + result = testdir.runpytest() + assert result.ret == 0 + result.stdout.fnmatch_lines([ + "*2 passed*", + ]) + + def test_runtest_setup_sees_filled_funcargs(self, testdir): + testdir.makeconftest(""" + def pytest_runtest_setup(item): + assert item.funcargs is None + """) + testdir.makepyfile(""" + def pytest_funcarg__a(request): + return 1 + def pytest_funcarg__b(request): + return request.getfuncargvalue("a") + 1 + def test_hello(b): + assert b == 2 + """) + result = testdir.runpytest() + assert result.ret == 0 + result.stdout.fnmatch_lines([ + "*1 passed*", + ]) + + result = testdir.makeconftest(""" + import pytest + @pytest.mark.trylast + def pytest_runtest_setup(item): + assert item.funcargs == {"a": 1, "b": 2} + """) + result = testdir.runpytest() + assert result.ret == 0 + result.stdout.fnmatch_lines([ + "*1 passed*", + ]) + + diff --git a/testing/test_tmpdir.py b/testing/test_tmpdir.py index 5e67e94f6..4c1f13eab 100644 --- a/testing/test_tmpdir.py +++ b/testing/test_tmpdir.py @@ -2,7 +2,6 @@ import py, pytest import os from _pytest.tmpdir import pytest_funcarg__tmpdir, TempdirHandler -from _pytest.python import FuncargRequest def test_funcarg(testdir): item = testdir.getitem(""" @@ -11,12 +10,12 @@ def test_funcarg(testdir): metafunc.addcall(id='b') def test_func(tmpdir): pass """, 'test_func[a]') - p = pytest_funcarg__tmpdir(FuncargRequest(item)) + p = pytest_funcarg__tmpdir(item) assert p.check() bn = p.basename.strip("0123456789") assert bn.endswith("test_func_a_") item.name = "qwe/\\abc" - p = pytest_funcarg__tmpdir(FuncargRequest(item)) + p = pytest_funcarg__tmpdir(item) assert p.check() bn = p.basename.strip("0123456789") assert bn == "qwe__abc" diff --git a/tox.ini b/tox.ini index 8bdf9a2f5..d9dd8d8ed 100644 --- a/tox.ini +++ b/tox.ini @@ -24,7 +24,7 @@ basepython=python2.7 deps=pytest-xdist commands= py.test -n3 -rfsxX \ - --ignore .tox --junitxml={envlogdir}/junit-{envname}.xml [] + --ignore .tox --junitxml={envlogdir}/junit-{envname}.xml testing [testenv:trial] changedir=.