1""" discover and run doctests in modules and test files."""
2from __future__ import absolute_import
3
4import traceback
5
6import pytest
7from _pytest._code.code import TerminalRepr, ReprFileLocation, ExceptionInfo
8from _pytest.python import FixtureRequest
9
10
11
12def pytest_addoption(parser):
13    parser.addini('doctest_optionflags', 'option flags for doctests',
14        type="args", default=["ELLIPSIS"])
15    group = parser.getgroup("collect")
16    group.addoption("--doctest-modules",
17        action="store_true", default=False,
18        help="run doctests in all .py modules",
19        dest="doctestmodules")
20    group.addoption("--doctest-glob",
21        action="append", default=[], metavar="pat",
22        help="doctests file matching pattern, default: test*.txt",
23        dest="doctestglob")
24    group.addoption("--doctest-ignore-import-errors",
25        action="store_true", default=False,
26        help="ignore doctest ImportErrors",
27        dest="doctest_ignore_import_errors")
28
29
30def pytest_collect_file(path, parent):
31    config = parent.config
32    if path.ext == ".py":
33        if config.option.doctestmodules:
34            return DoctestModule(path, parent)
35    elif _is_doctest(config, path, parent):
36        return DoctestTextfile(path, parent)
37
38
39def _is_doctest(config, path, parent):
40    if path.ext in ('.txt', '.rst') and parent.session.isinitpath(path):
41        return True
42    globs = config.getoption("doctestglob") or ['test*.txt']
43    for glob in globs:
44        if path.check(fnmatch=glob):
45            return True
46    return False
47
48
49class ReprFailDoctest(TerminalRepr):
50
51    def __init__(self, reprlocation, lines):
52        self.reprlocation = reprlocation
53        self.lines = lines
54
55    def toterminal(self, tw):
56        for line in self.lines:
57            tw.line(line)
58        self.reprlocation.toterminal(tw)
59
60
61class DoctestItem(pytest.Item):
62
63    def __init__(self, name, parent, runner=None, dtest=None):
64        super(DoctestItem, self).__init__(name, parent)
65        self.runner = runner
66        self.dtest = dtest
67        self.obj = None
68        self.fixture_request = None
69
70    def setup(self):
71        if self.dtest is not None:
72            self.fixture_request = _setup_fixtures(self)
73            globs = dict(getfixture=self.fixture_request.getfuncargvalue)
74            self.dtest.globs.update(globs)
75
76    def runtest(self):
77        _check_all_skipped(self.dtest)
78        self.runner.run(self.dtest)
79
80    def repr_failure(self, excinfo):
81        import doctest
82        if excinfo.errisinstance((doctest.DocTestFailure,
83                                  doctest.UnexpectedException)):
84            doctestfailure = excinfo.value
85            example = doctestfailure.example
86            test = doctestfailure.test
87            filename = test.filename
88            if test.lineno is None:
89                lineno = None
90            else:
91                lineno = test.lineno + example.lineno + 1
92            message = excinfo.type.__name__
93            reprlocation = ReprFileLocation(filename, lineno, message)
94            checker = _get_checker()
95            REPORT_UDIFF = doctest.REPORT_UDIFF
96            if lineno is not None:
97                lines = doctestfailure.test.docstring.splitlines(False)
98                # add line numbers to the left of the error message
99                lines = ["%03d %s" % (i + test.lineno + 1, x)
100                         for (i, x) in enumerate(lines)]
101                # trim docstring error lines to 10
102                lines = lines[example.lineno - 9:example.lineno + 1]
103            else:
104                lines = ['EXAMPLE LOCATION UNKNOWN, not showing all tests of that example']
105                indent = '>>>'
106                for line in example.source.splitlines():
107                    lines.append('??? %s %s' % (indent, line))
108                    indent = '...'
109            if excinfo.errisinstance(doctest.DocTestFailure):
110                lines += checker.output_difference(example,
111                        doctestfailure.got, REPORT_UDIFF).split("\n")
112            else:
113                inner_excinfo = ExceptionInfo(excinfo.value.exc_info)
114                lines += ["UNEXPECTED EXCEPTION: %s" %
115                            repr(inner_excinfo.value)]
116                lines += traceback.format_exception(*excinfo.value.exc_info)
117            return ReprFailDoctest(reprlocation, lines)
118        else:
119            return super(DoctestItem, self).repr_failure(excinfo)
120
121    def reportinfo(self):
122        return self.fspath, None, "[doctest] %s" % self.name
123
124
125def _get_flag_lookup():
126    import doctest
127    return dict(DONT_ACCEPT_TRUE_FOR_1=doctest.DONT_ACCEPT_TRUE_FOR_1,
128                DONT_ACCEPT_BLANKLINE=doctest.DONT_ACCEPT_BLANKLINE,
129                NORMALIZE_WHITESPACE=doctest.NORMALIZE_WHITESPACE,
130                ELLIPSIS=doctest.ELLIPSIS,
131                IGNORE_EXCEPTION_DETAIL=doctest.IGNORE_EXCEPTION_DETAIL,
132                COMPARISON_FLAGS=doctest.COMPARISON_FLAGS,
133                ALLOW_UNICODE=_get_allow_unicode_flag(),
134                ALLOW_BYTES=_get_allow_bytes_flag(),
135                )
136
137
138def get_optionflags(parent):
139    optionflags_str = parent.config.getini("doctest_optionflags")
140    flag_lookup_table = _get_flag_lookup()
141    flag_acc = 0
142    for flag in optionflags_str:
143        flag_acc |= flag_lookup_table[flag]
144    return flag_acc
145
146
147class DoctestTextfile(DoctestItem, pytest.Module):
148
149    def runtest(self):
150        import doctest
151        fixture_request = _setup_fixtures(self)
152
153        # inspired by doctest.testfile; ideally we would use it directly,
154        # but it doesn't support passing a custom checker
155        text = self.fspath.read()
156        filename = str(self.fspath)
157        name = self.fspath.basename
158        globs = dict(getfixture=fixture_request.getfuncargvalue)
159        if '__name__' not in globs:
160            globs['__name__'] = '__main__'
161
162        optionflags = get_optionflags(self)
163        runner = doctest.DebugRunner(verbose=0, optionflags=optionflags,
164                                     checker=_get_checker())
165
166        parser = doctest.DocTestParser()
167        test = parser.get_doctest(text, globs, name, filename, 0)
168        _check_all_skipped(test)
169        runner.run(test)
170
171
172def _check_all_skipped(test):
173    """raises pytest.skip() if all examples in the given DocTest have the SKIP
174    option set.
175    """
176    import doctest
177    all_skipped = all(x.options.get(doctest.SKIP, False) for x in test.examples)
178    if all_skipped:
179        pytest.skip('all tests skipped by +SKIP option')
180
181
182class DoctestModule(pytest.Module):
183    def collect(self):
184        import doctest
185        if self.fspath.basename == "conftest.py":
186            module = self.config.pluginmanager._importconftest(self.fspath)
187        else:
188            try:
189                module = self.fspath.pyimport()
190            except ImportError:
191                if self.config.getvalue('doctest_ignore_import_errors'):
192                    pytest.skip('unable to import module %r' % self.fspath)
193                else:
194                    raise
195        # uses internal doctest module parsing mechanism
196        finder = doctest.DocTestFinder()
197        optionflags = get_optionflags(self)
198        runner = doctest.DebugRunner(verbose=0, optionflags=optionflags,
199                                     checker=_get_checker())
200        for test in finder.find(module, module.__name__):
201            if test.examples:  # skip empty doctests
202                yield DoctestItem(test.name, self, runner, test)
203
204
205def _setup_fixtures(doctest_item):
206    """
207    Used by DoctestTextfile and DoctestItem to setup fixture information.
208    """
209    def func():
210        pass
211
212    doctest_item.funcargs = {}
213    fm = doctest_item.session._fixturemanager
214    doctest_item._fixtureinfo = fm.getfixtureinfo(node=doctest_item, func=func,
215                                                  cls=None, funcargs=False)
216    fixture_request = FixtureRequest(doctest_item)
217    fixture_request._fillfixtures()
218    return fixture_request
219
220
221def _get_checker():
222    """
223    Returns a doctest.OutputChecker subclass that takes in account the
224    ALLOW_UNICODE option to ignore u'' prefixes in strings and ALLOW_BYTES
225    to strip b'' prefixes.
226    Useful when the same doctest should run in Python 2 and Python 3.
227
228    An inner class is used to avoid importing "doctest" at the module
229    level.
230    """
231    if hasattr(_get_checker, 'LiteralsOutputChecker'):
232        return _get_checker.LiteralsOutputChecker()
233
234    import doctest
235    import re
236
237    class LiteralsOutputChecker(doctest.OutputChecker):
238        """
239        Copied from doctest_nose_plugin.py from the nltk project:
240            https://github.com/nltk/nltk
241
242        Further extended to also support byte literals.
243        """
244
245        _unicode_literal_re = re.compile(r"(\W|^)[uU]([rR]?[\'\"])", re.UNICODE)
246        _bytes_literal_re = re.compile(r"(\W|^)[bB]([rR]?[\'\"])", re.UNICODE)
247
248        def check_output(self, want, got, optionflags):
249            res = doctest.OutputChecker.check_output(self, want, got,
250                                                     optionflags)
251            if res:
252                return True
253
254            allow_unicode = optionflags & _get_allow_unicode_flag()
255            allow_bytes = optionflags & _get_allow_bytes_flag()
256            if not allow_unicode and not allow_bytes:
257                return False
258
259            else:  # pragma: no cover
260                def remove_prefixes(regex, txt):
261                    return re.sub(regex, r'\1\2', txt)
262
263                if allow_unicode:
264                    want = remove_prefixes(self._unicode_literal_re, want)
265                    got = remove_prefixes(self._unicode_literal_re, got)
266                if allow_bytes:
267                    want = remove_prefixes(self._bytes_literal_re, want)
268                    got = remove_prefixes(self._bytes_literal_re, got)
269                res = doctest.OutputChecker.check_output(self, want, got,
270                                                         optionflags)
271                return res
272
273    _get_checker.LiteralsOutputChecker = LiteralsOutputChecker
274    return _get_checker.LiteralsOutputChecker()
275
276
277def _get_allow_unicode_flag():
278    """
279    Registers and returns the ALLOW_UNICODE flag.
280    """
281    import doctest
282    return doctest.register_optionflag('ALLOW_UNICODE')
283
284
285def _get_allow_bytes_flag():
286    """
287    Registers and returns the ALLOW_BYTES flag.
288    """
289    import doctest
290    return doctest.register_optionflag('ALLOW_BYTES')
291