Fix check_untyped_defs errors in doctest

In order to make the LiteralOutputChecker lazy initialization more
amenable to type checking, I changed it to match the scheme already used
in this file to lazy-initialize PytestDoctestRunner.
This commit is contained in:
Ran Benita 2019-08-30 10:35:08 +03:00
parent 3246d8a6e9
commit 1984c10427
2 changed files with 65 additions and 37 deletions

View File

@ -6,8 +6,12 @@ import sys
import traceback import traceback
import warnings import warnings
from contextlib import contextmanager from contextlib import contextmanager
from typing import Dict
from typing import List
from typing import Optional
from typing import Sequence from typing import Sequence
from typing import Tuple from typing import Tuple
from typing import Union
import pytest import pytest
from _pytest import outcomes from _pytest import outcomes
@ -20,6 +24,10 @@ from _pytest.outcomes import Skipped
from _pytest.python_api import approx from _pytest.python_api import approx
from _pytest.warning_types import PytestWarning from _pytest.warning_types import PytestWarning
if False: # TYPE_CHECKING
import doctest
from typing import Type
DOCTEST_REPORT_CHOICE_NONE = "none" DOCTEST_REPORT_CHOICE_NONE = "none"
DOCTEST_REPORT_CHOICE_CDIFF = "cdiff" DOCTEST_REPORT_CHOICE_CDIFF = "cdiff"
DOCTEST_REPORT_CHOICE_NDIFF = "ndiff" DOCTEST_REPORT_CHOICE_NDIFF = "ndiff"
@ -36,6 +44,8 @@ DOCTEST_REPORT_CHOICES = (
# Lazy definition of runner class # Lazy definition of runner class
RUNNER_CLASS = None RUNNER_CLASS = None
# Lazy definition of output checker class
CHECKER_CLASS = None # type: Optional[Type[doctest.OutputChecker]]
def pytest_addoption(parser): def pytest_addoption(parser):
@ -139,7 +149,7 @@ class MultipleDoctestFailures(Exception):
self.failures = failures self.failures = failures
def _init_runner_class(): def _init_runner_class() -> "Type[doctest.DocTestRunner]":
import doctest import doctest
class PytestDoctestRunner(doctest.DebugRunner): class PytestDoctestRunner(doctest.DebugRunner):
@ -177,12 +187,19 @@ def _init_runner_class():
return PytestDoctestRunner return PytestDoctestRunner
def _get_runner(checker=None, verbose=None, optionflags=0, continue_on_failure=True): def _get_runner(
checker: Optional["doctest.OutputChecker"] = None,
verbose: Optional[bool] = None,
optionflags: int = 0,
continue_on_failure: bool = True,
) -> "doctest.DocTestRunner":
# We need this in order to do a lazy import on doctest # We need this in order to do a lazy import on doctest
global RUNNER_CLASS global RUNNER_CLASS
if RUNNER_CLASS is None: if RUNNER_CLASS is None:
RUNNER_CLASS = _init_runner_class() RUNNER_CLASS = _init_runner_class()
return RUNNER_CLASS( # Type ignored because the continue_on_failure argument is only defined on
# PytestDoctestRunner, which is lazily defined so can't be used as a type.
return RUNNER_CLASS( # type: ignore
checker=checker, checker=checker,
verbose=verbose, verbose=verbose,
optionflags=optionflags, optionflags=optionflags,
@ -211,7 +228,7 @@ class DoctestItem(pytest.Item):
def runtest(self): def runtest(self):
_check_all_skipped(self.dtest) _check_all_skipped(self.dtest)
self._disable_output_capturing_for_darwin() self._disable_output_capturing_for_darwin()
failures = [] failures = [] # type: List[doctest.DocTestFailure]
self.runner.run(self.dtest, out=failures) self.runner.run(self.dtest, out=failures)
if failures: if failures:
raise MultipleDoctestFailures(failures) raise MultipleDoctestFailures(failures)
@ -232,7 +249,9 @@ class DoctestItem(pytest.Item):
def repr_failure(self, excinfo): def repr_failure(self, excinfo):
import doctest import doctest
failures = None failures = (
None
) # type: Optional[List[Union[doctest.DocTestFailure, doctest.UnexpectedException]]]
if excinfo.errisinstance((doctest.DocTestFailure, doctest.UnexpectedException)): if excinfo.errisinstance((doctest.DocTestFailure, doctest.UnexpectedException)):
failures = [excinfo.value] failures = [excinfo.value]
elif excinfo.errisinstance(MultipleDoctestFailures): elif excinfo.errisinstance(MultipleDoctestFailures):
@ -255,8 +274,10 @@ class DoctestItem(pytest.Item):
self.config.getoption("doctestreport") self.config.getoption("doctestreport")
) )
if lineno is not None: if lineno is not None:
assert failure.test.docstring is not None
lines = failure.test.docstring.splitlines(False) lines = failure.test.docstring.splitlines(False)
# add line numbers to the left of the error message # add line numbers to the left of the error message
assert test.lineno is not None
lines = [ lines = [
"%03d %s" % (i + test.lineno + 1, x) "%03d %s" % (i + test.lineno + 1, x)
for (i, x) in enumerate(lines) for (i, x) in enumerate(lines)
@ -288,7 +309,7 @@ class DoctestItem(pytest.Item):
return self.fspath, self.dtest.lineno, "[doctest] %s" % self.name return self.fspath, self.dtest.lineno, "[doctest] %s" % self.name
def _get_flag_lookup(): def _get_flag_lookup() -> Dict[str, int]:
import doctest import doctest
return dict( return dict(
@ -340,14 +361,16 @@ class DoctestTextfile(pytest.Module):
optionflags = get_optionflags(self) optionflags = get_optionflags(self)
runner = _get_runner( runner = _get_runner(
verbose=0, verbose=False,
optionflags=optionflags, optionflags=optionflags,
checker=_get_checker(), checker=_get_checker(),
continue_on_failure=_get_continue_on_failure(self.config), continue_on_failure=_get_continue_on_failure(self.config),
) )
parser = doctest.DocTestParser() parser = doctest.DocTestParser()
test = parser.get_doctest(text, globs, name, filename, 0) # Remove ignore once this reaches mypy:
# https://github.com/python/typeshed/commit/3e4a251b2b6da6bb43137acf5abf81ecfa7ba8ee
test = parser.get_doctest(text, globs, name, filename, 0) # type: ignore
if test.examples: if test.examples:
yield DoctestItem(test.name, self, runner, test) yield DoctestItem(test.name, self, runner, test)
@ -419,7 +442,8 @@ class DoctestModule(pytest.Module):
return return
with _patch_unwrap_mock_aware(): with _patch_unwrap_mock_aware():
doctest.DocTestFinder._find( # Type ignored because this is a private function.
doctest.DocTestFinder._find( # type: ignore
self, tests, obj, name, module, source_lines, globs, seen self, tests, obj, name, module, source_lines, globs, seen
) )
@ -437,7 +461,7 @@ class DoctestModule(pytest.Module):
finder = MockAwareDocTestFinder() finder = MockAwareDocTestFinder()
optionflags = get_optionflags(self) optionflags = get_optionflags(self)
runner = _get_runner( runner = _get_runner(
verbose=0, verbose=False,
optionflags=optionflags, optionflags=optionflags,
checker=_get_checker(), checker=_get_checker(),
continue_on_failure=_get_continue_on_failure(self.config), continue_on_failure=_get_continue_on_failure(self.config),
@ -466,24 +490,7 @@ def _setup_fixtures(doctest_item):
return fixture_request return fixture_request
def _get_checker(): def _init_checker_class() -> "Type[doctest.OutputChecker]":
"""
Returns a doctest.OutputChecker subclass that supports some
additional options:
* ALLOW_UNICODE and ALLOW_BYTES options to ignore u'' and b''
prefixes (respectively) in string literals. Useful when the same
doctest should run in Python 2 and Python 3.
* NUMBER to ignore floating-point differences smaller than the
precision of the literal number in the doctest.
An inner class is used to avoid importing "doctest" at the module
level.
"""
if hasattr(_get_checker, "LiteralsOutputChecker"):
return _get_checker.LiteralsOutputChecker()
import doctest import doctest
import re import re
@ -573,11 +580,31 @@ def _get_checker():
offset += w.end() - w.start() - (g.end() - g.start()) offset += w.end() - w.start() - (g.end() - g.start())
return got return got
_get_checker.LiteralsOutputChecker = LiteralsOutputChecker return LiteralsOutputChecker
return _get_checker.LiteralsOutputChecker()
def _get_allow_unicode_flag(): def _get_checker() -> "doctest.OutputChecker":
"""
Returns a doctest.OutputChecker subclass that supports some
additional options:
* ALLOW_UNICODE and ALLOW_BYTES options to ignore u'' and b''
prefixes (respectively) in string literals. Useful when the same
doctest should run in Python 2 and Python 3.
* NUMBER to ignore floating-point differences smaller than the
precision of the literal number in the doctest.
An inner class is used to avoid importing "doctest" at the module
level.
"""
global CHECKER_CLASS
if CHECKER_CLASS is None:
CHECKER_CLASS = _init_checker_class()
return CHECKER_CLASS()
def _get_allow_unicode_flag() -> int:
""" """
Registers and returns the ALLOW_UNICODE flag. Registers and returns the ALLOW_UNICODE flag.
""" """
@ -586,7 +613,7 @@ def _get_allow_unicode_flag():
return doctest.register_optionflag("ALLOW_UNICODE") return doctest.register_optionflag("ALLOW_UNICODE")
def _get_allow_bytes_flag(): def _get_allow_bytes_flag() -> int:
""" """
Registers and returns the ALLOW_BYTES flag. Registers and returns the ALLOW_BYTES flag.
""" """
@ -595,7 +622,7 @@ def _get_allow_bytes_flag():
return doctest.register_optionflag("ALLOW_BYTES") return doctest.register_optionflag("ALLOW_BYTES")
def _get_number_flag(): def _get_number_flag() -> int:
""" """
Registers and returns the NUMBER flag. Registers and returns the NUMBER flag.
""" """
@ -604,7 +631,7 @@ def _get_number_flag():
return doctest.register_optionflag("NUMBER") return doctest.register_optionflag("NUMBER")
def _get_report_choice(key): def _get_report_choice(key: str) -> int:
""" """
This function returns the actual `doctest` module flag value, we want to do it as late as possible to avoid This function returns the actual `doctest` module flag value, we want to do it as late as possible to avoid
importing `doctest` and all its dependencies when parsing options, as it adds overhead and breaks tests. importing `doctest` and all its dependencies when parsing options, as it adds overhead and breaks tests.

View File

@ -839,7 +839,8 @@ class TestLiterals:
reprec = testdir.inline_run() reprec = testdir.inline_run()
reprec.assertoutcome(failed=1) reprec.assertoutcome(failed=1)
def test_number_re(self): def test_number_re(self) -> None:
_number_re = _get_checker()._number_re # type: ignore
for s in [ for s in [
"1.", "1.",
"+1.", "+1.",
@ -861,12 +862,12 @@ class TestLiterals:
"-1.2e-3", "-1.2e-3",
]: ]:
print(s) print(s)
m = _get_checker()._number_re.match(s) m = _number_re.match(s)
assert m is not None assert m is not None
assert float(m.group()) == pytest.approx(float(s)) assert float(m.group()) == pytest.approx(float(s))
for s in ["1", "abc"]: for s in ["1", "abc"]:
print(s) print(s)
assert _get_checker()._number_re.match(s) is None assert _number_re.match(s) is None
@pytest.mark.parametrize("config_mode", ["ini", "comment"]) @pytest.mark.parametrize("config_mode", ["ini", "comment"])
def test_number_precision(self, testdir, config_mode): def test_number_precision(self, testdir, config_mode):