3737
3838import ast
3939from collections .abc import Callable
40- from collections .abc import Mapping
41- import copy
4240import sys
4341import textwrap
4442from typing import cast
5250# ---------------------------------------------------------------------------
5351
5452
55- def _rewrite_source (src : str ) -> ast .Module :
56- """Parse and rewrite assertions in source code."""
53+ def _exec_check (
54+ src : str ,
55+ * ,
56+ rewrite : bool = True ,
57+ ns : dict [str , object ] | None = None ,
58+ ) -> Callable [[], object ]:
59+ """Compile and execute ``src``, returning the ``check`` function it defines.
60+
61+ ``src`` is dedented first. When ``rewrite`` is true the assertions are
62+ rewritten before compiling, which is the usual case; the axes that compare
63+ against unrewritten behaviour ask for both.
64+
65+ ``ns`` is the namespace the source executes in. Pass one in to seed it
66+ (``assert_single_evaluation`` needs a ``counter``) or to read side effects
67+ back out afterwards; each call otherwise gets a fresh namespace, which is
68+ what keeps a plain and a rewritten run from leaking state into each other.
69+ """
70+ src = textwrap .dedent (src )
5771 tree = ast .parse (src )
58- rewrite_asserts (tree , src .encode ())
59- return tree
72+ if rewrite :
73+ rewrite_asserts (tree , src .encode ())
74+ if ns is None :
75+ ns = {}
76+ exec (compile (tree , "<test-rewritten>" if rewrite else "<test-plain>" , "exec" ), ns )
77+ return cast (Callable [[], object ], ns ["check" ])
6078
6179
62- def get_failure_message (
63- src : str ,
64- extra_ns : Mapping [str , object ] | None = None ,
65- ) -> str :
80+ def get_failure_message (src : str ) -> str :
6681 """Compile rewritten source, execute it, and return the failure message.
6782
6883 The source should contain a function named ``check`` with a failing assert.
6984 Returns the AssertionError message string.
7085
7186 Raises AssertionError via pytest.fail if the code does not raise.
7287 """
73- src = textwrap .dedent (src )
74- mod = _rewrite_source (src )
75- code = compile (mod , "<test>" , "exec" )
76- ns : dict [str , object ] = {}
77- if extra_ns is not None :
78- ns .update (extra_ns )
79- exec (code , ns )
80- func = cast (Callable [[], None ], ns ["check" ])
88+ func = _exec_check (src )
8189 try :
8290 func ()
8391 except AssertionError :
@@ -94,7 +102,6 @@ def assert_introspects(
94102 * ,
95103 must_contain : list [str ],
96104 must_not_contain : list [str ] | None = None ,
97- extra_ns : Mapping [str , object ] | None = None ,
98105) -> str :
99106 """Verify a failing assert produces a message with expected intermediate values.
100107
@@ -106,15 +113,13 @@ def assert_introspects(
106113 Substrings that MUST appear in the failure message.
107114 must_not_contain : list[str] | None
108115 Substrings that must NOT appear in the failure message.
109- extra_ns : Mapping[str, object] | None
110- Additional namespace entries available during execution.
111116
112117 Returns
113118 -------
114119 str
115120 The full failure message (for further inspection if needed).
116121 """
117- msg = get_failure_message (src , extra_ns = extra_ns )
122+ msg = get_failure_message (src )
118123 for expected in must_contain :
119124 assert expected in msg , (
120125 f"Expected { expected !r} in failure message.\n Got:\n { msg } "
@@ -130,31 +135,22 @@ def assert_single_evaluation(
130135 src : str ,
131136 * ,
132137 expected_call_count : int = 1 ,
133- extra_ns : Mapping [str , object ] | None = None ,
134138) -> None :
135139 """Verify side-effecting expressions in assert are evaluated exactly once.
136140
137- The source should define a ``check()`` function and use a ``counter`` list
138- (provided via extra_ns or defined in the source) that tracks how many times
139- a side-effecting expression is evaluated.
141+ The source should define a ``check()`` function and use the ``counter``
142+ list seeded here, which tracks how many times a side-effecting expression
143+ is evaluated.
140144
141145 Parameters
142146 ----------
143147 src : str
144148 Source containing a ``check()`` function whose assert has side effects.
145149 expected_call_count : int
146150 How many times the side-effecting expression should be evaluated.
147- extra_ns : Mapping[str, object] | None
148- Additional namespace. Should include ``counter`` if not defined in src.
149151 """
150- src = textwrap .dedent (src )
151- mod = _rewrite_source (src )
152- code = compile (mod , "<test>" , "exec" )
153152 ns : dict [str , object ] = {"counter" : [0 ]}
154- if extra_ns is not None :
155- ns .update (extra_ns )
156- exec (code , ns )
157- func = cast (Callable [[], None ], ns ["check" ])
153+ func = _exec_check (src , ns = ns )
158154 counter = cast (list [int ], ns ["counter" ])
159155 counter [0 ] = 0
160156 try :
@@ -167,108 +163,53 @@ def assert_single_evaluation(
167163 )
168164
169165
170- def assert_passes_when_true (
171- src : str ,
172- * ,
173- extra_ns : Mapping [str , object ] | None = None ,
174- ) -> None :
166+ def assert_passes_when_true (src : str ) -> None :
175167 """Verify rewritten assertion does not raise when the condition is true.
176168
177169 Parameters
178170 ----------
179171 src : str
180172 Source containing a ``check()`` function with a passing assertion.
181- extra_ns : Mapping[str, object] | None
182- Additional namespace entries available during execution.
183173 """
184- src = textwrap .dedent (src )
185- mod = _rewrite_source (src )
186- code = compile (mod , "<test>" , "exec" )
187- ns : dict [str , object ] = {}
188- if extra_ns is not None :
189- ns .update (extra_ns )
190- exec (code , ns )
191- func = cast (Callable [[], None ], ns ["check" ])
192- func ()
174+ _exec_check (src )()
193175
194176
195- def assert_semantically_equivalent (
196- src : str ,
197- * ,
198- extra_ns : Mapping [str , object ] | None = None ,
199- ) -> None :
177+ _Outcome = tuple [bool , object ]
178+
179+
180+ def _run_both (src : str ) -> tuple [_Outcome , _Outcome ]:
181+ """Execute ``check()`` plain and rewritten, returning (raised, result) pairs."""
182+ outcomes : list [_Outcome ] = []
183+ for rewrite in (False , True ):
184+ func = _exec_check (src , rewrite = rewrite )
185+ try :
186+ outcomes .append ((False , func ()))
187+ except AssertionError :
188+ outcomes .append ((True , None ))
189+ plain , rewritten = outcomes
190+ return plain , rewritten
191+
192+
193+ def assert_semantically_equivalent (src : str ) -> None :
200194 """Verify rewritten code has same pass/fail semantics as unrewritten code.
201195
202196 Runs the source both with and without rewriting, and asserts they agree
203- on whether an AssertionError is raised.
197+ on whether an AssertionError is raised. Only the raise is compared --
198+ :func:`assert_evaluation_order` compares the returned observations too.
204199
205200 Parameters
206201 ----------
207202 src : str
208203 Source containing a ``check()`` function with an assertion.
209- extra_ns : Mapping[str, object] | None
210- Additional namespace entries available during execution.
211204 """
212- src = textwrap .dedent (src )
213-
214- # Run without rewriting — use deepcopy of extra_ns to isolate mutable state
215- plain_code = compile (src , "<test-plain>" , "exec" )
216- plain_ns : dict [str , object ] = {}
217- if extra_ns is not None :
218- plain_ns .update (copy .deepcopy (dict (extra_ns )))
219- exec (plain_code , plain_ns )
220- plain_func = cast (Callable [[], None ], plain_ns ["check" ])
221- plain_raised = False
222- try :
223- plain_func ()
224- except AssertionError :
225- plain_raised = True
226-
227- # Run with rewriting — fresh deepcopy so mutations from first run don't leak
228- mod = _rewrite_source (src )
229- rewritten_code = compile (mod , "<test-rewritten>" , "exec" )
230- rewritten_ns : dict [str , object ] = {}
231- if extra_ns is not None :
232- rewritten_ns .update (copy .deepcopy (dict (extra_ns )))
233- exec (rewritten_code , rewritten_ns )
234- rewritten_func = cast (Callable [[], None ], rewritten_ns ["check" ])
235- rewritten_raised = False
236- try :
237- rewritten_func ()
238- except AssertionError :
239- rewritten_raised = True
240-
205+ (plain_raised , _ ), (rewritten_raised , _ ) = _run_both (src )
241206 assert plain_raised == rewritten_raised , (
242207 f"Semantic mismatch: plain { 'raised' if plain_raised else 'passed' } , "
243208 f"rewritten { 'raised' if rewritten_raised else 'passed' } "
244209 )
245210
246211
247- def _run_both (src : str , extra_ns : Mapping [str , object ] | None ) -> list [object ]:
248- """Execute ``check()`` plain and rewritten, returning (raised, result) pairs."""
249- src = textwrap .dedent (src )
250- outcomes : list [object ] = []
251- for code in (
252- compile (src , "<test-plain>" , "exec" ),
253- compile (_rewrite_source (src ), "<test-rewritten>" , "exec" ),
254- ):
255- ns : dict [str , object ] = {}
256- if extra_ns is not None :
257- ns .update (copy .deepcopy (dict (extra_ns )))
258- exec (code , ns )
259- func = cast (Callable [[], object ], ns ["check" ])
260- try :
261- outcomes .append ((False , func ()))
262- except AssertionError :
263- outcomes .append ((True , None ))
264- return outcomes
265-
266-
267- def assert_evaluation_order (
268- src : str ,
269- * ,
270- extra_ns : Mapping [str , object ] | None = None ,
271- ) -> None :
212+ def assert_evaluation_order (src : str ) -> None :
272213 """Verify rewriting preserves the values Python's evaluation order produces.
273214
274215 ``check()`` should return whatever the order is observable through -- the
@@ -291,10 +232,8 @@ def assert_evaluation_order(
291232 ----------
292233 src : str
293234 Source containing a ``check()`` function that returns its observations.
294- extra_ns : Mapping[str, object] | None
295- Additional namespace entries available during execution.
296235 """
297- plain , rewritten = _run_both (src , extra_ns )
236+ plain , rewritten = _run_both (src )
298237 assert plain == rewritten , (
299238 f"Evaluation order mismatch:\n plain (raised, result) = { plain } \n "
300239 f" rewritten (raised, result) = { rewritten } "
@@ -343,6 +282,29 @@ def check():
343282 must_contain = ["this is not in the message" ],
344283 )
345284
285+ def test_assert_introspects_must_not_contain (self ) -> None :
286+ assert_introspects (
287+ """
288+ def check():
289+ x = 3
290+ assert x == 5
291+ """ ,
292+ must_contain = ["assert 3 == 5" ],
293+ must_not_contain = ["this is not in the message" ],
294+ )
295+
296+ def test_assert_introspects_fails_on_unexpected (self ) -> None :
297+ with pytest .raises (AssertionError , match = r"Did NOT expect.*in failure" ):
298+ assert_introspects (
299+ """
300+ def check():
301+ x = 3
302+ assert x == 5
303+ """ ,
304+ must_contain = ["assert 3 == 5" ],
305+ must_not_contain = ["assert 3" ],
306+ )
307+
346308 def test_assert_single_evaluation (self ) -> None :
347309 assert_single_evaluation ("""
348310def check():
0 commit comments