##// 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 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,55 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 if int(pytest.__version__.split(".")[0]) < 7:
144 if path.basename != "setup.py":
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 return False
164 return False
146 contents = path.read_binary()
165 contents = path.read_bytes()
147 return b"setuptools" in contents or b"distutils" in contents
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:
169 def _is_ipdoctest(config: Config, path: Path, parent: Collector) -> bool:
151 if path.ext in (".txt", ".rst") and parent.session.isinitpath(path):
170 if path.suffix in (".txt", ".rst") and parent.session.isinitpath(path):
152 return True
171 return True
153 globs = config.getoption("ipdoctestglob") or ["test*.txt"]
172 globs = config.getoption("ipdoctestglob") or ["test*.txt"]
154 for glob in globs:
173 return any(fnmatch_ex(glob, path) for glob in globs)
155 if path.check(fnmatch=glob):
174
156 return True
175
157 return False
176 def _is_main_py(path: Path) -> bool:
177 return path.name == "__main__.py"
158
178
159
179
160 class ReprFailDoctest(TerminalRepr):
180 class ReprFailDoctest(TerminalRepr):
@@ -273,7 +293,7 b' class IPDoctestItem(pytest.Item):'
273 runner: "IPDocTestRunner",
293 runner: "IPDocTestRunner",
274 dtest: "doctest.DocTest",
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 """The public named constructor."""
297 """The public named constructor."""
278 return super().from_parent(name=name, parent=parent, runner=runner, dtest=dtest)
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 elif isinstance(excinfo.value, MultipleDoctestFailures):
392 elif isinstance(excinfo.value, MultipleDoctestFailures):
373 failures = excinfo.value.failures
393 failures = excinfo.value.failures
374
394
375 if failures is not None:
395 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)
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 assert self.dtest is not None
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 def _get_flag_lookup() -> Dict[str, int]:
454 def _get_flag_lookup() -> Dict[str, int]:
@@ -474,9 +496,9 b' class IPDoctestTextfile(pytest.Module):'
474 # Inspired by doctest.testfile; ideally we would use it directly,
496 # Inspired by doctest.testfile; ideally we would use it directly,
475 # but it doesn't support passing a custom checker.
497 # but it doesn't support passing a custom checker.
476 encoding = self.config.getini("ipdoctest_encoding")
498 encoding = self.config.getini("ipdoctest_encoding")
477 text = self.fspath.read_text(encoding)
499 text = self.path.read_text(encoding)
478 filename = str(self.fspath)
500 filename = str(self.path)
479 name = self.fspath.basename
501 name = self.path.name
480 globs = {"__name__": "__main__"}
502 globs = {"__name__": "__main__"}
481
503
482 optionflags = get_optionflags(self)
504 optionflags = get_optionflags(self)
@@ -495,6 +517,27 b' class IPDoctestTextfile(pytest.Module):'
495 self, name=test.name, runner=runner, dtest=test
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 def _check_all_skipped(test: "doctest.DocTest") -> None:
542 def _check_all_skipped(test: "doctest.DocTest") -> None:
500 """Raise pytest.skip() if all examples in the given DocTest have the SKIP
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 def _find_lineno(self, obj, source_lines):
603 def _find_lineno(self, obj, source_lines):
561 """Doctest code does not take into account `@property`, this
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 if isinstance(obj, property):
610 if isinstance(obj, property):
567 obj = getattr(obj, "fget", obj)
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 # Type ignored because this is a private function.
617 # Type ignored because this is a private function.
569 return DocTestFinder._find_lineno( # type: ignore
618 return super()._find_lineno( # type:ignore[misc]
570 self,
571 obj,
619 obj,
572 source_lines,
620 source_lines,
573 )
621 )
@@ -580,20 +628,28 b' class IPDoctestModule(pytest.Module):'
580 with _patch_unwrap_mock_aware():
628 with _patch_unwrap_mock_aware():
581
629
582 # Type ignored because this is a private function.
630 # Type ignored because this is a private function.
583 DocTestFinder._find( # type: ignore
631 super()._find( # type:ignore[misc]
584 self, tests, obj, name, module, source_lines, globs, seen
632 tests, obj, name, module, source_lines, globs, seen
585 )
633 )
586
634
587 if self.fspath.basename == "conftest.py":
635 if self.path.name == "conftest.py":
588 module = self.config.pluginmanager._importconftest(
636 if int(pytest.__version__.split(".")[0]) < 7:
589 self.fspath, self.config.getoption("importmode")
637 module = self.config.pluginmanager._importconftest(
590 )
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 else:
647 else:
592 try:
648 try:
593 module = import_path(self.fspath)
649 module = import_path(self.path, root=self.config.rootpath)
594 except ImportError:
650 except ImportError:
595 if self.config.getvalue("ipdoctest_ignore_import_errors"):
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 else:
653 else:
598 raise
654 raise
599 # Uses internal doctest module parsing mechanism.
655 # Uses internal doctest module parsing mechanism.
@@ -612,6 +668,27 b' class IPDoctestModule(pytest.Module):'
612 self, name=test.name, runner=runner, dtest=test
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 def _setup_fixtures(doctest_item: IPDoctestItem) -> FixtureRequest:
693 def _setup_fixtures(doctest_item: IPDoctestItem) -> FixtureRequest:
617 """Used by IPDoctestTextfile and IPDoctestItem to setup fixture information."""
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 def check_output(self, want: str, got: str, optionflags: int) -> bool:
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 return True
746 return True
670
747
671 allow_unicode = optionflags & _get_allow_unicode_flag()
748 allow_unicode = optionflags & _get_allow_unicode_flag()
@@ -689,7 +766,7 b' def _init_checker_class() -> Type["IPDoctestOutputChecker"]:'
689 if allow_number:
766 if allow_number:
690 got = self._remove_unwanted_precision(want, got)
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 def _remove_unwanted_precision(self, want: str, got: str) -> str:
771 def _remove_unwanted_precision(self, want: str, got: str) -> str:
695 wants = list(self._number_re.finditer(want))
772 wants = list(self._number_re.finditer(want))
@@ -702,10 +779,7 b' def _init_checker_class() -> Type["IPDoctestOutputChecker"]:'
702 exponent: Optional[str] = w.group("exponent1")
779 exponent: Optional[str] = w.group("exponent1")
703 if exponent is None:
780 if exponent is None:
704 exponent = w.group("exponent2")
781 exponent = w.group("exponent2")
705 if fraction is None:
782 precision = 0 if fraction is None else len(fraction)
706 precision = 0
707 else:
708 precision = len(fraction)
709 if exponent is not None:
783 if exponent is not None:
710 precision -= int(exponent)
784 precision -= int(exponent)
711 if float(w.group()) == approx(float(g.group()), abs=10 ** -precision):
785 if float(w.group()) == approx(float(g.group()), abs=10 ** -precision):
@@ -63,7 +63,7 b' qtconsole ='
63 qtconsole
63 qtconsole
64 terminal =
64 terminal =
65 test =
65 test =
66 pytest<7
66 pytest
67 pytest-asyncio
67 pytest-asyncio
68 testpath
68 testpath
69 test_extra =
69 test_extra =
@@ -72,7 +72,7 b' test_extra ='
72 nbformat
72 nbformat
73 numpy>=1.19
73 numpy>=1.19
74 pandas
74 pandas
75 pytest<7
75 pytest
76 testpath
76 testpath
77 trio
77 trio
78 all =
78 all =
General Comments 0
You need to be logged in to leave comments. Login now