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

1# SPDX-FileCopyrightText: 2026 Marco Ricci <software@the13thletter.info> 

2# 

3# SPDX-License-Identifier: Zlib 

4 

5"""`hypothesis` testing machinery for the `derivepassphrase` test suite. 

6 

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. 

11 

12All similar-minded code requiring only plain `pytest` lives in [the 

13`pytest` sibling module][tests.machinery.pytest]. 

14 

15""" 

16 

17from __future__ import annotations 

18 

19import copy 

20import importlib 

21import importlib.util 

22import math 

23import sys 

24from collections.abc import Sequence 

25from typing import TYPE_CHECKING, Callable 

26 

27import hypothesis 

28import hypothesis.errors 

29from _pytest import mark as pytest_mark 

30from hypothesis import strategies 

31from typing_extensions import assert_type 

32 

33from derivepassphrase import _types 

34from tests import data, machinery 

35 

36__all__ = () 

37 

38if TYPE_CHECKING: 

39 from typing_extensions import Any, TypeIs 

40 

41 

42# Hypothesis settings management 

43# ============================== 

44 

45 

46def _hypothesis_settings_setup() -> None: 

47 """ 

48 Ensure sensible hypothesis settings if running under coverage. 

49 

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. 

55 

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. 

59 

60 This setup function is idempotent: if it detects that the profiles 

61 have already been registered, then it silently does nothing. 

62 

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 

70 

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 ) 

140 

141 

142def get_concurrency_step_count( 

143 settings: hypothesis.settings | None = None, 

144) -> int: 

145 """Return the desired step count for concurrency-related tests. 

146 

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. 

150 

151 Args: 

152 settings: 

153 The hypothesis settings for a specific tests. If not given, 

154 then the current profile will be queried directly. 

155 

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

160 

161 

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. 

166 

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. 

175 

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. 

183 

184 Args: 

185 settings: 

186 The hypothesis settings for a specific tests. If not given, 

187 then the current profile will be queried directly. 

188 

189 """ 

190 if settings is None: # pragma: no cover 

191 settings = hypothesis.settings() 

192 

193 # Ensure the "intense" profile exists. 

194 _hypothesis_settings_setup() 

195 

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 ) 

214 

215 

216# Hypothesis strategies 

217# ===================== 

218 

219 

220@strategies.composite 

221def vault_full_service_config( 

222 draw: strategies.DrawFn, 

223) -> _types.VaultConfigServicesSettings: 

224 """Hypothesis strategy for full vault service configurations. 

225 

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. 

229 

230 Args: 

231 draw: 

232 The `draw` function, as provided for by hypothesis. 

233 

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 } 

261 

262 

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. 

273 

274 Uses [`_types.js_truthiness`][] internally, which is tested 

275 separately by 

276 [`tests.test_derivepassphrase_types.test_heavy_duty.test_js_truthiness`][]. 

277 

278 Args: 

279 draw: 

280 The `draw` function, as provided for by hypothesis. 

281 config: 

282 A strategy which generates [`data.VaultTestConfig`][] 

283 objects. 

284 

285 Returns: 

286 A new [`data.VaultTestConfig`][] where some falsy values have 

287 been replaced or added. 

288 

289 """ 

290 

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

331 

332 

333# Hypothesis decorators 

334# ===================== 

335 

336 

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 ) 

343 

344 

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 

363 

364 

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) 

379 

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)) 

399 

400 return examples_data, final_ids 

401 

402 

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. 

411 

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]). 

423 

424 [H4774]: https://github.com/HypothesisWorks/hypothesis/issues/4774 

425 

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. 

443 

444 Returns: 

445 A decorator that does the equivalent of 

446 

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(...) -> ...: ... 

464 

465 and, if not already a hypothesis test, prepends 

466 

467 @hypothesis.given( 

468 argnames[0]=strategies.just(argvalues[0][0]), 

469 argnames[1]=strategies.just(argvalues[0][1]), 

470 ... 

471 ) 

472 

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*! 

482 

483 """ 

484 if not argnames or not argvalues: # pragma: no cover [failsafe] 

485 msg = "no explicit examples given" 

486 raise ValueError(msg) 

487 

488 examples_data, final_ids = _get_examples_data_and_ids( 

489 argnames=argnames, argvalues=argvalues, ids=ids 

490 ) 

491 

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 

517 

518 return decorator