pytest_ipdoctest.py
860 lines
| 29.0 KiB
| text/x-python
|
PythonLexer
Nikita Kniazev
|
r26996 | # 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 | ||||
Nikita Kniazev
|
r26995 | import bdb | ||
import inspect | ||||
Nikita Kniazev
|
r26996 | import os | ||
Nikita Kniazev
|
r26995 | import platform | ||
import sys | ||||
import traceback | ||||
import types | ||||
import warnings | ||||
from contextlib import contextmanager | ||||
Nikita Kniazev
|
r27516 | from pathlib import Path | ||
Nikita Kniazev
|
r26995 | 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 | ||||
Nikita Kniazev
|
r27516 | from _pytest.pathlib import fnmatch_ex | ||
Nikita Kniazev
|
r26995 | 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 | ||||
Nikita Kniazev
|
r26996 | CHECKER_CLASS: Optional[Type["IPDoctestOutputChecker"]] = None | ||
Nikita Kniazev
|
r26995 | |||
def pytest_addoption(parser: Parser) -> None: | ||||
parser.addini( | ||||
Nikita Kniazev
|
r26996 | "ipdoctest_optionflags", | ||
"option flags for ipdoctests", | ||||
Nikita Kniazev
|
r26995 | type="args", | ||
default=["ELLIPSIS"], | ||||
) | ||||
parser.addini( | ||||
Nikita Kniazev
|
r26996 | "ipdoctest_encoding", "encoding used for ipdoctest files", default="utf-8" | ||
Nikita Kniazev
|
r26995 | ) | ||
group = parser.getgroup("collect") | ||||
group.addoption( | ||||
Nikita Kniazev
|
r26996 | "--ipdoctest-modules", | ||
Nikita Kniazev
|
r26995 | action="store_true", | ||
default=False, | ||||
Nikita Kniazev
|
r26996 | help="run ipdoctests in all .py modules", | ||
dest="ipdoctestmodules", | ||||
Nikita Kniazev
|
r26995 | ) | ||
group.addoption( | ||||
Nikita Kniazev
|
r26996 | "--ipdoctest-report", | ||
Nikita Kniazev
|
r26995 | type=str.lower, | ||
default="udiff", | ||||
Nikita Kniazev
|
r26996 | help="choose another output format for diffs on ipdoctest failure", | ||
Nikita Kniazev
|
r26995 | choices=DOCTEST_REPORT_CHOICES, | ||
Nikita Kniazev
|
r26996 | dest="ipdoctestreport", | ||
Nikita Kniazev
|
r26995 | ) | ||
group.addoption( | ||||
Nikita Kniazev
|
r26996 | "--ipdoctest-glob", | ||
Nikita Kniazev
|
r26995 | action="append", | ||
default=[], | ||||
metavar="pat", | ||||
Nikita Kniazev
|
r26996 | help="ipdoctests file matching pattern, default: test*.txt", | ||
dest="ipdoctestglob", | ||||
Nikita Kniazev
|
r26995 | ) | ||
group.addoption( | ||||
Nikita Kniazev
|
r26996 | "--ipdoctest-ignore-import-errors", | ||
Nikita Kniazev
|
r26995 | action="store_true", | ||
default=False, | ||||
Nikita Kniazev
|
r26996 | help="ignore ipdoctest ImportErrors", | ||
dest="ipdoctest_ignore_import_errors", | ||||
Nikita Kniazev
|
r26995 | ) | ||
group.addoption( | ||||
Nikita Kniazev
|
r26996 | "--ipdoctest-continue-on-failure", | ||
Nikita Kniazev
|
r26995 | action="store_true", | ||
default=False, | ||||
Nikita Kniazev
|
r26996 | help="for a given ipdoctest, continue to run after the first failure", | ||
dest="ipdoctest_continue_on_failure", | ||||
Nikita Kniazev
|
r26995 | ) | ||
def pytest_unconfigure() -> None: | ||||
global RUNNER_CLASS | ||||
RUNNER_CLASS = None | ||||
def pytest_collect_file( | ||||
Nikita Kniazev
|
r27516 | file_path: Path, | ||
Nikita Kniazev
|
r26996 | parent: Collector, | ||
) -> Optional[Union["IPDoctestModule", "IPDoctestTextfile"]]: | ||||
Nikita Kniazev
|
r26995 | config = parent.config | ||
Nikita Kniazev
|
r27516 | 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) | ||||
Nikita Kniazev
|
r26995 | return mod | ||
Nikita Kniazev
|
r27516 | elif _is_ipdoctest(config, file_path, parent): | ||
txt: IPDoctestTextfile = IPDoctestTextfile.from_parent(parent, path=file_path) | ||||
Nikita Kniazev
|
r26995 | return txt | ||
return None | ||||
Nikita Kniazev
|
r27517 | 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)) | ||||
Nikita Kniazev
|
r27516 | def _is_setup_py(path: Path) -> bool: | ||
if path.name != "setup.py": | ||||
Nikita Kniazev
|
r26995 | return False | ||
Nikita Kniazev
|
r27516 | contents = path.read_bytes() | ||
Nikita Kniazev
|
r26995 | return b"setuptools" in contents or b"distutils" in contents | ||
Nikita Kniazev
|
r27516 | def _is_ipdoctest(config: Config, path: Path, parent: Collector) -> bool: | ||
if path.suffix in (".txt", ".rst") and parent.session.isinitpath(path): | ||||
Nikita Kniazev
|
r26995 | return True | ||
Nikita Kniazev
|
r26996 | globs = config.getoption("ipdoctestglob") or ["test*.txt"] | ||
Nikita Kniazev
|
r27516 | return any(fnmatch_ex(glob, path) for glob in globs) | ||
def _is_main_py(path: Path) -> bool: | ||||
return path.name == "__main__.py" | ||||
Nikita Kniazev
|
r26995 | |||
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 | ||||
Nikita Kniazev
|
r26996 | def _init_runner_class() -> Type["IPDocTestRunner"]: | ||
Nikita Kniazev
|
r26995 | import doctest | ||
Nikita Kniazev
|
r26996 | from .ipdoctest import IPDocTestRunner | ||
Nikita Kniazev
|
r26995 | |||
Nikita Kniazev
|
r26996 | class PytestDoctestRunner(IPDocTestRunner): | ||
Nikita Kniazev
|
r26995 | """Runner to collect failures. | ||
Note that the out variable in this case is a list instead of a | ||||
stdout-like object. | ||||
""" | ||||
def __init__( | ||||
self, | ||||
Nikita Kniazev
|
r26996 | checker: Optional["IPDoctestOutputChecker"] = None, | ||
Nikita Kniazev
|
r26995 | verbose: Optional[bool] = None, | ||
optionflags: int = 0, | ||||
continue_on_failure: bool = True, | ||||
) -> None: | ||||
Nikita Kniazev
|
r26996 | super().__init__(checker=checker, verbose=verbose, optionflags=optionflags) | ||
Nikita Kniazev
|
r26995 | self.continue_on_failure = continue_on_failure | ||
def report_failure( | ||||
Nikita Kniazev
|
r26996 | self, | ||
out, | ||||
test: "doctest.DocTest", | ||||
example: "doctest.Example", | ||||
got: str, | ||||
Nikita Kniazev
|
r26995 | ) -> 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( | ||||
Nikita Kniazev
|
r26996 | checker: Optional["IPDoctestOutputChecker"] = None, | ||
Nikita Kniazev
|
r26995 | verbose: Optional[bool] = None, | ||
optionflags: int = 0, | ||||
continue_on_failure: bool = True, | ||||
Nikita Kniazev
|
r26996 | ) -> "IPDocTestRunner": | ||
Nikita Kniazev
|
r26995 | # 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, | ||||
) | ||||
Nikita Kniazev
|
r26996 | class IPDoctestItem(pytest.Item): | ||
Nikita Kniazev
|
r26995 | def __init__( | ||
self, | ||||
name: str, | ||||
Nikita Kniazev
|
r26996 | parent: "Union[IPDoctestTextfile, IPDoctestModule]", | ||
runner: Optional["IPDocTestRunner"] = None, | ||||
Nikita Kniazev
|
r26995 | 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, | ||||
Nikita Kniazev
|
r26996 | parent: "Union[IPDoctestTextfile, IPDoctestModule]", | ||
Nikita Kniazev
|
r26995 | *, | ||
name: str, | ||||
Nikita Kniazev
|
r26996 | runner: "IPDocTestRunner", | ||
Nikita Kniazev
|
r26995 | dtest: "doctest.DocTest", | ||
): | ||||
Nikita Kniazev
|
r27516 | # incompatible signature due to imposed limits on subclass | ||
Nikita Kniazev
|
r26995 | """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( | ||||
Nikita Kniazev
|
r26996 | "ipdoctest_namespace" | ||
Nikita Kniazev
|
r26995 | ).items(): | ||
globs[name] = value | ||||
self.dtest.globs.update(globs) | ||||
Nikita Kniazev
|
r26996 | 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() | ||||
Nikita Kniazev
|
r26995 | 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"] = [] | ||||
Nikita Kniazev
|
r26996 | |||
# 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, "_") | ||||
Nikita Kniazev
|
r26995 | if failures: | ||
raise MultipleDoctestFailures(failures) | ||||
def _disable_output_capturing_for_darwin(self) -> None: | ||||
Nikita Kniazev
|
r26996 | """Disable output capturing. Otherwise, stdout is lost to ipdoctest (pytest#985).""" | ||
Nikita Kniazev
|
r26995 | 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] | ||||
Nikita Kniazev
|
r26996 | self, | ||
excinfo: ExceptionInfo[BaseException], | ||||
Nikita Kniazev
|
r26995 | ) -> Union[str, TerminalRepr]: | ||
import doctest | ||||
failures: Optional[ | ||||
Sequence[Union[doctest.DocTestFailure, doctest.UnexpectedException]] | ||||
Nikita Kniazev
|
r26996 | ] = None | ||
Nikita Kniazev
|
r26995 | if isinstance( | ||
excinfo.value, (doctest.DocTestFailure, doctest.UnexpectedException) | ||||
): | ||||
failures = [excinfo.value] | ||||
elif isinstance(excinfo.value, MultipleDoctestFailures): | ||||
failures = excinfo.value.failures | ||||
Nikita Kniazev
|
r27516 | if failures is None: | ||
Nikita Kniazev
|
r26995 | return super().repr_failure(excinfo) | ||
Nikita Kniazev
|
r27516 | 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]: | ||||
Nikita Kniazev
|
r26995 | assert self.dtest is not None | ||
Nikita Kniazev
|
r27516 | return self.path, self.dtest.lineno, "[ipdoctest] %s" % self.name | ||
Nikita Kniazev
|
r26995 | |||
Nikita Kniazev
|
r27517 | if int(pytest.__version__.split(".")[0]) < 7: | ||
@property | ||||
def path(self) -> Path: | ||||
return Path(self.fspath) | ||||
Nikita Kniazev
|
r26995 | |||
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): | ||||
Nikita Kniazev
|
r26996 | optionflags_str = parent.config.getini("ipdoctest_optionflags") | ||
Nikita Kniazev
|
r26995 | 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): | ||||
Nikita Kniazev
|
r26996 | continue_on_failure = config.getvalue("ipdoctest_continue_on_failure") | ||
Nikita Kniazev
|
r26995 | 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 | ||||
Nikita Kniazev
|
r26996 | class IPDoctestTextfile(pytest.Module): | ||
Nikita Kniazev
|
r26995 | obj = None | ||
Nikita Kniazev
|
r26996 | def collect(self) -> Iterable[IPDoctestItem]: | ||
Nikita Kniazev
|
r26995 | import doctest | ||
Nikita Kniazev
|
r26996 | from .ipdoctest import IPDocTestParser | ||
Nikita Kniazev
|
r26995 | |||
# Inspired by doctest.testfile; ideally we would use it directly, | ||||
# but it doesn't support passing a custom checker. | ||||
Nikita Kniazev
|
r26996 | encoding = self.config.getini("ipdoctest_encoding") | ||
Nikita Kniazev
|
r27516 | text = self.path.read_text(encoding) | ||
filename = str(self.path) | ||||
name = self.path.name | ||||
Nikita Kniazev
|
r26995 | 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), | ||||
) | ||||
Nikita Kniazev
|
r26996 | parser = IPDocTestParser() | ||
Nikita Kniazev
|
r26995 | test = parser.get_doctest(text, globs, name, filename, 0) | ||
if test.examples: | ||||
Nikita Kniazev
|
r26996 | yield IPDoctestItem.from_parent( | ||
Nikita Kniazev
|
r26995 | self, name=test.name, runner=runner, dtest=test | ||
) | ||||
Nikita Kniazev
|
r27517 | 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) | ||||
Nikita Kniazev
|
r26995 | |||
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: | ||||
Matthias Bussonnier
|
r27356 | pytest.skip("all docstests skipped by +SKIP option") | ||
Nikita Kniazev
|
r26995 | |||
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 | ||||
Nikita Kniazev
|
r26996 | class IPDoctestModule(pytest.Module): | ||
def collect(self) -> Iterable[IPDoctestItem]: | ||||
Nikita Kniazev
|
r26995 | import doctest | ||
Nikita Kniazev
|
r26996 | from .ipdoctest import DocTestFinder, IPDocTestParser | ||
Nikita Kniazev
|
r26995 | |||
Nikita Kniazev
|
r26996 | class MockAwareDocTestFinder(DocTestFinder): | ||
"""A hackish ipdoctest finder that overrides stdlib internals to fix a stdlib bug. | ||||
Nikita Kniazev
|
r26995 | |||
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 | ||||
Nikita Kniazev
|
r27516 | is a hackish way to fix it. https://bugs.python.org/issue17446 | ||
Nikita Kniazev
|
r26995 | |||
Nikita Kniazev
|
r27516 | Wrapped Doctests will need to be unwrapped so the correct | ||
line number is returned. This will be reported upstream. #8796 | ||||
Nikita Kniazev
|
r26995 | """ | ||
if isinstance(obj, property): | ||||
obj = getattr(obj, "fget", obj) | ||||
Nikita Kniazev
|
r27516 | |||
if hasattr(obj, "__wrapped__"): | ||||
# Get the main obj in case of it being wrapped | ||||
obj = inspect.unwrap(obj) | ||||
Nikita Kniazev
|
r26995 | # Type ignored because this is a private function. | ||
Nikita Kniazev
|
r27516 | return super()._find_lineno( # type:ignore[misc] | ||
Nikita Kniazev
|
r26996 | obj, | ||
source_lines, | ||||
Nikita Kniazev
|
r26995 | ) | ||
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. | ||||
Nikita Kniazev
|
r27516 | super()._find( # type:ignore[misc] | ||
tests, obj, name, module, source_lines, globs, seen | ||||
Nikita Kniazev
|
r26995 | ) | ||
Nikita Kniazev
|
r27516 | if self.path.name == "conftest.py": | ||
Nikita Kniazev
|
r27517 | 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, | ||||
) | ||||
Nikita Kniazev
|
r26995 | else: | ||
try: | ||||
Nikita Kniazev
|
r27516 | module = import_path(self.path, root=self.config.rootpath) | ||
Nikita Kniazev
|
r26995 | except ImportError: | ||
Nikita Kniazev
|
r26996 | if self.config.getvalue("ipdoctest_ignore_import_errors"): | ||
Nikita Kniazev
|
r27516 | pytest.skip("unable to import module %r" % self.path) | ||
Nikita Kniazev
|
r26995 | else: | ||
raise | ||||
# Uses internal doctest module parsing mechanism. | ||||
Nikita Kniazev
|
r26996 | finder = MockAwareDocTestFinder(parser=IPDocTestParser()) | ||
Nikita Kniazev
|
r26995 | 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__): | ||||
Nikita Kniazev
|
r26996 | if test.examples: # skip empty ipdoctests | ||
yield IPDoctestItem.from_parent( | ||||
Nikita Kniazev
|
r26995 | self, name=test.name, runner=runner, dtest=test | ||
) | ||||
Nikita Kniazev
|
r27517 | 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) | ||||
Nikita Kniazev
|
r26995 | |||
Nikita Kniazev
|
r26996 | def _setup_fixtures(doctest_item: IPDoctestItem) -> FixtureRequest: | ||
"""Used by IPDoctestTextfile and IPDoctestItem to setup fixture information.""" | ||||
Nikita Kniazev
|
r26995 | |||
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 | ||||
Nikita Kniazev
|
r26996 | def _init_checker_class() -> Type["IPDoctestOutputChecker"]: | ||
Nikita Kniazev
|
r26995 | import doctest | ||
import re | ||||
Nikita Kniazev
|
r26996 | from .ipdoctest import IPDoctestOutputChecker | ||
Nikita Kniazev
|
r26995 | |||
Nikita Kniazev
|
r26996 | class LiteralsOutputChecker(IPDoctestOutputChecker): | ||
Nikita Kniazev
|
r26995 | # 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: | ||||
Nikita Kniazev
|
r27516 | if super().check_output(want, got, optionflags): | ||
Nikita Kniazev
|
r26995 | 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) | ||||
Nikita Kniazev
|
r27516 | return super().check_output(want, got, optionflags) | ||
Nikita Kniazev
|
r26995 | |||
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") | ||||
Nikita Kniazev
|
r27516 | precision = 0 if fraction is None else len(fraction) | ||
Nikita Kniazev
|
r26995 | if exponent is not None: | ||
precision -= int(exponent) | ||||
Nikita Kniazev
|
r27517 | if float(w.group()) == approx(float(g.group()), abs=10 ** -precision): | ||
Nikita Kniazev
|
r26995 | # 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 | ||||
Nikita Kniazev
|
r26996 | def _get_checker() -> "IPDoctestOutputChecker": | ||
"""Return a IPDoctestOutputChecker subclass that supports some | ||||
Nikita Kniazev
|
r26995 | additional options: | ||
* ALLOW_UNICODE and ALLOW_BYTES options to ignore u'' and b'' | ||||
prefixes (respectively) in string literals. Useful when the same | ||||
Nikita Kniazev
|
r26996 | ipdoctest should run in Python 2 and Python 3. | ||
Nikita Kniazev
|
r26995 | |||
* NUMBER to ignore floating-point differences smaller than the | ||||
Nikita Kniazev
|
r26996 | precision of the literal number in the ipdoctest. | ||
Nikita Kniazev
|
r26995 | |||
Nikita Kniazev
|
r26996 | An inner class is used to avoid importing "ipdoctest" at the module | ||
Nikita Kniazev
|
r26995 | 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: | ||||
Nikita Kniazev
|
r26996 | """Return the actual `ipdoctest` module flag value. | ||
Nikita Kniazev
|
r26995 | |||
Nikita Kniazev
|
r26996 | We want to do it as late as possible to avoid importing `ipdoctest` and all | ||
Nikita Kniazev
|
r26995 | 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") | ||||
Nikita Kniazev
|
r26996 | def ipdoctest_namespace() -> Dict[str, Any]: | ||
Nikita Kniazev
|
r26995 | """Fixture that returns a :py:class:`dict` that will be injected into the | ||
Nikita Kniazev
|
r26996 | namespace of ipdoctests.""" | ||
Nikita Kniazev
|
r26995 | return dict() | ||