|
|
# Based on Pytest doctest.py
|
|
|
# Original license:
|
|
|
# The MIT License (MIT)
|
|
|
#
|
|
|
# Copyright (c) 2004-2021 Holger Krekel and others
|
|
|
"""Discover and run ipdoctests in modules and test files."""
|
|
|
import builtins
|
|
|
import bdb
|
|
|
import inspect
|
|
|
import os
|
|
|
import platform
|
|
|
import sys
|
|
|
import traceback
|
|
|
import types
|
|
|
import warnings
|
|
|
from contextlib import contextmanager
|
|
|
from pathlib import Path
|
|
|
from typing import Any
|
|
|
from typing import Callable
|
|
|
from typing import Dict
|
|
|
from typing import Generator
|
|
|
from typing import Iterable
|
|
|
from typing import List
|
|
|
from typing import Optional
|
|
|
from typing import Pattern
|
|
|
from typing import Sequence
|
|
|
from typing import Tuple
|
|
|
from typing import Type
|
|
|
from typing import TYPE_CHECKING
|
|
|
from typing import Union
|
|
|
|
|
|
import pytest
|
|
|
from _pytest import outcomes
|
|
|
from _pytest._code.code import ExceptionInfo
|
|
|
from _pytest._code.code import ReprFileLocation
|
|
|
from _pytest._code.code import TerminalRepr
|
|
|
from _pytest._io import TerminalWriter
|
|
|
from _pytest.compat import safe_getattr
|
|
|
from _pytest.config import Config
|
|
|
from _pytest.config.argparsing import Parser
|
|
|
from _pytest.fixtures import FixtureRequest
|
|
|
from _pytest.nodes import Collector
|
|
|
from _pytest.outcomes import OutcomeException
|
|
|
from _pytest.pathlib import fnmatch_ex
|
|
|
from _pytest.pathlib import import_path
|
|
|
from _pytest.python_api import approx
|
|
|
from _pytest.warning_types import PytestWarning
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
|
import doctest
|
|
|
|
|
|
DOCTEST_REPORT_CHOICE_NONE = "none"
|
|
|
DOCTEST_REPORT_CHOICE_CDIFF = "cdiff"
|
|
|
DOCTEST_REPORT_CHOICE_NDIFF = "ndiff"
|
|
|
DOCTEST_REPORT_CHOICE_UDIFF = "udiff"
|
|
|
DOCTEST_REPORT_CHOICE_ONLY_FIRST_FAILURE = "only_first_failure"
|
|
|
|
|
|
DOCTEST_REPORT_CHOICES = (
|
|
|
DOCTEST_REPORT_CHOICE_NONE,
|
|
|
DOCTEST_REPORT_CHOICE_CDIFF,
|
|
|
DOCTEST_REPORT_CHOICE_NDIFF,
|
|
|
DOCTEST_REPORT_CHOICE_UDIFF,
|
|
|
DOCTEST_REPORT_CHOICE_ONLY_FIRST_FAILURE,
|
|
|
)
|
|
|
|
|
|
# Lazy definition of runner class
|
|
|
RUNNER_CLASS = None
|
|
|
# Lazy definition of output checker class
|
|
|
CHECKER_CLASS: Optional[Type["IPDoctestOutputChecker"]] = None
|
|
|
|
|
|
|
|
|
def pytest_addoption(parser: Parser) -> None:
|
|
|
parser.addini(
|
|
|
"ipdoctest_optionflags",
|
|
|
"option flags for ipdoctests",
|
|
|
type="args",
|
|
|
default=["ELLIPSIS"],
|
|
|
)
|
|
|
parser.addini(
|
|
|
"ipdoctest_encoding", "encoding used for ipdoctest files", default="utf-8"
|
|
|
)
|
|
|
group = parser.getgroup("collect")
|
|
|
group.addoption(
|
|
|
"--ipdoctest-modules",
|
|
|
action="store_true",
|
|
|
default=False,
|
|
|
help="run ipdoctests in all .py modules",
|
|
|
dest="ipdoctestmodules",
|
|
|
)
|
|
|
group.addoption(
|
|
|
"--ipdoctest-report",
|
|
|
type=str.lower,
|
|
|
default="udiff",
|
|
|
help="choose another output format for diffs on ipdoctest failure",
|
|
|
choices=DOCTEST_REPORT_CHOICES,
|
|
|
dest="ipdoctestreport",
|
|
|
)
|
|
|
group.addoption(
|
|
|
"--ipdoctest-glob",
|
|
|
action="append",
|
|
|
default=[],
|
|
|
metavar="pat",
|
|
|
help="ipdoctests file matching pattern, default: test*.txt",
|
|
|
dest="ipdoctestglob",
|
|
|
)
|
|
|
group.addoption(
|
|
|
"--ipdoctest-ignore-import-errors",
|
|
|
action="store_true",
|
|
|
default=False,
|
|
|
help="ignore ipdoctest ImportErrors",
|
|
|
dest="ipdoctest_ignore_import_errors",
|
|
|
)
|
|
|
group.addoption(
|
|
|
"--ipdoctest-continue-on-failure",
|
|
|
action="store_true",
|
|
|
default=False,
|
|
|
help="for a given ipdoctest, continue to run after the first failure",
|
|
|
dest="ipdoctest_continue_on_failure",
|
|
|
)
|
|
|
|
|
|
|
|
|
def pytest_unconfigure() -> None:
|
|
|
global RUNNER_CLASS
|
|
|
|
|
|
RUNNER_CLASS = None
|
|
|
|
|
|
|
|
|
def pytest_collect_file(
|
|
|
file_path: Path,
|
|
|
parent: Collector,
|
|
|
) -> Optional[Union["IPDoctestModule", "IPDoctestTextfile"]]:
|
|
|
config = parent.config
|
|
|
if file_path.suffix == ".py":
|
|
|
if config.option.ipdoctestmodules and not any(
|
|
|
(_is_setup_py(file_path), _is_main_py(file_path))
|
|
|
):
|
|
|
mod: IPDoctestModule = IPDoctestModule.from_parent(parent, path=file_path)
|
|
|
return mod
|
|
|
elif _is_ipdoctest(config, file_path, parent):
|
|
|
txt: IPDoctestTextfile = IPDoctestTextfile.from_parent(parent, path=file_path)
|
|
|
return txt
|
|
|
return None
|
|
|
|
|
|
|
|
|
if int(pytest.__version__.split(".")[0]) < 7:
|
|
|
_collect_file = pytest_collect_file
|
|
|
|
|
|
def pytest_collect_file(
|
|
|
path,
|
|
|
parent: Collector,
|
|
|
) -> Optional[Union["IPDoctestModule", "IPDoctestTextfile"]]:
|
|
|
return _collect_file(Path(path), parent)
|
|
|
|
|
|
_import_path = import_path
|
|
|
|
|
|
def import_path(path, root):
|
|
|
import py.path
|
|
|
|
|
|
return _import_path(py.path.local(path))
|
|
|
|
|
|
|
|
|
def _is_setup_py(path: Path) -> bool:
|
|
|
if path.name != "setup.py":
|
|
|
return False
|
|
|
contents = path.read_bytes()
|
|
|
return b"setuptools" in contents or b"distutils" in contents
|
|
|
|
|
|
|
|
|
def _is_ipdoctest(config: Config, path: Path, parent: Collector) -> bool:
|
|
|
if path.suffix in (".txt", ".rst") and parent.session.isinitpath(path):
|
|
|
return True
|
|
|
globs = config.getoption("ipdoctestglob") or ["test*.txt"]
|
|
|
return any(fnmatch_ex(glob, path) for glob in globs)
|
|
|
|
|
|
|
|
|
def _is_main_py(path: Path) -> bool:
|
|
|
return path.name == "__main__.py"
|
|
|
|
|
|
|
|
|
class ReprFailDoctest(TerminalRepr):
|
|
|
def __init__(
|
|
|
self, reprlocation_lines: Sequence[Tuple[ReprFileLocation, Sequence[str]]]
|
|
|
) -> None:
|
|
|
self.reprlocation_lines = reprlocation_lines
|
|
|
|
|
|
def toterminal(self, tw: TerminalWriter) -> None:
|
|
|
for reprlocation, lines in self.reprlocation_lines:
|
|
|
for line in lines:
|
|
|
tw.line(line)
|
|
|
reprlocation.toterminal(tw)
|
|
|
|
|
|
|
|
|
class MultipleDoctestFailures(Exception):
|
|
|
def __init__(self, failures: Sequence["doctest.DocTestFailure"]) -> None:
|
|
|
super().__init__()
|
|
|
self.failures = failures
|
|
|
|
|
|
|
|
|
def _init_runner_class() -> Type["IPDocTestRunner"]:
|
|
|
import doctest
|
|
|
from .ipdoctest import IPDocTestRunner
|
|
|
|
|
|
class PytestDoctestRunner(IPDocTestRunner):
|
|
|
"""Runner to collect failures.
|
|
|
|
|
|
Note that the out variable in this case is a list instead of a
|
|
|
stdout-like object.
|
|
|
"""
|
|
|
|
|
|
def __init__(
|
|
|
self,
|
|
|
checker: Optional["IPDoctestOutputChecker"] = None,
|
|
|
verbose: Optional[bool] = None,
|
|
|
optionflags: int = 0,
|
|
|
continue_on_failure: bool = True,
|
|
|
) -> None:
|
|
|
super().__init__(checker=checker, verbose=verbose, optionflags=optionflags)
|
|
|
self.continue_on_failure = continue_on_failure
|
|
|
|
|
|
def report_failure(
|
|
|
self,
|
|
|
out,
|
|
|
test: "doctest.DocTest",
|
|
|
example: "doctest.Example",
|
|
|
got: str,
|
|
|
) -> None:
|
|
|
failure = doctest.DocTestFailure(test, example, got)
|
|
|
if self.continue_on_failure:
|
|
|
out.append(failure)
|
|
|
else:
|
|
|
raise failure
|
|
|
|
|
|
def report_unexpected_exception(
|
|
|
self,
|
|
|
out,
|
|
|
test: "doctest.DocTest",
|
|
|
example: "doctest.Example",
|
|
|
exc_info: Tuple[Type[BaseException], BaseException, types.TracebackType],
|
|
|
) -> None:
|
|
|
if isinstance(exc_info[1], OutcomeException):
|
|
|
raise exc_info[1]
|
|
|
if isinstance(exc_info[1], bdb.BdbQuit):
|
|
|
outcomes.exit("Quitting debugger")
|
|
|
failure = doctest.UnexpectedException(test, example, exc_info)
|
|
|
if self.continue_on_failure:
|
|
|
out.append(failure)
|
|
|
else:
|
|
|
raise failure
|
|
|
|
|
|
return PytestDoctestRunner
|
|
|
|
|
|
|
|
|
def _get_runner(
|
|
|
checker: Optional["IPDoctestOutputChecker"] = None,
|
|
|
verbose: Optional[bool] = None,
|
|
|
optionflags: int = 0,
|
|
|
continue_on_failure: bool = True,
|
|
|
) -> "IPDocTestRunner":
|
|
|
# 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()
|
|
|
# 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,
|
|
|
continue_on_failure=continue_on_failure,
|
|
|
)
|
|
|
|
|
|
|
|
|
class IPDoctestItem(pytest.Item):
|
|
|
def __init__(
|
|
|
self,
|
|
|
name: str,
|
|
|
parent: "Union[IPDoctestTextfile, IPDoctestModule]",
|
|
|
runner: Optional["IPDocTestRunner"] = None,
|
|
|
dtest: Optional["doctest.DocTest"] = None,
|
|
|
) -> None:
|
|
|
super().__init__(name, parent)
|
|
|
self.runner = runner
|
|
|
self.dtest = dtest
|
|
|
self.obj = None
|
|
|
self.fixture_request: Optional[FixtureRequest] = None
|
|
|
|
|
|
@classmethod
|
|
|
def from_parent( # type: ignore
|
|
|
cls,
|
|
|
parent: "Union[IPDoctestTextfile, IPDoctestModule]",
|
|
|
*,
|
|
|
name: str,
|
|
|
runner: "IPDocTestRunner",
|
|
|
dtest: "doctest.DocTest",
|
|
|
):
|
|
|
# incompatible signature due to imposed limits on subclass
|
|
|
"""The public named constructor."""
|
|
|
return super().from_parent(name=name, parent=parent, runner=runner, dtest=dtest)
|
|
|
|
|
|
def setup(self) -> None:
|
|
|
if self.dtest is not None:
|
|
|
self.fixture_request = _setup_fixtures(self)
|
|
|
globs = dict(getfixture=self.fixture_request.getfixturevalue)
|
|
|
for name, value in self.fixture_request.getfixturevalue(
|
|
|
"ipdoctest_namespace"
|
|
|
).items():
|
|
|
globs[name] = value
|
|
|
self.dtest.globs.update(globs)
|
|
|
|
|
|
from .ipdoctest import IPExample
|
|
|
|
|
|
if isinstance(self.dtest.examples[0], IPExample):
|
|
|
# for IPython examples *only*, we swap the globals with the ipython
|
|
|
# namespace, after updating it with the globals (which doctest
|
|
|
# fills with the necessary info from the module being tested).
|
|
|
self._user_ns_orig = {}
|
|
|
self._user_ns_orig.update(_ip.user_ns)
|
|
|
_ip.user_ns.update(self.dtest.globs)
|
|
|
# We must remove the _ key in the namespace, so that Python's
|
|
|
# doctest code sets it naturally
|
|
|
_ip.user_ns.pop("_", None)
|
|
|
_ip.user_ns["__builtins__"] = builtins
|
|
|
self.dtest.globs = _ip.user_ns
|
|
|
|
|
|
def teardown(self) -> None:
|
|
|
from .ipdoctest import IPExample
|
|
|
|
|
|
# Undo the test.globs reassignment we made
|
|
|
if isinstance(self.dtest.examples[0], IPExample):
|
|
|
self.dtest.globs = {}
|
|
|
_ip.user_ns.clear()
|
|
|
_ip.user_ns.update(self._user_ns_orig)
|
|
|
del self._user_ns_orig
|
|
|
|
|
|
self.dtest.globs.clear()
|
|
|
|
|
|
def runtest(self) -> None:
|
|
|
assert self.dtest is not None
|
|
|
assert self.runner is not None
|
|
|
_check_all_skipped(self.dtest)
|
|
|
self._disable_output_capturing_for_darwin()
|
|
|
failures: List["doctest.DocTestFailure"] = []
|
|
|
|
|
|
# exec(compile(..., "single", ...), ...) puts result in builtins._
|
|
|
had_underscore_value = hasattr(builtins, "_")
|
|
|
underscore_original_value = getattr(builtins, "_", None)
|
|
|
|
|
|
# Save our current directory and switch out to the one where the
|
|
|
# test was originally created, in case another doctest did a
|
|
|
# directory change. We'll restore this in the finally clause.
|
|
|
curdir = os.getcwd()
|
|
|
os.chdir(self.fspath.dirname)
|
|
|
try:
|
|
|
# Type ignored because we change the type of `out` from what
|
|
|
# ipdoctest expects.
|
|
|
self.runner.run(self.dtest, out=failures, clear_globs=False) # type: ignore[arg-type]
|
|
|
finally:
|
|
|
os.chdir(curdir)
|
|
|
if had_underscore_value:
|
|
|
setattr(builtins, "_", underscore_original_value)
|
|
|
elif hasattr(builtins, "_"):
|
|
|
delattr(builtins, "_")
|
|
|
|
|
|
if failures:
|
|
|
raise MultipleDoctestFailures(failures)
|
|
|
|
|
|
def _disable_output_capturing_for_darwin(self) -> None:
|
|
|
"""Disable output capturing. Otherwise, stdout is lost to ipdoctest (pytest#985)."""
|
|
|
if platform.system() != "Darwin":
|
|
|
return
|
|
|
capman = self.config.pluginmanager.getplugin("capturemanager")
|
|
|
if capman:
|
|
|
capman.suspend_global_capture(in_=True)
|
|
|
out, err = capman.read_global_capture()
|
|
|
sys.stdout.write(out)
|
|
|
sys.stderr.write(err)
|
|
|
|
|
|
# TODO: Type ignored -- breaks Liskov Substitution.
|
|
|
def repr_failure( # type: ignore[override]
|
|
|
self,
|
|
|
excinfo: ExceptionInfo[BaseException],
|
|
|
) -> Union[str, TerminalRepr]:
|
|
|
import doctest
|
|
|
|
|
|
failures: Optional[
|
|
|
Sequence[Union[doctest.DocTestFailure, doctest.UnexpectedException]]
|
|
|
] = None
|
|
|
if isinstance(
|
|
|
excinfo.value, (doctest.DocTestFailure, doctest.UnexpectedException)
|
|
|
):
|
|
|
failures = [excinfo.value]
|
|
|
elif isinstance(excinfo.value, MultipleDoctestFailures):
|
|
|
failures = excinfo.value.failures
|
|
|
|
|
|
if failures is None:
|
|
|
return super().repr_failure(excinfo)
|
|
|
|
|
|
reprlocation_lines = []
|
|
|
for failure in failures:
|
|
|
example = failure.example
|
|
|
test = failure.test
|
|
|
filename = test.filename
|
|
|
if test.lineno is None:
|
|
|
lineno = None
|
|
|
else:
|
|
|
lineno = test.lineno + example.lineno + 1
|
|
|
message = type(failure).__name__
|
|
|
# TODO: ReprFileLocation doesn't expect a None lineno.
|
|
|
reprlocation = ReprFileLocation(filename, lineno, message) # type: ignore[arg-type]
|
|
|
checker = _get_checker()
|
|
|
report_choice = _get_report_choice(self.config.getoption("ipdoctestreport"))
|
|
|
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)
|
|
|
]
|
|
|
# trim docstring error lines to 10
|
|
|
lines = lines[max(example.lineno - 9, 0) : example.lineno + 1]
|
|
|
else:
|
|
|
lines = [
|
|
|
"EXAMPLE LOCATION UNKNOWN, not showing all tests of that example"
|
|
|
]
|
|
|
indent = ">>>"
|
|
|
for line in example.source.splitlines():
|
|
|
lines.append(f"??? {indent} {line}")
|
|
|
indent = "..."
|
|
|
if isinstance(failure, doctest.DocTestFailure):
|
|
|
lines += checker.output_difference(
|
|
|
example, failure.got, report_choice
|
|
|
).split("\n")
|
|
|
else:
|
|
|
inner_excinfo = ExceptionInfo.from_exc_info(failure.exc_info)
|
|
|
lines += ["UNEXPECTED EXCEPTION: %s" % repr(inner_excinfo.value)]
|
|
|
lines += [
|
|
|
x.strip("\n") for x in traceback.format_exception(*failure.exc_info)
|
|
|
]
|
|
|
reprlocation_lines.append((reprlocation, lines))
|
|
|
return ReprFailDoctest(reprlocation_lines)
|
|
|
|
|
|
def reportinfo(self) -> Tuple[Union["os.PathLike[str]", str], Optional[int], str]:
|
|
|
assert self.dtest is not None
|
|
|
return self.path, self.dtest.lineno, "[ipdoctest] %s" % self.name
|
|
|
|
|
|
if int(pytest.__version__.split(".")[0]) < 7:
|
|
|
|
|
|
@property
|
|
|
def path(self) -> Path:
|
|
|
return Path(self.fspath)
|
|
|
|
|
|
|
|
|
def _get_flag_lookup() -> Dict[str, int]:
|
|
|
import doctest
|
|
|
|
|
|
return dict(
|
|
|
DONT_ACCEPT_TRUE_FOR_1=doctest.DONT_ACCEPT_TRUE_FOR_1,
|
|
|
DONT_ACCEPT_BLANKLINE=doctest.DONT_ACCEPT_BLANKLINE,
|
|
|
NORMALIZE_WHITESPACE=doctest.NORMALIZE_WHITESPACE,
|
|
|
ELLIPSIS=doctest.ELLIPSIS,
|
|
|
IGNORE_EXCEPTION_DETAIL=doctest.IGNORE_EXCEPTION_DETAIL,
|
|
|
COMPARISON_FLAGS=doctest.COMPARISON_FLAGS,
|
|
|
ALLOW_UNICODE=_get_allow_unicode_flag(),
|
|
|
ALLOW_BYTES=_get_allow_bytes_flag(),
|
|
|
NUMBER=_get_number_flag(),
|
|
|
)
|
|
|
|
|
|
|
|
|
def get_optionflags(parent):
|
|
|
optionflags_str = parent.config.getini("ipdoctest_optionflags")
|
|
|
flag_lookup_table = _get_flag_lookup()
|
|
|
flag_acc = 0
|
|
|
for flag in optionflags_str:
|
|
|
flag_acc |= flag_lookup_table[flag]
|
|
|
return flag_acc
|
|
|
|
|
|
|
|
|
def _get_continue_on_failure(config):
|
|
|
continue_on_failure = config.getvalue("ipdoctest_continue_on_failure")
|
|
|
if continue_on_failure:
|
|
|
# We need to turn off this if we use pdb since we should stop at
|
|
|
# the first failure.
|
|
|
if config.getvalue("usepdb"):
|
|
|
continue_on_failure = False
|
|
|
return continue_on_failure
|
|
|
|
|
|
|
|
|
class IPDoctestTextfile(pytest.Module):
|
|
|
obj = None
|
|
|
|
|
|
def collect(self) -> Iterable[IPDoctestItem]:
|
|
|
import doctest
|
|
|
from .ipdoctest import IPDocTestParser
|
|
|
|
|
|
# Inspired by doctest.testfile; ideally we would use it directly,
|
|
|
# but it doesn't support passing a custom checker.
|
|
|
encoding = self.config.getini("ipdoctest_encoding")
|
|
|
text = self.path.read_text(encoding)
|
|
|
filename = str(self.path)
|
|
|
name = self.path.name
|
|
|
globs = {"__name__": "__main__"}
|
|
|
|
|
|
optionflags = get_optionflags(self)
|
|
|
|
|
|
runner = _get_runner(
|
|
|
verbose=False,
|
|
|
optionflags=optionflags,
|
|
|
checker=_get_checker(),
|
|
|
continue_on_failure=_get_continue_on_failure(self.config),
|
|
|
)
|
|
|
|
|
|
parser = IPDocTestParser()
|
|
|
test = parser.get_doctest(text, globs, name, filename, 0)
|
|
|
if test.examples:
|
|
|
yield IPDoctestItem.from_parent(
|
|
|
self, name=test.name, runner=runner, dtest=test
|
|
|
)
|
|
|
|
|
|
if int(pytest.__version__.split(".")[0]) < 7:
|
|
|
|
|
|
@property
|
|
|
def path(self) -> Path:
|
|
|
return Path(self.fspath)
|
|
|
|
|
|
@classmethod
|
|
|
def from_parent(
|
|
|
cls,
|
|
|
parent,
|
|
|
*,
|
|
|
fspath=None,
|
|
|
path: Optional[Path] = None,
|
|
|
**kw,
|
|
|
):
|
|
|
if path is not None:
|
|
|
import py.path
|
|
|
|
|
|
fspath = py.path.local(path)
|
|
|
return super().from_parent(parent=parent, fspath=fspath, **kw)
|
|
|
|
|
|
|
|
|
def _check_all_skipped(test: "doctest.DocTest") -> None:
|
|
|
"""Raise pytest.skip() if all examples in the given DocTest have the SKIP
|
|
|
option set."""
|
|
|
import doctest
|
|
|
|
|
|
all_skipped = all(x.options.get(doctest.SKIP, False) for x in test.examples)
|
|
|
if all_skipped:
|
|
|
pytest.skip("all docstests skipped by +SKIP option")
|
|
|
|
|
|
|
|
|
def _is_mocked(obj: object) -> bool:
|
|
|
"""Return if an object is possibly a mock object by checking the
|
|
|
existence of a highly improbable attribute."""
|
|
|
return (
|
|
|
safe_getattr(obj, "pytest_mock_example_attribute_that_shouldnt_exist", None)
|
|
|
is not None
|
|
|
)
|
|
|
|
|
|
|
|
|
@contextmanager
|
|
|
def _patch_unwrap_mock_aware() -> Generator[None, None, None]:
|
|
|
"""Context manager which replaces ``inspect.unwrap`` with a version
|
|
|
that's aware of mock objects and doesn't recurse into them."""
|
|
|
real_unwrap = inspect.unwrap
|
|
|
|
|
|
def _mock_aware_unwrap(
|
|
|
func: Callable[..., Any], *, stop: Optional[Callable[[Any], Any]] = None
|
|
|
) -> Any:
|
|
|
try:
|
|
|
if stop is None or stop is _is_mocked:
|
|
|
return real_unwrap(func, stop=_is_mocked)
|
|
|
_stop = stop
|
|
|
return real_unwrap(func, stop=lambda obj: _is_mocked(obj) or _stop(func))
|
|
|
except Exception as e:
|
|
|
warnings.warn(
|
|
|
"Got %r when unwrapping %r. This is usually caused "
|
|
|
"by a violation of Python's object protocol; see e.g. "
|
|
|
"https://github.com/pytest-dev/pytest/issues/5080" % (e, func),
|
|
|
PytestWarning,
|
|
|
)
|
|
|
raise
|
|
|
|
|
|
inspect.unwrap = _mock_aware_unwrap
|
|
|
try:
|
|
|
yield
|
|
|
finally:
|
|
|
inspect.unwrap = real_unwrap
|
|
|
|
|
|
|
|
|
class IPDoctestModule(pytest.Module):
|
|
|
def collect(self) -> Iterable[IPDoctestItem]:
|
|
|
import doctest
|
|
|
from .ipdoctest import DocTestFinder, IPDocTestParser
|
|
|
|
|
|
class MockAwareDocTestFinder(DocTestFinder):
|
|
|
"""A hackish ipdoctest finder that overrides stdlib internals to fix a stdlib bug.
|
|
|
|
|
|
https://github.com/pytest-dev/pytest/issues/3456
|
|
|
https://bugs.python.org/issue25532
|
|
|
"""
|
|
|
|
|
|
def _find_lineno(self, obj, source_lines):
|
|
|
"""Doctest code does not take into account `@property`, this
|
|
|
is a hackish way to fix it. https://bugs.python.org/issue17446
|
|
|
|
|
|
Wrapped Doctests will need to be unwrapped so the correct
|
|
|
line number is returned. This will be reported upstream. #8796
|
|
|
"""
|
|
|
if isinstance(obj, property):
|
|
|
obj = getattr(obj, "fget", obj)
|
|
|
|
|
|
if hasattr(obj, "__wrapped__"):
|
|
|
# Get the main obj in case of it being wrapped
|
|
|
obj = inspect.unwrap(obj)
|
|
|
|
|
|
# Type ignored because this is a private function.
|
|
|
return super()._find_lineno( # type:ignore[misc]
|
|
|
obj,
|
|
|
source_lines,
|
|
|
)
|
|
|
|
|
|
def _find(
|
|
|
self, tests, obj, name, module, source_lines, globs, seen
|
|
|
) -> None:
|
|
|
if _is_mocked(obj):
|
|
|
return
|
|
|
with _patch_unwrap_mock_aware():
|
|
|
|
|
|
# Type ignored because this is a private function.
|
|
|
super()._find( # type:ignore[misc]
|
|
|
tests, obj, name, module, source_lines, globs, seen
|
|
|
)
|
|
|
|
|
|
if self.path.name == "conftest.py":
|
|
|
if int(pytest.__version__.split(".")[0]) < 7:
|
|
|
module = self.config.pluginmanager._importconftest(
|
|
|
self.path,
|
|
|
self.config.getoption("importmode"),
|
|
|
)
|
|
|
else:
|
|
|
module = self.config.pluginmanager._importconftest(
|
|
|
self.path,
|
|
|
self.config.getoption("importmode"),
|
|
|
rootpath=self.config.rootpath,
|
|
|
)
|
|
|
else:
|
|
|
try:
|
|
|
module = import_path(self.path, root=self.config.rootpath)
|
|
|
except ImportError:
|
|
|
if self.config.getvalue("ipdoctest_ignore_import_errors"):
|
|
|
pytest.skip("unable to import module %r" % self.path)
|
|
|
else:
|
|
|
raise
|
|
|
# Uses internal doctest module parsing mechanism.
|
|
|
finder = MockAwareDocTestFinder(parser=IPDocTestParser())
|
|
|
optionflags = get_optionflags(self)
|
|
|
runner = _get_runner(
|
|
|
verbose=False,
|
|
|
optionflags=optionflags,
|
|
|
checker=_get_checker(),
|
|
|
continue_on_failure=_get_continue_on_failure(self.config),
|
|
|
)
|
|
|
|
|
|
for test in finder.find(module, module.__name__):
|
|
|
if test.examples: # skip empty ipdoctests
|
|
|
yield IPDoctestItem.from_parent(
|
|
|
self, name=test.name, runner=runner, dtest=test
|
|
|
)
|
|
|
|
|
|
if int(pytest.__version__.split(".")[0]) < 7:
|
|
|
|
|
|
@property
|
|
|
def path(self) -> Path:
|
|
|
return Path(self.fspath)
|
|
|
|
|
|
@classmethod
|
|
|
def from_parent(
|
|
|
cls,
|
|
|
parent,
|
|
|
*,
|
|
|
fspath=None,
|
|
|
path: Optional[Path] = None,
|
|
|
**kw,
|
|
|
):
|
|
|
if path is not None:
|
|
|
import py.path
|
|
|
|
|
|
fspath = py.path.local(path)
|
|
|
return super().from_parent(parent=parent, fspath=fspath, **kw)
|
|
|
|
|
|
|
|
|
def _setup_fixtures(doctest_item: IPDoctestItem) -> FixtureRequest:
|
|
|
"""Used by IPDoctestTextfile and IPDoctestItem to setup fixture information."""
|
|
|
|
|
|
def func() -> None:
|
|
|
pass
|
|
|
|
|
|
doctest_item.funcargs = {} # type: ignore[attr-defined]
|
|
|
fm = doctest_item.session._fixturemanager
|
|
|
doctest_item._fixtureinfo = fm.getfixtureinfo( # type: ignore[attr-defined]
|
|
|
node=doctest_item, func=func, cls=None, funcargs=False
|
|
|
)
|
|
|
fixture_request = FixtureRequest(doctest_item, _ispytest=True)
|
|
|
fixture_request._fillfixtures()
|
|
|
return fixture_request
|
|
|
|
|
|
|
|
|
def _init_checker_class() -> Type["IPDoctestOutputChecker"]:
|
|
|
import doctest
|
|
|
import re
|
|
|
from .ipdoctest import IPDoctestOutputChecker
|
|
|
|
|
|
class LiteralsOutputChecker(IPDoctestOutputChecker):
|
|
|
# Based on doctest_nose_plugin.py from the nltk project
|
|
|
# (https://github.com/nltk/nltk) and on the "numtest" doctest extension
|
|
|
# by Sebastien Boisgerault (https://github.com/boisgera/numtest).
|
|
|
|
|
|
_unicode_literal_re = re.compile(r"(\W|^)[uU]([rR]?[\'\"])", re.UNICODE)
|
|
|
_bytes_literal_re = re.compile(r"(\W|^)[bB]([rR]?[\'\"])", re.UNICODE)
|
|
|
_number_re = re.compile(
|
|
|
r"""
|
|
|
(?P<number>
|
|
|
(?P<mantissa>
|
|
|
(?P<integer1> [+-]?\d*)\.(?P<fraction>\d+)
|
|
|
|
|
|
|
(?P<integer2> [+-]?\d+)\.
|
|
|
)
|
|
|
(?:
|
|
|
[Ee]
|
|
|
(?P<exponent1> [+-]?\d+)
|
|
|
)?
|
|
|
|
|
|
|
(?P<integer3> [+-]?\d+)
|
|
|
(?:
|
|
|
[Ee]
|
|
|
(?P<exponent2> [+-]?\d+)
|
|
|
)
|
|
|
)
|
|
|
""",
|
|
|
re.VERBOSE,
|
|
|
)
|
|
|
|
|
|
def check_output(self, want: str, got: str, optionflags: int) -> bool:
|
|
|
if super().check_output(want, got, optionflags):
|
|
|
return True
|
|
|
|
|
|
allow_unicode = optionflags & _get_allow_unicode_flag()
|
|
|
allow_bytes = optionflags & _get_allow_bytes_flag()
|
|
|
allow_number = optionflags & _get_number_flag()
|
|
|
|
|
|
if not allow_unicode and not allow_bytes and not allow_number:
|
|
|
return False
|
|
|
|
|
|
def remove_prefixes(regex: Pattern[str], txt: str) -> str:
|
|
|
return re.sub(regex, r"\1\2", txt)
|
|
|
|
|
|
if allow_unicode:
|
|
|
want = remove_prefixes(self._unicode_literal_re, want)
|
|
|
got = remove_prefixes(self._unicode_literal_re, got)
|
|
|
|
|
|
if allow_bytes:
|
|
|
want = remove_prefixes(self._bytes_literal_re, want)
|
|
|
got = remove_prefixes(self._bytes_literal_re, got)
|
|
|
|
|
|
if allow_number:
|
|
|
got = self._remove_unwanted_precision(want, got)
|
|
|
|
|
|
return super().check_output(want, got, optionflags)
|
|
|
|
|
|
def _remove_unwanted_precision(self, want: str, got: str) -> str:
|
|
|
wants = list(self._number_re.finditer(want))
|
|
|
gots = list(self._number_re.finditer(got))
|
|
|
if len(wants) != len(gots):
|
|
|
return got
|
|
|
offset = 0
|
|
|
for w, g in zip(wants, gots):
|
|
|
fraction: Optional[str] = w.group("fraction")
|
|
|
exponent: Optional[str] = w.group("exponent1")
|
|
|
if exponent is None:
|
|
|
exponent = w.group("exponent2")
|
|
|
precision = 0 if fraction is None else len(fraction)
|
|
|
if exponent is not None:
|
|
|
precision -= int(exponent)
|
|
|
if float(w.group()) == approx(float(g.group()), abs=10**-precision):
|
|
|
# They're close enough. Replace the text we actually
|
|
|
# got with the text we want, so that it will match when we
|
|
|
# check the string literally.
|
|
|
got = (
|
|
|
got[: g.start() + offset] + w.group() + got[g.end() + offset :]
|
|
|
)
|
|
|
offset += w.end() - w.start() - (g.end() - g.start())
|
|
|
return got
|
|
|
|
|
|
return LiteralsOutputChecker
|
|
|
|
|
|
|
|
|
def _get_checker() -> "IPDoctestOutputChecker":
|
|
|
"""Return a IPDoctestOutputChecker 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
|
|
|
ipdoctest should run in Python 2 and Python 3.
|
|
|
|
|
|
* NUMBER to ignore floating-point differences smaller than the
|
|
|
precision of the literal number in the ipdoctest.
|
|
|
|
|
|
An inner class is used to avoid importing "ipdoctest" 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:
|
|
|
"""Register and return the ALLOW_UNICODE flag."""
|
|
|
import doctest
|
|
|
|
|
|
return doctest.register_optionflag("ALLOW_UNICODE")
|
|
|
|
|
|
|
|
|
def _get_allow_bytes_flag() -> int:
|
|
|
"""Register and return the ALLOW_BYTES flag."""
|
|
|
import doctest
|
|
|
|
|
|
return doctest.register_optionflag("ALLOW_BYTES")
|
|
|
|
|
|
|
|
|
def _get_number_flag() -> int:
|
|
|
"""Register and return the NUMBER flag."""
|
|
|
import doctest
|
|
|
|
|
|
return doctest.register_optionflag("NUMBER")
|
|
|
|
|
|
|
|
|
def _get_report_choice(key: str) -> int:
|
|
|
"""Return the actual `ipdoctest` module flag value.
|
|
|
|
|
|
We want to do it as late as possible to avoid importing `ipdoctest` and all
|
|
|
its dependencies when parsing options, as it adds overhead and breaks tests.
|
|
|
"""
|
|
|
import doctest
|
|
|
|
|
|
return {
|
|
|
DOCTEST_REPORT_CHOICE_UDIFF: doctest.REPORT_UDIFF,
|
|
|
DOCTEST_REPORT_CHOICE_CDIFF: doctest.REPORT_CDIFF,
|
|
|
DOCTEST_REPORT_CHOICE_NDIFF: doctest.REPORT_NDIFF,
|
|
|
DOCTEST_REPORT_CHOICE_ONLY_FIRST_FAILURE: doctest.REPORT_ONLY_FIRST_FAILURE,
|
|
|
DOCTEST_REPORT_CHOICE_NONE: 0,
|
|
|
}[key]
|
|
|
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
|
def ipdoctest_namespace() -> Dict[str, Any]:
|
|
|
"""Fixture that returns a :py:class:`dict` that will be injected into the
|
|
|
namespace of ipdoctests."""
|
|
|
return dict()
|
|
|
|