Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/hypothesis/internal/observability.py: 52%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

208 statements  

1# This file is part of Hypothesis, which may be found at 

2# https://github.com/HypothesisWorks/hypothesis/ 

3# 

4# Copyright the Hypothesis Authors. 

5# Individual contributors are listed in AUTHORS.rst and the git log. 

6# 

7# This Source Code Form is subject to the terms of the Mozilla Public License, 

8# v. 2.0. If a copy of the MPL was not distributed with this file, You can 

9# obtain one at https://mozilla.org/MPL/2.0/. 

10 

11"""Observability tools to spit out analysis-ready tables, one row per test case.""" 

12 

13import base64 

14import dataclasses 

15import json 

16import math 

17import os 

18import sys 

19import threading 

20import time 

21import warnings 

22from collections import deque 

23from collections.abc import Callable, Generator 

24from contextlib import contextmanager 

25from dataclasses import dataclass 

26from datetime import date, timedelta 

27from functools import lru_cache 

28from pathlib import Path 

29from threading import Lock 

30from typing import ( 

31 TYPE_CHECKING, 

32 Any, 

33 Literal, 

34 Optional, 

35 TypeAlias, 

36 Union, 

37 cast, 

38) 

39 

40from hypothesis.configuration import storage_directory 

41from hypothesis.errors import HypothesisWarning 

42from hypothesis.internal.conjecture.choice import ( 

43 BooleanConstraints, 

44 BytesConstraints, 

45 ChoiceConstraintsT, 

46 ChoiceNode, 

47 ChoiceT, 

48 ChoiceTypeT, 

49 FloatConstraints, 

50 IntegerConstraints, 

51 StringConstraints, 

52) 

53from hypothesis.internal.escalation import InterestingOrigin 

54from hypothesis.internal.floats import float_to_int 

55from hypothesis.internal.intervalsets import IntervalSet 

56from hypothesis.utils.deprecation import note_deprecation 

57 

58if TYPE_CHECKING: 

59 from hypothesis.internal.conjecture.data import ConjectureData, Spans, Status 

60 

61 

62Observation: TypeAlias = Union["InfoObservation", "TestCaseObservation"] 

63CallbackThreadT: TypeAlias = Callable[[Observation], None] 

64# for all_threads=True, we pass the thread id as well. 

65CallbackAllThreadsT: TypeAlias = Callable[[Observation, int], None] 

66CallbackT: TypeAlias = CallbackThreadT | CallbackAllThreadsT 

67 

68# thread_id: list[callback] 

69_callbacks: dict[int | None, list[CallbackThreadT]] = {} 

70# callbacks where all_threads=True was set 

71_callbacks_all_threads: list[CallbackAllThreadsT] = [] 

72 

73 

74@dataclass(slots=True, frozen=False) 

75class PredicateCounts: 

76 satisfied: int = 0 

77 unsatisfied: int = 0 

78 

79 def update_count(self, *, condition: bool) -> None: 

80 if condition: 

81 self.satisfied += 1 

82 else: 

83 self.unsatisfied += 1 

84 

85 

86def _choice_to_json(choice: ChoiceT | None) -> Any: 

87 if choice is None: 

88 return None 

89 # see the note on the same check in to_jsonable for why we cast large 

90 # integers to floats. 

91 if ( 

92 isinstance(choice, int) 

93 and not isinstance(choice, bool) 

94 and abs(choice) >= 2**63 

95 ): 

96 return ["integer", str(choice)] 

97 elif isinstance(choice, bytes): 

98 return ["bytes", base64.b64encode(choice).decode()] 

99 elif isinstance(choice, float) and math.isnan(choice): 

100 # handle nonstandard nan bit patterns. We don't need to do this for -0.0 

101 # vs 0.0 since json doesn't normalize -0.0 to 0.0. 

102 return ["float", float_to_int(choice)] 

103 return choice 

104 

105 

106def choices_to_json(choices: tuple[ChoiceT, ...]) -> list[Any]: 

107 return [_choice_to_json(choice) for choice in choices] 

108 

109 

110def _constraints_to_json( 

111 choice_type: ChoiceTypeT, constraints: ChoiceConstraintsT 

112) -> dict[str, Any]: 

113 constraints = constraints.copy() 

114 if choice_type == "integer": 

115 constraints = cast(IntegerConstraints, constraints) 

116 return { 

117 "min_value": _choice_to_json(constraints["min_value"]), 

118 "max_value": _choice_to_json(constraints["max_value"]), 

119 "weights": ( 

120 None 

121 if constraints["weights"] is None 

122 # wrap up in a list, instead of a dict, because json dicts 

123 # require string keys 

124 else [ 

125 (_choice_to_json(k), v) for k, v in constraints["weights"].items() 

126 ] 

127 ), 

128 "shrink_towards": _choice_to_json(constraints["shrink_towards"]), 

129 } 

130 elif choice_type == "float": 

131 constraints = cast(FloatConstraints, constraints) 

132 return { 

133 "min_value": _choice_to_json(constraints["min_value"]), 

134 "max_value": _choice_to_json(constraints["max_value"]), 

135 "allow_nan": constraints["allow_nan"], 

136 "smallest_nonzero_magnitude": constraints["smallest_nonzero_magnitude"], 

137 } 

138 elif choice_type == "string": 

139 constraints = cast(StringConstraints, constraints) 

140 assert isinstance(constraints["intervals"], IntervalSet) 

141 return { 

142 "intervals": constraints["intervals"].intervals, 

143 "min_size": _choice_to_json(constraints["min_size"]), 

144 "max_size": _choice_to_json(constraints["max_size"]), 

145 } 

146 elif choice_type == "bytes": 

147 constraints = cast(BytesConstraints, constraints) 

148 return { 

149 "min_size": _choice_to_json(constraints["min_size"]), 

150 "max_size": _choice_to_json(constraints["max_size"]), 

151 } 

152 elif choice_type == "boolean": 

153 constraints = cast(BooleanConstraints, constraints) 

154 return { 

155 "p": constraints["p"], 

156 } 

157 else: 

158 raise NotImplementedError(f"unknown choice type {choice_type}") 

159 

160 

161def nodes_to_json(nodes: tuple[ChoiceNode, ...]) -> list[dict[str, Any]]: 

162 return [ 

163 { 

164 "type": node.type, 

165 "value": _choice_to_json(node.value), 

166 "constraints": _constraints_to_json(node.type, node.constraints), 

167 "was_forced": node.was_forced, 

168 } 

169 for node in nodes 

170 ] 

171 

172 

173@dataclass(slots=True, frozen=True) 

174class ObservationMetadata: 

175 traceback: str | None 

176 reproduction_decorator: str | None 

177 notes: list[str] 

178 predicates: dict[str, PredicateCounts] 

179 backend: dict[str, Any] 

180 sys_argv: list[str] 

181 os_getpid: int 

182 imported_at: float 

183 data_status: "Status" 

184 phase: str 

185 interesting_origin: InterestingOrigin | None 

186 status_reason_location: str | None 

187 choice_nodes: tuple[ChoiceNode, ...] | None 

188 choice_spans: Optional["Spans"] 

189 

190 def to_json(self) -> dict[str, Any]: 

191 data = { 

192 "traceback": self.traceback, 

193 "reproduction_decorator": self.reproduction_decorator, 

194 "notes": self.notes, 

195 "predicates": self.predicates, 

196 "backend": self.backend, 

197 "sys.argv": self.sys_argv, 

198 "os.getpid()": self.os_getpid, 

199 "imported_at": self.imported_at, 

200 "data_status": self.data_status, 

201 "phase": self.phase, 

202 "interesting_origin": self.interesting_origin, 

203 "status_reason_location": self.status_reason_location, 

204 "choice_nodes": ( 

205 None if self.choice_nodes is None else nodes_to_json(self.choice_nodes) 

206 ), 

207 "choice_spans": ( 

208 None 

209 if self.choice_spans is None 

210 else [ 

211 ( 

212 # span.label is an int, but cast to string to avoid conversion 

213 # to float (and loss of precision) for large label values. 

214 # 

215 # The value of this label is opaque to consumers anyway, so its 

216 # type shouldn't matter as long as it's consistent. 

217 str(span.label), 

218 span.start, 

219 span.end, 

220 span.discarded, 

221 ) 

222 for span in self.choice_spans 

223 ] 

224 ), 

225 } 

226 # check that we didn't forget one 

227 assert len(data) == len(dataclasses.fields(self)) 

228 return data 

229 

230 

231@dataclass(slots=True, frozen=True) 

232class BaseObservation: 

233 type: Literal["test_case", "info", "alert", "error"] 

234 property: str 

235 run_start: float 

236 

237 

238InfoObservationType = Literal["info", "alert", "error"] 

239TestCaseStatus = Literal["gave_up", "passed", "failed"] 

240 

241 

242@dataclass(slots=True, frozen=True) 

243class InfoObservation(BaseObservation): 

244 type: InfoObservationType 

245 title: str 

246 content: str | dict 

247 

248 

249@dataclass(slots=True, frozen=True) 

250class TestCaseObservation(BaseObservation): 

251 __test__ = False # no! bad pytest! 

252 

253 type: Literal["test_case"] 

254 status: TestCaseStatus 

255 status_reason: str 

256 representation: str 

257 arguments: dict 

258 how_generated: str 

259 features: dict 

260 coverage: dict[str, list[int]] | None 

261 timing: dict[str, float] 

262 metadata: ObservationMetadata 

263 

264 

265def add_observability_callback(f: CallbackT, /, *, all_threads: bool = False) -> None: 

266 """ 

267 Adds ``f`` as a callback for |observability|. ``f`` 

268 should accept one argument, which is an observation. Whenever Hypothesis 

269 produces a new observation, it calls each callback with that observation. 

270 

271 If Hypothesis tests are being run from multiple threads, callbacks are tracked 

272 per-thread. In other words, ``add_observability_callback(f)`` only adds ``f`` 

273 as an observability callback for observations produced on that thread. 

274 

275 If ``all_threads=True`` is passed, ``f`` will instead be registered as a 

276 callback for all threads. This means it will be called for observations 

277 generated by all threads, not just the thread which registered ``f`` as a 

278 callback. In this case, ``f`` will be passed two arguments: the first is the 

279 observation, and the second is the integer thread id from 

280 :func:`python:threading.get_ident` where that observation was generated. 

281 

282 We recommend against registering ``f`` as a callback for both ``all_threads=True`` 

283 and the default ``all_threads=False``, due to unclear semantics with 

284 |remove_observability_callback|. 

285 """ 

286 if all_threads: 

287 _callbacks_all_threads.append(cast(CallbackAllThreadsT, f)) 

288 return 

289 

290 thread_id = threading.get_ident() 

291 if thread_id not in _callbacks: 

292 _callbacks[thread_id] = [] 

293 

294 _callbacks[thread_id].append(cast(CallbackThreadT, f)) 

295 

296 

297def remove_observability_callback(f: CallbackT, /) -> None: 

298 """ 

299 Removes ``f`` from the |observability| callbacks. 

300 

301 If ``f`` is not in the list of observability callbacks, silently do nothing. 

302 

303 If running under multiple threads, ``f`` will only be removed from the 

304 callbacks for this thread. 

305 """ 

306 if f in _callbacks_all_threads: 

307 _callbacks_all_threads.remove(f) 

308 

309 thread_id = threading.get_ident() 

310 if thread_id not in _callbacks: 

311 return 

312 

313 callbacks = _callbacks[thread_id] 

314 if f in callbacks: 

315 callbacks.remove(f) 

316 

317 if not callbacks: 

318 del _callbacks[thread_id] 

319 

320 

321def observability_enabled() -> bool: 

322 """ 

323 Returns whether or not Hypothesis considers |observability| 

324 to be enabled. Observability is enabled if there is at least one observability 

325 callback present. 

326 

327 Callers might use this method to determine whether they should compute an 

328 expensive representation that is only used under observability, for instance 

329 by |alternative backends|. 

330 """ 

331 return bool(_callbacks) or bool(_callbacks_all_threads) 

332 

333 

334@contextmanager 

335def with_observability_callback( 

336 f: Callable[[Observation], None], /, *, all_threads: bool = False 

337) -> Generator[None, None, None]: 

338 """ 

339 A simple context manager which calls |add_observability_callback| on ``f`` 

340 when it enters and |remove_observability_callback| on ``f`` when it exits. 

341 """ 

342 add_observability_callback(f, all_threads=all_threads) 

343 try: 

344 yield 

345 finally: 

346 remove_observability_callback(f) 

347 

348 

349def deliver_observation(observation: Observation) -> None: 

350 thread_id = threading.get_ident() 

351 

352 for callback in _callbacks.get(thread_id, []): 

353 callback(observation) 

354 

355 for callback in _callbacks_all_threads: 

356 callback(observation, thread_id) 

357 

358 

359class _TestcaseCallbacks: 

360 def __bool__(self): 

361 self._note_deprecation() 

362 return bool(_callbacks) 

363 

364 def _note_deprecation(self): 

365 note_deprecation( 

366 "hypothesis.internal.observability.TESTCASE_CALLBACKS is deprecated. " 

367 "Replace TESTCASE_CALLBACKS.append with add_observability_callback, " 

368 "TESTCASE_CALLBACKS.remove with remove_observability_callback, and " 

369 "bool(TESTCASE_CALLBACKS) with observability_enabled().", 

370 since="2025-08-01", 

371 has_codemod=False, 

372 ) 

373 

374 def append(self, f): 

375 self._note_deprecation() 

376 add_observability_callback(f) 

377 

378 def remove(self, f): 

379 self._note_deprecation() 

380 remove_observability_callback(f) 

381 

382 

383#: .. warning:: 

384#: 

385#: Deprecated in favor of |add_observability_callback|, 

386#: |remove_observability_callback|, and |observability_enabled|. 

387#: 

388#: |TESTCASE_CALLBACKS| remains a thin compatibility 

389#: shim which forwards ``.append``, ``.remove``, and ``bool()`` to those 

390#: three methods. It is not an attempt to be fully compatible with the previous 

391#: ``TESTCASE_CALLBACKS = []``, so iteration or other usages will not work 

392#: anymore. Please update to using the new methods instead. 

393#: 

394#: |TESTCASE_CALLBACKS| will eventually be removed. 

395TESTCASE_CALLBACKS = _TestcaseCallbacks() 

396 

397 

398def make_testcase( 

399 *, 

400 run_start: float, 

401 property: str, 

402 data: "ConjectureData", 

403 how_generated: str, 

404 representation: str = "<unknown>", 

405 timing: dict[str, float], 

406 arguments: dict | None = None, 

407 coverage: dict[str, list[int]] | None = None, 

408 phase: str | None = None, 

409 backend_metadata: dict[str, Any] | None = None, 

410 status: ( 

411 Union[TestCaseStatus, "Status"] | None 

412 ) = None, # overrides automatic calculation 

413 status_reason: str | None = None, # overrides automatic calculation 

414 # added to calculated metadata. If keys overlap, the value from this `metadata` 

415 # is used 

416 metadata: dict[str, Any] | None = None, 

417) -> TestCaseObservation: 

418 from hypothesis.core import reproduction_decorator 

419 from hypothesis.internal.conjecture.data import Status 

420 

421 # We should only be sending observability reports for datas that have finished 

422 # being modified. 

423 assert data.frozen 

424 

425 if status_reason is not None: 

426 pass 

427 elif data.interesting_origin: 

428 status_reason = str(data.interesting_origin) 

429 elif phase == "shrink" and data.status == Status.OVERRUN: 

430 status_reason = "exceeded size of current best test case" 

431 elif data.status == Status.OVERRUN: 

432 status_reason = ( 

433 str(data.events.pop("gave up because", "")) 

434 or "exceeded maximum test case size" 

435 ) 

436 else: 

437 status_reason = str(data.events.pop("gave up because", "")) 

438 

439 status_map: dict[Status, TestCaseStatus] = { 

440 Status.OVERRUN: "gave_up", 

441 Status.INVALID: "gave_up", 

442 Status.VALID: "passed", 

443 Status.INTERESTING: "failed", 

444 } 

445 

446 if status is not None and isinstance(status, Status): 

447 status = status_map[status] 

448 if status is None: 

449 status = status_map[data.status] 

450 

451 status_reason_location: str | None = None 

452 if (origin := data.interesting_origin) is not None: 

453 if origin.filename is not None: 

454 status_reason_location = f"{origin.filename}:{origin.lineno}" 

455 elif status != "failed": 

456 status_reason_location = data.invalid_location 

457 

458 metadata_fields: dict[str, Any] = { 

459 "traceback": data.expected_traceback, 

460 "reproduction_decorator": ( 

461 reproduction_decorator(data.choices) if status == "failed" else None 

462 ), 

463 "notes": list(data.notes), 

464 "predicates": dict(data._observability_predicates), 

465 "backend": backend_metadata or {}, 

466 "data_status": data.status, 

467 "phase": phase, 

468 "interesting_origin": data.interesting_origin, 

469 "status_reason_location": status_reason_location, 

470 "choice_nodes": data.nodes if OBSERVABILITY_CHOICES else None, 

471 "choice_spans": data.spans if OBSERVABILITY_CHOICES else None, 

472 **_system_metadata(), 

473 # unpack last so it takes precedence for duplicate keys 

474 **(metadata or {}), 

475 } 

476 

477 return TestCaseObservation( 

478 type="test_case", 

479 status=status, 

480 status_reason=status_reason, 

481 representation=representation, 

482 arguments={ 

483 k.removeprefix("generate:"): v for k, v in (arguments or {}).items() 

484 }, 

485 how_generated=how_generated, # iid, mutation, etc. 

486 features={ 

487 **{ 

488 f"target:{k}".strip(":"): v for k, v in data.target_observations.items() 

489 }, 

490 **data.events, 

491 }, 

492 coverage=coverage, 

493 timing=timing, 

494 metadata=ObservationMetadata(**metadata_fields), 

495 run_start=run_start, 

496 property=property, 

497 ) 

498 

499 

500_WROTE_TO: set[Path] = set() 

501_deliver_to_file_queues: dict[Path, tuple[Lock, deque[bytes]]] = {} 

502_deliver_to_file_queues_lock = Lock() 

503_evicted_old_observations = False 

504 

505 

506def _evict_old_observations(observed_dir: Path) -> None: 

507 # Remove files more than a week old, to cap the size on disk 

508 max_age = (date.today() - timedelta(days=8)).isoformat() 

509 for p in observed_dir.glob("*.jsonl"): 

510 if p.stem < max_age: 

511 p.unlink(missing_ok=True) 

512 

513 

514def _deliver_to_file( 

515 observation: Observation, thread_id: int 

516) -> None: # pragma: no cover 

517 global _evicted_old_observations 

518 from hypothesis.strategies._internal.utils import to_jsonable 

519 

520 kind = "testcases" if observation.type == "test_case" else "info" 

521 observed_dir = storage_directory("observed") 

522 if not _evicted_old_observations: 

523 _evicted_old_observations = True 

524 _evict_old_observations(observed_dir.path) 

525 observed_dir.create_if_missing() 

526 observation_p = observed_dir.path / f"{date.today().isoformat()}_{kind}.jsonl" 

527 _WROTE_TO.add(observation_p) 

528 

529 observation_bytes = ( 

530 json.dumps(to_jsonable(observation, avoid_realization=False)) + "\n" 

531 ).encode() 

532 

533 if (state := _deliver_to_file_queues.get(observation_p)) is None: 

534 with _deliver_to_file_queues_lock: 

535 state = _deliver_to_file_queues.setdefault(observation_p, (Lock(), deque())) 

536 lock, queue = state 

537 

538 # We want this to be cheap in both single-threaded and contended 

539 # multithreaded setups, without a dedicated writer thread. So each caller 

540 # enqueues its observation, and then writes out the queue if nobody else is 

541 # currently doing so. Re-checking the queue after releasing the lock means 

542 # that an observation enqueued just as the previous writer finished is 

543 # never left behind. 

544 queue.append(observation_bytes) 

545 while queue and lock.acquire(blocking=False): 

546 try: 

547 with observation_p.open(mode="ab") as f: 

548 while queue: 

549 f.write(queue.popleft()) 

550 finally: 

551 lock.release() 

552 

553 

554_imported_at = time.time() 

555 

556 

557@lru_cache 

558def _system_metadata() -> dict[str, Any]: 

559 return { 

560 "sys_argv": sys.argv, 

561 "os_getpid": os.getpid(), 

562 "imported_at": _imported_at, 

563 } 

564 

565 

566#: If ``False``, do not collect coverage information when observability is enabled. 

567#: 

568#: This is exposed both for performance (as coverage collection can be slow on 

569#: Python 3.11 and earlier) and size (if you do not use coverage information, 

570#: you may not want to store it in-memory). 

571OBSERVABILITY_COLLECT_COVERAGE = ( 

572 "HYPOTHESIS_EXPERIMENTAL_OBSERVABILITY_NOCOVER" not in os.environ 

573) 

574#: If ``True``, include the ``metadata.choice_nodes`` and ``metadata.spans`` keys 

575#: in test case observations. 

576#: 

577#: ``False`` by default. ``metadata.choice_nodes`` and ``metadata.spans`` can be 

578#: a substantial amount of data, and so must be opted-in to, even when 

579#: observability is enabled. 

580#: 

581#: .. warning:: 

582#: 

583#: EXPERIMENTAL AND UNSTABLE. We are actively working towards a better 

584#: interface for this as of June 2025, and this attribute may disappear or 

585#: be renamed without notice. 

586#: 

587OBSERVABILITY_CHOICES = "HYPOTHESIS_EXPERIMENTAL_OBSERVABILITY_CHOICES" in os.environ 

588 

589if OBSERVABILITY_COLLECT_COVERAGE is False and ( 

590 sys.version_info[:2] >= (3, 12) 

591): # pragma: no cover 

592 warnings.warn( 

593 "Coverage data collection should be quite fast in Python 3.12 or later " 

594 "so there should be no need to turn coverage reporting off.", 

595 HypothesisWarning, 

596 stacklevel=2, 

597 ) 

598 

599if ( 

600 "HYPOTHESIS_EXPERIMENTAL_OBSERVABILITY" in os.environ 

601 or OBSERVABILITY_COLLECT_COVERAGE is False 

602): # pragma: no cover 

603 add_observability_callback(_deliver_to_file, all_threads=True)