##// END OF EJS Templates
ipdoctest: Merge upstream changes to pytest doctest plugin
Nikita Kniazev -
Show More
@@ -14,6 +14,7 b' import traceback'
14 import types
14 import types
15 import warnings
15 import warnings
16 from contextlib import contextmanager
16 from contextlib import contextmanager
17 from pathlib import Path
17 from typing import Any
18 from typing import Any
18 from typing import Callable
19 from typing import Callable
19 from typing import Dict
20 from typing import Dict
@@ -28,8 +29,6 b' from typing import Type'
28 from typing import TYPE_CHECKING
29 from typing import TYPE_CHECKING
29 from typing import Union
30 from typing import Union
30
31
31 import py.path
32
33 import pytest
32 import pytest
34 from _pytest import outcomes
33 from _pytest import outcomes
35 from _pytest._code.code import ExceptionInfo
34 from _pytest._code.code import ExceptionInfo
@@ -42,6 +41,7 b' from _pytest.config.argparsing import Parser'
42 from _pytest.fixtures import FixtureRequest
41 from _pytest.fixtures import FixtureRequest
43 from _pytest.nodes import Collector
42 from _pytest.nodes import Collector
44 from _pytest.outcomes import OutcomeException
43 from _pytest.outcomes import OutcomeException
44 from _pytest.pathlib import fnmatch_ex
45 from _pytest.pathlib import import_path
45 from _pytest.pathlib import import_path
46 from _pytest.python_api import approx
46 from _pytest.python_api import approx
47 from _pytest.warning_types import PytestWarning
47 from _pytest.warning_types import PytestWarning
@@ -126,35 +126,38 b' def pytest_unconfigure() -> None:'
126
126
127
127
128 def pytest_collect_file(
128 def pytest_collect_file(
129 path: py.path.local,
129 file_path: Path,
130 parent: Collector,
130 parent: Collector,
131 ) -> Optional[Union["IPDoctestModule", "IPDoctestTextfile"]]:
131 ) -> Optional[Union["IPDoctestModule", "IPDoctestTextfile"]]:
132 config = parent.config
132 config = parent.config
133 if path.ext == ".py":
133 if file_path.suffix == ".py":
134 if config.option.ipdoctestmodules and not _is_setup_py(path):
134 if config.option.ipdoctestmodules and not any(
135 mod: IPDoctestModule = IPDoctestModule.from_parent(parent, fspath=path)
135 (_is_setup_py(file_path), _is_main_py(file_path))
136 ):
137 mod: IPDoctestModule = IPDoctestModule.from_parent(parent, path=file_path)
136 return mod
138 return mod
137 elif _is_ipdoctest(config, path, parent):
139 elif _is_ipdoctest(config, file_path, parent):
138 txt: IPDoctestTextfile = IPDoctestTextfile.from_parent(parent, fspath=path)
140 txt: IPDoctestTextfile = IPDoctestTextfile.from_parent(parent, path=file_path)
139 return txt
141 return txt
140 return None
142 return None
141
143
142
144
143 def _is_setup_py(path: py.path.local) -> bool:
145 def _is_setup_py(path: Path) -> bool:
144 if path.basename != "setup.py":
146 if path.name != "setup.py":
145 return False
147 return False
146 contents = path.read_binary()
148 contents = path.read_bytes()
147 return b"setuptools" in contents or b"distutils" in contents
149 return b"setuptools" in contents or b"distutils" in contents
148
150
149
151
150 def _is_ipdoctest(config: Config, path: py.path.local, parent) -> bool:
152 def _is_ipdoctest(config: Config, path: Path, parent: Collector) -> bool:
151 if path.ext in (".txt", ".rst") and parent.session.isinitpath(path):
153 if path.suffix in (".txt", ".rst") and parent.session.isinitpath(path):
152 return True
154 return True
153 globs = config.getoption("ipdoctestglob") or ["test*.txt"]
155 globs = config.getoption("ipdoctestglob") or ["test*.txt"]
154 for glob in globs:
156 return any(fnmatch_ex(glob, path) for glob in globs)
155 if path.check(fnmatch=glob):
157
156 return True
158
157 return False
159 def _is_main_py(path: Path) -> bool:
160 return path.name == "__main__.py"
158
161
159
162
160 class ReprFailDoctest(TerminalRepr):
163 class ReprFailDoctest(TerminalRepr):
@@ -273,7 +276,7 b' class IPDoctestItem(pytest.Item):'
273 runner: "IPDocTestRunner",
276 runner: "IPDocTestRunner",
274 dtest: "doctest.DocTest",
277 dtest: "doctest.DocTest",
275 ):
278 ):
276 # incompatible signature due to to imposed limits on sublcass
279 # incompatible signature due to imposed limits on subclass
277 """The public named constructor."""
280 """The public named constructor."""
278 return super().from_parent(name=name, parent=parent, runner=runner, dtest=dtest)
281 return super().from_parent(name=name, parent=parent, runner=runner, dtest=dtest)
279
282
@@ -372,61 +375,57 b' class IPDoctestItem(pytest.Item):'
372 elif isinstance(excinfo.value, MultipleDoctestFailures):
375 elif isinstance(excinfo.value, MultipleDoctestFailures):
373 failures = excinfo.value.failures
376 failures = excinfo.value.failures
374
377
375 if failures is not None:
378 if failures is None:
376 reprlocation_lines = []
377 for failure in failures:
378 example = failure.example
379 test = failure.test
380 filename = test.filename
381 if test.lineno is None:
382 lineno = None
383 else:
384 lineno = test.lineno + example.lineno + 1
385 message = type(failure).__name__
386 # TODO: ReprFileLocation doesn't expect a None lineno.
387 reprlocation = ReprFileLocation(filename, lineno, message) # type: ignore[arg-type]
388 checker = _get_checker()
389 report_choice = _get_report_choice(
390 self.config.getoption("ipdoctestreport")
391 )
392 if lineno is not None:
393 assert failure.test.docstring is not None
394 lines = failure.test.docstring.splitlines(False)
395 # add line numbers to the left of the error message
396 assert test.lineno is not None
397 lines = [
398 "%03d %s" % (i + test.lineno + 1, x)
399 for (i, x) in enumerate(lines)
400 ]
401 # trim docstring error lines to 10
402 lines = lines[max(example.lineno - 9, 0) : example.lineno + 1]
403 else:
404 lines = [
405 "EXAMPLE LOCATION UNKNOWN, not showing all tests of that example"
406 ]
407 indent = ">>>"
408 for line in example.source.splitlines():
409 lines.append(f"??? {indent} {line}")
410 indent = "..."
411 if isinstance(failure, doctest.DocTestFailure):
412 lines += checker.output_difference(
413 example, failure.got, report_choice
414 ).split("\n")
415 else:
416 inner_excinfo = ExceptionInfo(failure.exc_info)
417 lines += ["UNEXPECTED EXCEPTION: %s" % repr(inner_excinfo.value)]
418 lines += [
419 x.strip("\n")
420 for x in traceback.format_exception(*failure.exc_info)
421 ]
422 reprlocation_lines.append((reprlocation, lines))
423 return ReprFailDoctest(reprlocation_lines)
424 else:
425 return super().repr_failure(excinfo)
379 return super().repr_failure(excinfo)
426
380
427 def reportinfo(self):
381 reprlocation_lines = []
382 for failure in failures:
383 example = failure.example
384 test = failure.test
385 filename = test.filename
386 if test.lineno is None:
387 lineno = None
388 else:
389 lineno = test.lineno + example.lineno + 1
390 message = type(failure).__name__
391 # TODO: ReprFileLocation doesn't expect a None lineno.
392 reprlocation = ReprFileLocation(filename, lineno, message) # type: ignore[arg-type]
393 checker = _get_checker()
394 report_choice = _get_report_choice(self.config.getoption("ipdoctestreport"))
395 if lineno is not None:
396 assert failure.test.docstring is not None
397 lines = failure.test.docstring.splitlines(False)
398 # add line numbers to the left of the error message
399 assert test.lineno is not None
400 lines = [
401 "%03d %s" % (i + test.lineno + 1, x) for (i, x) in enumerate(lines)
402 ]
403 # trim docstring error lines to 10
404 lines = lines[max(example.lineno - 9, 0) : example.lineno + 1]
405 else:
406 lines = [
407 "EXAMPLE LOCATION UNKNOWN, not showing all tests of that example"
408 ]
409 indent = ">>>"
410 for line in example.source.splitlines():
411 lines.append(f"??? {indent} {line}")
412 indent = "..."
413 if isinstance(failure, doctest.DocTestFailure):
414 lines += checker.output_difference(
415 example, failure.got, report_choice
416 ).split("\n")
417 else:
418 inner_excinfo = ExceptionInfo.from_exc_info(failure.exc_info)
419 lines += ["UNEXPECTED EXCEPTION: %s" % repr(inner_excinfo.value)]
420 lines += [
421 x.strip("\n") for x in traceback.format_exception(*failure.exc_info)
422 ]
423 reprlocation_lines.append((reprlocation, lines))
424 return ReprFailDoctest(reprlocation_lines)
425
426 def reportinfo(self) -> Tuple[Union["os.PathLike[str]", str], Optional[int], str]:
428 assert self.dtest is not None
427 assert self.dtest is not None
429 return self.fspath, self.dtest.lineno, "[ipdoctest] %s" % self.name
428 return self.path, self.dtest.lineno, "[ipdoctest] %s" % self.name
430
429
431
430
432 def _get_flag_lookup() -> Dict[str, int]:
431 def _get_flag_lookup() -> Dict[str, int]:
@@ -474,9 +473,9 b' class IPDoctestTextfile(pytest.Module):'
474 # Inspired by doctest.testfile; ideally we would use it directly,
473 # Inspired by doctest.testfile; ideally we would use it directly,
475 # but it doesn't support passing a custom checker.
474 # but it doesn't support passing a custom checker.
476 encoding = self.config.getini("ipdoctest_encoding")
475 encoding = self.config.getini("ipdoctest_encoding")
477 text = self.fspath.read_text(encoding)
476 text = self.path.read_text(encoding)
478 filename = str(self.fspath)
477 filename = str(self.path)
479 name = self.fspath.basename
478 name = self.path.name
480 globs = {"__name__": "__main__"}
479 globs = {"__name__": "__main__"}
481
480
482 optionflags = get_optionflags(self)
481 optionflags = get_optionflags(self)
@@ -559,15 +558,20 b' class IPDoctestModule(pytest.Module):'
559
558
560 def _find_lineno(self, obj, source_lines):
559 def _find_lineno(self, obj, source_lines):
561 """Doctest code does not take into account `@property`, this
560 """Doctest code does not take into account `@property`, this
562 is a hackish way to fix it.
561 is a hackish way to fix it. https://bugs.python.org/issue17446
563
562
564 https://bugs.python.org/issue17446
563 Wrapped Doctests will need to be unwrapped so the correct
564 line number is returned. This will be reported upstream. #8796
565 """
565 """
566 if isinstance(obj, property):
566 if isinstance(obj, property):
567 obj = getattr(obj, "fget", obj)
567 obj = getattr(obj, "fget", obj)
568
569 if hasattr(obj, "__wrapped__"):
570 # Get the main obj in case of it being wrapped
571 obj = inspect.unwrap(obj)
572
568 # Type ignored because this is a private function.
573 # Type ignored because this is a private function.
569 return DocTestFinder._find_lineno( # type: ignore
574 return super()._find_lineno( # type:ignore[misc]
570 self,
571 obj,
575 obj,
572 source_lines,
576 source_lines,
573 )
577 )
@@ -580,20 +584,22 b' class IPDoctestModule(pytest.Module):'
580 with _patch_unwrap_mock_aware():
584 with _patch_unwrap_mock_aware():
581
585
582 # Type ignored because this is a private function.
586 # Type ignored because this is a private function.
583 DocTestFinder._find( # type: ignore
587 super()._find( # type:ignore[misc]
584 self, tests, obj, name, module, source_lines, globs, seen
588 tests, obj, name, module, source_lines, globs, seen
585 )
589 )
586
590
587 if self.fspath.basename == "conftest.py":
591 if self.path.name == "conftest.py":
588 module = self.config.pluginmanager._importconftest(
592 module = self.config.pluginmanager._importconftest(
589 self.fspath, self.config.getoption("importmode")
593 self.path,
594 self.config.getoption("importmode"),
595 rootpath=self.config.rootpath,
590 )
596 )
591 else:
597 else:
592 try:
598 try:
593 module = import_path(self.fspath)
599 module = import_path(self.path, root=self.config.rootpath)
594 except ImportError:
600 except ImportError:
595 if self.config.getvalue("ipdoctest_ignore_import_errors"):
601 if self.config.getvalue("ipdoctest_ignore_import_errors"):
596 pytest.skip("unable to import module %r" % self.fspath)
602 pytest.skip("unable to import module %r" % self.path)
597 else:
603 else:
598 raise
604 raise
599 # Uses internal doctest module parsing mechanism.
605 # Uses internal doctest module parsing mechanism.
@@ -665,7 +671,7 b' def _init_checker_class() -> Type["IPDoctestOutputChecker"]:'
665 )
671 )
666
672
667 def check_output(self, want: str, got: str, optionflags: int) -> bool:
673 def check_output(self, want: str, got: str, optionflags: int) -> bool:
668 if IPDoctestOutputChecker.check_output(self, want, got, optionflags):
674 if super().check_output(want, got, optionflags):
669 return True
675 return True
670
676
671 allow_unicode = optionflags & _get_allow_unicode_flag()
677 allow_unicode = optionflags & _get_allow_unicode_flag()
@@ -689,7 +695,7 b' def _init_checker_class() -> Type["IPDoctestOutputChecker"]:'
689 if allow_number:
695 if allow_number:
690 got = self._remove_unwanted_precision(want, got)
696 got = self._remove_unwanted_precision(want, got)
691
697
692 return IPDoctestOutputChecker.check_output(self, want, got, optionflags)
698 return super().check_output(want, got, optionflags)
693
699
694 def _remove_unwanted_precision(self, want: str, got: str) -> str:
700 def _remove_unwanted_precision(self, want: str, got: str) -> str:
695 wants = list(self._number_re.finditer(want))
701 wants = list(self._number_re.finditer(want))
@@ -702,13 +708,10 b' def _init_checker_class() -> Type["IPDoctestOutputChecker"]:'
702 exponent: Optional[str] = w.group("exponent1")
708 exponent: Optional[str] = w.group("exponent1")
703 if exponent is None:
709 if exponent is None:
704 exponent = w.group("exponent2")
710 exponent = w.group("exponent2")
705 if fraction is None:
711 precision = 0 if fraction is None else len(fraction)
706 precision = 0
707 else:
708 precision = len(fraction)
709 if exponent is not None:
712 if exponent is not None:
710 precision -= int(exponent)
713 precision -= int(exponent)
711 if float(w.group()) == approx(float(g.group()), abs=10 ** -precision):
714 if float(w.group()) == approx(float(g.group()), abs=10**-precision):
712 # They're close enough. Replace the text we actually
715 # They're close enough. Replace the text we actually
713 # got with the text we want, so that it will match when we
716 # got with the text we want, so that it will match when we
714 # check the string literally.
717 # check the string literally.
General Comments 0
You need to be logged in to leave comments. Login now