##// END OF EJS Templates
Merge pull request #13523 from Kojoley/ipdoctest-pytest7...
Matthias Bussonnier -
r27521:f23c369d merge
parent child Browse files
Show More
@@ -14,6 +14,7 b' import traceback'
14 14 import types
15 15 import warnings
16 16 from contextlib import contextmanager
17 from pathlib import Path
17 18 from typing import Any
18 19 from typing import Callable
19 20 from typing import Dict
@@ -28,8 +29,6 b' from typing import Type'
28 29 from typing import TYPE_CHECKING
29 30 from typing import Union
30 31
31 import py.path
32
33 32 import pytest
34 33 from _pytest import outcomes
35 34 from _pytest._code.code import ExceptionInfo
@@ -42,6 +41,7 b' from _pytest.config.argparsing import Parser'
42 41 from _pytest.fixtures import FixtureRequest
43 42 from _pytest.nodes import Collector
44 43 from _pytest.outcomes import OutcomeException
44 from _pytest.pathlib import fnmatch_ex
45 45 from _pytest.pathlib import import_path
46 46 from _pytest.python_api import approx
47 47 from _pytest.warning_types import PytestWarning
@@ -126,35 +126,55 b' def pytest_unconfigure() -> None:'
126 126
127 127
128 128 def pytest_collect_file(
129 path: py.path.local,
129 file_path: Path,
130 130 parent: Collector,
131 131 ) -> Optional[Union["IPDoctestModule", "IPDoctestTextfile"]]:
132 132 config = parent.config
133 if path.ext == ".py":
134 if config.option.ipdoctestmodules and not _is_setup_py(path):
135 mod: IPDoctestModule = IPDoctestModule.from_parent(parent, fspath=path)
133 if file_path.suffix == ".py":
134 if config.option.ipdoctestmodules and not any(
135 (_is_setup_py(file_path), _is_main_py(file_path))
136 ):
137 mod: IPDoctestModule = IPDoctestModule.from_parent(parent, path=file_path)
136 138 return mod
137 elif _is_ipdoctest(config, path, parent):
138 txt: IPDoctestTextfile = IPDoctestTextfile.from_parent(parent, fspath=path)
139 elif _is_ipdoctest(config, file_path, parent):
140 txt: IPDoctestTextfile = IPDoctestTextfile.from_parent(parent, path=file_path)
139 141 return txt
140 142 return None
141 143
142 144
143 def _is_setup_py(path: py.path.local) -> bool:
144 if path.basename != "setup.py":
145 if int(pytest.__version__.split(".")[0]) < 7:
146 _collect_file = pytest_collect_file
147
148 def pytest_collect_file(
149 path,
150 parent: Collector,
151 ) -> Optional[Union["IPDoctestModule", "IPDoctestTextfile"]]:
152 return _collect_file(Path(path), parent)
153
154 _import_path = import_path
155
156 def import_path(path, root):
157 import py.path
158
159 return _import_path(py.path.local(path))
160
161
162 def _is_setup_py(path: Path) -> bool:
163 if path.name != "setup.py":
145 164 return False
146 contents = path.read_binary()
165 contents = path.read_bytes()
147 166 return b"setuptools" in contents or b"distutils" in contents
148 167
149 168
150 def _is_ipdoctest(config: Config, path: py.path.local, parent) -> bool:
151 if path.ext in (".txt", ".rst") and parent.session.isinitpath(path):
169 def _is_ipdoctest(config: Config, path: Path, parent: Collector) -> bool:
170 if path.suffix in (".txt", ".rst") and parent.session.isinitpath(path):
152 171 return True
153 172 globs = config.getoption("ipdoctestglob") or ["test*.txt"]
154 for glob in globs:
155 if path.check(fnmatch=glob):
156 return True
157 return False
173 return any(fnmatch_ex(glob, path) for glob in globs)
174
175
176 def _is_main_py(path: Path) -> bool:
177 return path.name == "__main__.py"
158 178
159 179
160 180 class ReprFailDoctest(TerminalRepr):
@@ -273,7 +293,7 b' class IPDoctestItem(pytest.Item):'
273 293 runner: "IPDocTestRunner",
274 294 dtest: "doctest.DocTest",
275 295 ):
276 # incompatible signature due to to imposed limits on sublcass
296 # incompatible signature due to imposed limits on subclass
277 297 """The public named constructor."""
278 298 return super().from_parent(name=name, parent=parent, runner=runner, dtest=dtest)
279 299
@@ -372,61 +392,63 b' class IPDoctestItem(pytest.Item):'
372 392 elif isinstance(excinfo.value, MultipleDoctestFailures):
373 393 failures = excinfo.value.failures
374 394
375 if failures is not 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:
395 if failures is None:
425 396 return super().repr_failure(excinfo)
426 397
427 def reportinfo(self):
398 reprlocation_lines = []
399 for failure in failures:
400 example = failure.example
401 test = failure.test
402 filename = test.filename
403 if test.lineno is None:
404 lineno = None
405 else:
406 lineno = test.lineno + example.lineno + 1
407 message = type(failure).__name__
408 # TODO: ReprFileLocation doesn't expect a None lineno.
409 reprlocation = ReprFileLocation(filename, lineno, message) # type: ignore[arg-type]
410 checker = _get_checker()
411 report_choice = _get_report_choice(self.config.getoption("ipdoctestreport"))
412 if lineno is not None:
413 assert failure.test.docstring is not None
414 lines = failure.test.docstring.splitlines(False)
415 # add line numbers to the left of the error message
416 assert test.lineno is not None
417 lines = [
418 "%03d %s" % (i + test.lineno + 1, x) for (i, x) in enumerate(lines)
419 ]
420 # trim docstring error lines to 10
421 lines = lines[max(example.lineno - 9, 0) : example.lineno + 1]
422 else:
423 lines = [
424 "EXAMPLE LOCATION UNKNOWN, not showing all tests of that example"
425 ]
426 indent = ">>>"
427 for line in example.source.splitlines():
428 lines.append(f"??? {indent} {line}")
429 indent = "..."
430 if isinstance(failure, doctest.DocTestFailure):
431 lines += checker.output_difference(
432 example, failure.got, report_choice
433 ).split("\n")
434 else:
435 inner_excinfo = ExceptionInfo.from_exc_info(failure.exc_info)
436 lines += ["UNEXPECTED EXCEPTION: %s" % repr(inner_excinfo.value)]
437 lines += [
438 x.strip("\n") for x in traceback.format_exception(*failure.exc_info)
439 ]
440 reprlocation_lines.append((reprlocation, lines))
441 return ReprFailDoctest(reprlocation_lines)
442
443 def reportinfo(self) -> Tuple[Union["os.PathLike[str]", str], Optional[int], str]:
428 444 assert self.dtest is not None
429 return self.fspath, self.dtest.lineno, "[ipdoctest] %s" % self.name
445 return self.path, self.dtest.lineno, "[ipdoctest] %s" % self.name
446
447 if int(pytest.__version__.split(".")[0]) < 7:
448
449 @property
450 def path(self) -> Path:
451 return Path(self.fspath)
430 452
431 453
432 454 def _get_flag_lookup() -> Dict[str, int]:
@@ -474,9 +496,9 b' class IPDoctestTextfile(pytest.Module):'
474 496 # Inspired by doctest.testfile; ideally we would use it directly,
475 497 # but it doesn't support passing a custom checker.
476 498 encoding = self.config.getini("ipdoctest_encoding")
477 text = self.fspath.read_text(encoding)
478 filename = str(self.fspath)
479 name = self.fspath.basename
499 text = self.path.read_text(encoding)
500 filename = str(self.path)
501 name = self.path.name
480 502 globs = {"__name__": "__main__"}
481 503
482 504 optionflags = get_optionflags(self)
@@ -495,6 +517,27 b' class IPDoctestTextfile(pytest.Module):'
495 517 self, name=test.name, runner=runner, dtest=test
496 518 )
497 519
520 if int(pytest.__version__.split(".")[0]) < 7:
521
522 @property
523 def path(self) -> Path:
524 return Path(self.fspath)
525
526 @classmethod
527 def from_parent(
528 cls,
529 parent,
530 *,
531 fspath=None,
532 path: Optional[Path] = None,
533 **kw,
534 ):
535 if path is not None:
536 import py.path
537
538 fspath = py.path.local(path)
539 return super().from_parent(parent=parent, fspath=fspath, **kw)
540
498 541
499 542 def _check_all_skipped(test: "doctest.DocTest") -> None:
500 543 """Raise pytest.skip() if all examples in the given DocTest have the SKIP
@@ -559,15 +602,20 b' class IPDoctestModule(pytest.Module):'
559 602
560 603 def _find_lineno(self, obj, source_lines):
561 604 """Doctest code does not take into account `@property`, this
562 is a hackish way to fix it.
605 is a hackish way to fix it. https://bugs.python.org/issue17446
563 606
564 https://bugs.python.org/issue17446
607 Wrapped Doctests will need to be unwrapped so the correct
608 line number is returned. This will be reported upstream. #8796
565 609 """
566 610 if isinstance(obj, property):
567 611 obj = getattr(obj, "fget", obj)
612
613 if hasattr(obj, "__wrapped__"):
614 # Get the main obj in case of it being wrapped
615 obj = inspect.unwrap(obj)
616
568 617 # Type ignored because this is a private function.
569 return DocTestFinder._find_lineno( # type: ignore
570 self,
618 return super()._find_lineno( # type:ignore[misc]
571 619 obj,
572 620 source_lines,
573 621 )
@@ -580,20 +628,28 b' class IPDoctestModule(pytest.Module):'
580 628 with _patch_unwrap_mock_aware():
581 629
582 630 # Type ignored because this is a private function.
583 DocTestFinder._find( # type: ignore
584 self, tests, obj, name, module, source_lines, globs, seen
631 super()._find( # type:ignore[misc]
632 tests, obj, name, module, source_lines, globs, seen
585 633 )
586 634
587 if self.fspath.basename == "conftest.py":
588 module = self.config.pluginmanager._importconftest(
589 self.fspath, self.config.getoption("importmode")
590 )
635 if self.path.name == "conftest.py":
636 if int(pytest.__version__.split(".")[0]) < 7:
637 module = self.config.pluginmanager._importconftest(
638 self.path,
639 self.config.getoption("importmode"),
640 )
641 else:
642 module = self.config.pluginmanager._importconftest(
643 self.path,
644 self.config.getoption("importmode"),
645 rootpath=self.config.rootpath,
646 )
591 647 else:
592 648 try:
593 module = import_path(self.fspath)
649 module = import_path(self.path, root=self.config.rootpath)
594 650 except ImportError:
595 651 if self.config.getvalue("ipdoctest_ignore_import_errors"):
596 pytest.skip("unable to import module %r" % self.fspath)
652 pytest.skip("unable to import module %r" % self.path)
597 653 else:
598 654 raise
599 655 # Uses internal doctest module parsing mechanism.
@@ -612,6 +668,27 b' class IPDoctestModule(pytest.Module):'
612 668 self, name=test.name, runner=runner, dtest=test
613 669 )
614 670
671 if int(pytest.__version__.split(".")[0]) < 7:
672
673 @property
674 def path(self) -> Path:
675 return Path(self.fspath)
676
677 @classmethod
678 def from_parent(
679 cls,
680 parent,
681 *,
682 fspath=None,
683 path: Optional[Path] = None,
684 **kw,
685 ):
686 if path is not None:
687 import py.path
688
689 fspath = py.path.local(path)
690 return super().from_parent(parent=parent, fspath=fspath, **kw)
691
615 692
616 693 def _setup_fixtures(doctest_item: IPDoctestItem) -> FixtureRequest:
617 694 """Used by IPDoctestTextfile and IPDoctestItem to setup fixture information."""
@@ -665,7 +742,7 b' def _init_checker_class() -> Type["IPDoctestOutputChecker"]:'
665 742 )
666 743
667 744 def check_output(self, want: str, got: str, optionflags: int) -> bool:
668 if IPDoctestOutputChecker.check_output(self, want, got, optionflags):
745 if super().check_output(want, got, optionflags):
669 746 return True
670 747
671 748 allow_unicode = optionflags & _get_allow_unicode_flag()
@@ -689,7 +766,7 b' def _init_checker_class() -> Type["IPDoctestOutputChecker"]:'
689 766 if allow_number:
690 767 got = self._remove_unwanted_precision(want, got)
691 768
692 return IPDoctestOutputChecker.check_output(self, want, got, optionflags)
769 return super().check_output(want, got, optionflags)
693 770
694 771 def _remove_unwanted_precision(self, want: str, got: str) -> str:
695 772 wants = list(self._number_re.finditer(want))
@@ -702,10 +779,7 b' def _init_checker_class() -> Type["IPDoctestOutputChecker"]:'
702 779 exponent: Optional[str] = w.group("exponent1")
703 780 if exponent is None:
704 781 exponent = w.group("exponent2")
705 if fraction is None:
706 precision = 0
707 else:
708 precision = len(fraction)
782 precision = 0 if fraction is None else len(fraction)
709 783 if exponent is not None:
710 784 precision -= int(exponent)
711 785 if float(w.group()) == approx(float(g.group()), abs=10 ** -precision):
@@ -63,7 +63,7 b' qtconsole ='
63 63 qtconsole
64 64 terminal =
65 65 test =
66 pytest<7
66 pytest
67 67 pytest-asyncio
68 68 testpath
69 69 test_extra =
@@ -72,7 +72,7 b' test_extra ='
72 72 nbformat
73 73 numpy>=1.19
74 74 pandas
75 pytest<7
75 pytest
76 76 testpath
77 77 trio
78 78 all =
General Comments 0
You need to be logged in to leave comments. Login now