1"""Support for presenting detailed information in failing assertions."""

2 import sys

3 from typing import Any

4 from typing import Generator

5 from typing import List

6 from typing import Optional

7 from typing import TYPE_CHECKING

8

9 from _pytest.assertion import rewrite

10 from _pytest.assertion import truncate

11 from _pytest.assertion import util

12 from _pytest.assertion.rewrite import assertstate_key

13 from _pytest.config import Config

14 from _pytest.config import hookimpl

15 from _pytest.config.argparsing import Parser

16 from _pytest.nodes import Item

17

18 if TYPE_CHECKING:

19 from _pytest.main import Session

20

21

22 def pytest_addoption(parser: Parser) -> None:

23 group = parser.getgroup("debugconfig")

24 group.addoption(

25 "--assert",

26 action="store",

27 dest="assertmode",

28 choices=("rewrite", "plain"),

29 default="rewrite",

30 metavar="MODE",

31 help=(

32 "Control assertion debugging tools.\n"

33 "'plain' performs no assertion debugging.\n"

34 "'rewrite' (the default) rewrites assert statements in test modules"

35 " on import to provide assert expression information."

36 ),

37 )

38 parser.addini(

39 "enable_assertion_pass_hook",

40 type="bool",

41 default=False,

42 help="Enables the pytest_assertion_pass hook. "

43 "Make sure to delete any previously generated pyc cache files.",

44 )

45

46

47 def register_assert_rewrite(*names: str) -> None:

48 """Register one or more module names to be rewritten on import.

49

50 This function will make sure that this module or all modules inside

51 the package will get their assert statements rewritten.

52 Thus you should make sure to call this before the module is

53 actually imported, usually in your __init__.py if you are a plugin

54 using a package.

55

56 :param names: The module names to register.

57 """

58 for name in names:

59 if not isinstance(name, str):

60 msg = "expected module names as *args, got {0} instead" # type: ignore[unreachable]

61 raise TypeError(msg.format(repr(names)))

62 for hook in sys.meta_path:

63 if isinstance(hook, rewrite.AssertionRewritingHook):

64 importhook = hook

65 break

66 else:

67 # TODO(typing): Add a protocol for mark_rewrite() and use it

68 # for importhook and for PytestPluginManager.rewrite_hook.

69 importhook = DummyRewriteHook() # type: ignore

70 importhook.mark_rewrite(*names)

71

72

73 class DummyRewriteHook:

74 """A no-op import hook for when rewriting is disabled."""

75

76 def mark_rewrite(self, *names: str) -> None:

77 pass

78

79

80 class AssertionState:

81 """State for the assertion plugin."""

82

83 def __init__(self, config: Config, mode) -> None:

84 self.mode = mode

85 self.trace = config.trace.root.get("assertion")

86 self.hook: Optional[rewrite.AssertionRewritingHook] = None

87

88

89 def install_importhook(config: Config) -> rewrite.AssertionRewritingHook:

90 """Try to install the rewrite hook, raise SystemError if it fails."""

91 config.stash[assertstate_key] = AssertionState(config, "rewrite")

92 config.stash[assertstate_key].hook = hook = rewrite.AssertionRewritingHook(config)

93 sys.meta_path.insert(0, hook)

94 config.stash[assertstate_key].trace("installed rewrite import hook")

95

96 def undo() -> None:

97 hook = config.stash[assertstate_key].hook

98 if hook is not None and hook in sys.meta_path:

99 sys.meta_path.remove(hook)

100

101 config.add_cleanup(undo)

102 return hook

103

104

105 def pytest_collection(session: "Session") -> None:

106 # This hook is only called when test modules are collected

107 # so for example not in the managing process of pytest-xdist

108 # (which does not collect test modules).

109 assertstate = session.config.stash.get(assertstate_key, None)

110 if assertstate:

111 if assertstate.hook is not None:

112 assertstate.hook.set_session(session)

113

114

115@hookimpl(tryfirst=True, hookwrapper=True)

116 def pytest_runtest_protocol(item: Item) -> Generator[None, None, None]:

117 """Setup the pytest_assertrepr_compare and pytest_assertion_pass hooks.

118

119 The rewrite module will use util._reprcompare if it exists to use custom

120 reporting via the pytest_assertrepr_compare hook. This sets up this custom

121 comparison for the test.

122 """

123

124 ihook = item.ihook

125

126 def callbinrepr(op, left: object, right: object) -> Optional[str]:

127 """Call the pytest_assertrepr_compare hook and prepare the result.

128

129 This uses the first result from the hook and then ensures the

130 following:

131 * Overly verbose explanations are truncated unless configured otherwise

132 (eg. if running in verbose mode).

133 * Embedded newlines are escaped to help util.format_explanation()

134 later.

135 * If the rewrite mode is used embedded %-characters are replaced

136 to protect later % formatting.

137

138 The result can be formatted by util.format_explanation() for

139 pretty printing.

140 """

141 hook_result = ihook.pytest_assertrepr_compare(

142 config=item.config, op=op, left=left, right=right

143 )

144 for new_expl in hook_result:

145 if new_expl:

146 new_expl = truncate.truncate_if_required(new_expl, item)

147 new_expl = [line.replace("\n", "\\n") for line in new_expl]

148 res = "\n~".join(new_expl)

149 if item.config.getvalue("assertmode") == "rewrite":

150 res = res.replace("%", "%%")

151 return res

152 return None

153

154 saved_assert_hooks = util._reprcompare, util._assertion_pass

155 util._reprcompare = callbinrepr

156 util._config = item.config

157

158 if ihook.pytest_assertion_pass.get_hookimpls():

159

160 def call_assertion_pass_hook(lineno: int, orig: str, expl: str) -> None:

161 ihook.pytest_assertion_pass(item=item, lineno=lineno, orig=orig, expl=expl)

162

163 util._assertion_pass = call_assertion_pass_hook

164

165 yield

166

167 util._reprcompare, util._assertion_pass = saved_assert_hooks

168 util._config = None

169

170

171 def pytest_sessionfinish(session: "Session") -> None:

172 assertstate = session.config.stash.get(assertstate_key, None)

173 if assertstate:

174 if assertstate.hook is not None:

175 assertstate.hook.set_session(None)

176

177

178 def pytest_assertrepr_compare(

179 config: Config, op: str, left: Any, right: Any

180) -> Optional[List[str]]:

181 return util.assertrepr_compare(config=config, op=op, left=left, right=right)