Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions hypothesis-python/RELEASE.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
RELEASE_TYPE: minor

|event|'s ``payload`` is now typed as accepting |Any|, matching its runtime behavior of accepting any string-coercible object.
6 changes: 2 additions & 4 deletions hypothesis-python/docs/how-to/custom-database.rst
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,13 @@ For example, here's a simple database class that uses :mod:`sqlite <sqlite3>` as
def __init__(self, db_path: str):
self.conn = sqlite3.connect(db_path)

self.conn.execute(
"""
self.conn.execute("""
CREATE TABLE examples (
key BLOB,
value BLOB,
UNIQUE (key, value)
)
"""
)
""")

def save(self, key: bytes, value: bytes) -> None:
self.conn.execute(
Expand Down
1 change: 1 addition & 0 deletions hypothesis-python/docs/prolog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@
.. |TypeAlias| replace:: :obj:`python:typing.TypeAlias`
.. |TypeAliasType| replace:: :class:`python:typing.TypeAliasType`
.. |NewType| replace:: :class:`python:typing.NewType`
.. |Any| replace:: :obj:`~python:typing.Any`

.. |alternative backend| replace:: :ref:`alternative backend <alternative-backends>`
.. |alternative backends| replace:: :ref:`alternative backends <alternative-backends>`
Expand Down
34 changes: 23 additions & 11 deletions hypothesis-python/src/hypothesis/control.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
from collections import defaultdict
from collections.abc import Callable, Generator, Sequence
from contextlib import contextmanager
from typing import Any, Literal, NoReturn, Optional, overload
from types import TracebackType
from typing import TYPE_CHECKING, Any, Literal, NoReturn, Optional, overload
from weakref import WeakKeyDictionary

from hypothesis import Verbosity, settings
Expand All @@ -29,6 +30,9 @@
from hypothesis.utils.dynamicvariables import DynamicVariable
from hypothesis.vendor.pretty import ArgLabelsT, IDKey, PrettyPrintFunction, pretty

if TYPE_CHECKING:
from typing_extensions import Self


def _calling_function_location(what: str, frame: Any) -> str:
where = frame.f_back
Expand Down Expand Up @@ -100,7 +104,7 @@ def current_build_context() -> "BuildContext":


@contextmanager
def deprecate_random_in_strategy(fmt, *args):
def deprecate_random_in_strategy(fmt: str, *args: Any) -> Generator[None, None, None]:
from hypothesis.internal import entropy

state_before = random.getstate()
Expand Down Expand Up @@ -221,12 +225,17 @@ def prep_args_kwargs_from_strategies(

return kwargs, arg_labels

def __enter__(self):
def __enter__(self) -> "Self":
self.assign_variable = _current_build_context.with_value(self)
self.assign_variable.__enter__()
return self

def __exit__(self, exc_type, exc_value, tb):
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
tb: TracebackType | None,
) -> None:
self.assign_variable.__exit__(exc_type, exc_value, tb)
errors = []
for task in self.tasks:
Expand All @@ -240,7 +249,7 @@ def __exit__(self, exc_type, exc_value, tb):
raise BaseExceptionGroup("Cleanup failed", errors) from exc_value


def cleanup(teardown):
def cleanup(teardown: Callable[[], Any]) -> None:
"""Register a function to be called when the current test has finished
executing. Any exceptions thrown in teardown will be printed but not
rethrown.
Expand All @@ -255,10 +264,11 @@ def cleanup(teardown):
context.tasks.append(teardown)


def should_note():
def should_note() -> bool:
context = _current_build_context.value
if context is None:
raise InvalidArgument("Cannot make notes outside of a test")
assert settings.default is not None
return context.is_final or settings.default.verbosity >= Verbosity.verbose


Expand All @@ -270,7 +280,7 @@ def note(value: object) -> None:
report(value)


def event(value: str, payload: str | int | float = "") -> None:
def event(value: str, payload: Any = "") -> None:
Comment on lines -273 to +283
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is a case where I actually prefer our type annotations to be stricter than the runtime logic - while more exotic payloads 'mostly work', it'd be nice to keep weird problems with string conversion as the user's problem.

For the same usage-hint reason that we say value: str, let's keep payload: str | int | float.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I disagree with this. My interpretation of your position: type hints have two purposes; documentation, and correctness. We type payload: str | int | float to communicate "these are the normal types you'll be passing" to a user seeing event for the first time. But this comes at the expense of requiring other users to add type: ignore at their usage sites. Since we explicitly document that string-convertible objects are valid, I think the types should reflect this.

What about payload: SupportsStr, which is a typing protocol that requires __str__? I hadn't realized we do the same conversion for value, so I'd also support value: SupportsStr.

"""Record an event that occurred during this test. Statistics on the number of test
runs with each event will be reported at the end if you run Hypothesis in
statistics reporting mode.
Expand All @@ -283,17 +293,19 @@ def event(value: str, payload: str | int | float = "") -> None:
raise InvalidArgument("Cannot record events outside of a test")

avoid_realization = context.data.provider.avoid_realization
payload = _event_to_string(
payload = _serialize_event(
payload, allowed_types=(str, int, float), avoid_realization=avoid_realization
)
value = _event_to_string(value, avoid_realization=avoid_realization)
value = _serialize_event(value, avoid_realization=avoid_realization)
context.data.events[value] = payload


_events_to_strings: WeakKeyDictionary = WeakKeyDictionary()
_events_to_strings: WeakKeyDictionary[Any, str] = WeakKeyDictionary()


def _event_to_string(event, *, allowed_types=str, avoid_realization):
def _serialize_event(
event: Any, *, allowed_types: tuple[type, ...] = (str,), avoid_realization: bool
) -> Any:
if isinstance(event, allowed_types):
return event

Expand Down
2 changes: 1 addition & 1 deletion hypothesis-python/src/hypothesis/extra/_patching.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ def get_patch_for(
if patch is None:
return None

(before, after) = patch
before, after = patch
return (str(fname), before, after)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -931,7 +931,7 @@ def _draw_from_cache(
key: ChoiceT,
random: Random,
) -> ChoiceT:
(generator, children, rejected) = self._get_children_cache(
generator, children, rejected = self._get_children_cache(
choice_type, constraints, key=key
)
# Keep a stock of 100 potentially-valid children at all times.
Expand Down Expand Up @@ -961,7 +961,7 @@ def _reject_child(
child: ChoiceT,
key: ChoiceT,
) -> None:
(_generator, children, rejected) = self._get_children_cache(
_generator, children, rejected = self._get_children_cache(
choice_type, constraints, key=key
)
rejected.add(child)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@
MIN_TEST_CALLS: int = 10

# we use this to isolate Hypothesis from interacting with the global random,
# to make it easier to reason about our global random warning logic easier (see
# to make it easier to reason about our global random warning logic (see
# deprecate_random_in_strategy).
_random = Random()

Expand Down
31 changes: 13 additions & 18 deletions hypothesis-python/src/hypothesis/internal/conjecture/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,13 +269,15 @@
bytes=SortedSet(),
strings=SortedSet(),
)
# modules that we've already seen and processed for local constants. These are
# Modules that we've already seen and processed for local constants. These are
# are all modules, not necessarily local ones. This lets us quickly see which
# modules are new without an expensive path.resolve() or is_local_module_file
# cache lookup.
# We track by module object when hashable, falling back to the module name
# (str key in sys.modules) for unhashable entries like SimpleNamespace.
_seen_modules: set = set()
#
# We track by id so we can handle users that put unhashable types like SimpleNamespace
# into sys.modules. ModuleType.__hash__ falls back to id, so this is equivalent
# in the standard case.
_seen_modules: set[int] = set()
_sys_modules_len: int | None = None


Expand Down Expand Up @@ -311,27 +313,20 @@ def _get_local_constants() -> Constants:
# careful: store sys.modules length when we first check to avoid race conditions
# with other threads loading a module before we set _sys_modules_len.
if (sys_modules_len := len(sys.modules)) != _sys_modules_len:
new_modules = []
for name, module in list(sys.modules.items()):
try:
seen = module in _seen_modules
except TypeError:
# unhashable module (e.g. SimpleNamespace); fall back to name
seen = name in _seen_modules
if not seen:
new_modules.append((name, module))
new_modules = [
module
for module in list(sys.modules.values())
if id(module) not in _seen_modules
]
# Repeated SortedSet unions are expensive. Do the initial unions on a
# set(), then do a one-time union with _local_constants after.
new_constants = Constants()
for name, module in new_modules:
for module in new_modules:
if (
module_file := getattr(module, "__file__", None)
) is not None and is_local_module_file(module_file):
new_constants |= constants_from_module(module)
try:
_seen_modules.add(module)
except TypeError:
_seen_modules.add(name)
_seen_modules.add(id(module))
_local_constants |= new_constants
_sys_modules_len = sys_modules_len

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ def check_invariants(self, value):
Does nothing by default.
"""

def short_circuit(self):
def short_circuit(self) -> bool:
"""Possibly attempt to do some shrinking.

If this returns True, the ``run`` method will terminate early
Expand Down
2 changes: 1 addition & 1 deletion hypothesis-python/src/hypothesis/internal/coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
pretty_file_name_cache: dict[str, str] = {}


def pretty_file_name(f):
def pretty_file_name(f: str) -> str:
try:
return pretty_file_name_cache[f]
except KeyError:
Expand Down
11 changes: 9 additions & 2 deletions hypothesis-python/src/hypothesis/internal/healthcheck.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,21 @@
# v. 2.0. If a copy of the MPL was not distributed with this file, You can
# obtain one at https://mozilla.org/MPL/2.0/.

from typing import TYPE_CHECKING

from hypothesis.errors import FailedHealthCheck

if TYPE_CHECKING:
from hypothesis._settings import HealthCheck, settings as Settings


def fail_health_check(settings, message, label):
def fail_health_check(
settings: "Settings", message: str, health_check: "HealthCheck"
) -> None:
# Tell pytest to omit the body of this function from tracebacks
# https://docs.pytest.org/en/latest/example/simple.html#writing-well-integrated-assertion-helpers
__tracebackhide__ = True

if label in settings.suppress_health_check:
if health_check in settings.suppress_health_check:
return
raise FailedHealthCheck(message)
1 change: 1 addition & 0 deletions hypothesis-python/src/hypothesis/provisional.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
Internet strategies should conform to :rfc:`3986` or the authoritative
definitions it links to. If not, report the bug!
"""

# https://tools.ietf.org/html/rfc3696

import string
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -493,7 +493,7 @@ def iterables(
# {"a": st.integers(), "b": st.booleans()}
# )
# * the arguments may be of any dict-compatible type, in which case the return
# value will be of that type instead of dit
# value will be of that type instead of dict
#
# Overloads may help here, but I doubt we'll be able to satisfy all these
# constraints.
Expand Down
24 changes: 14 additions & 10 deletions hypothesis-python/src/hypothesis/strategies/_internal/recursive.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@

import threading
import warnings
from collections.abc import Callable
from collections.abc import Callable, Generator
from contextlib import contextmanager
from typing import Any, TypeVar

from hypothesis.errors import HypothesisWarning, InvalidArgument
from hypothesis.internal.conjecture.data import ConjectureData
from hypothesis.internal.reflection import (
get_pretty_function_description,
is_first_param_referenced_in_function,
Expand All @@ -27,31 +29,33 @@
)
from hypothesis.utils.deprecation import note_deprecation

T = TypeVar("T")


class LimitReached(BaseException):
pass


class LimitedStrategy(SearchStrategy):
def __init__(self, strategy):
class LimitedStrategy(SearchStrategy[T]):
def __init__(self, strategy: SearchStrategy[T]):
super().__init__()
self.base_strategy = strategy
self._threadlocal = threading.local()

@property
def marker(self):
def marker(self) -> int:
return getattr(self._threadlocal, "marker", 0)

@marker.setter
def marker(self, value):
def marker(self, value: int) -> None:
self._threadlocal.marker = value

@property
def currently_capped(self):
def currently_capped(self) -> bool:
return getattr(self._threadlocal, "currently_capped", False)

@currently_capped.setter
def currently_capped(self, value):
def currently_capped(self, value: bool) -> None:
self._threadlocal.currently_capped = value

def __repr__(self) -> str:
Expand All @@ -60,15 +64,15 @@ def __repr__(self) -> str:
def do_validate(self) -> None:
self.base_strategy.validate()

def do_draw(self, data):
def do_draw(self, data: ConjectureData) -> T:
assert self.currently_capped
if self.marker <= 0:
raise LimitReached
self.marker -= 1
return data.draw(self.base_strategy)

@contextmanager
def capped(self, max_templates):
def capped(self, max_templates: int) -> Generator[None, None, None]:
try:
was_capped = self.currently_capped
self.currently_capped = True
Expand Down Expand Up @@ -157,7 +161,7 @@ def do_validate(self) -> None:
f"max_leaves={self.max_leaves!r}"
)

def do_draw(self, data):
def do_draw(self, data: ConjectureData) -> Any:
min_leaves_retries = 0
while True:
try:
Expand Down
Loading
Loading