ipunittest.py
186 lines
| 6.6 KiB
| text/x-python
|
PythonLexer
|
r2368 | """Experimental code for cleaner support of IPython syntax with unittest. | ||
In IPython up until 0.10, we've used very hacked up nose machinery for running | ||||
tests with IPython special syntax, and this has proved to be extremely slow. | ||||
This module provides decorators to try a different approach, stemming from a | ||||
conversation Brian and I (FP) had about this problem Sept/09. | ||||
The goal is to be able to easily write simple functions that can be seen by | ||||
unittest as tests, and ultimately for these to support doctests with full | ||||
IPython syntax. Nose already offers this based on naming conventions and our | ||||
hackish plugins, but we are seeking to move away from nose dependencies if | ||||
possible. | ||||
This module follows a different approach, based on decorators. | ||||
- A decorator called @ipdoctest can mark any function as having a docstring | ||||
that should be viewed as a doctest, but after syntax conversion. | ||||
Authors | ||||
------- | ||||
- Fernando Perez <Fernando.Perez@berkeley.edu> | ||||
""" | ||||
|
r2414 | |||
|
r2368 | #----------------------------------------------------------------------------- | ||
|
r5390 | # Copyright (C) 2009-2011 The IPython Development Team | ||
|
r2368 | # | ||
# Distributed under the terms of the BSD License. The full license is in | ||||
# the file COPYING, distributed as part of this software. | ||||
#----------------------------------------------------------------------------- | ||||
#----------------------------------------------------------------------------- | ||||
# Imports | ||||
#----------------------------------------------------------------------------- | ||||
# Stdlib | ||||
import re | ||||
|
r28577 | import sys | ||
|
r2368 | import unittest | ||
|
r3298 | from doctest import DocTestFinder, DocTestRunner, TestResults | ||
|
r25117 | from IPython.terminal.interactiveshell import InteractiveShell | ||
|
r2368 | |||
#----------------------------------------------------------------------------- | ||||
# Classes and functions | ||||
#----------------------------------------------------------------------------- | ||||
def count_failures(runner): | ||||
"""Count number of failures in a doctest runner. | ||||
Code modeled after the summarize() method in doctest. | ||||
""" | ||||
|
r28577 | if sys.version_info < (3, 13): | ||
return [TestResults(f, t) for f, t in runner._name2ft.values() if f > 0] | ||||
else: | ||||
|
r28578 | return [ | ||
TestResults(failure, try_) | ||||
for failure, try_, skip in runner._stats.values() | ||||
|
r28580 | if failure > 0 | ||
|
r28578 | ] | ||
|
r2368 | |||
class IPython2PythonConverter(object): | ||||
"""Convert IPython 'syntax' to valid Python. | ||||
Eventually this code may grow to be the full IPython syntax conversion | ||||
|
r24084 | implementation, but for now it only does prompt conversion.""" | ||
|
r2368 | |||
def __init__(self): | ||||
|
r2414 | self.rps1 = re.compile(r'In\ \[\d+\]: ') | ||
self.rps2 = re.compile(r'\ \ \ \.\.\.+: ') | ||||
self.rout = re.compile(r'Out\[\d+\]: \s*?\n?') | ||||
self.pyps1 = '>>> ' | ||||
self.pyps2 = '... ' | ||||
|
r24780 | self.rpyps1 = re.compile (r'(\s*%s)(.*)$' % self.pyps1) | ||
self.rpyps2 = re.compile (r'(\s*%s)(.*)$' % self.pyps2) | ||||
|
r2368 | |||
def __call__(self, ds): | ||||
"""Convert IPython prompts to python ones in a string.""" | ||||
|
r2461 | from . import globalipapp | ||
|
r2368 | pyps1 = '>>> ' | ||
pyps2 = '... ' | ||||
pyout = '' | ||||
dnew = ds | ||||
|
r2414 | dnew = self.rps1.sub(pyps1, dnew) | ||
dnew = self.rps2.sub(pyps2, dnew) | ||||
dnew = self.rout.sub(pyout, dnew) | ||||
|
r25117 | ip = InteractiveShell.instance() | ||
|
r2414 | |||
# Convert input IPython source into valid Python. | ||||
out = [] | ||||
newline = out.append | ||||
for line in dnew.splitlines(): | ||||
mps1 = self.rpyps1.match(line) | ||||
if mps1 is not None: | ||||
prompt, text = mps1.groups() | ||||
newline(prompt+ip.prefilter(text, False)) | ||||
continue | ||||
mps2 = self.rpyps2.match(line) | ||||
if mps2 is not None: | ||||
prompt, text = mps2.groups() | ||||
newline(prompt+ip.prefilter(text, True)) | ||||
continue | ||||
newline(line) | ||||
newline('') # ensure a closing newline, needed by doctest | ||||
|
r28756 | # print("PYSRC:", '\n'.join(out)) # dbg | ||
|
r2414 | return '\n'.join(out) | ||
#return dnew | ||||
|
r2368 | |||
class Doc2UnitTester(object): | ||||
"""Class whose instances act as a decorator for docstring testing. | ||||
In practice we're only likely to need one instance ever, made below (though | ||||
no attempt is made at turning it into a singleton, there is no need for | ||||
that). | ||||
""" | ||||
def __init__(self, verbose=False): | ||||
"""New decorator. | ||||
Parameters | ||||
---------- | ||||
verbose : boolean, optional (False) | ||||
Passed to the doctest finder and runner to control verbosity. | ||||
""" | ||||
self.verbose = verbose | ||||
# We can reuse the same finder for all instances | ||||
self.finder = DocTestFinder(verbose=verbose, recurse=False) | ||||
def __call__(self, func): | ||||
"""Use as a decorator: doctest a function's docstring as a unittest. | ||||
This version runs normal doctests, but the idea is to make it later run | ||||
ipython syntax instead.""" | ||||
# Capture the enclosing instance with a different name, so the new | ||||
# class below can see it without confusion regarding its own 'self' | ||||
# that will point to the test instance at runtime | ||||
d2u = self | ||||
# Rewrite the function's docstring to have python syntax | ||||
if func.__doc__ is not None: | ||||
func.__doc__ = ip2py(func.__doc__) | ||||
# Now, create a tester object that is a real unittest instance, so | ||||
# normal unittest machinery (or Nose, or Trial) can find it. | ||||
class Tester(unittest.TestCase): | ||||
def test(self): | ||||
# Make a new runner per function to be tested | ||||
runner = DocTestRunner(verbose=d2u.verbose) | ||||
|
r24503 | for the_test in d2u.finder.find(func, func.__name__): | ||
runner.run(the_test) | ||||
|
r2368 | failed = count_failures(runner) | ||
if failed: | ||||
# Since we only looked at a single function's docstring, | ||||
# failed should contain at most one item. More than that | ||||
# is a case we can't handle and should error out on | ||||
if len(failed) > 1: | ||||
|
r25384 | err = "Invalid number of test results: %s" % failed | ||
|
r2368 | raise ValueError(err) | ||
# Report a normal failure. | ||||
self.fail('failed doctests: %s' % str(failed[0])) | ||||
# Rename it so test reports have the original signature. | ||||
Tester.__name__ = func.__name__ | ||||
return Tester | ||||
def ipdocstring(func): | ||||
"""Change the function docstring via ip2py. | ||||
""" | ||||
if func.__doc__ is not None: | ||||
func.__doc__ = ip2py(func.__doc__) | ||||
return func | ||||
# Make an instance of the classes for public use | ||||
ipdoctest = Doc2UnitTester() | ||||
ip2py = IPython2PythonConverter() | ||||