1 # Based on Pytest doctest.py 2 # Original license: 3 # The MIT License (MIT) 4 # 5 # Copyright (c) 2004-2021 Holger Krekel and others 6 """Discover and run ipdoctests in modules and test files.""" 7 import bdb 8 import builtins 9 import inspect 10 import os 11 import platform 12 import sys 13 import traceback 14 import types 15 import warnings 16 from contextlib import contextmanager 17 from pathlib import Path 18 from typing import ( 19 TYPE_CHECKING, 20 Any, 21 Callable, 22 Dict, 23 Generator, 24 Iterable, 25 List, 26 Optional, 27 Pattern, 28 Sequence, 29 Tuple, 30 Type, 31 Union, 32 ) 33 34 import pytest 35 from _pytest import outcomes 36 from _pytest._code.code import ExceptionInfo, ReprFileLocation, TerminalRepr 37 from _pytest._io import TerminalWriter 38 from _pytest.compat import safe_getattr 39 from _pytest.config import Config 40 from _pytest.config.argparsing import Parser 41 42 try: 43 from _pytest.fixtures import TopRequest as FixtureRequest 44 except ImportError: 45 from _pytest.fixtures import FixtureRequest 46 from _pytest.nodes import Collector 47 from _pytest.outcomes import OutcomeException 48 from _pytest.pathlib import fnmatch_ex, import_path 49 from _pytest.python_api import approx 50 from _pytest.warning_types import PytestWarning 51 52 if TYPE_CHECKING: 53 import doctest 54 55 from .ipdoctest import IPDoctestOutputChecker 56 57 DOCTEST_REPORT_CHOICE_NONE = "none" 58 DOCTEST_REPORT_CHOICE_CDIFF = "cdiff" 59 DOCTEST_REPORT_CHOICE_NDIFF = "ndiff" 60 DOCTEST_REPORT_CHOICE_UDIFF = "udiff" 61 DOCTEST_REPORT_CHOICE_ONLY_FIRST_FAILURE = "only_first_failure" 62 63 DOCTEST_REPORT_CHOICES = ( 64 DOCTEST_REPORT_CHOICE_NONE, 65 DOCTEST_REPORT_CHOICE_CDIFF, 66 DOCTEST_REPORT_CHOICE_NDIFF, 67 DOCTEST_REPORT_CHOICE_UDIFF, 68 DOCTEST_REPORT_CHOICE_ONLY_FIRST_FAILURE, 69 ) 70 71 # Lazy definition of runner class 72 RUNNER_CLASS = None 73 # Lazy definition of output checker class 74 CHECKER_CLASS: Optional[Type["IPDoctestOutputChecker"]] = None 75 76 pytest_version = tuple([int(part) for part in pytest.__version__.split(".")]) 77 78 79 def pytest_addoption(parser: Parser) -> None: 80 parser.addini( 81 "ipdoctest_optionflags", 82 "option flags for ipdoctests", 83 type="args", 84 default=["ELLIPSIS"], 85 ) 86 parser.addini( 87 "ipdoctest_encoding", "encoding used for ipdoctest files", default="utf-8" 88 ) 89 group = parser.getgroup("collect") 90 group.addoption( 91 "--ipdoctest-modules", 92 action="store_true", 93 default=False, 94 help="run ipdoctests in all .py modules", 95 dest="ipdoctestmodules", 96 ) 97 group.addoption( 98 "--ipdoctest-report", 99 type=str.lower, 100 default="udiff", 101 help="choose another output format for diffs on ipdoctest failure", 102 choices=DOCTEST_REPORT_CHOICES, 103 dest="ipdoctestreport", 104 ) 105 group.addoption( 106 "--ipdoctest-glob", 107 action="append", 108 default=[], 109 metavar="pat", 110 help="ipdoctests file matching pattern, default: test*.txt", 111 dest="ipdoctestglob", 112 ) 113 group.addoption( 114 "--ipdoctest-ignore-import-errors", 115 action="store_true", 116 default=False, 117 help="ignore ipdoctest ImportErrors", 118 dest="ipdoctest_ignore_import_errors", 119 ) 120 group.addoption( 121 "--ipdoctest-continue-on-failure", 122 action="store_true", 123 default=False, 124 help="for a given ipdoctest, continue to run after the first failure", 125 dest="ipdoctest_continue_on_failure", 126 ) 127 128 129 def pytest_unconfigure() -> None: 130 global RUNNER_CLASS 131 132 RUNNER_CLASS = None 133 134 135 def pytest_collect_file( 136 file_path: Path, 137 parent: Collector, 138 ) -> Optional[Union["IPDoctestModule", "IPDoctestTextfile"]]: 139 config = parent.config 140 if file_path.suffix == ".py": 141 if config.option.ipdoctestmodules and not any( 142 (_is_setup_py(file_path), _is_main_py(file_path)) 143 ): 144 mod: IPDoctestModule = IPDoctestModule.from_parent(parent, path=file_path) 145 return mod 146 elif _is_ipdoctest(config, file_path, parent): 147 txt: IPDoctestTextfile = IPDoctestTextfile.from_parent(parent, path=file_path) 148 return txt 149 return None 150 151 152 if pytest_version[0] < 7: 153 _collect_file = pytest_collect_file 154 155 def pytest_collect_file( 156 path, 157 parent: Collector, 158 ) -> Optional[Union["IPDoctestModule", "IPDoctestTextfile"]]: 159 return _collect_file(Path(path), parent) 160 161 _import_path = import_path 162 163 def import_path(path, root): 164 import py.path 165 166 return _import_path(py.path.local(path)) 167 168 169 def _is_setup_py(path: Path) -> bool: 170 if path.name != "setup.py": 171 return False 172 contents = path.read_bytes() 173 return b"setuptools" in contents or b"distutils" in contents 174 175 176 def _is_ipdoctest(config: Config, path: Path, parent: Collector) -> bool: 177 if path.suffix in (".txt", ".rst") and parent.session.isinitpath(path): 178 return True 179 globs = config.getoption("ipdoctestglob") or ["test*.txt"] 180 return any(fnmatch_ex(glob, path) for glob in globs) 181 182 183 def _is_main_py(path: Path) -> bool: 184 return path.name == "__main__.py" 185 186 187 class ReprFailDoctest(TerminalRepr): 188 def __init__( 189 self, reprlocation_lines: Sequence[Tuple[ReprFileLocation, Sequence[str]]] 190 ) -> None: 191 self.reprlocation_lines = reprlocation_lines 192 193 def toterminal(self, tw: TerminalWriter) -> None: 194 for reprlocation, lines in self.reprlocation_lines: 195 for line in lines: 196 tw.line(line) 197 reprlocation.toterminal(tw) 198 199 200 class MultipleDoctestFailures(Exception): 201 def __init__(self, failures: Sequence["doctest.DocTestFailure"]) -> None: 202 super().__init__() 203 self.failures = failures 204 205 206 def _init_runner_class() -> Type["IPDocTestRunner"]: 207 import doctest 208 from .ipdoctest import IPDocTestRunner 209 210 class PytestDoctestRunner(IPDocTestRunner): 211 """Runner to collect failures. 212 213 Note that the out variable in this case is a list instead of a 214 stdout-like object. 215 """ 216 217 def __init__( 218 self, 219 checker: Optional["IPDoctestOutputChecker"] = None, 220 verbose: Optional[bool] = None, 221 optionflags: int = 0, 222 continue_on_failure: bool = True, 223 ) -> None: 224 super().__init__(checker=checker, verbose=verbose, optionflags=optionflags) 225 self.continue_on_failure = continue_on_failure 226 227 def report_failure( 228 self, 229 out, 230 test: "doctest.DocTest", 231 example: "doctest.Example", 232 got: str, 233 ) -> None: 234 failure = doctest.DocTestFailure(test, example, got) 235 if self.continue_on_failure: 236 out.append(failure) 237 else: 238 raise failure 239 240 def report_unexpected_exception( 241 self, 242 out, 243 test: "doctest.DocTest", 244 example: "doctest.Example", 245 exc_info: Tuple[Type[BaseException], BaseException, types.TracebackType], 246 ) -> None: 247 if isinstance(exc_info[1], OutcomeException): 248 raise exc_info[1] 249 if isinstance(exc_info[1], bdb.BdbQuit): 250 outcomes.exit("Quitting debugger") 251 failure = doctest.UnexpectedException(test, example, exc_info) 252 if self.continue_on_failure: 253 out.append(failure) 254 else: 255 raise failure 256 257 return PytestDoctestRunner 258 259 260 def _get_runner( 261 checker: Optional["IPDoctestOutputChecker"] = None, 262 verbose: Optional[bool] = None, 263 optionflags: int = 0, 264 continue_on_failure: bool = True, 265 ) -> "IPDocTestRunner": 266 # We need this in order to do a lazy import on doctest 267 global RUNNER_CLASS 268 if RUNNER_CLASS is None: 269 RUNNER_CLASS = _init_runner_class() 270 # Type ignored because the continue_on_failure argument is only defined on 271 # PytestDoctestRunner, which is lazily defined so can't be used as a type. 272 return RUNNER_CLASS( # type: ignore 273 checker=checker, 274 verbose=verbose, 275 optionflags=optionflags, 276 continue_on_failure=continue_on_failure, 277 ) 278 279 280 class IPDoctestItem(pytest.Item): 281 _user_ns_orig: Dict[str, Any] 282 283 def __init__( 284 self, 285 name: str, 286 parent: "Union[IPDoctestTextfile, IPDoctestModule]", 287 runner: Optional["IPDocTestRunner"] = None, 288 dtest: Optional["doctest.DocTest"] = None, 289 ) -> None: 290 super().__init__(name, parent) 291 self.runner = runner 292 self.dtest = dtest 293 self.obj = None 294 self.fixture_request: Optional[FixtureRequest] = None 295 self._user_ns_orig = {} 296 297 @classmethod 298 def from_parent( # type: ignore 299 cls, 300 parent: "Union[IPDoctestTextfile, IPDoctestModule]", 301 *, 302 name: str, 303 runner: "IPDocTestRunner", 304 dtest: "doctest.DocTest", 305 ): 306 # incompatible signature due to imposed limits on subclass 307 """The public named constructor.""" 308 return super().from_parent(name=name, parent=parent, runner=runner, dtest=dtest) 309 310 def setup(self) -> None: 311 if self.dtest is not None: 312 self.fixture_request = _setup_fixtures(self) 313 globs = dict(getfixture=self.fixture_request.getfixturevalue) 314 for name, value in self.fixture_request.getfixturevalue( 315 "ipdoctest_namespace" 316 ).items(): 317 globs[name] = value 318 self.dtest.globs.update(globs) 319 320 from .ipdoctest import IPExample 321 322 if isinstance(self.dtest.examples[0], IPExample): 323 # for IPython examples *only*, we swap the globals with the ipython 324 # namespace, after updating it with the globals (which doctest 325 # fills with the necessary info from the module being tested). 326 self._user_ns_orig = {} 327 self._user_ns_orig.update(_ip.user_ns) 328 _ip.user_ns.update(self.dtest.globs) 329 # We must remove the _ key in the namespace, so that Python's 330 # doctest code sets it naturally 331 _ip.user_ns.pop("_", None) 332 _ip.user_ns["__builtins__"] = builtins 333 self.dtest.globs = _ip.user_ns 334 335 def teardown(self) -> None: 336 from .ipdoctest import IPExample 337 338 # Undo the test.globs reassignment we made 339 if isinstance(self.dtest.examples[0], IPExample): 340 self.dtest.globs = {} 341 _ip.user_ns.clear() 342 _ip.user_ns.update(self._user_ns_orig) 343 del self._user_ns_orig 344 345 self.dtest.globs.clear() 346 347 def runtest(self) -> None: 348 assert self.dtest is not None 349 assert self.runner is not None 350 _check_all_skipped(self.dtest) 351 self._disable_output_capturing_for_darwin() 352 failures: List["doctest.DocTestFailure"] = [] 353 354 # exec(compile(..., "single", ...), ...) puts result in builtins._ 355 had_underscore_value = hasattr(builtins, "_") 356 underscore_original_value = getattr(builtins, "_", None) 357 358 # Save our current directory and switch out to the one where the 359 # test was originally created, in case another doctest did a 360 # directory change. We'll restore this in the finally clause. 361 curdir = os.getcwd() 362 os.chdir(self.fspath.dirname) 363 try: 364 # Type ignored because we change the type of `out` from what 365 # ipdoctest expects. 366 self.runner.run(self.dtest, out=failures, clear_globs=False) # type: ignore[arg-type] 367 finally: 368 os.chdir(curdir) 369 if had_underscore_value: 370 setattr(builtins, "_", underscore_original_value) 371 elif hasattr(builtins, "_"): 372 delattr(builtins, "_") 373 374 if failures: 375 raise MultipleDoctestFailures(failures) 376 377 def _disable_output_capturing_for_darwin(self) -> None: 378 """Disable output capturing. Otherwise, stdout is lost to ipdoctest (pytest#985).""" 379 if platform.system() != "Darwin": 380 return 381 capman = self.config.pluginmanager.getplugin("capturemanager") 382 if capman: 383 capman.suspend_global_capture(in_=True) 384 out, err = capman.read_global_capture() 385 sys.stdout.write(out) 386 sys.stderr.write(err) 387 388 # TODO: Type ignored -- breaks Liskov Substitution. 389 def repr_failure( # type: ignore[override] 390 self, 391 excinfo: ExceptionInfo[BaseException], 392 ) -> Union[str, TerminalRepr]: 393 import doctest 394 395 failures: Optional[ 396 Sequence[Union[doctest.DocTestFailure, doctest.UnexpectedException]] 397 ] = None 398 if isinstance( 399 excinfo.value, (doctest.DocTestFailure, doctest.UnexpectedException) 400 ): 401 failures = [excinfo.value] 402 elif isinstance(excinfo.value, MultipleDoctestFailures): 403 failures = excinfo.value.failures 404 405 if failures is None: 406 return super().repr_failure(excinfo) 407 408 reprlocation_lines = [] 409 for failure in failures: 410 example = failure.example 411 test = failure.test 412 filename = test.filename 413 if test.lineno is None: 414 lineno = None 415 else: 416 lineno = test.lineno + example.lineno + 1 417 message = type(failure).__name__ 418 # TODO: ReprFileLocation doesn't expect a None lineno. 419 reprlocation = ReprFileLocation(filename, lineno, message) # type: ignore[arg-type] 420 checker = _get_checker() 421 report_choice = _get_report_choice(self.config.getoption("ipdoctestreport")) 422 if lineno is not None: 423 assert failure.test.docstring is not None 424 lines = failure.test.docstring.splitlines(False) 425 # add line numbers to the left of the error message 426 assert test.lineno is not None 427 lines = [ 428 "%03d %s" % (i + test.lineno + 1, x) for (i, x) in enumerate(lines) 429 ] 430 # trim docstring error lines to 10 431 lines = lines[max(example.lineno - 9, 0) : example.lineno + 1] 432 else: 433 lines = [ 434 "EXAMPLE LOCATION UNKNOWN, not showing all tests of that example" 435 ] 436 indent = ">>>" 437 for line in example.source.splitlines(): 438 lines.append(f"??? {indent} {line}") 439 indent = "..." 440 if isinstance(failure, doctest.DocTestFailure): 441 lines += checker.output_difference( 442 example, failure.got, report_choice 443 ).split("\n") 444 else: 445 inner_excinfo = ExceptionInfo.from_exc_info(failure.exc_info) 446 lines += ["UNEXPECTED EXCEPTION: %s" % repr(inner_excinfo.value)] 447 lines += [ 448 x.strip("\n") for x in traceback.format_exception(*failure.exc_info) 449 ] 450 reprlocation_lines.append((reprlocation, lines)) 451 return ReprFailDoctest(reprlocation_lines) 452 453 def reportinfo(self) -> Tuple[Union["os.PathLike[str]", str], Optional[int], str]: 454 assert self.dtest is not None 455 return self.path, self.dtest.lineno, "[ipdoctest] %s" % self.name 456 457 if pytest_version[0] < 7: 458 459 @property 460 def path(self) -> Path: 461 return Path(self.fspath) 462 463 464 def _get_flag_lookup() -> Dict[str, int]: 465 import doctest 466 467 return dict( 468 DONT_ACCEPT_TRUE_FOR_1=doctest.DONT_ACCEPT_TRUE_FOR_1, 469 DONT_ACCEPT_BLANKLINE=doctest.DONT_ACCEPT_BLANKLINE, 470 NORMALIZE_WHITESPACE=doctest.NORMALIZE_WHITESPACE, 471 ELLIPSIS=doctest.ELLIPSIS, 472 IGNORE_EXCEPTION_DETAIL=doctest.IGNORE_EXCEPTION_DETAIL, 473 COMPARISON_FLAGS=doctest.COMPARISON_FLAGS, 474 ALLOW_UNICODE=_get_allow_unicode_flag(), 475 ALLOW_BYTES=_get_allow_bytes_flag(), 476 NUMBER=_get_number_flag(), 477 ) 478 479 480 def get_optionflags(parent): 481 optionflags_str = parent.config.getini("ipdoctest_optionflags") 482 flag_lookup_table = _get_flag_lookup() 483 flag_acc = 0 484 for flag in optionflags_str: 485 flag_acc |= flag_lookup_table[flag] 486 return flag_acc 487 488 489 def _get_continue_on_failure(config): 490 continue_on_failure = config.getvalue("ipdoctest_continue_on_failure") 491 if continue_on_failure: 492 # We need to turn off this if we use pdb since we should stop at 493 # the first failure. 494 if config.getvalue("usepdb"): 495 continue_on_failure = False 496 return continue_on_failure 497 498 499 class IPDoctestTextfile(pytest.Module): 500 obj = None 501 502 def collect(self) -> Iterable[IPDoctestItem]: 503 import doctest 504 from .ipdoctest import IPDocTestParser 505 506 # Inspired by doctest.testfile; ideally we would use it directly, 507 # but it doesn't support passing a custom checker. 508 encoding = self.config.getini("ipdoctest_encoding") 509 text = self.path.read_text(encoding) 510 filename = str(self.path) 511 name = self.path.name 512 globs = {"__name__": "__main__"} 513 514 optionflags = get_optionflags(self) 515 516 runner = _get_runner( 517 verbose=False, 518 optionflags=optionflags, 519 checker=_get_checker(), 520 continue_on_failure=_get_continue_on_failure(self.config), 521 ) 522 523 parser = IPDocTestParser() 524 test = parser.get_doctest(text, globs, name, filename, 0) 525 if test.examples: 526 yield IPDoctestItem.from_parent( 527 self, name=test.name, runner=runner, dtest=test 528 ) 529 530 if pytest_version[0] < 7: 531 532 @property 533 def path(self) -> Path: 534 return Path(self.fspath) 535 536 @classmethod 537 def from_parent( 538 cls, 539 parent, 540 *, 541 fspath=None, 542 path: Optional[Path] = None, 543 **kw, 544 ): 545 if path is not None: 546 import py.path 547 548 fspath = py.path.local(path) 549 return super().from_parent(parent=parent, fspath=fspath, **kw) 550 551 552 def _check_all_skipped(test: "doctest.DocTest") -> None: 553 """Raise pytest.skip() if all examples in the given DocTest have the SKIP 554 option set.""" 555 import doctest 556 557 all_skipped = all(x.options.get(doctest.SKIP, False) for x in test.examples) 558 if all_skipped: 559 pytest.skip("all docstests skipped by +SKIP option") 560 561 562 def _is_mocked(obj: object) -> bool: 563 """Return if an object is possibly a mock object by checking the 564 existence of a highly improbable attribute.""" 565 return ( 566 safe_getattr(obj, "pytest_mock_example_attribute_that_shouldnt_exist", None) 567 is not None 568 ) 569 570 571 @contextmanager 572 def _patch_unwrap_mock_aware() -> Generator[None, None, None]: 573 """Context manager which replaces ``inspect.unwrap`` with a version 574 that's aware of mock objects and doesn't recurse into them.""" 575 real_unwrap = inspect.unwrap 576 577 def _mock_aware_unwrap( 578 func: Callable[..., Any], *, stop: Optional[Callable[[Any], Any]] = None 579 ) -> Any: 580 try: 581 if stop is None or stop is _is_mocked: 582 return real_unwrap(func, stop=_is_mocked) 583 _stop = stop 584 return real_unwrap(func, stop=lambda obj: _is_mocked(obj) or _stop(func)) 585 except Exception as e: 586 warnings.warn( 587 "Got %r when unwrapping %r. This is usually caused " 588 "by a violation of Python's object protocol; see e.g. " 589 "https://github.com/pytest-dev/pytest/issues/5080" % (e, func), 590 PytestWarning, 591 ) 592 raise 593 594 inspect.unwrap = _mock_aware_unwrap 595 try: 596 yield 597 finally: 598 inspect.unwrap = real_unwrap 599 600 601 class IPDoctestModule(pytest.Module): 602 def collect(self) -> Iterable[IPDoctestItem]: 603 import doctest 604 from .ipdoctest import DocTestFinder, IPDocTestParser 605 606 class MockAwareDocTestFinder(DocTestFinder): 607 """A hackish ipdoctest finder that overrides stdlib internals to fix a stdlib bug. 608 609 https://github.com/pytest-dev/pytest/issues/3456 610 https://bugs.python.org/issue25532 611 """ 612 613 def _find_lineno(self, obj, source_lines): 614 """Doctest code does not take into account `@property`, this 615 is a hackish way to fix it. https://bugs.python.org/issue17446 616 617 Wrapped Doctests will need to be unwrapped so the correct 618 line number is returned. This will be reported upstream. #8796 619 """ 620 if isinstance(obj, property): 621 obj = getattr(obj, "fget", obj) 622 623 if hasattr(obj, "__wrapped__"): 624 # Get the main obj in case of it being wrapped 625 obj = inspect.unwrap(obj) 626 627 # Type ignored because this is a private function. 628 return super()._find_lineno( # type:ignore[misc] 629 obj, 630 source_lines, 631 ) 632 633 def _find( 634 self, tests, obj, name, module, source_lines, globs, seen 635 ) -> None: 636 if _is_mocked(obj): 637 return 638 with _patch_unwrap_mock_aware(): 639 # Type ignored because this is a private function. 640 super()._find( # type:ignore[misc] 641 tests, obj, name, module, source_lines, globs, seen 642 ) 643 644 if self.path.name == "conftest.py": 645 if pytest_version[0] < 7: 646 module = self.config.pluginmanager._importconftest( 647 self.path, 648 self.config.getoption("importmode"), 649 ) 650 else: 651 kwargs = {"rootpath": self.config.rootpath} 652 if pytest_version >= (8, 1): 653 kwargs["consider_namespace_packages"] = False 654 module = self.config.pluginmanager._importconftest( 655 self.path, 656 self.config.getoption("importmode"), 657 **kwargs, 658 ) 659 else: 660 try: 661 kwargs = {"root": self.config.rootpath} 662 if pytest_version >= (8, 1): 663 kwargs["consider_namespace_packages"] = False 664 module = import_path(self.path, **kwargs) 665 except ImportError: 666 if self.config.getvalue("ipdoctest_ignore_import_errors"): 667 pytest.skip("unable to import module %r" % self.path) 668 else: 669 raise 670 # Uses internal doctest module parsing mechanism. 671 finder = MockAwareDocTestFinder(parser=IPDocTestParser()) 672 optionflags = get_optionflags(self) 673 runner = _get_runner( 674 verbose=False, 675 optionflags=optionflags, 676 checker=_get_checker(), 677 continue_on_failure=_get_continue_on_failure(self.config), 678 ) 679 680 for test in finder.find(module, module.__name__): 681 if test.examples: # skip empty ipdoctests 682 yield IPDoctestItem.from_parent( 683 self, name=test.name, runner=runner, dtest=test 684 ) 685 686 if pytest_version[0] < 7: 687 688 @property 689 def path(self) -> Path: 690 return Path(self.fspath) 691 692 @classmethod 693 def from_parent( 694 cls, 695 parent, 696 *, 697 fspath=None, 698 path: Optional[Path] = None, 699 **kw, 700 ): 701 if path is not None: 702 import py.path 703 704 fspath = py.path.local(path) 705 return super().from_parent(parent=parent, fspath=fspath, **kw) 706 707 708 def _setup_fixtures(doctest_item: IPDoctestItem) -> FixtureRequest: 709 """Used by IPDoctestTextfile and IPDoctestItem to setup fixture information.""" 710 711 def func() -> None: 712 pass 713 714 doctest_item.funcargs = {} # type: ignore[attr-defined] 715 fm = doctest_item.session._fixturemanager 716 kwargs = {"node": doctest_item, "func": func, "cls": None} 717 if pytest_version <= (8, 0): 718 kwargs["funcargs"] = False 719 doctest_item._fixtureinfo = fm.getfixtureinfo( # type: ignore[attr-defined] 720 **kwargs 721 ) 722 fixture_request = FixtureRequest(doctest_item, _ispytest=True) 723 if pytest_version <= (8, 0): 724 fixture_request._fillfixtures() 725 return fixture_request 726 727 728 def _init_checker_class() -> Type["IPDoctestOutputChecker"]: 729 import doctest 730 import re 731 from .ipdoctest import IPDoctestOutputChecker 732 733 class LiteralsOutputChecker(IPDoctestOutputChecker): 734 # Based on doctest_nose_plugin.py from the nltk project 735 # (https://github.com/nltk/nltk) and on the "numtest" doctest extension 736 # by Sebastien Boisgerault (https://github.com/boisgera/numtest). 737 738 _unicode_literal_re = re.compile(r"(\W|^)[uU]([rR]?[\'\"])", re.UNICODE) 739 _bytes_literal_re = re.compile(r"(\W|^)[bB]([rR]?[\'\"])", re.UNICODE) 740 _number_re = re.compile( 741 r""" 742 (?P<number> 743 (?P<mantissa> 744 (?P<integer1> [+-]?\d*)\.(?P<fraction>\d+) 745 | 746 (?P<integer2> [+-]?\d+)\. 747 ) 748 (?: 749 [Ee] 750 (?P<exponent1> [+-]?\d+) 751 )? 752 | 753 (?P<integer3> [+-]?\d+) 754 (?: 755 [Ee] 756 (?P<exponent2> [+-]?\d+) 757 ) 758 ) 759 """, 760 re.VERBOSE, 761 ) 762 763 def check_output(self, want: str, got: str, optionflags: int) -> bool: 764 if super().check_output(want, got, optionflags): 765 return True 766 767 allow_unicode = optionflags & _get_allow_unicode_flag() 768 allow_bytes = optionflags & _get_allow_bytes_flag() 769 allow_number = optionflags & _get_number_flag() 770 771 if not allow_unicode and not allow_bytes and not allow_number: 772 return False 773 774 def remove_prefixes(regex: Pattern[str], txt: str) -> str: 775 return re.sub(regex, r"\1\2", txt) 776 777 if allow_unicode: 778 want = remove_prefixes(self._unicode_literal_re, want) 779 got = remove_prefixes(self._unicode_literal_re, got) 780 781 if allow_bytes: 782 want = remove_prefixes(self._bytes_literal_re, want) 783 got = remove_prefixes(self._bytes_literal_re, got) 784 785 if allow_number: 786 got = self._remove_unwanted_precision(want, got) 787 788 return super().check_output(want, got, optionflags) 789 790 def _remove_unwanted_precision(self, want: str, got: str) -> str: 791 wants = list(self._number_re.finditer(want)) 792 gots = list(self._number_re.finditer(got)) 793 if len(wants) != len(gots): 794 return got 795 offset = 0 796 for w, g in zip(wants, gots): 797 fraction: Optional[str] = w.group("fraction") 798 exponent: Optional[str] = w.group("exponent1") 799 if exponent is None: 800 exponent = w.group("exponent2") 801 precision = 0 if fraction is None else len(fraction) 802 if exponent is not None: 803 precision -= int(exponent) 804 if float(w.group()) == approx(float(g.group()), abs=10**-precision): 805 # They're close enough. Replace the text we actually 806 # got with the text we want, so that it will match when we 807 # check the string literally. 808 got = ( 809 got[: g.start() + offset] + w.group() + got[g.end() + offset :] 810 ) 811 offset += w.end() - w.start() - (g.end() - g.start()) 812 return got 813 814 return LiteralsOutputChecker 815 816 817 def _get_checker() -> "IPDoctestOutputChecker": 818 """Return a IPDoctestOutputChecker subclass that supports some 819 additional options: 820 821 * ALLOW_UNICODE and ALLOW_BYTES options to ignore u'' and b'' 822 prefixes (respectively) in string literals. Useful when the same 823 ipdoctest should run in Python 2 and Python 3. 824 825 * NUMBER to ignore floating-point differences smaller than the 826 precision of the literal number in the ipdoctest. 827 828 An inner class is used to avoid importing "ipdoctest" at the module 829 level. 830 """ 831 global CHECKER_CLASS 832 if CHECKER_CLASS is None: 833 CHECKER_CLASS = _init_checker_class() 834 return CHECKER_CLASS() 835 836 837 def _get_allow_unicode_flag() -> int: 838 """Register and return the ALLOW_UNICODE flag.""" 839 import doctest 840 841 return doctest.register_optionflag("ALLOW_UNICODE") 842 843 844 def _get_allow_bytes_flag() -> int: 845 """Register and return the ALLOW_BYTES flag.""" 846 import doctest 847 848 return doctest.register_optionflag("ALLOW_BYTES") 849 850 851 def _get_number_flag() -> int: 852 """Register and return the NUMBER flag.""" 853 import doctest 854 855 return doctest.register_optionflag("NUMBER") 856 857 858 def _get_report_choice(key: str) -> int: 859 """Return the actual `ipdoctest` module flag value. 860 861 We want to do it as late as possible to avoid importing `ipdoctest` and all 862 its dependencies when parsing options, as it adds overhead and breaks tests. 863 """ 864 import doctest 865 866 return { 867 DOCTEST_REPORT_CHOICE_UDIFF: doctest.REPORT_UDIFF, 868 DOCTEST_REPORT_CHOICE_CDIFF: doctest.REPORT_CDIFF, 869 DOCTEST_REPORT_CHOICE_NDIFF: doctest.REPORT_NDIFF, 870 DOCTEST_REPORT_CHOICE_ONLY_FIRST_FAILURE: doctest.REPORT_ONLY_FIRST_FAILURE, 871 DOCTEST_REPORT_CHOICE_NONE: 0, 872 }[key] 873 874 875 @pytest.fixture(scope="session") 876 def ipdoctest_namespace() -> Dict[str, Any]: 877 """Fixture that returns a :py:class:`dict` that will be injected into the 878 namespace of ipdoctests.""" 879 return dict() 880