Skip to content

Commit d4a3459

Browse files
committed
stabilize observability
1 parent 4a3cbdc commit d4a3459

File tree

13 files changed

+259
-101
lines changed

13 files changed

+259
-101
lines changed

hypothesis-python/RELEASE.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
RELEASE_TYPE: minor
2+
3+
This release stabilizes our :ref:`observability interface <observability>`, which was previously marked as experimental. When observability is enabled, Hypothesis writes data about each test case to ``.hypothesis/observed`` in an analysis-ready `jsonlines <https://jsonlines.org/>`_ format, intended to help you understand the performance of your Hypothesis tests.
4+
5+
Observability can be controlled in two ways:
6+
7+
* via the new |settings.observability| argument,
8+
* or via the ``HYPOTHESIS_OBSERVABILITY`` environment variable.
9+
10+
See :ref:`Configuring observability <observability-configuration>` for details.
11+
12+
If you use VSCode, we recommend the `Tyche <https://github.com/tyche-pbt/tyche-extension>`__ extension, a PBT-specific visualization tool designed for Hypothesis's observability interface.

hypothesis-python/docs/changelog.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -668,11 +668,11 @@ Further improve the performance of the constants-collection feature introduced i
668668
6.135.3 - 2025-06-08
669669
--------------------
670670

671-
This release adds the experimental and unstable |OBSERVABILITY_CHOICES| option for :ref:`observability <observability>`. If set, the choice sequence is included in ``metadata.choice_nodes``, and choice sequence spans are included in ``metadata.choice_spans``.
671+
This release adds the experimental and unstable ``OBSERVABILITY_CHOICES`` option for :ref:`observability <observability>`. If set, the choice sequence is included in ``metadata.choice_nodes``, and choice sequence spans are included in ``metadata.choice_spans``.
672672

673673
These are relatively low-level implementation detail of Hypothesis, and are exposed in observability for users building tools or research on top of Hypothesis. See |PrimitiveProvider| for more details about the choice sequence and choice spans.
674674

675-
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!
675+
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!
676676

677677
.. _v6.135.2:
678678

hypothesis-python/docs/prolog.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
.. |settings.suppress_health_check| replace:: :obj:`settings.suppress_health_check <hypothesis.settings.suppress_health_check>`
2727
.. |settings.stateful_step_count| replace:: :obj:`settings.stateful_step_count <hypothesis.settings.stateful_step_count>`
2828
.. |settings.backend| replace:: :obj:`settings.backend <hypothesis.settings.backend>`
29+
.. |settings.observability| replace:: :obj:`settings.observability <hypothesis.settings.observability>`
2930

3031
.. |~settings.max_examples| replace:: :obj:`~hypothesis.settings.max_examples`
3132
.. |~settings.database| replace:: :obj:`~hypothesis.settings.database`
@@ -38,6 +39,7 @@
3839
.. |~settings.suppress_health_check| replace:: :obj:`~hypothesis.settings.suppress_health_check`
3940
.. |~settings.stateful_step_count| replace:: :obj:`~hypothesis.settings.stateful_step_count`
4041
.. |~settings.backend| replace:: :obj:`~hypothesis.settings.backend`
42+
.. |~settings.observability| replace:: :obj:`settings.observability <hypothesis.settings.observability>`
4143

4244
.. |settings.register_profile| replace:: :func:`~hypothesis.settings.register_profile`
4345
.. |settings.get_profile| replace:: :func:`~hypothesis.settings.get_profile`
@@ -147,7 +149,6 @@
147149
.. |with_observability_callback| replace:: :data:`~hypothesis.internal.observability.with_observability_callback`
148150
.. |observability_enabled| replace:: :data:`~hypothesis.internal.observability.observability_enabled`
149151
.. |TESTCASE_CALLBACKS| replace:: :data:`~hypothesis.internal.observability.TESTCASE_CALLBACKS`
150-
.. |OBSERVABILITY_CHOICES| replace:: :data:`~hypothesis.internal.observability.OBSERVABILITY_CHOICES`
151152
.. |BUFFER_SIZE| replace:: :data:`~hypothesis.internal.conjecture.engine.BUFFER_SIZE`
152153
.. |MAX_SHRINKS| replace:: :data:`~hypothesis.internal.conjecture.engine.MAX_SHRINKS`
153154
.. |MAX_SHRINKING_SECONDS| replace:: :data:`~hypothesis.internal.conjecture.engine.MAX_SHRINKING_SECONDS`

hypothesis-python/docs/reference/integrations.rst

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -75,17 +75,10 @@ If you're interested in similar questions, `drop me an email`_!
7575
Observability
7676
-------------
7777

78-
.. note::
78+
.. tip::
7979

8080
The `Tyche <https://github.com/tyche-pbt/tyche-extension>`__ VSCode extension provides an in-editor UI for observability results generated by Hypothesis. If you want to *view* observability results, rather than programmatically consume or display them, we recommend using Tyche.
8181

82-
.. warning::
83-
84-
This feature is experimental, and could have breaking changes or even be removed
85-
without notice. Try it out, let us know what you think, but don't rely on it
86-
just yet!
87-
88-
8982
Motivation
9083
~~~~~~~~~~
9184

@@ -108,24 +101,36 @@ debuggers such as `rr <https://rr-project.org/>`__ or `pytrace <https://pytrace.
108101
because there's no good way to compare multiple traces from these tools and their
109102
Python support is relatively immature.
110103

104+
.. _observability-configuration:
105+
106+
Configuring observability
107+
~~~~~~~~~~~~~~~~~~~~~~~~~
108+
109+
There are two ways to configure observability:
111110

112-
Configuration
113-
~~~~~~~~~~~~~
111+
* Setting the ``HYPOTHESIS_OBSERVABILITY`` environment variable. ``HYPOTHESIS_OBSERVABILITY`` may take one of the following values:
114112

115-
If you set the ``HYPOTHESIS_EXPERIMENTAL_OBSERVABILITY`` environment variable,
116-
Hypothesis will log various observations to jsonlines files in the
113+
* ``True``, ``true``, or ``1`` enables observability.
114+
* ``False``, ``false``, or ``0`` disables observability (the default).
115+
* ``HYPOTHESIS_OBSERVABILITY`` can be further customized with specific string values.
116+
117+
* ``HYPOTHESIS_OBSERVABILITY=coverage`` is equivalent to :obj:`settings(observability=["coverage"]) <hypothesis.settings.observability>`.
118+
* ``HYPOTHESIS_OBSERVABILITY=choices`` is equivalent to :obj:`settings(observability=["choices"]) <hypothesis.settings.observability>`.
119+
* ``HYPOTHESIS_OBSERVABILITY=choices,coverage`` is equivalent to :obj:`settings(observability=["choices", "coverage"]) <hypothesis.settings.observability>`.
120+
* and so on.
121+
122+
* Setting |settings.observability| on a specific test. See the docs there for details. |settings.observability| can be configured in the same way as ``HYPOTHESIS_OBSERVABILITY``.
123+
124+
If neither ``HYPOTHESIS_OBSERVABILITY`` or |settings.observability| are set, observability is disabled.
125+
126+
When observability is configured, Hypothesis will log various observations to jsonlines files in the
117127
``.hypothesis/observed/`` directory. You can load and explore these with e.g.
118128
:func:`pd.read_json(".hypothesis/observed/*_testcases.jsonl", lines=True) <pandas.read_json>`,
119129
or by using the :pypi:`sqlite-utils` and :pypi:`datasette` libraries::
120130

121131
sqlite-utils insert testcases.db testcases .hypothesis/observed/*_testcases.jsonl --nl --flatten
122132
datasette serve testcases.db
123133

124-
If you are experiencing a significant slow-down, you can try setting
125-
``HYPOTHESIS_EXPERIMENTAL_OBSERVABILITY_NOCOVER`` instead; this will disable coverage information
126-
collection. This should not be necessary on Python 3.12 or later, where coverage collection is very fast.
127-
128-
129134
Collecting more information
130135
^^^^^^^^^^^^^^^^^^^^^^^^^^^
131136

@@ -181,7 +186,7 @@ While the observability format is agnostic to the property-based testing library
181186
Choices metadata
182187
++++++++++++++++
183188

184-
These additional metadata elements are included in ``metadata`` (as e.g. ``metadata["choice_nodes"]`` or ``metadata["choice_spans"]``), if and only if |OBSERVABILITY_CHOICES| is set.
189+
These additional metadata elements are included in ``metadata`` (as e.g. ``metadata["choice_nodes"]`` or ``metadata["choice_spans"]``), if and only if observability is configured to include choices (see :ref:`observability-configuration`).
185190

186191
.. jsonschema:: ./schema_metadata_choices.json
187192
:hide_key: /additionalProperties, /type

hypothesis-python/docs/reference/internals.rst

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,6 @@ Observability
3636
.. autofunction:: hypothesis.internal.observability.observability_enabled
3737

3838
.. autodata:: hypothesis.internal.observability.TESTCASE_CALLBACKS
39-
.. autodata:: hypothesis.internal.observability.OBSERVABILITY_COLLECT_COVERAGE
40-
.. autodata:: hypothesis.internal.observability.OBSERVABILITY_CHOICES
4139

4240
Engine constants
4341
----------------

hypothesis-python/docs/reference/schema_metadata_choices.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"properties": {
44
"choice_nodes": {
55
"type": ["array", "null"],
6-
"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.",
6+
"description": ".. warning::\n\n EXPERIMENTAL AND UNSTABLE. This attribute may change format 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\n.. note::\n\n Only present if observability is configured to include choices (see :ref:`observability-configuration`).\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.",
77
"items": {
88
"type": "object",
99
"properties": {
@@ -31,7 +31,7 @@
3131
"choice_spans": {
3232
"type": "array",
3333
"items": {"type": "array"},
34-
"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."
34+
"description": ".. warning::\n\n EXPERIMENTAL AND UNSTABLE. This attribute may change format 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\n.. note::\n\n Only present if observability is configured to include choices (see :ref:`observability-configuration`).\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."
3535
}
3636
},
3737
"required": ["traceback", "reproduction_decorator", "predicates", "backend", "sys.argv", "os.getpid()", "imported_at", "data_status", "interesting_origin", "choice_nodes", "choice_spans"],

hypothesis-python/docs/reference/schema_observations.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
},
3737
"coverage": {
3838
"type": ["object", "null"],
39-
"description": "Mapping of filename to list of covered line numbers, if coverage information is available, or None if not. Hypothesis deliberately omits stdlib and site-packages code.",
39+
"description": "Mapping of filename to list of covered line numbers, if coverage information is available, or None if not. Hypothesis deliberately omits stdlib and site-packages code.\n\n.. note::\n\n Only present if observability is configured to include coverage (see :ref:`observability-configuration`).",
4040
"additionalProperties": {
4141
"type": "array",
4242
"items": {"type": "integer", "minimum": 1},

hypothesis-python/src/hypothesis/_settings.py

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
InvalidArgument,
3535
)
3636
from hypothesis.internal.conjecture.providers import AVAILABLE_PROVIDERS
37+
from hypothesis.internal.observability import ObservabilityOption, envvar_observability
3738
from hypothesis.internal.reflection import get_pretty_function_description
3839
from hypothesis.internal.validation import check_type, try_convert
3940
from hypothesis.utils.conventions import not_set
@@ -57,6 +58,7 @@
5758
"deadline",
5859
"print_blob",
5960
"backend",
61+
"observability",
6062
]
6163

6264

@@ -427,6 +429,18 @@ def _validate_backend(backend: str) -> str:
427429
return backend
428430

429431

432+
def _validate_observability(
433+
observability: Any,
434+
) -> bool | Collection[ObservabilityOption]:
435+
if isinstance(observability, bool):
436+
return observability
437+
observability = try_convert(tuple, observability, "observability")
438+
return tuple(
439+
try_convert(ObservabilityOption, option, "observability")
440+
for option in observability
441+
)
442+
443+
430444
class settingsMeta(type):
431445
def __init__(cls, *args, **kwargs):
432446
super().__init__(*args, **kwargs)
@@ -467,7 +481,8 @@ class settings(metaclass=settingsMeta):
467481
|~settings.max_examples|, |~settings.derandomize|, |~settings.database|,
468482
|~settings.verbosity|, |~settings.phases|, |~settings.stateful_step_count|,
469483
|~settings.report_multiple_bugs|, |~settings.suppress_health_check|,
470-
|~settings.deadline|, |~settings.print_blob|, and |~settings.backend|.
484+
|~settings.deadline|, |~settings.print_blob|, |~settings.backend|, and
485+
|~settings.observability|.
471486
472487
A settings object can be applied as a decorator to a test function, in which
473488
case that test function will use those settings. A test may only have one
@@ -516,7 +531,7 @@ class settings(metaclass=settingsMeta):
516531
"default",
517532
max_examples=100,
518533
derandomize=False,
519-
database=not_set, # see settings.database for the default database
534+
database=not_set, # see settings.database for default behavior
520535
verbosity=Verbosity.normal,
521536
phases=tuple(Phase),
522537
stateful_step_count=50,
@@ -525,6 +540,7 @@ class settings(metaclass=settingsMeta):
525540
deadline=duration(milliseconds=200),
526541
print_blob=False,
527542
backend="hypothesis",
543+
observability=not_set, # see settings.observability for default behavior
528544
)
529545
530546
ci = settings.register_profile(
@@ -571,6 +587,7 @@ def __init__(
571587
deadline: int | float | datetime.timedelta | None = not_set, # type: ignore
572588
print_blob: bool = not_set, # type: ignore
573589
backend: str = not_set, # type: ignore
590+
observability: bool | Collection[str] = not_set, # type: ignore
574591
) -> None:
575592
self._in_definition = True
576593

@@ -597,10 +614,12 @@ def __init__(
597614
if derandomize is not_set # type: ignore
598615
else _validate_choices("derandomize", derandomize, choices=[True, False])
599616
)
617+
600618
if database is not not_set: # type: ignore
601619
database = _validate_database(database)
602620
self._database = database
603621
self._cached_database = None
622+
604623
self._verbosity = (
605624
self._fallback.verbosity # type: ignore
606625
if verbosity is not_set # type: ignore
@@ -644,6 +663,15 @@ def __init__(
644663
else _validate_backend(backend)
645664
)
646665

666+
if observability is not_set: # type: ignore
667+
self._observability = (
668+
envvar_observability
669+
if self._fallback is None
670+
else self._fallback.observability
671+
)
672+
else:
673+
self._observability = _validate_observability(observability)
674+
647675
self._in_definition = False
648676

649677
@property
@@ -901,6 +929,45 @@ def backend(self):
901929
"""
902930
return self._backend
903931

932+
@property
933+
def observability(self):
934+
"""
935+
Controls the :ref:`observability <observability>` behavior of Hypothesis.
936+
937+
Observability may be enabled or disabled by passing ``True`` or ``False``.
938+
939+
Observability behavior can be further customized by passing a collection
940+
of valid string options instead. If a collection of string options is passed,
941+
observability is enabled, and will additionally include output corresponding
942+
to each option. The valid options are:
943+
944+
* ``"coverage"``: include the ``coverage`` field in test case observations.
945+
* ``"choices"``: include the ``metadata.choice_nodes`` and
946+
``metadata.choice_spans`` fields in test case observations.
947+
948+
For example, observations generated under the following settings will include
949+
both coverage data and choice sequence data:
950+
951+
.. code-block:: python
952+
953+
from hypothesis import settings
954+
s = settings(observability=["coverage", "choices"])
955+
956+
``observability=[]`` is treated as ``observability=False``.
957+
958+
If not set, the ``observability`` will be inherited from the
959+
:ref:`HYPOTHESIS_OBSERVABILITY <observability-configuration>`
960+
environment variable. If that environment variable is also not set,
961+
Hypothesis defaults to leaving observability disabled.
962+
"""
963+
return self._observability
964+
965+
@property
966+
def _observability_options(self) -> tuple[ObservabilityOption, ...]:
967+
if not isinstance(self.observability, tuple):
968+
return ()
969+
return self.observability
970+
904971
def __call__(self, test: T) -> T:
905972
"""Make the settings object (self) an attribute of the test.
906973
@@ -1073,6 +1140,7 @@ def note_deprecation(
10731140
deadline=duration(milliseconds=200),
10741141
print_blob=False,
10751142
backend="hypothesis",
1143+
observability=not_set, # type: ignore
10761144
)
10771145
settings.register_profile("default", default)
10781146
settings.load_profile("default")

0 commit comments

Comments
 (0)