Coverage for tests / machinery / hypothesis.py: 100.000%
113 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-07-06 21:34 +0200
« prev ^ index » next coverage.py v7.13.1, created at 2026-07-06 21:34 +0200
1# SPDX-FileCopyrightText: 2026 Marco Ricci <software@the13thletter.info>
2#
3# SPDX-License-Identifier: Zlib
5"""`hypothesis` testing machinery for the `derivepassphrase` test suite.
7This is all the `hypothesis`-specific data and functionality used in the
8`derivepassphrase` test suite; this includes custom `hypothesis`
9strategies, or state machines, or state machine helper functions, or
10functions interacting with the `hypothesis` settings.
12All similar-minded code requiring only plain `pytest` lives in [the
13`pytest` sibling module][tests.machinery.pytest].
15"""
17from __future__ import annotations
19import copy
20import importlib
21import importlib.util
22import math
23import sys
24from collections.abc import Sequence
25from typing import TYPE_CHECKING, Callable
27import hypothesis
28import hypothesis.errors
29from _pytest import mark as pytest_mark
30from hypothesis import strategies
31from typing_extensions import assert_type
33from derivepassphrase import _types
34from tests import data, machinery
36__all__ = ()
38if TYPE_CHECKING:
39 from typing_extensions import Any, TypeIs
42# Hypothesis settings management
43# ==============================
46def _hypothesis_settings_setup() -> None:
47 """
48 Ensure sensible hypothesis settings if running under coverage.
50 In our tests, the sys.monitoring tracer slows down execution speed
51 by a factor of roughly 3, the C tracer by roughly 2.5, and the
52 Python tracer by roughly 40. Ensure that hypothesis default
53 timeouts apply relative to this *new* execution speed, not the old
54 one.
56 In any case, we *also* reduce the state machine step count to 32
57 steps per run, because the current state machines defined in the
58 tests rather benefit from broad testing rather than deep testing.
60 This setup function is idempotent: if it detects that the profiles
61 have already been registered, then it silently does nothing.
63 """
64 try:
65 hypothesis.settings.get_profile("intense")
66 except hypothesis.errors.InvalidArgument: # pragma: no cover [external]
67 pass
68 else: # pragma: no cover [external]
69 return
71 settings = hypothesis.settings()
72 slowdown: float | None = None
73 if (
74 importlib.util.find_spec("coverage") is not None
75 and settings.deadline is not None
76 and settings.deadline.total_seconds() < 1.0
77 ): # pragma: no cover [external]
78 ctracer_class = (
79 importlib.import_module("coverage.tracer").CTracer
80 if importlib.util.find_spec("coverage.tracer") is not None
81 else type(None)
82 )
83 pytracer_class = importlib.import_module("coverage.pytracer").PyTracer
84 if (
85 getattr(sys, "monitoring", None) is not None
86 and sys.monitoring.get_tool(sys.monitoring.COVERAGE_ID)
87 == "coverage.py"
88 ):
89 slowdown = 3.0
90 elif (
91 trace_func := getattr(sys, "gettrace", lambda: None)()
92 ) is not None and isinstance(trace_func, ctracer_class):
93 slowdown = 2.5
94 elif (
95 trace_func is not None
96 and hasattr(trace_func, "__self__")
97 and isinstance(trace_func.__self__, pytracer_class)
98 ):
99 slowdown = 8.0
100 hypothesis.settings.register_profile(
101 "default",
102 parent=settings,
103 deadline=slowdown * settings.deadline
104 if slowdown and settings.deadline is not None
105 else settings.deadline,
106 stateful_step_count=32,
107 suppress_health_check=[hypothesis.HealthCheck.too_slow],
108 )
109 default_profile = hypothesis.settings.get_profile("default")
110 hypothesis.settings.register_profile(
111 "regression",
112 parent=hypothesis.settings.get_profile("ci"),
113 derandomize=True,
114 database=None,
115 max_examples=3,
116 stateful_step_count=3,
117 suppress_health_check=[hypothesis.HealthCheck.too_slow],
118 phases=[
119 hypothesis.Phase.explicit,
120 hypothesis.Phase.reuse,
121 hypothesis.Phase.generate,
122 ],
123 )
124 hypothesis.settings.register_profile(
125 "debug",
126 parent=default_profile,
127 verbosity=hypothesis.Verbosity.verbose,
128 )
129 hypothesis.settings.register_profile(
130 "debug-regression",
131 parent=hypothesis.settings.get_profile("regression"),
132 verbosity=hypothesis.Verbosity.verbose,
133 )
134 hypothesis.settings.register_profile(
135 "correctness",
136 parent=default_profile,
137 derandomize=False,
138 max_examples=10 * default_profile.max_examples,
139 )
142def get_concurrency_step_count(
143 settings: hypothesis.settings | None = None,
144) -> int:
145 """Return the desired step count for concurrency-related tests.
147 This is the smaller of the [general concurrency
148 limit][tests.machinery.get_concurrency_limit] and the step count
149 from the current hypothesis settings.
151 Args:
152 settings:
153 The hypothesis settings for a specific tests. If not given,
154 then the current profile will be queried directly.
156 """
157 if settings is None: # pragma: no cover 1af
158 settings = hypothesis.settings()
159 return min(machinery.get_concurrency_limit(), settings.stateful_step_count) 1af
162def get_process_spawning_state_machine_examples_count(
163 settings: hypothesis.settings | None = None,
164) -> int:
165 """Return the examples count for process-spawning state machines.
167 That is, return the desired `max_examples` setting for state
168 machines that spawn processes as part of their operation. Since
169 Python 3.14, process spawning is no longer cheap by default on *any*
170 of the main operating systems (they all default to the "forkserver"
171 or "spawn" startup methods), and on The Annoying OS, process
172 spawning is inherently expensive. Therefore, we want to limit the
173 examples count by default, and require the user to opt-in to the
174 original naive example count explicitly.
176 If the "correctness" profile is in effect, or something with even
177 higher `max_examples` and `stateful_step_count`, then we return the
178 unaltered example count for the *default* profile. Otherwise, we
179 return the square root of the `max_examples` setting (rounded down).
180 We *never* return a value below the "regression" profile's example
181 count: any lower computed example count is increased to the
182 "regression" profile's example count.
184 Args:
185 settings:
186 The hypothesis settings for a specific tests. If not given,
187 then the current profile will be queried directly.
189 """
190 if settings is None: # pragma: no cover
191 settings = hypothesis.settings()
193 # Ensure the "intense" profile exists.
194 _hypothesis_settings_setup()
196 these_values = (settings.max_examples, settings.stateful_step_count)
197 correctness_profile = hypothesis.settings.get_profile("correctness")
198 correctness_values = (
199 correctness_profile.max_examples,
200 correctness_profile.stateful_step_count,
201 )
202 min_count = hypothesis.settings.get_profile("regression").max_examples
203 high_count = hypothesis.settings.get_profile("default").max_examples
204 correctness_based_testing = (
205 these_values[0] >= correctness_values[0]
206 and these_values[1] >= correctness_values[1]
207 )
208 return max(
209 min_count,
210 high_count
211 if correctness_based_testing
212 else math.isqrt(settings.max_examples),
213 )
216# Hypothesis strategies
217# =====================
220@strategies.composite
221def vault_full_service_config(
222 draw: strategies.DrawFn,
223) -> _types.VaultConfigServicesSettings:
224 """Hypothesis strategy for full vault service configurations.
226 Returns a sample configuration with restrictions on length, repeat
227 count, and all character classes, while ensuring the settings are
228 not obviously unsatisfiable.
230 Args:
231 draw:
232 The `draw` function, as provided for by hypothesis.
234 """
235 repeat = draw(strategies.integers(min_value=0, max_value=10)) 1fghi
236 lower = draw(strategies.integers(min_value=0, max_value=10)) 1fghi
237 upper = draw(strategies.integers(min_value=0, max_value=10)) 1fghi
238 number = draw(strategies.integers(min_value=0, max_value=10)) 1fghi
239 space = draw(strategies.integers(min_value=0, max_value=repeat)) 1fghi
240 dash = draw(strategies.integers(min_value=0, max_value=10)) 1fghi
241 symbol = draw(strategies.integers(min_value=0, max_value=10)) 1fghi
242 length = draw( 1fghi
243 strategies.integers(
244 min_value=max(1, lower + upper + number + space + dash + symbol),
245 max_value=70,
246 )
247 )
248 hypothesis.assume(lower + upper + number + dash + symbol > 0) 1fghi
249 hypothesis.assume(lower + upper + number + space + symbol > 0) 1fghi
250 hypothesis.assume(repeat >= space) 1fghi
251 return { 1fghi
252 "lower": lower,
253 "upper": upper,
254 "number": number,
255 "space": space,
256 "dash": dash,
257 "symbol": symbol,
258 "repeat": repeat,
259 "length": length,
260 }
263@strategies.composite
264def smudged_vault_test_config(
265 draw: strategies.DrawFn,
266 config: strategies.SearchStrategy[
267 data.VaultTestConfig
268 ] = strategies.sampled_from(data.TEST_CONFIGS).filter( # noqa: B008
269 data.VaultTestConfig.is_smudgable
270 ),
271) -> data.VaultTestConfig:
272 """Hypothesis strategy to replace falsy values with other falsy values.
274 Uses [`_types.js_truthiness`][] internally, which is tested
275 separately by
276 [`tests.test_derivepassphrase_types.test_heavy_duty.test_js_truthiness`][].
278 Args:
279 draw:
280 The `draw` function, as provided for by hypothesis.
281 config:
282 A strategy which generates [`data.VaultTestConfig`][]
283 objects.
285 Returns:
286 A new [`data.VaultTestConfig`][] where some falsy values have
287 been replaced or added.
289 """
291 falsy = (None, False, 0, 0.0, "", float("nan")) 1bcde
292 falsy_no_str = (None, False, 0, 0.0, float("nan")) 1bcde
293 falsy_no_zero = (None, False, "", float("nan")) 1bcde
294 conf = draw(config) 1bcde
295 hypothesis.assume(conf.is_smudgable()) 1bcde
296 obj = copy.deepcopy(conf.config) 1bcde
297 services: list[dict[str, Any]] = list(obj["services"].values()) 1bcde
298 if "global" in obj: 1bcde
299 services.append(obj["global"]) 1bcde
300 assert all(isinstance(x, dict) for x in services), ( 1bcde
301 "is_smudgable_vault_test_config guard failed to "
302 "ensure each settings dict is a dict"
303 )
304 for service in services: 1bcde
305 for key in ("phrase",): 1bcde
306 value = service.get(key) 1bcde
307 if not _types.js_truthiness(value) and value != "": 1bcde
308 service[key] = draw(strategies.sampled_from(falsy_no_str)) 1bcde
309 for key in ( 1bcde
310 "notes",
311 "key",
312 "length",
313 "repeat",
314 ):
315 value = service.get(key) 1bcde
316 if not _types.js_truthiness(value): 1bcde
317 service[key] = draw(strategies.sampled_from(falsy)) 1bcde
318 for key in ( 1bcde
319 "lower",
320 "upper",
321 "number",
322 "space",
323 "dash",
324 "symbol",
325 ):
326 value = service.get(key) 1bcde
327 if not _types.js_truthiness(value) and value != 0: 1bcde
328 service[key] = draw(strategies.sampled_from(falsy_no_zero)) 1bcde
329 hypothesis.assume(obj != conf.config) 1bcde
330 return data.VaultTestConfig(obj, conf.comment, conf.validation_settings) 1bcde
333# Hypothesis decorators
334# =====================
337def _is_paramset(
338 seq: Sequence[pytest_mark.ParameterSet] | Sequence[object], /
339) -> TypeIs[Sequence[pytest_mark.ParameterSet]]:
340 return all(
341 isinstance(argvalue, pytest_mark.ParameterSet) for argvalue in seq
342 )
345def _get_id(
346 argvalue: object,
347 ids: Sequence[str | None] | Callable[[Any], str | None] | None,
348 i: int,
349) -> str | None: # pragma: no cover[external]
350 if (
351 isinstance(argvalue, pytest_mark.ParameterSet)
352 and argvalue.id is not None
353 ):
354 return argvalue.id
355 if callable(ids):
356 result = ids(argvalue)
357 return result if result is not None else None
358 if isinstance(ids, Sequence):
359 result = ids[i]
360 return result if result is not None else None
361 assert_type(ids, None)
362 return None
365def _get_examples_data_and_ids(
366 *,
367 argnames: str | Sequence[str],
368 argvalues: Sequence[pytest_mark.ParameterSet] | Sequence[object],
369 ids: Sequence[str | None] | Callable[[Any], str | None] | None,
370) -> tuple[list[dict[str, Any]], list[str | None]]:
371 names = (
372 tuple(argnames.split(","))
373 if isinstance(argnames, str)
374 else tuple(argnames)
375 )
376 examples_data: list[dict[str, Any]] = []
377 final_ids: list[str | None] = []
378 k = len(names)
380 if _is_paramset(argvalues):
381 for i, argvalue in enumerate(argvalues):
382 if len(argvalue.values) != k: # pragma: no cover [failsafe]
383 msg = f"not a {k}-tuple: {argvalue!r}"
384 raise ValueError(msg)
385 examples_data.append(dict(zip(names, argvalue.values)))
386 final_ids.append(_get_id(argvalue, ids, i))
387 else:
388 for i, argvalue in enumerate(argvalues):
389 if k > 1:
390 if (
391 not isinstance(argvalue, Sequence) or len(argvalue) != k
392 ): # pragma: no cover [failsafe]
393 msg = f"not a {k}-tuple: {argvalue!r}"
394 raise ValueError(msg)
395 examples_data.append(dict(zip(names, argvalue)))
396 else:
397 examples_data.append({names[0]: argvalue})
398 final_ids.append(_get_id(argvalue, ids, i))
400 return examples_data, final_ids
403def explicit_examples(
404 argnames: str | Sequence[str],
405 argvalues: Sequence[pytest_mark.ParameterSet] | Sequence[object],
406 ids: Sequence[str | None] | Callable[[Any], str | None] | None = None,
407 *,
408 settings: hypothesis.settings | None = None,
409) -> Callable[[Callable[..., None]], Callable[..., None]]:
410 """Decorate a function to take explicit (hypothesis) examples.
412 Using the same signature as [`pytest.mark.parametrize`][], we
413 replicate the effect of decorating the function with multiple
414 [`hypothesis.example`][] calls. This is useful in particular if the
415 exact number of examples isn't known statically. If the decorated
416 function is not already a hypothesis test, we also decorate the
417 function with [`hypothesis.given`][] and [`hypothesis.settings`][],
418 in a manner that only ever runs the explicit examples. One obvious
419 way of writing the (sentinel) strategy to be passed to
420 [`hypothesis.given`][] actually triggers
421 [`hypothesis.errors.Unsatisfiable`][], so we are careful to avoid
422 that ([Hypothesis bug #4774][H4774]).
424 [H4774]: https://github.com/HypothesisWorks/hypothesis/issues/4774
426 Args:
427 argnames:
428 A comma-separated list of argument names, or a sequence of
429 argument name strings.
430 argvalues:
431 A sequence of realizations for the named arguments. If
432 only one argument name is given, then each realization must
433 be a single value. Otherwise, each realization must be a
434 (correctly sized) tuple of argument values.
435 ids:
436 An optional sequence of id strings to use for the
437 respective realization, or a callable that takes the
438 realization and returns an id string or `None`, or `None`.
439 If `None` or if the callable returns `None`, and if
440 additionally the argvalue is a pytest parameter set that
441 has its `id` set, then use that `id`; else fall back to
442 auto-generated IDs.
444 Returns:
445 A decorator that does the equivalent of
447 @hypothesis.example(
448 argnames[0]=argvalues[0][0],
449 argnames[1]=argvalues[0][1],
450 ...
451 )
452 @hypothesis.example(
453 argnames[1]=argvalues[1][0],
454 argnames[1]=argvalues[1][1],
455 ...
456 )
457 ...
458 @hypothesis.example(
459 argnames[0]=argvalues[-1][0],
460 argnames[1]=argvalues[-1][1],
461 ...
462 )
463 def f(...) -> ...: ...
465 and, if not already a hypothesis test, prepends
467 @hypothesis.given(
468 argnames[0]=strategies.just(argvalues[0][0]),
469 argnames[1]=strategies.just(argvalues[0][1]),
470 ...
471 )
473 Warning:
474 Because of the way [`pytest.mark.parametrize`][] and
475 [`hypothesis.example`][] work, having multiple
476 `explicit_examples` decorators behaves differently from
477 multiple [`pytest.mark.parametrize`][] decorators, and
478 is not drop-in compatible. `explicit_examples` adds
479 *more examples on the same argument names*, whereas
480 [`pytest.mark.parametrize`][] adds *more argument names
481 and values to the same set of existing examples*!
483 """
484 if not argnames or not argvalues: # pragma: no cover [failsafe]
485 msg = "no explicit examples given"
486 raise ValueError(msg)
488 examples_data, final_ids = _get_examples_data_and_ids(
489 argnames=argnames, argvalues=argvalues, ids=ids
490 )
492 def decorator(f: Callable[..., None], /) -> Callable[..., None]:
493 ret = f
494 for i, arg in reversed(list(enumerate(examples_data))):
495 ex_id = final_ids[i]
496 ex = hypothesis.example(**arg)
497 ex = (
498 ex.via("id={!r}".format(str(ex_id))) # noqa: UP032
499 if ex_id is not None
500 else ex
501 )
502 ret = ex(ret)
503 if not hypothesis.is_hypothesis_test(
504 f
505 ): # pragma: no branch [external]
506 kwargs = {
507 k: strategies.just(v) for k, v in examples_data[0].items()
508 }
509 given = hypothesis.given(**kwargs)
510 ret = given(ret)
511 new_settings = hypothesis.settings(
512 parent=settings,
513 phases=[hypothesis.Phase.explicit, hypothesis.Phase.reuse],
514 )
515 ret = new_settings(ret)
516 return ret
518 return decorator