From 1984c10427adc264d7cdd1bedbb315792181c14b Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 30 Aug 2019 10:35:08 +0300 Subject: [PATCH] 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. --- src/_pytest/doctest.py | 95 ++++++++++++++++++++++++++--------------- testing/test_doctest.py | 7 +-- 2 files changed, 65 insertions(+), 37 deletions(-) diff --git a/src/_pytest/doctest.py b/src/_pytest/doctest.py index db1de1986..7449a56c8 100644 --- a/src/_pytest/doctest.py +++ b/src/_pytest/doctest.py @@ -6,8 +6,12 @@ import sys import traceback import warnings from contextlib import contextmanager +from typing import Dict +from typing import List +from typing import Optional from typing import Sequence from typing import Tuple +from typing import Union import pytest from _pytest import outcomes @@ -20,6 +24,10 @@ from _pytest.outcomes import Skipped from _pytest.python_api import approx from _pytest.warning_types import PytestWarning +if False: # TYPE_CHECKING + import doctest + from typing import Type + DOCTEST_REPORT_CHOICE_NONE = "none" DOCTEST_REPORT_CHOICE_CDIFF = "cdiff" DOCTEST_REPORT_CHOICE_NDIFF = "ndiff" @@ -36,6 +44,8 @@ DOCTEST_REPORT_CHOICES = ( # Lazy definition of runner class RUNNER_CLASS = None +# Lazy definition of output checker class +CHECKER_CLASS = None # type: Optional[Type[doctest.OutputChecker]] def pytest_addoption(parser): @@ -139,7 +149,7 @@ class MultipleDoctestFailures(Exception): self.failures = failures -def _init_runner_class(): +def _init_runner_class() -> "Type[doctest.DocTestRunner]": import doctest class PytestDoctestRunner(doctest.DebugRunner): @@ -177,12 +187,19 @@ def _init_runner_class(): 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 global RUNNER_CLASS if RUNNER_CLASS is None: 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, verbose=verbose, optionflags=optionflags, @@ -211,7 +228,7 @@ class DoctestItem(pytest.Item): def runtest(self): _check_all_skipped(self.dtest) self._disable_output_capturing_for_darwin() - failures = [] + failures = [] # type: List[doctest.DocTestFailure] self.runner.run(self.dtest, out=failures) if failures: raise MultipleDoctestFailures(failures) @@ -232,7 +249,9 @@ class DoctestItem(pytest.Item): def repr_failure(self, excinfo): import doctest - failures = None + failures = ( + None + ) # type: Optional[List[Union[doctest.DocTestFailure, doctest.UnexpectedException]]] if excinfo.errisinstance((doctest.DocTestFailure, doctest.UnexpectedException)): failures = [excinfo.value] elif excinfo.errisinstance(MultipleDoctestFailures): @@ -255,8 +274,10 @@ class DoctestItem(pytest.Item): self.config.getoption("doctestreport") ) if lineno is not None: + assert failure.test.docstring is not None lines = failure.test.docstring.splitlines(False) # add line numbers to the left of the error message + assert test.lineno is not None lines = [ "%03d %s" % (i + test.lineno + 1, x) for (i, x) in enumerate(lines) @@ -288,7 +309,7 @@ class DoctestItem(pytest.Item): return self.fspath, self.dtest.lineno, "[doctest] %s" % self.name -def _get_flag_lookup(): +def _get_flag_lookup() -> Dict[str, int]: import doctest return dict( @@ -340,14 +361,16 @@ class DoctestTextfile(pytest.Module): optionflags = get_optionflags(self) runner = _get_runner( - verbose=0, + verbose=False, optionflags=optionflags, checker=_get_checker(), continue_on_failure=_get_continue_on_failure(self.config), ) 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: yield DoctestItem(test.name, self, runner, test) @@ -419,7 +442,8 @@ class DoctestModule(pytest.Module): return 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 ) @@ -437,7 +461,7 @@ class DoctestModule(pytest.Module): finder = MockAwareDocTestFinder() optionflags = get_optionflags(self) runner = _get_runner( - verbose=0, + verbose=False, optionflags=optionflags, checker=_get_checker(), continue_on_failure=_get_continue_on_failure(self.config), @@ -466,24 +490,7 @@ def _setup_fixtures(doctest_item): return fixture_request -def _get_checker(): - """ - 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() - +def _init_checker_class() -> "Type[doctest.OutputChecker]": import doctest import re @@ -573,11 +580,31 @@ def _get_checker(): offset += w.end() - w.start() - (g.end() - g.start()) return got - _get_checker.LiteralsOutputChecker = LiteralsOutputChecker - return _get_checker.LiteralsOutputChecker() + return 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. """ @@ -586,7 +613,7 @@ def _get_allow_unicode_flag(): return doctest.register_optionflag("ALLOW_UNICODE") -def _get_allow_bytes_flag(): +def _get_allow_bytes_flag() -> int: """ Registers and returns the ALLOW_BYTES flag. """ @@ -595,7 +622,7 @@ def _get_allow_bytes_flag(): return doctest.register_optionflag("ALLOW_BYTES") -def _get_number_flag(): +def _get_number_flag() -> int: """ Registers and returns the NUMBER flag. """ @@ -604,7 +631,7 @@ def _get_number_flag(): 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 importing `doctest` and all its dependencies when parsing options, as it adds overhead and breaks tests. diff --git a/testing/test_doctest.py b/testing/test_doctest.py index 755f26286..37b3988f7 100644 --- a/testing/test_doctest.py +++ b/testing/test_doctest.py @@ -839,7 +839,8 @@ class TestLiterals: reprec = testdir.inline_run() 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 [ "1.", "+1.", @@ -861,12 +862,12 @@ class TestLiterals: "-1.2e-3", ]: print(s) - m = _get_checker()._number_re.match(s) + m = _number_re.match(s) assert m is not None assert float(m.group()) == pytest.approx(float(s)) for s in ["1", "abc"]: 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"]) def test_number_precision(self, testdir, config_mode):