Merge lp:~jml/testtools/better-doctest-output-checker into lp:~testtools-committers/testtools/trunk

Proposed by Jonathan Lange
Status: Work in progress
Proposed branch: lp:~jml/testtools/better-doctest-output-checker
Merge into: lp:~testtools-committers/testtools/trunk
Diff against target: 356 lines (+216/-40)
5 files modified
NEWS (+7/-0)
doc/for-test-authors.rst (+10/-0)
testtools/helpers.py (+118/-0)
testtools/matchers.py (+2/-40)
testtools/tests/test_helpers.py (+79/-0)
To merge this branch: bzr merge lp:~jml/testtools/better-doctest-output-checker
Reviewer Review Type Date Requested Status
testtools committers Pending
Review via email: mp+74842@code.launchpad.net

Description of the change

An attempt to make a better OutputChecker for doctests.

To post a comment you must log in.

Unmerged revisions

239. By Jonathan Lange

Documentation.

238. By Jonathan Lange

Use it for DocTestMatches

237. By Jonathan Lange

NEWS update.

236. By Jonathan Lange

Whitespace.

235. By Jonathan Lange

Get the abstraction right.:

234. By Jonathan Lange

Merge IntelligentOutputChecker with _NonManglingOutputChecker.

233. By Jonathan Lange

Collapse multiple blank lines.

232. By Jonathan Lange

Move ellipsize into the smart output checker

231. By Jonathan Lange

Refactor so we don't have to copy and paste.

230. By Jonathan Lange

Limited line-based ellipsis normalization.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'NEWS'
--- NEWS 2011-08-14 12:13:52 +0000
+++ NEWS 2011-09-09 17:39:51 +0000
@@ -14,6 +14,9 @@
14 now deprecated. Please stop using it.14 now deprecated. Please stop using it.
15 (Jonathan Lange, #813460)15 (Jonathan Lange, #813460)
1616
17* ``DocTestMatches`` now uses ``SmartOutputChecker``, leading to slightly
18 different error output. (Jonathan Lange)
19
17* ``gather_details`` takes two dicts, rather than two detailed objects.20* ``gather_details`` takes two dicts, rather than two detailed objects.
18 (Jonathan Lange, #801027)21 (Jonathan Lange, #801027)
1922
@@ -76,6 +79,10 @@
76* New convenience assertions, ``assertIsNone`` and ``assertIsNotNone``.79* New convenience assertions, ``assertIsNone`` and ``assertIsNotNone``.
77 (Christian Kampka)80 (Christian Kampka)
7881
82* New doctest helper, ``SmartOutputChecker`` added. It normalizes whitespace
83 and ellipses in diff output if the checker has those options set.
84 (Jonathan Lange)
85
79* New matchers:86* New matchers:
8087
81 * ``AllMatch`` matches many values against a single matcher.88 * ``AllMatch`` matches many values against a single matcher.
8289
=== modified file 'doc/for-test-authors.rst'
--- doc/for-test-authors.rst 2011-08-15 16:14:42 +0000
+++ doc/for-test-authors.rst 2011-09-09 17:39:51 +0000
@@ -1179,6 +1179,16 @@
1179particular attribute.1179particular attribute.
11801180
11811181
1182Better doctest output
1183---------------------
1184
1185If you have doctests, then you might want to use ``SmartOutputChecker``
1186instead of the default. It has correct unicode behaviour across all of the
1187Pythons supported by testtools, and also will reformat the test output
1188slightly if NORMALIZE_WHITESPACE or ELLIPSIS options have been specified, in
1189order to reduce the amount of noise in the error messages.
1190
1191
1182.. _testrepository: https://launchpad.net/testrepository1192.. _testrepository: https://launchpad.net/testrepository
1183.. _Trial: http://twistedmatrix.com/documents/current/core/howto/testing.html1193.. _Trial: http://twistedmatrix.com/documents/current/core/howto/testing.html
1184.. _nose: http://somethingaboutorange.com/mrl/projects/nose/1194.. _nose: http://somethingaboutorange.com/mrl/projects/nose/
11851195
=== modified file 'testtools/helpers.py'
--- testtools/helpers.py 2011-07-20 20:34:29 +0000
+++ testtools/helpers.py 2011-09-09 17:39:51 +0000
@@ -2,12 +2,17 @@
22
3__all__ = [3__all__ = [
4 'safe_hasattr',4 'safe_hasattr',
5 'SmartOutputChecker',
5 'try_import',6 'try_import',
6 'try_imports',7 'try_imports',
7 ]8 ]
89
10import doctest
11import re
9import sys12import sys
1013
14from testtools.monkey import MonkeyPatcher
15
1116
12def try_import(name, alternative=None, error_callback=None):17def try_import(name, alternative=None, error_callback=None):
13 """Attempt to import ``name``. If it fails, return ``alternative``.18 """Attempt to import ``name``. If it fails, return ``alternative``.
@@ -85,3 +90,116 @@
85 properties.90 properties.
86 """91 """
87 return getattr(obj, attr, _marker) is not _marker92 return getattr(obj, attr, _marker) is not _marker
93
94
95class _NonManglingOutputChecker(doctest.OutputChecker):
96 """Doctest checker that works with unicode rather than mangling strings
97
98 This is needed because current Python versions have tried to fix string
99 encoding related problems, but regressed the default behaviour with
100 unicode inputs in the process.
101
102 In Python 2.6 and 2.7 `OutputChecker.output_difference` is was changed to
103 return a bytestring encoded as per `sys.stdout.encoding`, or utf-8 if that
104 can't be determined. Worse, that encoding process happens in the innocent
105 looking `_indent` global function. Because the `DocTestMismatch.describe`
106 result may well not be destined for printing to stdout, this is no good
107 for us. To get a unicode return as before, the method is monkey patched if
108 `doctest._encoding` exists.
109
110 Python 3 has a different problem. For some reason both inputs are encoded
111 to ascii with 'backslashreplace', making an escaped string matches its
112 unescaped form. Overriding the offending `OutputChecker._toAscii` method
113 is sufficient to revert this.
114 """
115
116 def _toAscii(self, s):
117 """Return `s` unchanged rather than mangling it to ascii"""
118 return s
119
120 def output_difference(self, example, got, optionflags):
121 """Describe the difference between 'example' and 'got'.
122
123 On versions of Python that have a broken doctest._indent function,
124 replace the behaviour of that function.
125 """
126 if getattr(doctest, "_encoding", None) is None:
127 return doctest.OutputChecker.output_difference(
128 self, example, got, optionflags)
129 else:
130 def _indent(s, indent=4, _pattern=re.compile("^(?!$)", re.MULTILINE)):
131 """Prepend non-empty lines in `s` with `indent` number of spaces"""
132 return _pattern.sub(indent*" ", s)
133 # Only do this overriding hackery if doctest has a broken _indent
134 # function
135 patcher = MonkeyPatcher((doctest, '_indent', _indent))
136 return patcher.run_with_patches(
137 doctest.OutputChecker.output_difference,
138 self, example, got, optionflags)
139
140
141class SmartOutputChecker(_NonManglingOutputChecker):
142 """OutputChecker that mangles the 'got' based on matching options.
143
144 One problem with doctests is that when you use, say, NORMALIZE_WHITESPACE,
145 and an example fails, then the diff will display as if the differences in
146 whitespace matter.
147
148 Likewise, the diff will treat lines that match correctly with the ELLIPSIS
149 option as differing.
150
151 This makes it difficult to determine which lines are the *actual* cause of
152 the difference.
153
154 This ``OutputChecker`` mangles the output of the doctest example so that
155 it normalizes whitespace and ellipses if needed.
156 """
157
158 def ellipsize(self, want, got):
159 """Turn 'got' into a version of itself, but replaced with ellipses."""
160 if doctest.ELLIPSIS_MARKER not in want:
161 return got
162
163 wants = want.split(doctest.ELLIPSIS_MARKER)
164 gots = []
165
166 startpos = 0
167 for w in wants:
168 index = got.find(w, startpos)
169 if index < 0:
170 if gots:
171 gots[-1] += got[startpos:]
172 else:
173 gots = [got]
174 break
175 else:
176 gots.append(w)
177 startpos = index + len(w)
178 return doctest.ELLIPSIS_MARKER.join(gots)
179
180 def normalize_whitespace(self, output):
181 """Take 'output', preserve its lines, but normalize other whitespace.
182 """
183 lines = []
184 for line in output.splitlines():
185 normalized = ' '.join(line.split())
186 if normalized or not (lines and lines[-1] == ''):
187 lines.append(normalized)
188 return '\n'.join(lines)
189
190 def output_difference(self, example, got, optionflags):
191 """Describe the difference between 'example' and 'got'.
192
193 Instead of the default ``OutputChecker``, this one applies the same
194 transformations to the 'got' string that ``check_output`` does. This
195 means that the diff comparison is easier.
196 """
197 if optionflags & doctest.ELLIPSIS:
198 got = self.ellipsize(example.want, got)
199
200 if optionflags & doctest.NORMALIZE_WHITESPACE:
201 example.want = self.normalize_whitespace(example.want)
202 got = self.normalize_whitespace(got)
203
204 return _NonManglingOutputChecker.output_difference(
205 self, example, got, optionflags)
88206
=== modified file 'testtools/matchers.py'
--- testtools/matchers.py 2011-08-15 13:22:29 +0000
+++ testtools/matchers.py 2011-09-09 17:39:51 +0000
@@ -38,7 +38,6 @@
38 'StartsWith',38 'StartsWith',
39 ]39 ]
4040
41import doctest
42import operator41import operator
43from pprint import pformat42from pprint import pformat
44import re43import re
@@ -51,6 +50,7 @@
51 isbaseexception,50 isbaseexception,
52 istext,51 istext,
53 )52 )
53from testtools.helpers import SmartOutputChecker
5454
5555
56class Matcher(object):56class Matcher(object):
@@ -156,44 +156,6 @@
156 return self.original.get_details()156 return self.original.get_details()
157157
158158
159class _NonManglingOutputChecker(doctest.OutputChecker):
160 """Doctest checker that works with unicode rather than mangling strings
161
162 This is needed because current Python versions have tried to fix string
163 encoding related problems, but regressed the default behaviour with unicode
164 inputs in the process.
165
166 In Python 2.6 and 2.7 `OutputChecker.output_difference` is was changed to
167 return a bytestring encoded as per `sys.stdout.encoding`, or utf-8 if that
168 can't be determined. Worse, that encoding process happens in the innocent
169 looking `_indent` global function. Because the `DocTestMismatch.describe`
170 result may well not be destined for printing to stdout, this is no good
171 for us. To get a unicode return as before, the method is monkey patched if
172 `doctest._encoding` exists.
173
174 Python 3 has a different problem. For some reason both inputs are encoded
175 to ascii with 'backslashreplace', making an escaped string matches its
176 unescaped form. Overriding the offending `OutputChecker._toAscii` method
177 is sufficient to revert this.
178 """
179
180 def _toAscii(self, s):
181 """Return `s` unchanged rather than mangling it to ascii"""
182 return s
183
184 # Only do this overriding hackery if doctest has a broken _input function
185 if getattr(doctest, "_encoding", None) is not None:
186 from types import FunctionType as __F
187 __f = doctest.OutputChecker.output_difference.im_func
188 __g = dict(__f.func_globals)
189 def _indent(s, indent=4, _pattern=re.compile("^(?!$)", re.MULTILINE)):
190 """Prepend non-empty lines in `s` with `indent` number of spaces"""
191 return _pattern.sub(indent*" ", s)
192 __g["_indent"] = _indent
193 output_difference = __F(__f.func_code, __g, "output_difference")
194 del __F, __f, __g, _indent
195
196
197class DocTestMatches(object):159class DocTestMatches(object):
198 """See if a string matches a doctest example."""160 """See if a string matches a doctest example."""
199161
@@ -208,7 +170,7 @@
208 example += '\n'170 example += '\n'
209 self.want = example # required variable name by doctest.171 self.want = example # required variable name by doctest.
210 self.flags = flags172 self.flags = flags
211 self._checker = _NonManglingOutputChecker()173 self._checker = SmartOutputChecker()
212174
213 def __str__(self):175 def __str__(self):
214 if self.flags:176 if self.flags:
215177
=== modified file 'testtools/tests/test_helpers.py'
--- testtools/tests/test_helpers.py 2011-08-15 13:48:10 +0000
+++ testtools/tests/test_helpers.py 2011-09-09 17:39:51 +0000
@@ -1,7 +1,10 @@
1# Copyright (c) 2010-2011 testtools developers. See LICENSE for details.1# Copyright (c) 2010-2011 testtools developers. See LICENSE for details.
22
3import doctest
4
3from testtools import TestCase5from testtools import TestCase
4from testtools.helpers import (6from testtools.helpers import (
7 SmartOutputChecker,
5 try_import,8 try_import,
6 try_imports,9 try_imports,
7 )10 )
@@ -235,6 +238,82 @@
235 self.assertThat(self.modules, StackHidden(False))238 self.assertThat(self.modules, StackHidden(False))
236239
237240
241class TestSmartOutputChecker(TestCase):
242
243 def test_ellipsis_matches(self):
244 checker = SmartOutputChecker()
245 self.assertTrue(checker.check_output('x...', 'xyz', doctest.ELLIPSIS))
246
247 def test_normalize_whitespace_multiple_blank_lines(self):
248 checker = SmartOutputChecker()
249 output = checker.normalize_whitespace('a\n\n\n\nb')
250 self.assertEqual('a\n\nb', output)
251
252 def test_diff_with_normalize_whitespace(self):
253 # If we're checking with normalized whitespace, then normalize the
254 # whitespace in the diff.
255 checker = SmartOutputChecker()
256 example = doctest.Example('f()', 'a b\nc\n')
257 diff = checker.output_difference(
258 example, 'a b\nd\n',
259 doctest.NORMALIZE_WHITESPACE | doctest.REPORT_NDIFF)
260 self.assertEqual(
261 ('Differences (ndiff with -expected +actual):\n'
262 ' a b\n'
263 ' - c\n'
264 ' + d\n'), diff)
265
266 def test_ellipsis(self):
267 # If we're checking with ellipsis, then lines that match based on
268 # ellipsis matching should not appear as different in the diff.
269 checker = SmartOutputChecker()
270 example = doctest.Example('f()', 'a...\nb...\nc...\n')
271 diff = checker.output_difference(
272 example, 'apple\nbobble\ndog\n',
273 doctest.ELLIPSIS | doctest.REPORT_NDIFF)
274 self.assertEqual(
275 ('Differences (ndiff with -expected +actual):\n'
276 ' a...\n'
277 ' - b...\n'
278 ' - c...\n'
279 ' + bobble\n'
280 ' + dog\n'), diff)
281
282 def test_no_ellipsis(self):
283 got = SmartOutputChecker().ellipsize('xxx', 'apple\ncat\n')
284 self.assertEqual('apple\ncat\n', got)
285
286 def test_starts_with_literal(self):
287 got = SmartOutputChecker().ellipsize('app...', 'apple\ncat\n')
288 self.assertEqual('app...', got)
289
290 def test_starts_with_literal_mismatch(self):
291 got = SmartOutputChecker().ellipsize('app...', 'cattle\n')
292 self.assertEqual('cattle\n', got)
293
294 def test_ends_with_literal(self):
295 got = SmartOutputChecker().ellipsize('...cat\n', 'apple\ncat\n')
296 self.assertEqual('...cat\n', got)
297
298 def test_ends_with_literal_mismatch(self):
299 got = SmartOutputChecker().ellipsize('...cat\n', 'apple\ndog\n')
300 self.assertEqual('apple\ndog\n', got)
301
302 def test_surrounded_by_literals(self):
303 got = SmartOutputChecker().ellipsize(
304 'app...cat\n', 'apple\ncat\n')
305 self.assertEqual('app...cat\n', got)
306
307 def test_partial_match(self):
308 got = SmartOutputChecker().ellipsize(
309 'app...\nc...\nd...\n', 'apple\ncat\negg\n')
310 self.assertEqual('app...\ncat\negg\n', got)
311
312 def test_too_many_literals(self):
313 got = SmartOutputChecker().ellipsize('aa...aa', 'aaa')
314 self.assertEqual('aaa', got)
315
316
238def test_suite():317def test_suite():
239 from unittest import TestLoader318 from unittest import TestLoader
240 return TestLoader().loadTestsFromName(__name__)319 return TestLoader().loadTestsFromName(__name__)

Subscribers

People subscribed via source and target branches