-
Notifications
You must be signed in to change notification settings - Fork 2.4k
/
docex.py
237 lines (213 loc) · 9.41 KB
/
docex.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
"""A framework for running unit tests and examples, written in docstrings.
This lets you write "Ex: sqrt(4) ==> 2; sqrt(-1) raises ValueError" in a
docstring, and then execute the examples as unit tests.
This functionality is similar to the doctest module. The major
differences between docex and doctest are:
(1) Brevity. With docex you write the one-line comment
"Ex: len('abc') ==> 3; len([]) ==> 0; len(5) raises TypeError"
With doctest you would need 9 lines for the same thing:
'''>>> len('abc')
3
>>> len([])
0
>>> len(5))
Traceback (most recent call last):
...
TypeError: len() of unsized object
'''
(2) Docex handles both examples and unit tests.
It took me a while to recognize this distinction: when I write
"sqrt(4) ==> 2" it has two purposes -- to serve as a unit test
and to serve as an example of how to use the sqrt function.
When I write "random.choice('abc')" it serves as an example of
how to use the choice function, but it is not a unit test.
docex lets you do both; doctest only supports tests. Of course
you can coerce this into a test in doctest, with something like
>>> random.choice('abc') in 'abc'
True
(3) Eval-based rather than string-comparison based. The docex string
"dict(zip([1,4,9], [1,2,3])) ==> {1: 1, 4: 2, 9: 3}" works even
when a different version of Python decides to print the dict as
"{9: 3, 4: 2, 1: 1}" because docex evals the right-hand-side and
checks to see if it is equal. That's good for dicts, its good for
writing "1+1==2 ==> True" and having it work in versions of Python
where True prints as "1" rather than as "True", and so on,
but doctest has the edge if you want to compare against something
that doesn't have an eval-able output, or if you want to test
printed output.
(4) Doctest has many more features, and is better supported.
I wrote docex before doctest was an official part of Python, but
with the refactoring of doctest in Python 2.4, I decided to switch
my code over to doctest, even though I prefer the brevity of docex.
I still offer docex for those who want it.
From Python, when you want to test modules m1, m2, ... do:
docex.Docex([m1, m2, ...])
From the shell, when you want to test files *.py, do:
python docex.py [log-file] *.py
If log file ends in .htm or .html, it will be written in HTML.
If log file is -, or if it is missing, then standard output is used.
For each module, Docex looks at the __doc__ and _docex strings of the
module itself, and of each member, and recursively for each member
class. If a line in a docstring starts with r'^\s*Ex: ' (a line with
blanks, then 'Ex: '), then the remainder of the string after the colon
is treated as examples. Each line of the examples should conform to
one of the following formats:
(1) Blank line or a comment; these just get echoed verbatim to the log.
(2) Of the form example1 ; example2 ; ...
(3) Of the form 'x ==> y' for any expressions x and y.
x is evaled and assigned to _, then y is evaled.
If x != y, an error message is printed.
(4) Of the form 'x raises y', for any statement x and expression y.
First y is evaled to yield an exception type, then x is execed.
If x doesn't raise the right exception, an error msg is printed.
(5) Of the form 'statement'. Statement is execed for side effect.
(6) Of the form 'expression'. Expression is evaled for side effect.
"""
from __future__ import print_function
import re, sys, types
class Docex:
"""A class to run test examples written in docstrings or in _docex."""
def __init__(self, modules=None, html=0, out=None,
title='Docex Example Output'):
if modules is None:
modules = sys.modules.values()
self.passed = self.failed = 0;
self.dictionary = {}
self.already_seen = {}
self.html = html
try:
if out: sys.stdout = out
self.writeln(title, '<h1>', '</h1><pre>')
for module in modules:
self.run_module(module)
self.writeln(str(self), '</pre>\n<hr><h1>', '</h1>\n')
finally:
if out:
sys.stdout = sys.__stdout__
out.close()
def __repr__(self):
if self.failed:
return ('<Test: #### failed %d, passed %d>'
% (self.failed, self.passed))
else:
return '<Test: passed all %d>' % self.passed
def run_module(self, object):
"""Run the docstrings, and then all members of the module."""
if not self.seen(object):
self.dictionary.update(vars(object)) # import module into self
name = object.__name__
self.writeln('## Module %s ' % name,
'\n</pre><a name=%s><h1>' % name,
'</h1><pre>')
self.run_docstring(object)
names = object.__dict__.keys()
names.sort()
for name in names:
val = object.__dict__[name]
if isinstance(val, types.ClassType):
self.run_class(val)
elif isinstance(val, types.ModuleType):
pass
elif not self.seen(val):
self.run_docstring(val)
def run_class(self, object):
"""Run the docstrings, and then all members of the class."""
if not self.seen(object):
self.run_docstring(object)
names = object.__dict__.keys()
names.sort()
for name in names:
self.run_docstring(object.__dict__[name])
def run_docstring(self, object, search=re.compile(r'(?m)^\s*Ex: ').search):
"Run the __doc__ and _docex attributes, if the object has them."
if hasattr(object, '__doc__'):
s = object.__doc__
if isinstance(s, str):
match = search(s)
if match: self.run_string(s[match.end():])
if hasattr(object, '_docex'):
self.run_string(object._docex)
def run_string(self, teststr):
"""Run a test string, printing inputs and results."""
if not teststr: return
teststr = teststr.strip()
if teststr.find('\n') > -1:
map(self.run_string, teststr.split('\n'))
elif teststr == '' or teststr.startswith('#'):
self.writeln(teststr)
elif teststr.find('; ') > -1:
for substr in teststr.split('; '): self.run_string(substr)
elif teststr.find('==>') > -1:
teststr, result = teststr.split('==>')
self.evaluate(teststr, result)
elif teststr.find(' raises ') > -1:
teststr, exception = teststr.split(' raises ')
self.raises(teststr, exception)
else: ## Try to eval, but if it is a statement, exec
try:
self.evaluate(teststr)
except SyntaxError:
exec(teststr, self.dictionary)
def evaluate(self, teststr, resultstr=None):
"Eval teststr and check if resultstr (if given) evals to the same."
self.writeln('>>> ' + teststr.strip())
result = eval(teststr, self.dictionary)
self.dictionary['_'] = result
self.writeln(repr(result))
if resultstr == None:
return
elif result == eval(resultstr, self.dictionary):
self.passed += 1
else:
self.fail(teststr, resultstr)
def raises(self, teststr, exceptionstr):
teststr = teststr.strip()
self.writeln('>>> ' + teststr)
except_class = eval(exceptionstr, self.dictionary)
try:
exec(teststr, self.dictionary)
except except_class:
self.writeln('# raises %s as expected' % exceptionstr)
self.passed += 1
return
self.fail(teststr, exceptionstr)
def fail(self, teststr, resultstr):
self.writeln('###### ERROR, TEST FAILED: expected %s for %s'
% (resultstr, teststr),
'<font color=red><b>', '</b></font>')
self.failed += 1
def writeln(self, s, before='', after=''):
"Write s, html escaped, and wrapped with html code before and after."
s = str(s)
if self.html:
s = s.replace('&','&').replace('<','<').replace('>','>')
print('%s%s%s' % (before, s, after))
else:
print(s)
def seen(self, object):
"""Return true if this object has been seen before.
In any case, record that we have seen it."""
result = self.already_seen.has_key(id(object))
self.already_seen[id(object)] = 1
return result
def main(args):
"""Run Docex. args should be a list of python filenames.
If the first arg is a non-python filename, it is taken as the
name of a log file to which output is written. If it ends in
".htm" or ".html", then the output is written as html. If the
first arg is "-", then standard output is used as the log file."""
import glob
out = None
html = 0
if args[0] != "-" and not args[0].endswith(".py"):
out = open(args[0], 'w')
if args[0].endswith(".html") or args[0].endswith(".htm"):
html = 1
modules = []
for arg in args:
for file in glob.glob(arg):
if file.endswith('.py'):
modules.append(__import__(file[:-3]))
print(Docex(modules, html=html, out=out))
if __name__ == '__main__':
main(sys.argv[1:])