Skip to content

Commit 6dc159d

Browse files
committed
add metadata.spans
1 parent aa3b215 commit 6dc159d

File tree

9 files changed

+59
-18
lines changed

9 files changed

+59
-18
lines changed

hypothesis-python/RELEASE.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
RELEASE_TYPE: patch
22

3-
This release adds the experimental and unstable |OBSERVABILITY_CHOICE_NODES| option for :ref:`observability <observability>`, which includes the choice sequence in ``metadata.choice_nodes`` for test case observations if set.
3+
This release adds the experimental and unstable |OBSERVABILITY_CHOICES| option for :ref:`observability <observability>`, which includes the choice sequence in ``metadata.choice_nodes`` for test case observations if set.
44

5-
We are actively working towards a better interface for this. Feel free to use |OBSERVABILITY_CHOICE_NODES| to experiment, but don't rely on it yet!
5+
We are actively working towards a better interface for this. Feel free to use |OBSERVABILITY_CHOICES| to experiment, but don't rely on it yet!

hypothesis-python/docs/prolog.rst

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,10 +120,12 @@
120120
.. |PrimitiveProvider.observe_information_messages| replace:: :func:`~hypothesis.internal.conjecture.providers.PrimitiveProvider.observe_information_messages`
121121
.. |PrimitiveProvider.per_test_case_context_manager| replace:: :func:`~hypothesis.internal.conjecture.providers.PrimitiveProvider.per_test_case_context_manager`
122122
.. |PrimitiveProvider.add_observability_callback| replace:: :data:`~hypothesis.internal.conjecture.providers.PrimitiveProvider.add_observability_callback`
123+
.. |PrimitiveProvider.span_start| replace:: :func:`~hypothesis.internal.conjecture.providers.PrimitiveProvider.span_start`
124+
.. |PrimitiveProvider.span_end| replace:: :func:`~hypothesis.internal.conjecture.providers.PrimitiveProvider.span_end`
123125

124126
.. |AVAILABLE_PROVIDERS| replace:: :data:`~hypothesis.internal.conjecture.providers.AVAILABLE_PROVIDERS`
125127
.. |TESTCASE_CALLBACKS| replace:: :data:`~hypothesis.internal.observability.TESTCASE_CALLBACKS`
126-
.. |OBSERVABILITY_CHOICE_NODES| replace:: :data:`~hypothesis.internal.observability.OBSERVABILITY_CHOICE_NODES`
128+
.. |OBSERVABILITY_CHOICES| replace:: :data:`~hypothesis.internal.observability.OBSERVABILITY_CHOICES`
127129
.. |BUFFER_SIZE| replace:: :data:`~hypothesis.internal.conjecture.engine.BUFFER_SIZE`
128130
.. |MAX_SHRINKS| replace:: :data:`~hypothesis.internal.conjecture.engine.MAX_SHRINKS`
129131
.. |MAX_SHRINKING_SECONDS| replace:: :data:`~hypothesis.internal.conjecture.engine.MAX_SHRINKING_SECONDS`

hypothesis-python/docs/reference/internals.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ Observability
3232

3333
.. autodata:: hypothesis.internal.observability.TESTCASE_CALLBACKS
3434
.. autodata:: hypothesis.internal.observability.OBSERVABILITY_COLLECT_COVERAGE
35-
.. autodata:: hypothesis.internal.observability.OBSERVABILITY_CHOICE_NODES
35+
.. autodata:: hypothesis.internal.observability.OBSERVABILITY_CHOICES
3636

3737
Engine constants
3838
----------------

hypothesis-python/docs/reference/schema_metadata.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@
5959
},
6060
"choice_nodes": {
6161
"type": ["array", "null"],
62-
"description": ".. warning::\n\n EXPERIMENTAL AND UNSTABLE. This attribute may change or disappear without warning.\n\nThe sequence of choices made during this test case. This includes the choice value, as well as its constraints and whether it was forced or not. The choice sequence is a relatively low-level implementation detail of Hypothesis, and is exposed here for users building tools or research on top of Hypothesis. See |PrimitiveProvider| for more details about the choice sequence.\n\nOnly present if |OBSERVABILITY_CHOICE_NODES| is ``True``.",
62+
"description": ".. warning::\n\n EXPERIMENTAL AND UNSTABLE. This attribute may change format or disappear without warning.\n\nThe sequence of choices made during this test case. This includes the choice value, as well as its constraints and whether it was forced or not.\n\nOnly present if |OBSERVABILITY_CHOICES| is ``True``.\n\n.. note::\n\n The choice sequence is a relatively low-level implementation detail of Hypothesis, and is exposed in observability for users building tools or research on top of Hypothesis. See |PrimitiveProvider| for more details about the choice sequence.",
6363
"items": {
6464
"type": "object",
6565
"properties": {
@@ -77,12 +77,17 @@
7777
},
7878
"was_forced": {
7979
"type": "boolean",
80-
"description": "Whether this choice was forced. As an implementation detail, Hypothesis occasionally requires that some choices take on a specific value, for instance to end generation of collection elements early for performance. These values are \"forced\", and have ``was_forced = True``."
80+
"description": "Whether this choice was forced. As an implementation detail, Hypothesis occasionally requires that some choices take on a specific value, for instance to end generation of collection elements early for performance. These values are called \"forced\", and have ``was_forced = True``."
8181
}
8282
},
8383
"required": ["type", "value", "constraints", "was_forced"],
8484
"additionalProperties": false
8585
}
86+
},
87+
"spans": {
88+
"type": "array",
89+
"items": {"type": "array"},
90+
"description": ".. warning::\n\n EXPERIMENTAL AND UNSTABLE. This attribute may change format or disappear without warning.\n\nThe semantically-meaningful spans of the choice sequence of this test case.\n\nEach span has the format ``[label, start, end, discarded]``, where:\n\n* ``label`` is an opaque integer-value string shared by all spans drawn from a particular strategy.\n* ``start`` and ``end`` are indices into the choice sequence for this span, such that ``choices[start:end]`` are the corresponding choices.\n* ``discarded`` is a boolean indicating whether this span was discarded (see |PrimitiveProvider.span_end|).\n\nOnly present if |OBSERVABILITY_CHOICES| is ``True``.\n\n.. note::\n\n Spans are a relatively low-level implementation detail of Hypothesis, and are exposed in observability for users building tools or research on top of Hypothesis. See |PrimitiveProvider| (and particularly |PrimitiveProvider.span_start| and |PrimitiveProvider.span_end|) for more details about spans."
8691
}
8792
},
8893
"required": ["traceback", "reproduction_decorator", "predicates", "backend", "sys_argv", "os_getpid", "imported_at", "data_status", "interesting_origin", "choice_nodes"],

hypothesis-python/src/hypothesis/internal/observability.py

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
if TYPE_CHECKING:
4646
from typing import TypeAlias
4747

48-
from hypothesis.internal.conjecture.data import ConjectureData, Status
48+
from hypothesis.internal.conjecture.data import ConjectureData, Spans, Status
4949

5050

5151
@dataclass
@@ -159,6 +159,7 @@ class ObservationMetadata:
159159
data_status: "Status"
160160
interesting_origin: Optional[InterestingOrigin]
161161
choice_nodes: Optional[tuple[ChoiceNode, ...]]
162+
spans: Optional["Spans"]
162163

163164
def to_json(self) -> dict[str, Any]:
164165
data = {
@@ -174,6 +175,24 @@ def to_json(self) -> dict[str, Any]:
174175
"choice_nodes": (
175176
None if self.choice_nodes is None else nodes_to_json(self.choice_nodes)
176177
),
178+
"spans": (
179+
None
180+
if self.spans is None
181+
else [
182+
(
183+
# span.label is an int, but cast to string to avoid conversion
184+
# to float (and loss of precision) for large label values.
185+
#
186+
# The value of this label is opaque to consumers anyway, so its
187+
# type shouldn't matter as long as it's consistent.
188+
str(span.label),
189+
span.start,
190+
span.end,
191+
span.discarded,
192+
)
193+
for span in self.spans
194+
]
195+
),
177196
}
178197
# check that we didn't forget one
179198
assert len(data) == len(dataclasses.fields(self))
@@ -315,7 +334,8 @@ def make_testcase(
315334
"backend": backend_metadata or {},
316335
"data_status": data.status,
317336
"interesting_origin": data.interesting_origin,
318-
"choice_nodes": data.nodes if OBSERVABILITY_CHOICE_NODES else None,
337+
"choice_nodes": data.nodes if OBSERVABILITY_CHOICES else None,
338+
"spans": data.spans if OBSERVABILITY_CHOICES else None,
319339
**_system_metadata(),
320340
# unpack last so it takes precedence for duplicate keys
321341
**(metadata or {}),
@@ -360,21 +380,20 @@ def _system_metadata() -> dict[str, Any]:
360380
OBSERVABILITY_COLLECT_COVERAGE = (
361381
"HYPOTHESIS_EXPERIMENTAL_OBSERVABILITY_NOCOVER" not in os.environ
362382
)
363-
#: If ``True``, include the ``metadata.choice_nodes`` key in test case
364-
#: observations.
383+
#: If ``True``, include the ``metadata.choice_nodes`` and ``metadata.spans`` keys
384+
#: in test case observations.
365385
#:
366-
#: ``False`` by default. ``metadata.choice_nodes`` can be substantial amount of
367-
#: data, and so must be opted-in to, even when observability is enabled.
386+
#: ``False`` by default. ``metadata.choice_nodes`` and ``metadata.spans`` can be
387+
#: a substantial amount of data, and so must be opted-in to, even when
388+
#: observability is enabled.
368389
#:
369390
#: .. warning::
370391
#:
371392
#: EXPERIMENTAL AND UNSTABLE. We are actively working towards a better
372393
#: interface for this as of June 2025, and this attribute may disappear or
373394
#: be renamed without notice.
374395
#:
375-
OBSERVABILITY_CHOICE_NODES = (
376-
"HYPOTHESIS_EXPERIMENTAL_OBSERVABILITY_CHOICE_NODES" in os.environ
377-
)
396+
OBSERVABILITY_CHOICES = "HYPOTHESIS_EXPERIMENTAL_OBSERVABILITY_CHOICES" in os.environ
378397

379398
if OBSERVABILITY_COLLECT_COVERAGE is False and (
380399
sys.version_info[:2] >= (3, 12)

hypothesis-python/tests/common/utils.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
from hypothesis import Phase, settings
1919
from hypothesis.errors import HypothesisDeprecationWarning
20+
from hypothesis.internal import observability
2021
from hypothesis.internal.entropy import deterministic_PRNG
2122
from hypothesis.internal.floats import next_down
2223
from hypothesis.internal.observability import TESTCASE_CALLBACKS, Observation
@@ -235,13 +236,18 @@ def raises_warning(expected_warning, match=None):
235236

236237

237238
@contextlib.contextmanager
238-
def capture_observations():
239+
def capture_observations(*, choices=None):
239240
ls: list[Observation] = []
240241
TESTCASE_CALLBACKS.append(ls.append)
242+
if choices is not None:
243+
observability.OBSERVABILITY_CHOICES = choices
244+
241245
try:
242246
yield ls
243247
finally:
244248
TESTCASE_CALLBACKS.remove(ls.append)
249+
if choices is not None:
250+
observability.OBSERVABILITY_CHOICES = choices
245251

246252

247253
# Specifies whether we can represent subnormal floating point numbers.

hypothesis-python/tests/cover/test_observability.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from hypothesis.database import InMemoryExampleDatabase
3131
from hypothesis.internal.compat import PYPY
3232
from hypothesis.internal.conjecture.choice import ChoiceNode, choices_key
33+
from hypothesis.internal.conjecture.data import Span
3334
from hypothesis.internal.coverage import IN_COVERAGE_TESTS
3435
from hypothesis.internal.floats import SIGNALING_NAN, float_to_int, int_to_float
3536
from hypothesis.internal.intervalsets import IntervalSet
@@ -503,7 +504,7 @@ def test_metadata_to_json():
503504
def f(n):
504505
pass
505506

506-
with capture_observations() as observations:
507+
with capture_observations(choices=True) as observations:
507508
f()
508509

509510
observations = [obs for obs in observations if obs.type == "test_case"]
@@ -519,4 +520,11 @@ def f(n):
519520
"data_status",
520521
"interesting_origin",
521522
"choice_nodes",
523+
"spans",
522524
}
525+
assert observation.metadata.choice_nodes is not None
526+
527+
for span in observation.metadata.spans:
528+
assert isinstance(span, Span)
529+
assert 0 <= span.start <= len(observation.metadata.choice_nodes)
530+
assert 0 <= span.end <= len(observation.metadata.choice_nodes)

hypothesis-python/tests/watchdog/test_database.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ def test_database_listener_directory():
4343

4444

4545
# seen flaky on test-win; we get *three* of the same save events in the first
46-
# assertion, which...is baffling, and posibly a genuine bug (most likely in
46+
# assertion, which...is baffling, and possibly a genuine bug (most likely in
4747
# watchdog).
4848
@flaky(max_runs=2, min_passes=1)
4949
def test_database_listener_multiplexed(tmp_path):

tooling/codespell-ignore.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ strat
1010
tread
1111
rouge
1212
tey
13+
datas

0 commit comments

Comments
 (0)