From db3756dade0bba030946abcb8e50c914af84f31e Mon Sep 17 00:00:00 2001 From: Tim Peters Date: Sun, 29 Jun 2003 05:30:48 +0000 Subject: Some nifty doctest extensions from Jim Fulton, currently used in Zope3. I won't have time to write real docs, but spent a lot of time adding comments to his code and fleshing out the exported functions' docstrings. There's probably opportunity to consolidate how docstrings get extracted too, and the new code for that is probably better than the old code for that (which strained mightily to recover from 2.2's new class/type gimmicks). --- Lib/doctest.py | 268 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Misc/NEWS | 15 ++++ 2 files changed, 283 insertions(+) diff --git a/Lib/doctest.py b/Lib/doctest.py index 8bda8d6..55f15f1 100644 --- a/Lib/doctest.py +++ b/Lib/doctest.py @@ -277,6 +277,10 @@ __all__ = [ 'run_docstring_examples', 'is_private', 'Tester', + 'DocTestTestFailure', + 'DocTestSuite', + 'testsource', + 'debug', ] import __future__ @@ -1150,6 +1154,270 @@ def testmod(m=None, name=None, globs=None, verbose=None, isprivate=None, master.merge(tester) return failures, tries +########################################################################### +# Various doctest extensions, to make using doctest with unittest +# easier, and to help debugging when a doctest goes wrong. Original +# code by Jim Fulton. + +# Utilities. + +# If module is None, return the calling module (the module that called +# the routine that called _normalize_module -- this normally won't be +# doctest!). If module is a string, it should be the (possibly dotted) +# name of a module, and the (rightmost) module object is returned. Else +# module is returned untouched; the intent appears to be that module is +# already a module object in this case (although this isn't checked). + +def _normalize_module(module): + import sys + + if module is None: + # Get our caller's caller's module. + module = sys._getframe(2).f_globals['__name__'] + module = sys.modules[module] + + elif isinstance(module, (str, unicode)): + # The ["*"] at the end is a mostly meaningless incantation with + # a crucial property: if, e.g., module is 'a.b.c', it convinces + # __import__ to return c instead of a. + module = __import__(module, globals(), locals(), ["*"]) + + return module + +# tests is a list of (testname, docstring, filename, lineno) tuples. +# If object has a __doc__ attr, and the __doc__ attr looks like it +# contains a doctest (specifically, if it contains an instance of '>>>'), +# then tuple +# prefix + name, object.__doc__, filename, lineno +# is appended to tests. Else tests is left alone. +# There is no return value. + +def _get_doctest(name, object, tests, prefix, filename='', lineno=''): + doc = getattr(object, '__doc__', '') + if isinstance(doc, basestring) and '>>>' in doc: + tests.append((prefix + name, doc, filename, lineno)) + +# tests is a list of (testname, docstring, filename, lineno) tuples. +# docstrings containing doctests are appended to tests (if any are found). +# items is a dict, like a module or class dict, mapping strings to objects. +# mdict is the global dict of a "home" module -- only objects belonging +# to this module are searched for docstrings. module is the module to +# which mdict belongs. +# prefix is a string to be prepended to an object's name when adding a +# tuple to tests. +# The objects (values) in items are examined (recursively), and doctests +# belonging to functions and classes in the home module are appended to +# tests. +# minlineno is a gimmick to try to guess the file-relative line number +# at which a doctest probably begins. + +def _extract_doctests(items, module, mdict, tests, prefix, minlineno=0): + + for name, object in items: + # Only interested in named objects. + if not hasattr(object, '__name__'): + continue + + elif hasattr(object, 'func_globals'): + # Looks like a function. + if object.func_globals is not mdict: + # Non-local function. + continue + code = getattr(object, 'func_code', None) + filename = getattr(code, 'co_filename', '') + lineno = getattr(code, 'co_firstlineno', -1) + 1 + if minlineno: + minlineno = min(lineno, minlineno) + else: + minlineno = lineno + _get_doctest(name, object, tests, prefix, filename, lineno) + + elif hasattr(object, "__module__"): + # Maybe a class-like thing, in which case we care. + if object.__module__ != module.__name__: + # Not the same module. + continue + if not (hasattr(object, '__dict__') + and hasattr(object, '__bases__')): + # Not a class. + continue + + lineno = _extract_doctests(object.__dict__.items(), + module, + mdict, + tests, + prefix + name + ".") + # XXX "-3" is unclear. + _get_doctest(name, object, tests, prefix, + lineno="%s (or above)" % (lineno - 3)) + + return minlineno + +# Find all the doctests belonging to the module object. +# Return a list of +# (testname, docstring, filename, lineno) +# tuples. + +def _find_tests(module, prefix=None): + if prefix is None: + prefix = module.__name__ + mdict = module.__dict__ + tests = [] + # Get the module-level doctest (if any). + _get_doctest(prefix, module, tests, '', lineno="1 (or above)") + # Recursively search the module __dict__ for doctests. + if prefix: + prefix += "." + _extract_doctests(mdict.items(), module, mdict, tests, prefix) + return tests + +# unittest helpers. + +# A function passed to unittest, for unittest to drive. +# tester is doctest Tester instance. doc is the docstring whose +# doctests are to be run. + +def _utest(tester, name, doc, filename, lineno): + import sys + from StringIO import StringIO + + old = sys.stdout + sys.stdout = new = StringIO() + try: + failures, tries = tester.runstring(doc, name) + finally: + sys.stdout = old + + if failures: + msg = new.getvalue() + lname = '.'.join(name.split('.')[-1:]) + if not lineno: + lineno = "0 (don't know line number)" + # Don't change this format! It was designed so that Emacs can + # parse it naturally. + raise DocTestTestFailure('Failed doctest test for %s\n' + ' File "%s", line %s, in %s\n\n%s' % + (name, filename, lineno, lname, msg)) + +class DocTestTestFailure(Exception): + """A doctest test failed""" + +def DocTestSuite(module=None): + """Convert doctest tests for a module to a unittest TestSuite. + + The returned TestSuite is to be run by the unittest framework, and + runs each doctest in the module. If any of the doctests fail, + then the synthesized unit test fails, and an error is raised showing + the name of the file containing the test and a (sometimes approximate) + line number. + + The optional module argument provides the module to be tested. It + can be a module object or a (possibly dotted) module name. If not + specified, the module calling DocTestSuite() is used. + + Example (although note that unittest supplies many ways to use the + TestSuite returned; see the unittest docs): + + import unittest + import doctest + import my_module_with_doctests + + suite = doctest.DocTestSuite(my_module_with_doctests) + runner = unittest.TextTestRunner() + runner.run(suite) + """ + + import unittest + + module = _normalize_module(module) + tests = _find_tests(module) + if not tests: + raise ValueError(module, "has no tests") + + tests.sort() + suite = unittest.TestSuite() + tester = Tester(module) + for name, doc, filename, lineno in tests: + if not filename: + filename = module.__file__ + if filename.endswith(".pyc"): + filename = filename[:-1] + elif filename.endswith(".pyo"): + filename = filename[:-1] + def runit(name=name, doc=doc, filename=filename, lineno=lineno): + _utest(tester, name, doc, filename, lineno) + suite.addTest(unittest.FunctionTestCase( + runit, + description="doctest of " + name)) + return suite + +# Debugging support. + +def _expect(expect): + # Return the expected output (if any), formatted as a Python + # comment block. + if expect: + expect = "\n# ".join(expect.split("\n")) + expect = "\n# Expect:\n# %s" % expect + return expect + +def testsource(module, name): + """Extract the doctest examples from a docstring. + + Provide the module (or dotted name of the module) containing the + tests to be extracted, and the name (within the module) of the object + with the docstring containing the tests to be extracted. + + The doctest examples are returned as a string containing Python + code. The expected output blocks in the examples are converted + to Python comments. + """ + + module = _normalize_module(module) + tests = _find_tests(module, "") + test = [doc for (tname, doc, dummy, dummy) in tests + if tname == name] + if not test: + raise ValueError(name, "not found in tests") + test = test[0] + examples = [source + _expect(expect) + for source, expect, dummy in _extract_examples(test)] + return '\n'.join(examples) + +def debug(module, name): + """Debug a single docstring containing doctests. + + Provide the module (or dotted name of the module) containing the + docstring to be debugged, and the name (within the module) of the + object with the docstring to be debugged. + + The doctest examples are extracted (see function testsource()), + and written to a temp file. The Python debugger (pdb) is then + invoked on that file. + """ + + import os + import pdb + import tempfile + + module = _normalize_module(module) + testsrc = testsource(module, name) + srcfilename = tempfile.mktemp("doctestdebug.py") + f = file(srcfilename, 'w') + f.write(testsrc) + f.close() + + globs = {} + globs.update(module.__dict__) + try: + # Note that %r is vital here. '%s' instead can, e.g., cause + # backslashes to get treated as metacharacters on Windows. + pdb.run("execfile(%r)" % srcfilename, globs, globs) + finally: + os.remove(srcfilename) + + + class _TestClass: """ A pointless class, for sanity-checking of docstring testing. diff --git a/Misc/NEWS b/Misc/NEWS index 9367788..69d63fb 100644 --- a/Misc/NEWS +++ b/Misc/NEWS @@ -86,6 +86,21 @@ Extension modules Library ------- +- Some happy doctest extensions from Jim Fulton have been added to + doctest.py. These are already being used in Zope3. The two + primary ones: + + doctest.debug(module, name) extracts the doctests from the named object + in the given module, puts them in a temp file, and starts pdb running + on that file. This is great when a doctest fails. + + doctest.DocTestSuite(module=None) returns a synthesized unittest + TestSuite instance, to be run by the unittest framework, which + runs all the doctests in the module. This allows writing tests in + doctest style (which can be clearer and shorter than writing tests + in unittest style), without losing unittest's powerful testing + framework features (which doctest lacks). + - ZipFile.testzip() now only traps BadZipfile exceptions. Previously, a bare except caught to much and reported all errors as a problem in the archive. -- cgit v0.12