Coverage for /home/crpier/Projects/snektest/snektest/decorators.py: 47%
109 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-07 00:30 +0300
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-07 00:30 +0300
1import asyncio
2from collections.abc import Callable
3from concurrent.futures import Future
4from functools import wraps
5from inspect import Parameter, Signature, currentframe, iscoroutinefunction
6from typing import Any, Literal, Protocol, TypeVar, cast, overload
8from hypothesis import given
10from snektest.annotations import (
11 AsyncFixture,
12 AsyncSessionFixture,
13 Coroutine,
14 Fixture,
15 SessionFixture,
16)
17from snektest.fixtures import (
18 is_session_fixture,
19 load_function_fixture,
20 load_session_fixture,
21 register_session_fixture_from_namespace,
22)
23from snektest.models import Param
24from snektest.utils import get_code_from_generator, mark_test_function
26_given = cast("Any", given)
28T_co = TypeVar("T_co", covariant=True)
31class SearchStrategy(Protocol[T_co]):
32 def example(self) -> T_co: ...
35type Marker = Literal["fast", "medium", "slow"]
36"""Markers for test functions.
38Markers describe the resources a test may use,
39not how long it is expected to take.
41`fast` means the test runs entirely in memory,
42without IO, threads, or subprocesses.
43`medium` means the test may use local IO or threads,
44but not network IO or subprocesses.
45`slow` means the test may use network IO, subprocesses,
46or other expensive external resources.
47"""
50VALID_MARKERS: tuple[Marker, ...] = ("slow", "medium", "fast")
53def _normalize_marker_entry(entry: object) -> str:
54 if entry in VALID_MARKERS:
55 return entry
56 msg = "Markers must be Marker literals"
57 raise TypeError(msg)
60def _normalize_markers(mark: object | None) -> tuple[str, ...]:
61 if mark is None:
62 return ()
63 if mark in VALID_MARKERS:
64 return (_normalize_marker_entry(mark),)
65 msg = "Markers must be a single Marker literal"
66 raise TypeError(msg)
69@overload
70def test(
71 *params: list[Param[Any]],
72 mark: Marker | None = None,
73) -> Callable[
74 [Callable[[*tuple[Any, ...]], Coroutine[None] | None]],
75 Callable[[*tuple[Any, ...]], Coroutine[None] | None],
76]: ...
79@overload
80def test(
81 *params: list[Param[Any]],
82 mark: object | None = None,
83) -> Callable[
84 [Callable[[*tuple[Any, ...]], Coroutine[None] | None]],
85 Callable[[*tuple[Any, ...]], Coroutine[None] | None],
86]: ...
89def test(
90 *params: list[Param[Any]],
91 mark: object | None = None,
92) -> Callable[
93 [Callable[[*tuple[Any, ...]], Coroutine[None] | None]],
94 Callable[[*tuple[Any, ...]], Coroutine[None] | None],
95]:
96 """Mark a function as a test function with an optional built-in marker."""
98 markers = _normalize_markers(mark)
100 def decorator(
101 test_func: Callable[[*tuple[Any, ...]], Coroutine[None] | None],
102 ) -> Callable[[*tuple[Any, ...]], Coroutine[None] | None]:
103 mark_test_function(test_func, params, markers)
104 return test_func
106 return decorator
109def _maybe_apply_hypothesis_settings(
110 source: Callable[..., object],
111 target: Callable[..., object],
112) -> Callable[..., object]:
113 """Propagate `@hypothesis.settings` applied to `source` onto `target`."""
115 settings_obj = getattr(source, "_hypothesis_internal_use_settings", None)
116 if settings_obj is None:
117 return target
119 settings_decorator = cast(
120 "Callable[[Callable[..., object]], Callable[..., object]]",
121 settings_obj,
122 )
123 return settings_decorator(target)
126def _run_hypothesis(
127 wrapper: Callable[..., object],
128 strategies: tuple[SearchStrategy[Any], ...],
129 run_one_example: Callable[..., None],
130) -> None:
131 def hypothesis_runner(*strategy_values: Any) -> None:
132 run_one_example(*strategy_values)
134 signature = Signature(
135 parameters=[
136 Parameter(f"arg{i}", kind=Parameter.POSITIONAL_OR_KEYWORD)
137 for i in range(len(strategies))
138 ]
139 )
140 hypothesis_runner.__signature__ = signature # pyright: ignore[reportFunctionMemberAccess]
142 hypothesis_runner_wrapped = _given(*strategies)(hypothesis_runner)
144 runner = cast(
145 "Callable[[], None]",
146 _maybe_apply_hypothesis_settings(wrapper, hypothesis_runner_wrapped),
147 )
148 runner()
151def _run_async_example(
152 loop: asyncio.AbstractEventLoop,
153 test_func: Callable[..., Coroutine[None] | None],
154 *,
155 strategy_values: tuple[Any, ...],
156 param_values: tuple[Any, ...],
157) -> None:
158 done: Future[None] = Future()
160 def schedule() -> None:
161 try:
162 res = cast(
163 "Coroutine[None]",
164 test_func(*strategy_values, *param_values),
165 )
166 task: asyncio.Task[None] = loop.create_task(res)
167 except Exception as exc:
168 done.set_exception(exc)
169 return
171 def on_done(task: asyncio.Task[None]) -> None:
172 try:
173 task.result()
174 except Exception as exc:
175 done.set_exception(exc)
176 else:
177 done.set_result(None)
179 task.add_done_callback(on_done)
181 _ = loop.call_soon_threadsafe(schedule)
182 done.result()
185def test_hypothesis(
186 *strategies: SearchStrategy[Any],
187 mark: Marker | None = None,
188) -> Callable[
189 [Callable[..., Coroutine[None] | None]],
190 Callable[..., Coroutine[None] | None],
191]:
192 """Mark a function as a property-based test using Hypothesis.
194 Strategies are positional and fill function arguments from left to right.
195 Use `mark=` with a `Marker` literal to attach a built-in snektest marker.
197 Notes:
198 - Hypothesis cannot directly run async functions; for `async def` tests we run
199 the Hypothesis engine in a worker thread and schedule the async test body
200 back onto the main event loop.
201 - Apply `@hypothesis.settings(...)` above or below this decorator to adjust
202 Hypothesis behavior.
203 """
205 if len(strategies) == 0:
206 msg = "test_hypothesis() requires at least one strategy"
207 raise ValueError(msg)
209 strategies_tuple = tuple(strategies)
210 markers = _normalize_markers(mark)
212 def decorator(
213 test_func: Callable[..., Coroutine[None] | None],
214 ) -> Callable[..., Coroutine[None] | None]:
215 if iscoroutinefunction(test_func):
217 @wraps(test_func)
218 async def async_wrapper() -> None:
219 loop = asyncio.get_running_loop()
221 def run_one_example(*strategy_values: Any) -> None:
222 _run_async_example(
223 loop,
224 test_func,
225 strategy_values=tuple(strategy_values),
226 param_values=(),
227 )
229 def run_hypothesis() -> None:
230 _run_hypothesis(async_wrapper, strategies_tuple, run_one_example)
232 await asyncio.to_thread(run_hypothesis)
234 mark_test_function(async_wrapper, (), markers)
235 return async_wrapper
237 @wraps(test_func)
238 def sync_wrapper() -> None:
239 def run_one_example(*strategy_values: Any) -> None:
240 _ = test_func(*strategy_values)
242 _run_hypothesis(sync_wrapper, strategies_tuple, run_one_example)
244 mark_test_function(sync_wrapper, (), markers)
245 return sync_wrapper
247 return decorator
250@overload
251def load_fixture[R](
252 fixture_gen: Fixture[R] | SessionFixture[R],
253) -> R: ...
256@overload
257def load_fixture[R](
258 fixture_gen: AsyncFixture[R] | AsyncSessionFixture[R],
259) -> Coroutine[R]: ...
262def load_fixture[R](
263 fixture_gen: Fixture[R]
264 | AsyncFixture[R]
265 | SessionFixture[R]
266 | AsyncSessionFixture[R],
267) -> Coroutine[R] | R:
268 """Load a fixture from a generator.
269 When loading a fixture, `snektest` takes care to handle tearing down the
270 fixture after the test has finished."""
271 fixture_gen_code = get_code_from_generator(fixture_gen)
272 frame = currentframe()
273 caller_frame = frame.f_back if frame is not None else None
274 if caller_frame is not None:
275 register_session_fixture_from_namespace(
276 fixture_gen_code, caller_frame.f_globals
277 )
278 register_session_fixture_from_namespace(fixture_gen_code, caller_frame.f_locals)
279 del frame
280 del caller_frame
282 if is_session_fixture(fixture_gen_code):
283 return load_session_fixture(fixture_gen)
285 return load_function_fixture(fixture_gen)