From db21cac6946bb4e3ae4c4d1e550222eb985a6618 Mon Sep 17 00:00:00 2001 From: holger krekel Date: Tue, 29 Dec 2009 16:29:48 +0100 Subject: [PATCH] cleanup py.test.* namespace, docstrings for improved pydoc and interactive usage. use new apipkg __onfirstaccess__ feature to initialize the py.test namespace with the default plugins. This, besides other good implications, means that you can now type: pydoc py.test or help(py.test) --HG-- branch : trunk --- CHANGELOG | 8 +++++++- contrib/runtesthelper.py | 3 +-- py/__init__.py | 2 +- py/apipkg.py | 19 +++++++++++++++++-- py/impl/test/__init__.py | 2 +- py/impl/test/config.py | 20 ++++++++++++++------ py/impl/test/outcome.py | 28 +++++++++++++++++++++------- py/impl/test/pluginmanager.py | 3 +-- py/plugin/pytest_mark.py | 24 ++++++++++++++---------- py/plugin/pytest_skipping.py | 3 ++- testing/plugin/test_pytest_mark.py | 4 ++-- testing/pytest/acceptance_test.py | 17 ++++++++++++++++- testing/pytest/test_pluginmanager.py | 1 + 13 files changed, 98 insertions(+), 36 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index faa2b40fa..e517b470c 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,4 +1,4 @@ -Changes between 1.1.2 and 1.1.1 +Changes between 1.X and 1.1.1 ===================================== - new option: --ignore will prevent specified path from collection. @@ -7,6 +7,12 @@ Changes between 1.1.2 and 1.1.1 - install 'py.test' and `py.which` with a ``-$VERSION`` suffix to disambiguate between Python3, python2.X, Jython and PyPy installed versions. +- make py.test.* helpers provided by default plugins visible early - + works transparently both for pydoc and for interactive sessions + which will regularly see e.g. py.test.mark and py.test.importorskip. + +- simplify internal plugin manager machinery + - fix assert reinterpreation that sees a call containing "keyword=..." - skip some install-tests if no execnet is available diff --git a/contrib/runtesthelper.py b/contrib/runtesthelper.py index b288c8d27..96c90eb8d 100644 --- a/contrib/runtesthelper.py +++ b/contrib/runtesthelper.py @@ -14,5 +14,4 @@ def pytest(argv=None): except SystemExit: pass # we need to reset the global py.test.config object - py.test.config = py.test.config.__class__( - pluginmanager=py.test._PluginManager()) + py.test.config = py.test.config.__class__() diff --git a/py/__init__.py b/py/__init__.py index da88f87e1..33ccfd066 100644 --- a/py/__init__.py +++ b/py/__init__.py @@ -40,8 +40,8 @@ py.apipkg.initpkg(__name__, dict( test = { # helpers for use from test functions or collectors + '__onfirstaccess__' : '.impl.test.config:onpytestaccess', '__doc__' : '.impl.test:__doc__', - '_PluginManager' : '.impl.test.pluginmanager:PluginManager', 'raises' : '.impl.test.outcome:raises', 'skip' : '.impl.test.outcome:skip', 'importorskip' : '.impl.test.outcome:importorskip', diff --git a/py/apipkg.py b/py/apipkg.py index c34658dd0..9e77bc744 100644 --- a/py/apipkg.py +++ b/py/apipkg.py @@ -8,7 +8,7 @@ see http://pypi.python.org/pypi/apipkg import sys from types import ModuleType -__version__ = "1.0b2" +__version__ = "1.0b3" def initpkg(pkgname, exportdefs): """ initialize given package from the export definitions. """ @@ -26,7 +26,7 @@ def importobj(modpath, attrname): class ApiModule(ModuleType): def __init__(self, name, importspec, implprefix=None): self.__name__ = name - self.__all__ = list(importspec) + self.__all__ = [x for x in importspec if x != '__onfirstaccess__'] self.__map__ = {} self.__implprefix__ = implprefix or name for name, importspec in importspec.items(): @@ -45,12 +45,26 @@ class ApiModule(ModuleType): self.__map__[name] = (modpath, attrname) def __repr__(self): + l = [] + if hasattr(self, '__version__'): + l.append("version=" + repr(self.__version__)) + if hasattr(self, '__file__'): + l.append('from ' + repr(self.__file__)) + if l: + return '' % (self.__name__, " ".join(l)) return '' % (self.__name__,) def __getattr__(self, name): + target = None + if '__onfirstaccess__' in self.__map__: + target = self.__map__.pop('__onfirstaccess__') + importobj(*target)() try: modpath, attrname = self.__map__[name] except KeyError: + if target is not None and name != '__onfirstaccess__': + # retry, onfirstaccess might have set attrs + return getattr(self, name) raise AttributeError(name) else: result = importobj(modpath, attrname) @@ -63,6 +77,7 @@ class ApiModule(ModuleType): dictdescr = ModuleType.__dict__['__dict__'] dict = dictdescr.__get__(self) if dict is not None: + hasattr(self, 'some') for name in self.__all__: hasattr(self, name) # force attribute load, ignore errors return dict diff --git a/py/impl/test/__init__.py b/py/impl/test/__init__.py index 091799fd4..86bf9b3e1 100644 --- a/py/impl/test/__init__.py +++ b/py/impl/test/__init__.py @@ -1 +1 @@ -""" versatile unit-testing tool + libraries """ +""" assertion and py.test helper API.""" diff --git a/py/impl/test/config.py b/py/impl/test/config.py index af8f00858..bba10f7c5 100644 --- a/py/impl/test/config.py +++ b/py/impl/test/config.py @@ -1,11 +1,14 @@ import py, os from py.impl.test.conftesthandle import Conftest - +from py.impl.test.pluginmanager import PluginManager from py.impl.test import parseopt def ensuretemp(string, dir=1): - """ return temporary directory path with - the given string as the trailing part. + """ (deprecated) return temporary directory path with + the given string as the trailing part. It is usually + better to use the 'tmpdir' function argument which will + take care to provide empty unique directories for each + test call even if the test is called multiple times. """ return py.test.config.ensuretemp(string, dir=dir) @@ -33,7 +36,7 @@ class Config(object): usage="usage: %prog [options] [file_or_dir] [file_or_dir] [...]", processopt=self._processopt, ) - self.pluginmanager = py.test._PluginManager() + self.pluginmanager = PluginManager() self._conftest = Conftest(onimport=self._onimportconftest) self.hook = self.pluginmanager.hook @@ -178,7 +181,7 @@ class Config(object): This function gets invoked during testing session initialization. """ py.log._apiwarn("1.0", "define plugins to add options", stacklevel=2) - group = self._parser.addgroup(groupname) + group = self._parser.getgroup(groupname) for opt in specs: group._addoption_instance(opt) return self.option @@ -296,6 +299,11 @@ def gettopdir(args): else: return pkgdir.dirpath() +def onpytestaccess(): + # it's enough to have our containing module loaded as + # it initializes a per-process config instance + # which loads default plugins which add to py.test.* + pass -# this is default per-process instance of py.test configuration +# a default per-process instance of py.test configuration config_per_process = Config() diff --git a/py/impl/test/outcome.py b/py/impl/test/outcome.py index ecb537855..619de1309 100644 --- a/py/impl/test/outcome.py +++ b/py/impl/test/outcome.py @@ -47,23 +47,33 @@ class Exit(KeyboardInterrupt): # exposed helper methods def exit(msg): - """ exit testing process immediately. """ + """ exit testing process as if KeyboardInterrupt was triggered. """ __tracebackhide__ = True raise Exit(msg) def skip(msg=""): - """ skip with the given message. """ + """ skip an executing test with the given message. Note: it's usually + better use the py.test.mark.skipif marker to declare a test to be + skipped under certain conditions like mismatching platforms or + dependencies. See the pytest_skipping plugin for details. + """ __tracebackhide__ = True raise Skipped(msg=msg) -def fail(msg="unknown failure"): - """ fail with the given Message. """ +def fail(msg=""): + """ explicitely fail this executing test with the given Message. """ __tracebackhide__ = True raise Failed(msg=msg) def raises(ExpectedException, *args, **kwargs): - """ raise AssertionError, if target code does not raise the expected - exception. + """ if args[0] is callable: raise AssertionError if calling it with + the remaining arguments does not raise the expected exception. + if args[0] is a string: raise AssertionError if executing the + the string in the calling scope does not raise expected exception. + for examples: + x = 5 + raises(TypeError, lambda x: x + 'hello', x=x) + raises(TypeError, "x + 'hello'") """ __tracebackhide__ = True assert args @@ -95,7 +105,10 @@ def raises(ExpectedException, *args, **kwargs): expr=args, expected=ExpectedException) def importorskip(modname, minversion=None): - """ return imported module or perform a dynamic skip() """ + """ return imported module if it has a higher __version__ than the + optionally specified 'minversion' - otherwise call py.test.skip() + with a message detailing the mismatch. + """ compile(modname, '', 'eval') # to catch syntaxerrors try: mod = __import__(modname, None, None, ['__doc__']) @@ -114,6 +127,7 @@ def importorskip(modname, minversion=None): return mod + # exitcodes for the command line EXIT_OK = 0 EXIT_TESTSFAILED = 1 diff --git a/py/impl/test/pluginmanager.py b/py/impl/test/pluginmanager.py index 19367f559..2c1f3a7a5 100644 --- a/py/impl/test/pluginmanager.py +++ b/py/impl/test/pluginmanager.py @@ -15,8 +15,6 @@ def check_old_use(mod, modname): assert not hasattr(mod, clsname), (mod, clsname) class PluginManager(object): - class Error(Exception): - """signals a plugin specific error.""" def __init__(self): self.registry = Registry() self._name2plugin = {} @@ -157,6 +155,7 @@ class PluginManager(object): dic = self.call_plugin(plugin, "pytest_namespace", {}) or {} for name, value in dic.items(): setattr(py.test, name, value) + py.test.__all__.append(name) if hasattr(self, '_config'): self.call_plugin(plugin, "pytest_addoption", {'parser': self._config._parser}) diff --git a/py/plugin/pytest_mark.py b/py/plugin/pytest_mark.py index 3f979d86e..279a0b853 100644 --- a/py/plugin/pytest_mark.py +++ b/py/plugin/pytest_mark.py @@ -78,16 +78,18 @@ tests:: import py def pytest_namespace(): - return {'mark': Mark()} + return {'mark': MarkGenerator()} - -class Mark(object): +class MarkGenerator: + """ non-underscore attributes of this object can be used as decorators for + marking test functions. Example: @py.test.mark.slowtest in front of a + function will set the 'slowtest' marker object on it. """ def __getattr__(self, name): if name[0] == "_": raise AttributeError(name) - return MarkerDecorator(name) + return MarkDecorator(name) -class MarkerDecorator: +class MarkDecorator: """ decorator for setting function attributes. """ def __init__(self, name): self.markname = name @@ -97,15 +99,17 @@ class MarkerDecorator: 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. + otherwise add *args/**kwargs in-place to mark information. """ if args: if len(args) == 1 and hasattr(args[0], '__call__'): func = args[0] holder = getattr(func, self.markname, None) if holder is None: - holder = Marker(self.markname, self.args, self.kwargs) + holder = MarkInfo(self.markname, self.args, self.kwargs) setattr(func, self.markname, holder) else: holder.kwargs.update(self.kwargs) @@ -116,7 +120,7 @@ class MarkerDecorator: self.kwargs.update(kwargs) return self -class Marker: +class MarkInfo: def __init__(self, name, args, kwargs): self._name = name self.args = args @@ -129,7 +133,7 @@ class Marker: raise AttributeError(name) def __repr__(self): - return "" % ( + return "" % ( self._name, self.args, self.kwargs) @@ -143,6 +147,6 @@ def pytest_pycollect_makeitem(__multicall__, collector, name, obj): func = getattr(func, 'im_func', func) # py2 for parent in [x for x in (mod, cls) if x]: marker = getattr(parent.obj, 'pytestmark', None) - if isinstance(marker, MarkerDecorator): + if isinstance(marker, MarkDecorator): marker(func) return item diff --git a/py/plugin/pytest_skipping.py b/py/plugin/pytest_skipping.py index 0d80b938c..25bf9d970 100644 --- a/py/plugin/pytest_skipping.py +++ b/py/plugin/pytest_skipping.py @@ -3,7 +3,7 @@ advanced skipping for python test functions, classes or modules. With this plugin you can mark test functions for conditional skipping or as "xfail", expected-to-fail. Skipping a test will avoid running it -at all while xfail-marked tests will run and result in an inverted outcome: +while xfail-marked tests will run and result in an inverted outcome: a pass becomes a failure and a fail becomes a semi-passing one. The need for skipping a test is usually connected to a condition. @@ -121,6 +121,7 @@ within test or setup code. Example:: import py + def pytest_runtest_setup(item): expr, result = evalexpression(item, 'skipif') if result: diff --git a/testing/plugin/test_pytest_mark.py b/testing/plugin/test_pytest_mark.py index 5584d8d6c..908dd2783 100644 --- a/testing/plugin/test_pytest_mark.py +++ b/testing/plugin/test_pytest_mark.py @@ -1,10 +1,10 @@ import py -from py.plugin.pytest_mark import Mark +from py.plugin.pytest_mark import MarkGenerator as Mark class TestMark: def test_pytest_mark_notcallable(self): mark = Mark() - py.test.raises(TypeError, "mark()") + py.test.raises((AttributeError, TypeError), "mark()") def test_pytest_mark_bare(self): mark = Mark() diff --git a/testing/pytest/acceptance_test.py b/testing/pytest/acceptance_test.py index faf7fa690..c293d3a0b 100644 --- a/testing/pytest/acceptance_test.py +++ b/testing/pytest/acceptance_test.py @@ -1,4 +1,4 @@ -import py +import sys, py class TestGeneralUsage: def test_config_error(self, testdir): @@ -74,3 +74,18 @@ class TestGeneralUsage: assert result.stderr.fnmatch_lines([ "*ERROR: can't collect: %s" %(p2,) ]) + + + def test_earlyinit(self, testdir): + p = testdir.makepyfile(""" + import py + assert hasattr(py.test, 'mark') + """) + result = testdir._run(sys.executable, p) + assert result.ret == 0 + + def test_pydoc(self, testdir): + result = testdir._run(sys.executable, "-c", "import py ; help(py.test)") + assert result.ret == 0 + s = result.stdout.str() + assert 'MarkGenerator' in s diff --git a/testing/pytest/test_pluginmanager.py b/testing/pytest/test_pluginmanager.py index fc0f5c6ba..df308f361 100644 --- a/testing/pytest/test_pluginmanager.py +++ b/testing/pytest/test_pluginmanager.py @@ -223,6 +223,7 @@ class TestPytestPluginInteractions: import py def test_hello(): assert hello == "world" + assert 'hello' in py.test.__all__ """) result = testdir.runpytest(p) result.stdout.fnmatch_lines([