From 48d4308720c4b9f2142900c65008b5641f26a1b7 Mon Sep 17 00:00:00 2001 From: Liam DeVoe Date: Sat, 1 Nov 2025 14:50:56 -0700 Subject: [PATCH 01/10] indent form correctly --- docs/index.html | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/docs/index.html b/docs/index.html index 506190dc..1a0edbb8 100644 --- a/docs/index.html +++ b/docs/index.html @@ -153,27 +153,27 @@
We'll have plenty to share as we roll out new features! The first place you'll hear about them is our low-volume newsletter.
-
-
-
- - -
-
- - -
- - -
-
+
+
+
+ + +
+
+ + +
+ + +
+
From 0ff4c109a14372574084d6f48927c7dc89dbfcaf Mon Sep 17 00:00:00 2001 From: Liam DeVoe Date: Mon, 3 Nov 2025 14:53:24 -0800 Subject: [PATCH 02/10] update typing syntax --- .../src/example_suite/boyer_moore.py | 13 ++--- example_suite/src/example_suite/timsort.py | 12 ++--- src/hypofuzz/collection.py | 6 +-- src/hypofuzz/corpus.py | 4 +- src/hypofuzz/coverage.py | 8 ++-- src/hypofuzz/dashboard/api.py | 4 +- src/hypofuzz/dashboard/dashboard.py | 10 ++-- src/hypofuzz/dashboard/models.py | 20 ++++---- src/hypofuzz/dashboard/patching.py | 8 ++-- src/hypofuzz/dashboard/test.py | 8 ++-- src/hypofuzz/database/database.py | 24 +++++----- src/hypofuzz/database/models.py | 24 +++++----- src/hypofuzz/database/utils.py | 6 +-- .../docs/_ext/hypothesis_redirects.py | 3 +- src/hypofuzz/entrypoint.py | 6 +-- src/hypofuzz/hypofuzz.py | 42 ++++++++--------- src/hypofuzz/provider.py | 47 ++++++++++--------- src/hypofuzz/utils.py | 8 ++-- tests/common.py | 5 +- tests/test_linearize.py | 3 +- tests/test_provider.py | 3 +- 21 files changed, 126 insertions(+), 138 deletions(-) diff --git a/example_suite/src/example_suite/boyer_moore.py b/example_suite/src/example_suite/boyer_moore.py index 5a5f9e6c..401d1f32 100644 --- a/example_suite/src/example_suite/boyer_moore.py +++ b/example_suite/src/example_suite/boyer_moore.py @@ -1,7 +1,4 @@ -from typing import Dict, List - - -def boyer_moore_search(text: str, pattern: str) -> List[int]: +def boyer_moore_search(text: str, pattern: str) -> list[int]: """ Full Boyer-Moore string search algorithm implementation. @@ -48,7 +45,7 @@ def boyer_moore_search(text: str, pattern: str) -> List[int]: return matches -def _build_bad_char_table(pattern: str) -> Dict[str, int]: +def _build_bad_char_table(pattern: str) -> dict[str, int]: """ Build the bad character rule table. @@ -64,7 +61,7 @@ def _build_bad_char_table(pattern: str) -> Dict[str, int]: return table -def _get_bad_char_shift(table: Dict[str, int], char: str, j: int, m: int) -> int: +def _get_bad_char_shift(table: dict[str, int], char: str, j: int, m: int) -> int: """ Calculate shift using bad character rule. @@ -83,7 +80,7 @@ def _get_bad_char_shift(table: Dict[str, int], char: str, j: int, m: int) -> int return max(1, m - j) -def _build_border_table(pattern: str) -> List[int]: +def _build_border_table(pattern: str) -> list[int]: """ Build the border table for the good suffix rule. @@ -109,7 +106,7 @@ def _build_border_table(pattern: str) -> List[int]: return border -def _build_good_suffix_table(pattern: str) -> List[int]: +def _build_good_suffix_table(pattern: str) -> list[int]: """ Build the good suffix rule table. diff --git a/example_suite/src/example_suite/timsort.py b/example_suite/src/example_suite/timsort.py index 3a3a3256..c7582dd7 100644 --- a/example_suite/src/example_suite/timsort.py +++ b/example_suite/src/example_suite/timsort.py @@ -1,10 +1,10 @@ from collections.abc import Iterable, Sequence -from typing import List, TypeVar +from typing import TypeVar T = TypeVar("T") -def timsort(values: Sequence[T]) -> List[T]: +def timsort(values: Sequence[T]) -> list[T]: """ Sort and return a new list using a simplified Timsort-style algorithm. @@ -19,7 +19,7 @@ def timsort(values: Sequence[T]) -> List[T]: if n <= 1: return list(values) - runs: List[List[T]] = [] + runs: list[list[T]] = [] i = 0 while i < n: @@ -44,7 +44,7 @@ def timsort(values: Sequence[T]) -> List[T]: # Simple pairwise merge until one run remains. while len(runs) > 1: - merged: List[List[T]] = [] + merged: list[list[T]] = [] for k in range(0, len(runs), 2): if k + 1 == len(runs): merged.append(runs[k]) @@ -55,12 +55,12 @@ def timsort(values: Sequence[T]) -> List[T]: return runs[0] -def _merge_two_sorted(left: Iterable[T], right: Iterable[T]) -> List[T]: +def _merge_two_sorted(left: Iterable[T], right: Iterable[T]) -> list[T]: li = list(left) ri = list(right) i = 0 j = 0 - out: List[T] = [] + out: list[T] = [] while i < len(li) and j < len(ri): if li[i] <= ri[j]: diff --git a/src/hypofuzz/collection.py b/src/hypofuzz/collection.py index cb869aca..ae62dcf0 100644 --- a/src/hypofuzz/collection.py +++ b/src/hypofuzz/collection.py @@ -6,7 +6,7 @@ from collections.abc import Iterable from contextlib import redirect_stdout from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Any import pytest from _pytest.nodes import Item @@ -24,7 +24,7 @@ pytest8 = version.parse(pytest.__version__) >= version.parse("8.0.0") -def has_true_skipif(item: Item) -> tuple[bool, Optional[str]]: +def has_true_skipif(item: Item) -> tuple[bool, str | None]: # multiple @skipif decorators are treated as an OR. for mark in item.iter_markers("skipif"): result, reason = evaluate_condition(item, mark, condition=mark.args[0]) @@ -47,7 +47,7 @@ def __init__(self) -> None: self.not_collected: dict[str, dict[str, Any]] = {} def _skip_because( - self, status_reason: str, nodeid: str, kwargs: Optional[dict[str, Any]] = None + self, status_reason: str, nodeid: str, kwargs: dict[str, Any] | None = None ) -> None: self.not_collected[nodeid] = { "status_reason": status_reason, diff --git a/src/hypofuzz/corpus.py b/src/hypofuzz/corpus.py index 6c814a3a..0b70a09e 100644 --- a/src/hypofuzz/corpus.py +++ b/src/hypofuzz/corpus.py @@ -66,7 +66,7 @@ def __getitem__(self, i: int) -> ChoiceT: return self.choices[i] -def sort_key(nodes: Union[NodesT, ConjectureResult]) -> tuple[int, tuple[int, ...]]: +def sort_key(nodes: NodesT | ConjectureResult) -> tuple[int, tuple[int, ...]]: """Sort choice nodes in shortlex order. See `hypothesis.internal.conjecture.engine.sort_key` for details on why we @@ -81,7 +81,7 @@ def sort_key(nodes: Union[NodesT, ConjectureResult]) -> tuple[int, tuple[int, .. def get_shrinker( fn: Callable[[ConjectureData], None], *, - initial: Union[ConjectureData, ConjectureResult], + initial: ConjectureData | ConjectureResult, predicate: Callable[..., bool], random: Random, explain: bool = False, diff --git a/src/hypofuzz/coverage.py b/src/hypofuzz/coverage.py index 36572788..ed5730f6 100644 --- a/src/hypofuzz/coverage.py +++ b/src/hypofuzz/coverage.py @@ -5,7 +5,7 @@ import types from functools import cache from pathlib import Path -from typing import Any, NamedTuple, Optional +from typing import Any, NamedTuple import _pytest import attr @@ -23,7 +23,7 @@ # (start_file, end_file): {start: {end: branch}} _BRANCH_CACHE: dict[ tuple[str, str], - dict[tuple[int, Optional[int]], dict[tuple[int, Optional[int]], "Branch"]], + dict[tuple[int, int | None], dict[tuple[int, int | None], "Branch"]], ] = {} @@ -33,7 +33,7 @@ class Location(NamedTuple): filename: str line: int # column might be None if we're on pre-3.12 - column: Optional[int] + column: int | None class Branch(NamedTuple): @@ -146,7 +146,7 @@ class CoverageCollector: def __init__(self) -> None: self.branches: set[Branch] = set() - self.last: Optional[Location] = None + self.last: Location | None = None def trace_pre_312(self, frame: Any, event: Any, arg: Any) -> Any: if event == "line": diff --git a/src/hypofuzz/dashboard/api.py b/src/hypofuzz/dashboard/api.py index e06ebae3..79dfa885 100644 --- a/src/hypofuzz/dashboard/api.py +++ b/src/hypofuzz/dashboard/api.py @@ -1,5 +1,5 @@ import json -from typing import Any, Optional +from typing import Any import black from starlette.requests import Request @@ -52,7 +52,7 @@ async def api_test(request: Request) -> Response: return HypofuzzJSONResponse(dashboard_test(TESTS[nodeid])) -def _patches() -> dict[str, dict[str, Optional[str]]]: +def _patches() -> dict[str, dict[str, str | None]]: from hypofuzz.dashboard.dashboard import COLLECTION_RESULT assert COLLECTION_RESULT is not None diff --git a/src/hypofuzz/dashboard/dashboard.py b/src/hypofuzz/dashboard/dashboard.py index b2ef0942..70855624 100644 --- a/src/hypofuzz/dashboard/dashboard.py +++ b/src/hypofuzz/dashboard/dashboard.py @@ -4,7 +4,7 @@ import socket from collections import defaultdict from pathlib import Path -from typing import Any, Literal, Optional +from typing import Any, Literal import trio from hypercorn.config import Config @@ -62,8 +62,8 @@ TESTS_BY_KEY: dict[bytes, "Test"] = {} # databse_key: loaded LOADING_STATE: dict[bytes, bool] = {} -COLLECTION_RESULT: Optional[CollectionResult] = None -db: Optional[HypofuzzDatabase] = None +COLLECTION_RESULT: CollectionResult | None = None +db: HypofuzzDatabase | None = None class DocsStaticFiles(StaticFiles): @@ -103,8 +103,8 @@ def _add_patch( ) -def _dashboard_event(db_event: DatabaseEvent) -> Optional[DashboardEventT]: - event: Optional[DashboardEventT] = None +def _dashboard_event(db_event: DatabaseEvent) -> DashboardEventT | None: + event: DashboardEventT | None = None if db_event.type == "save": value = db_event.value assert value is not None diff --git a/src/hypofuzz/dashboard/models.py b/src/hypofuzz/dashboard/models.py index 5d1258d2..8c20aa2a 100644 --- a/src/hypofuzz/dashboard/models.py +++ b/src/hypofuzz/dashboard/models.py @@ -1,6 +1,6 @@ from dataclasses import dataclass from enum import IntEnum -from typing import Any, ClassVar, Literal, Optional, TypedDict, Union +from typing import Any, ClassVar, Literal, TypedDict, Union from hypofuzz.dashboard.test import Test from hypofuzz.database import ( @@ -16,8 +16,8 @@ class DashboardObservationMetadata(TypedDict): - traceback: Optional[str] - reproduction_decorator: Optional[str] + traceback: str | None + reproduction_decorator: str | None class DashboardObservation(TypedDict): @@ -32,7 +32,7 @@ class DashboardObservation(TypedDict): metadata: DashboardObservationMetadata property: str run_start: float - stability: Optional[Stability] + stability: Stability | None class DashboardFailure(TypedDict): @@ -50,7 +50,7 @@ class DashboardReport(TypedDict): behaviors: int fingerprints: int timestamp: float - since_new_behavior: Optional[int] + since_new_behavior: int | None phase: Phase @@ -62,9 +62,9 @@ class DashboardTest(TypedDict): rolling_observations: list[DashboardObservation] corpus_observations: list[DashboardObservation] failures: dict[str, DashboardFailure] - fatal_failure: Optional[DashboardFatalFailure] + fatal_failure: DashboardFatalFailure | None reports_by_worker: dict[str, list[DashboardReport]] - stability: Optional[float] + stability: float | None # keep in sync with DashboardEventType in DataProvider.tsx @@ -94,8 +94,8 @@ class AddTestsTest(TypedDict): database_key: str nodeid: str failures: dict[str, DashboardFailure] - fatal_failure: Optional[DashboardFatalFailure] - stability: Optional[float] + fatal_failure: DashboardFatalFailure | None + stability: float | None @dataclass @@ -151,7 +151,7 @@ class TestLoadFinishedEvent(DashboardEvent): class SetFatalFailureEvent(DashboardEvent): type = DashboardEventType.SET_FATAL_FAILURE nodeid: str - fatal_failure: Optional[DashboardFatalFailure] + fatal_failure: DashboardFatalFailure | None DashboardEventT = Union[ diff --git a/src/hypofuzz/dashboard/patching.py b/src/hypofuzz/dashboard/patching.py index e63b3e10..ef99e916 100644 --- a/src/hypofuzz/dashboard/patching.py +++ b/src/hypofuzz/dashboard/patching.py @@ -2,7 +2,7 @@ from collections import defaultdict from functools import lru_cache from queue import Empty, Queue -from typing import Any, Literal, Optional +from typing import Any, Literal from hypothesis.extra._patching import ( get_patch_for as _get_patch_for, @@ -28,7 +28,7 @@ get_patch_for = lru_cache(maxsize=8192)(_get_patch_for) _queue: Queue = Queue() -_thread: Optional[threading.Thread] = None +_thread: threading.Thread | None = None def add_patch( @@ -50,12 +50,12 @@ def make_patch(triples: tuple[tuple[str, str, str]], *, msg: str) -> str: ) -def failing_patch(nodeid: str) -> Optional[str]: +def failing_patch(nodeid: str) -> str | None: failing = PATCHES[nodeid]["failing"] return make_patch(tuple(failing), msg="add failing examples") if failing else None -def covering_patch(nodeid: str) -> Optional[str]: +def covering_patch(nodeid: str) -> str | None: covering = PATCHES[nodeid]["covering"] return ( make_patch(tuple(covering), msg="add covering examples") if covering else None diff --git a/src/hypofuzz/dashboard/test.py b/src/hypofuzz/dashboard/test.py index cfe5351d..0ccaf390 100644 --- a/src/hypofuzz/dashboard/test.py +++ b/src/hypofuzz/dashboard/test.py @@ -1,6 +1,6 @@ import math from dataclasses import dataclass, field -from typing import Optional, TypeVar +from typing import TypeVar from hypothesis.internal.cache import LRUCache @@ -28,7 +28,7 @@ class Test: rolling_observations: list[Observation] corpus_observations: list[Observation] failures: dict[str, tuple[FailureState, Observation]] - fatal_failure: Optional[FatalFailure] + fatal_failure: FatalFailure | None reports_by_worker: dict[str, list[ReportWithDiff]] linear_reports: list[ReportWithDiff] = field(init=False) @@ -212,7 +212,7 @@ def add_report(self, report: Report) -> None: cache[key] = (start_idx, values[: index - start_idx]) @property - def stability(self) -> Optional[float]: + def stability(self) -> float | None: if not self.rolling_observations: return None @@ -231,7 +231,7 @@ def stability(self) -> Optional[float]: return count_stable / (count_stable + count_unstable) @property - def phase(self) -> Optional[Phase]: + def phase(self) -> Phase | None: return self.linear_reports[-1].phase if self.linear_reports else None @property diff --git a/src/hypofuzz/database/database.py b/src/hypofuzz/database/database.py index fdce80c9..fcf259d2 100644 --- a/src/hypofuzz/database/database.py +++ b/src/hypofuzz/database/database.py @@ -3,16 +3,14 @@ import hashlib import json from collections import defaultdict, deque -from collections.abc import Iterable +from collections.abc import Callable, Iterable from dataclasses import dataclass, is_dataclass from enum import Enum from functools import lru_cache from typing import ( Any, - Callable, Literal, Optional, - Union, overload, ) @@ -62,7 +60,7 @@ def _check_observation(observation: Observation) -> None: def corpus_observation_key( key: bytes, # `choices` required to be hashable for @lru_cache - choices: Union[HashableIterable[ChoiceT], bytes], + choices: HashableIterable[ChoiceT] | bytes, ) -> bytes: choices_bytes = choices if isinstance(choices, bytes) else choices_to_bytes(choices) return ( @@ -93,7 +91,7 @@ def _failure_observation_postfix(*, state: FailureState) -> bytes: @lru_cache(maxsize=512) def failure_observation_key( - key: bytes, choices: Union[HashableIterable[ChoiceT], bytes], state: FailureState + key: bytes, choices: HashableIterable[ChoiceT] | bytes, state: FailureState ) -> bytes: choices_bytes = choices if isinstance(choices, bytes) else choices_to_bytes(choices) return ( @@ -221,7 +219,7 @@ def fetch(self, key: bytes, *, as_bytes: Literal[True]) -> Iterable[bytes]: ... def fetch( self, key: bytes, *, as_bytes: bool = False - ) -> Iterable[Union[ChoicesT, bytes]]: + ) -> Iterable[ChoicesT | bytes]: for value in self.db.fetch(key + corpus_key): if as_bytes: yield value @@ -264,8 +262,8 @@ def delete( def fetch( self, key: bytes, - choices: Union[HashableIterable[ChoiceT], bytes], - ) -> Optional[Observation]: + choices: HashableIterable[ChoiceT] | bytes, + ) -> Observation | None: # We expect there to be only a single entry. If there are multiple, we # arbitrarily pick one to return. try: @@ -276,7 +274,7 @@ def fetch( def fetch_all( self, key: bytes, - choices: Union[HashableIterable[ChoiceT], bytes], + choices: HashableIterable[ChoiceT] | bytes, ) -> Iterable[Observation]: for value in self.db.fetch(corpus_observation_key(key, choices)): if observation := Observation.from_json(value): @@ -300,7 +298,7 @@ def save( self, key: bytes, choices: ChoicesT, - observation: Optional[Observation], + observation: Observation | None, ) -> None: self.db.save(self._key(key, state=self.state), choices_to_bytes(choices)) @@ -375,7 +373,7 @@ def delete(self, key: bytes, choices: ChoicesT, observation: Observation) -> Non _encode(observation), ) - def fetch(self, key: bytes, choices: ChoicesT) -> Optional[Observation]: + def fetch(self, key: bytes, choices: ChoicesT) -> Observation | None: try: return next(iter(self.fetch_all(key, choices))) except StopIteration: @@ -427,7 +425,7 @@ def fetch_all(self, key: bytes) -> Iterable[FatalFailure]: if failure := FatalFailure.from_json(value): yield failure - def fetch(self, key: bytes) -> Optional[FatalFailure]: + def fetch(self, key: bytes) -> FatalFailure | None: try: return next(iter(self.fetch_all(key))) except StopIteration: @@ -455,7 +453,7 @@ def delete(self, uuid: bytes) -> None: for worker_identity in self.fetch_all(uuid): self.db.delete(self._key(uuid), _encode(worker_identity)) - def fetch(self, uuid: bytes) -> Optional[WorkerIdentity]: + def fetch(self, uuid: bytes) -> WorkerIdentity | None: try: return next(iter(self.fetch_all(uuid))) except StopIteration: diff --git a/src/hypofuzz/database/models.py b/src/hypofuzz/database/models.py index cccac322..09808b57 100644 --- a/src/hypofuzz/database/models.py +++ b/src/hypofuzz/database/models.py @@ -27,12 +27,12 @@ class WorkerIdentity: hypofuzz_version: str pid: int hostname: str - pod_name: Optional[str] - pod_namespace: Optional[str] - node_name: Optional[str] - pod_ip: Optional[str] - container_id: Optional[str] - git_hash: Optional[str] + pod_name: str | None + pod_namespace: str | None + node_name: str | None + pod_ip: str | None + container_id: str | None + git_hash: str | None @staticmethod def from_json(data: bytes) -> Optional["WorkerIdentity"]: @@ -63,8 +63,8 @@ class Stability(Enum): # only the subset of the metadata that we store from HypothesisObservation. @dataclass(frozen=True) class ObservationMetadata: - traceback: Optional[str] - reproduction_decorator: Optional[str] + traceback: str | None + reproduction_decorator: str | None predicates: dict[str, PredicateCounts] backend: dict[str, Any] sys_argv: list[str] @@ -105,11 +105,11 @@ class Observation: run_start: float # stability == None means we don't know the stability, because we didn't # re-execute this observation - stability: Optional[Stability] + stability: Stability | None @classmethod def from_hypothesis( - cls, observation: TestCaseObservation, stability: Optional[Stability] = None + cls, observation: TestCaseObservation, stability: Stability | None = None ) -> "Observation": return cls( type=observation.type, @@ -157,7 +157,7 @@ class Phase(Enum): class StatusCounts(dict): - def __init__(self, value: Optional[dict[Status, int]] = None) -> None: + def __init__(self, value: dict[Status, int] | None = None) -> None: if value is None: value = dict.fromkeys(Status, 0) super().__init__(value) @@ -202,7 +202,7 @@ class Report: status_counts: StatusCounts behaviors: int fingerprints: int - since_new_behavior: Optional[int] + since_new_behavior: int | None phase: Phase def __post_init__(self) -> None: diff --git a/src/hypofuzz/database/utils.py b/src/hypofuzz/database/utils.py index c6ba1abe..63e325ae 100644 --- a/src/hypofuzz/database/utils.py +++ b/src/hypofuzz/database/utils.py @@ -1,6 +1,6 @@ from base64 import b64decode, b64encode from collections.abc import Iterator -from typing import TYPE_CHECKING, Literal, Protocol, TypeVar, Union, overload +from typing import TYPE_CHECKING, Literal, Protocol, TypeVar, overload from hypothesis.internal.conjecture.choice import ChoiceT @@ -26,9 +26,7 @@ def convert_db_key(key: str, *, to: Literal["bytes"]) -> bytes: ... def convert_db_key(key: bytes, *, to: Literal["str"]) -> str: ... -def convert_db_key( - key: Union[str, bytes], *, to: Literal["str", "bytes"] -) -> Union[str, bytes]: +def convert_db_key(key: str | bytes, *, to: Literal["str", "bytes"]) -> str | bytes: if to == "str": assert isinstance(key, bytes) return b64encode(key).decode("ascii") diff --git a/src/hypofuzz/docs/_ext/hypothesis_redirects.py b/src/hypofuzz/docs/_ext/hypothesis_redirects.py index 01004038..3a53ac54 100644 --- a/src/hypofuzz/docs/_ext/hypothesis_redirects.py +++ b/src/hypofuzz/docs/_ext/hypothesis_redirects.py @@ -12,7 +12,6 @@ from fnmatch import fnmatch from pathlib import Path from string import Template -from typing import Optional from urllib.parse import urlparse from sphinx.application import Sphinx @@ -46,7 +45,7 @@ def setup(app: Sphinx) -> dict: return {"parallel_read_safe": True} -def init(app: Sphinx) -> Optional[Sequence]: +def init(app: Sphinx) -> Sequence | None: if not app.config[OPTION_REDIRECTS]: logger.debug("No redirects configured") return [] diff --git a/src/hypofuzz/entrypoint.py b/src/hypofuzz/entrypoint.py index 337cfe74..2ffbd636 100644 --- a/src/hypofuzz/entrypoint.py +++ b/src/hypofuzz/entrypoint.py @@ -3,7 +3,7 @@ import os import sys from multiprocessing import Process -from typing import NoReturn, Optional +from typing import NoReturn import click import hypothesis.extra.cli @@ -59,8 +59,8 @@ def fuzz( numprocesses: int, dashboard: bool, dashboard_only: bool, - host: Optional[str], - port: Optional[int], + host: str | None, + port: int | None, pytest_args: tuple[str, ...], ) -> NoReturn: """[hypofuzz] runs tests with an adaptive coverage-guided fuzzer. diff --git a/src/hypofuzz/hypofuzz.py b/src/hypofuzz/hypofuzz.py index dd69ce91..18a36f2e 100644 --- a/src/hypofuzz/hypofuzz.py +++ b/src/hypofuzz/hypofuzz.py @@ -12,12 +12,12 @@ import traceback from collections import defaultdict from collections.abc import Callable, Mapping, Sequence -from contextlib import nullcontext, redirect_stdout +from contextlib import nullcontext from functools import cache, partial from multiprocessing import Manager, Process from pathlib import Path from random import Random -from typing import Any, Literal, NoReturn, Optional, Union +from typing import Any, Literal, NoReturn import hypothesis import pytest @@ -108,8 +108,8 @@ def from_hypothesis_test( wrapped_test: Any, *, database: HypofuzzDatabase, - extra_kwargs: Optional[dict[str, object]] = None, - pytest_item: Optional[pytest.Function] = None, + extra_kwargs: dict[str, object] | None = None, + pytest_item: pytest.Function | None = None, ) -> "FuzzTarget": return cls( test_fn=wrapped_test.hypothesis.inner_test, @@ -128,7 +128,7 @@ def __init__( database: HypofuzzDatabase, database_key: bytes, wrapped_test: Callable, - pytest_item: Optional[pytest.Function] = None, + pytest_item: pytest.Function | None = None, ) -> None: self.test_fn = test_fn self.extra_kwargs = extra_kwargs @@ -141,7 +141,7 @@ def __init__( pytest_item, "nodeid", None ) or get_pretty_function_description(test_fn) self.database_key_str = convert_db_key(self.database_key, to="str") - self.fixtureinfo: Optional[FuncFixtureInfo] = None + self.fixtureinfo: FuncFixtureInfo | None = None if pytest_item is not None: manager = pytest_item._request._fixturemanager self.fixtureinfo = manager.getfixtureinfo( @@ -149,7 +149,7 @@ def __init__( ) self.random = Random() - self.state: Optional[HypofuzzStateForActualGivenExecution] = None + self.state: HypofuzzStateForActualGivenExecution | None = None self.provider = HypofuzzProvider(None, database_key=database_key) self.stop_shrinking_at = math.inf self.failed_fatally = False @@ -170,7 +170,7 @@ def _fail_fatally(self, exception: BaseException) -> NoReturn: raise FailedFatally def _new_state( - self, *, extra_kwargs: Optional[dict[str, Any]] = None + self, *, extra_kwargs: dict[str, Any] | None = None ) -> HypofuzzStateForActualGivenExecution: arguments: list[Any] = [] @@ -301,9 +301,7 @@ def _exit_fixtures(self) -> None: self._pytest_item_instance = None - def new_conjecture_data( - self, *, choices: Optional[ChoicesT] = None - ) -> ConjectureData: + def new_conjecture_data(self, *, choices: ChoicesT | None = None) -> ConjectureData: if choices is not None: return ConjectureData.for_choices( choices, provider=self.provider, random=self.random @@ -356,7 +354,7 @@ def run_one(self) -> None: observation = None def on_observation( - passed_observation: Union[TestCaseObservation, InfoObservation], + passed_observation: TestCaseObservation | InfoObservation, ) -> None: assert passed_observation.type == "test_case" assert passed_observation.property == self.nodeid @@ -390,11 +388,11 @@ def _execute_once( self, data: ConjectureData, *, - observability_callback: Union[ - Callable[[Union[InfoObservation, TestCaseObservation]], None], - Literal["provider"], - None, - ] = "provider", + observability_callback: ( + Callable[[InfoObservation | TestCaseObservation], None] + | Literal["provider"] + | None + ) = "provider", ) -> None: assert self.state is not None # setting current_pytest_item lets us access it in HypofuzzProvider, @@ -479,9 +477,9 @@ def __init__( # campaign, neither of which are feasible. self.dropped_targets: dict[str, FuzzTarget] = {} - self._current_target: Optional[FuzzTarget] = None - self.db: Optional[HypofuzzDatabase] = None - self.worker_identity: Optional[WorkerIdentity] = None + self._current_target: FuzzTarget | None = None + self.db: HypofuzzDatabase | None = None + self.worker_identity: WorkerIdentity | None = None self.event_dispatch: dict[bytes, list[FuzzTarget]] = defaultdict(list) def _add_target(self, nodeid: str) -> None: @@ -779,7 +777,7 @@ def _rebalance(self) -> None: @cache -def _git_head(*, in_directory: Optional[Path] = None) -> Optional[str]: +def _git_head(*, in_directory: Path | None = None) -> str | None: if in_directory is not None: assert in_directory.is_dir() @@ -797,7 +795,7 @@ def _git_head(*, in_directory: Optional[Path] = None) -> Optional[str]: @cache -def worker_identity(*, in_directory: Optional[Path] = None) -> WorkerIdentity: +def worker_identity(*, in_directory: Path | None = None) -> WorkerIdentity: """Returns a class identifying the machine running this code. This is intended to roughly represent the "unit of fuzz worker", so it includes diff --git a/src/hypofuzz/provider.py b/src/hypofuzz/provider.py index 8fb56c5b..8fe4f34b 100644 --- a/src/hypofuzz/provider.py +++ b/src/hypofuzz/provider.py @@ -1,3 +1,4 @@ +import dataclasses import math import time from base64 import b64encode @@ -7,7 +8,7 @@ from datetime import date, timedelta from enum import IntEnum from random import Random -from typing import Any, ClassVar, Optional, TypeVar, Union, cast +from typing import Any, ClassVar, TypeVar, cast import hypothesis import hypothesis.internal.observability @@ -72,7 +73,7 @@ def fresh_choice( # like choices_size, but handles ChoiceTemplate -def _choices_size(choices: tuple[Union[ChoiceT, ChoiceTemplate], ...]) -> int: +def _choices_size(choices: tuple[ChoiceT | ChoiceTemplate, ...]) -> int: return sum( 1 if isinstance(choice, ChoiceTemplate) else choices_size([choice]) for choice in choices @@ -109,7 +110,7 @@ class QueuePriority(IntEnum): @dataclass class State: choices: ChoicesT - priority: Optional[QueuePriority] + priority: QueuePriority | None start_time: float save_rolling_observation: bool # whether to re-execute this observation for stability, or save it with @@ -117,13 +118,13 @@ class State: rolling_observation_stability: bool choice_index: int = 0 - branches: Optional[frozenset[Branch]] = None - observation: Optional[Observation] = None - extra_queue_data: Optional[Any] = None + branches: frozenset[Branch] | None = None + observation: Observation | None = None + extra_queue_data: Any | None = None # (priority, choice sequence, extra_data) -QueueElement = tuple[QueuePriority, ChoicesT, Optional[Any]] +QueueElement = tuple[QueuePriority, ChoicesT, Any | None] def bucket_target_value(v: Any) -> Any: @@ -163,11 +164,11 @@ class HypofuzzProvider(PrimitiveProvider): def __init__( self, - conjecturedata: Optional[ConjectureData], + conjecturedata: ConjectureData | None, /, # allow test-time override of the coverage collector. - collector: Optional[CoverageCollector] = None, - database_key: Optional[bytes] = None, + collector: CoverageCollector | None = None, + database_key: bytes | None = None, ) -> None: super().__init__(conjecturedata) self.collector = collector @@ -195,7 +196,7 @@ def __init__( self.since_new_fingerprint: int = 0 self.random = Random() - self.phase: Optional[Phase] = None + self.phase: Phase | None = None # there's a subtle bug here: we don't want to defer computation of # database_key until _startup, because by then the _hypothesis_internal_add_digest # added to the shared inner_test function may have been changed to a @@ -205,9 +206,9 @@ def __init__( # Passing the database key upfront avoids from FuzzTarget avoids this. If # it's not passed, we're being used from Hypothesis, and it's fine to defer # computation. - self.database_key: Optional[bytes] = database_key - self.corpus: Optional[Corpus] = None - self.db: Optional[HypofuzzDatabase] = None + self.database_key: bytes | None = database_key + self.corpus: Corpus | None = None + self.db: HypofuzzDatabase | None = None self._choices_queue: SortedList[QueueElement] = SortedList( key=lambda x: (x[0], _choices_size(x[1])) @@ -222,7 +223,7 @@ def __init__( self._last_saved_report_at = -math.inf self._last_observed = -math.inf # per-test-case state, reset at the beginning of each test case. - self._state: Optional[State] = None + self._state: State | None = None self._started = False # we use this to ignore the on_observation observation if we error in # startup @@ -329,7 +330,7 @@ def _enqueue( priority: QueuePriority, choices: ChoicesT, *, - extra_data: Optional[Any] = None, + extra_data: Any | None = None, ) -> None: self._choices_queue.add((priority, choices, extra_data)) @@ -418,7 +419,7 @@ def per_test_case_context_manager(self) -> Generator[None, None, None]: def _test_case_choices( self, - ) -> tuple[ChoicesT, Optional[QueuePriority], Optional[Any]]: + ) -> tuple[ChoicesT, QueuePriority | None, Any | None]: # return the choices for this test case. The choices might come from # either _choices_queue, or a mutator. @@ -467,7 +468,7 @@ def _test_case_choices( return (choices, None, None) def _should_save_rolling_observation( - self, *, priority: Optional[QueuePriority] + self, *, priority: QueuePriority | None ) -> bool: return ( self.phase is Phase.GENERATE @@ -514,7 +515,7 @@ def before_test_case(self) -> None: ) def on_observation( - self, observation: Union[TestCaseObservation, InfoObservation] + self, observation: TestCaseObservation | InfoObservation ) -> None: assert observation.type == "test_case" if self._errored_in_startup: @@ -577,7 +578,7 @@ def downgrade_failure( ) def _save_observation( - self, observation: TestCaseObservation, *, stability: Optional[Stability] + self, observation: TestCaseObservation, *, stability: Stability | None ) -> None: assert self.db is not None assert self.database_key is not None @@ -829,11 +830,11 @@ def draw_boolean( def draw_integer( self, - min_value: Optional[int] = None, - max_value: Optional[int] = None, + min_value: int | None = None, + max_value: int | None = None, *, # weights are for choosing an element index from a bounded range - weights: Optional[dict[int, float]] = None, + weights: dict[int, float] | None = None, shrink_towards: int = 0, ) -> int: choice = self._pop_choice( diff --git a/src/hypofuzz/utils.py b/src/hypofuzz/utils.py index 55483e72..f7e56b8f 100644 --- a/src/hypofuzz/utils.py +++ b/src/hypofuzz/utils.py @@ -1,9 +1,9 @@ import heapq import math import threading -from collections.abc import Sequence +from collections.abc import Callable, Sequence from enum import Enum -from typing import Any, Callable, Generic, Optional, TypeVar +from typing import Any, Generic, TypeVar from uuid import uuid4 from hypofuzz.compat import bisect_right @@ -64,7 +64,7 @@ def lerp(a: float, b: float, t: float) -> float: def k_way_merge( - lists: Sequence[Sequence[T]], key: Optional[Callable[[T], Any]] = None + lists: Sequence[Sequence[T]], key: Callable[[T], Any] | None = None ) -> list[T]: # merges k sorted lists in O(nlg(k)) time, where n is the total number of # elements. @@ -98,7 +98,7 @@ def k_way_merge( def fast_bisect_right( - a: Sequence[Any], x: Any, key: Optional[Callable[[Any], Any]] = None + a: Sequence[Any], x: Any, key: Callable[[Any], Any] | None = None ) -> int: # this case isn't really for performance, but just to make the fast case checks # below easier. diff --git a/tests/common.py b/tests/common.py index a986d366..1a2a50d2 100644 --- a/tests/common.py +++ b/tests/common.py @@ -13,7 +13,6 @@ from dataclasses import dataclass from pathlib import Path from queue import Queue -from typing import Optional import requests from hypothesis.database import DirectoryBasedExampleDatabase @@ -81,7 +80,7 @@ def _enqueue_output(stream, queue): @contextmanager def dashboard( - *, port: int = 0, test_path: Optional[Path] = None, numprocesses: int = 0 + *, port: int = 0, test_path: Path | None = None, numprocesses: int = 0 ) -> Generator[Dashboard, None, None]: """ Launches a dashboard process with --dashboard-only (unless numprocesses is @@ -323,7 +322,7 @@ def setup_test_code(tmp_path, code): return (test_dir, db_dir) -def interesting_origin(n: Optional[int] = None) -> InterestingOrigin: +def interesting_origin(n: int | None = None) -> InterestingOrigin: """ Creates and returns an InterestingOrigin, parameterized by n, such that interesting_origin(n) == interesting_origin(m) iff n = m. diff --git a/tests/test_linearize.py b/tests/test_linearize.py index 86568a15..842a04fa 100644 --- a/tests/test_linearize.py +++ b/tests/test_linearize.py @@ -1,6 +1,5 @@ import dataclasses from collections import defaultdict -from typing import Optional import pytest from hypothesis import HealthCheck, assume, given, settings, strategies as st @@ -66,7 +65,7 @@ def report_inline( @st.composite def reports( - draw, *, count_workers: Optional[int] = None, overlap: bool = False + draw, *, count_workers: int | None = None, overlap: bool = False ) -> list[Report]: # all of this min_size=len(uuids) etc is going to lead to terrible shrinking. # But the alternative of while draw(st.booleans()) will generate too-small diff --git a/tests/test_provider.py b/tests/test_provider.py index 58972370..e71d28d7 100644 --- a/tests/test_provider.py +++ b/tests/test_provider.py @@ -1,7 +1,6 @@ import sys from collections.abc import Iterable, Sequence from random import Random -from typing import Optional import pytest from hypothesis import assume, given, settings, strategies as st @@ -53,7 +52,7 @@ def _wrapped_test(n): def _data( *, - random: Optional[Random] = None, + random: Random | None = None, queue: Iterable[tuple[QueuePriority, ChoicesT]] = (), ) -> ConjectureData: data = ConjectureData( From 2d8fad62f3c44f207bd01ed2998b2532eaf4b63d Mon Sep 17 00:00:00 2001 From: Liam DeVoe Date: Mon, 3 Nov 2025 14:55:14 -0800 Subject: [PATCH 03/10] Merge remote-tracking branch 'origin/master' into next --- src/hypofuzz/dashboard/patching.py | 114 +++++++++++++++++------------ src/hypofuzz/provider.py | 1 - 2 files changed, 66 insertions(+), 49 deletions(-) diff --git a/src/hypofuzz/dashboard/patching.py b/src/hypofuzz/dashboard/patching.py index ef99e916..4c227f5f 100644 --- a/src/hypofuzz/dashboard/patching.py +++ b/src/hypofuzz/dashboard/patching.py @@ -1,33 +1,49 @@ import threading from collections import defaultdict -from functools import lru_cache +from collections.abc import Sequence from queue import Empty, Queue -from typing import Any, Literal +from typing import TYPE_CHECKING, Any, Literal -from hypothesis.extra._patching import ( - get_patch_for as _get_patch_for, - make_patch as _make_patch, -) +from hypothesis.extra._patching import get_patch_for, make_patch as _make_patch +from sortedcontainers import SortedList from hypofuzz import __version__ from hypofuzz.database import Observation -COVERING_VIA = "covering example" -FAILING_VIA = "discovered failure" +if TYPE_CHECKING: + from typing import TypeAlias + +# we have a two tiered structure. +# * First, we store the list of test case reprs corresponding to the list of +# @examples. +# * Each time we add a new such input, we compute the new patch for the entire +# list. + # nodeid: { -# "covering": [(fname, before, after), ...], -# "failing": [(fname, before, after), ...], +# "covering": list[observation.representation], +# "failing": list[observation.representation], # } -# TODO this duplicates the test function contents in `before` and `after`, -# we probably want a more memory-efficient representation eventually -# (and a smaller win: map fname to a list of (before, after), instead of storing -# each fname) -PATCHES: dict[str, dict[str, list[tuple[str, str, str]]]] = defaultdict( - lambda: {"covering": [], "failing": []} +# +# We sort by string length, as a heuristic for putting simpler examples first in +# the patch. +EXAMPLES: dict[str, dict[str, SortedList[str]]] = defaultdict( + lambda: {"covering": SortedList(key=len), "failing": SortedList(key=len)} ) -get_patch_for = lru_cache(maxsize=8192)(_get_patch_for) - -_queue: Queue = Queue() +# nodeid: { +# "covering": patch, +# "failing": patch, +# } +PATCHES: dict[str, dict[str, str | None]] = defaultdict( + lambda: {"covering": None, "failing": None} +) +VIA = {"covering": "covering example", "failing": "discovered failure"} +COMMIT_MESSAGE = { + "covering": "add covering examples", + "failing": "add failing examples", +} + +ObservationTypeT: "TypeAlias" = Literal["covering", "failing"] +_queue: Queue[tuple[Any, str, Observation, ObservationTypeT]] = Queue() _thread: threading.Thread | None = None @@ -36,51 +52,45 @@ def add_patch( test_function: Any, nodeid: str, observation: Observation, - observation_type: Literal["covering", "failing"], + observation_type: ObservationTypeT, ) -> None: _queue.put((test_function, nodeid, observation, observation_type)) -@lru_cache(maxsize=1024) -def make_patch(triples: tuple[tuple[str, str, str]], *, msg: str) -> str: +def make_patch( + function: Any, examples: Sequence[str], observation_type: ObservationTypeT +) -> str | None: + via = VIA[observation_type] + triple = get_patch_for(function, examples=[(example, via) for example in examples]) + if triple is None: + return None + + commit_message = COMMIT_MESSAGE[observation_type] return _make_patch( - triples, - msg=msg, + (triple,), + msg=commit_message, author=f"HypoFuzz {__version__} ", ) -def failing_patch(nodeid: str) -> str | None: - failing = PATCHES[nodeid]["failing"] - return make_patch(tuple(failing), msg="add failing examples") if failing else None - - -def covering_patch(nodeid: str) -> str | None: - covering = PATCHES[nodeid]["covering"] - return ( - make_patch(tuple(covering), msg="add covering examples") if covering else None - ) - - def _worker() -> None: + # TODO We might optimize this by checking each function ahead of time for known + # reasons why a patch would fail, for instance using st.data in the signature, + # and then early-returning here before calling get_patch_for. while True: try: - item = _queue.get(timeout=1.0) + test_function, nodeid, observation, observation_type = _queue.get( + timeout=1.0 + ) except Empty: continue - test_function, nodeid, observation, observation_type = item - - via = COVERING_VIA if observation_type == "covering" else FAILING_VIA - # If this thread ends up using significant resources, we might optimize - # this by checking each function ahead of time for known reasons why a - # patch would fail, for instance using st.data in the signature, and then - # simply discarding those here entirely. - patch = get_patch_for( - test_function, ((observation.representation, via),), strip_via=via + examples = EXAMPLES[nodeid][observation_type] + examples.add(observation.representation) + PATCHES[nodeid][observation_type] = make_patch( + test_function, examples, observation_type ) - if patch is not None: - PATCHES[nodeid][observation_type].append(patch) + _queue.task_done() @@ -90,3 +100,11 @@ def start_patching_thread() -> None: _thread = threading.Thread(target=_worker, daemon=True) _thread.start() + + +def failing_patch(nodeid: str) -> str | None: + return PATCHES[nodeid]["failing"] + + +def covering_patch(nodeid: str) -> str | None: + return PATCHES[nodeid]["covering"] diff --git a/src/hypofuzz/provider.py b/src/hypofuzz/provider.py index 8fe4f34b..7192a2bd 100644 --- a/src/hypofuzz/provider.py +++ b/src/hypofuzz/provider.py @@ -1,4 +1,3 @@ -import dataclasses import math import time from base64 import b64encode From c35b5bce1eba19b7bd386b1e321fc50ad618bbe1 Mon Sep 17 00:00:00 2001 From: Liam DeVoe Date: Mon, 3 Nov 2025 14:58:26 -0800 Subject: [PATCH 04/10] accidental import removal --- src/hypofuzz/hypofuzz.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hypofuzz/hypofuzz.py b/src/hypofuzz/hypofuzz.py index 18a36f2e..8116dbaf 100644 --- a/src/hypofuzz/hypofuzz.py +++ b/src/hypofuzz/hypofuzz.py @@ -12,7 +12,7 @@ import traceback from collections import defaultdict from collections.abc import Callable, Mapping, Sequence -from contextlib import nullcontext +from contextlib import nullcontext, redirect_stdout from functools import cache, partial from multiprocessing import Manager, Process from pathlib import Path From 0aab842fcbbb10a926f1737ebab6170925ba8c9c Mon Sep 17 00:00:00 2001 From: Liam DeVoe Date: Mon, 3 Nov 2025 15:08:07 -0800 Subject: [PATCH 05/10] update to 3.10, bump deps, test 3.14 --- .github/workflows/ci.yml | 2 +- deps/check.txt | 34 +++++++++++++-------------- deps/docs.txt | 50 +++++++++++++++++++--------------------- deps/test.txt | 40 ++++++++++++++++---------------- pyproject.toml | 4 ++-- tox.ini | 1 + 6 files changed, 65 insertions(+), 66 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a564a56..2240b585 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -134,7 +134,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] toxenv: ["test", "pytest7"] fail-fast: false steps: diff --git a/deps/check.txt b/deps/check.txt index 77cbed8b..d358f5be 100644 --- a/deps/check.txt +++ b/deps/check.txt @@ -1,13 +1,13 @@ # -# This file is autogenerated by pip-compile with Python 3.9 +# This file is autogenerated by pip-compile with Python 3.10 # by the following command: # -# pip-compile --annotation-style=line --output-file=deps/check.txt deps/check.in +# pip-compile --annotation-style=line --no-strip-extras --output-file=deps/check.txt deps/check.in # anyio==4.11.0 # via starlette -attrs==25.3.0 # via hypothesis, outcome, trio +attrs==25.4.0 # via outcome, trio black==25.9.0 # via shed -click==8.1.8 # via black +click==8.3.0 # via black com2ann==0.3.0 # via shed exceptiongroup==1.3.0 # via anyio, hypercorn, hypothesis, pytest, taskgroup, trio flake8==7.3.0 # via pep8-naming @@ -16,10 +16,10 @@ h2==4.3.0 # via hypercorn hpack==4.1.0 # via h2 hypercorn==0.17.3 # via -r deps/check.in hyperframe==6.1.0 # via h2 -hypothesis==6.140.2 # via -r deps/check.in -idna==3.10 # via anyio, trio -iniconfig==2.1.0 # via pytest -libcst==1.8.5 # via shed +hypothesis==6.145.1 # via -r deps/check.in +idna==3.11 # via anyio, trio +iniconfig==2.3.0 # via pytest +libcst==1.8.6 # via shed mccabe==0.7.0 # via flake8 mypy==1.18.2 # via -r deps/check.in mypy-extensions==1.1.0 # via black, mypy @@ -27,28 +27,28 @@ outcome==1.3.0.post0 # via trio packaging==25.0 # via black, pytest pathspec==0.12.1 # via black, mypy pep8-naming==0.15.1 # via -r deps/check.in -platformdirs==4.4.0 # via black +platformdirs==4.5.0 # via black pluggy==1.6.0 # via pytest priority==2.0.0 # via hypercorn -psutil==7.1.0 # via -r deps/check.in +psutil==7.1.3 # via -r deps/check.in pycodestyle==2.14.0 # via flake8 pyflakes==3.4.0 # via flake8 pygments==2.19.2 # via pytest pytest==8.4.2 # via -r deps/check.in -pytokens==0.1.10 # via black -pyupgrade==3.20.0 # via shed +pytokens==0.2.0 # via black +pyupgrade==3.21.0 # via shed pyyaml==6.0.3 # via libcst -ruff==0.13.2 # via -r deps/check.in, shed +ruff==0.14.3 # via -r deps/check.in, shed shed==2025.6.1 # via -r deps/check.in sniffio==1.3.1 # via anyio, trio sortedcontainers==2.4.0 # via hypothesis, sortedcontainers-stubs, trio sortedcontainers-stubs==2.4.3 # via -r deps/check.in -starlette==0.48.0 # via -r deps/check.in +starlette==0.50.0 # via -r deps/check.in taskgroup==0.2.2 # via hypercorn tokenize-rt==6.2.0 # via pyupgrade -tomli==2.2.1 # via black, hypercorn, mypy, pytest -trio==0.31.0 # via -r deps/check.in +tomli==2.3.0 # via black, hypercorn, mypy, pytest +trio==0.32.0 # via -r deps/check.in types-requests==2.32.4.20250913 # via -r deps/check.in -typing-extensions==4.15.0 # via anyio, black, exceptiongroup, hypercorn, libcst, mypy, sortedcontainers-stubs, starlette, taskgroup +typing-extensions==4.15.0 # via anyio, black, exceptiongroup, hypercorn, mypy, sortedcontainers-stubs, starlette, taskgroup urllib3==2.5.0 # via types-requests wsproto==1.2.0 # via hypercorn diff --git a/deps/docs.txt b/deps/docs.txt index f127ab01..1af7f67d 100644 --- a/deps/docs.txt +++ b/deps/docs.txt @@ -1,20 +1,20 @@ # -# This file is autogenerated by pip-compile with Python 3.9 +# This file is autogenerated by pip-compile with Python 3.10 # by the following command: # -# pip-compile --annotation-style=line --output-file=deps/docs.txt deps/docs.in pyproject.toml +# pip-compile --annotation-style=line --no-strip-extras --output-file=deps/docs.txt deps/docs.in pyproject.toml # accessible-pygments==0.0.5 # via furo -alabaster==0.7.16 # via sphinx +alabaster==1.0.0 # via sphinx anyio==4.11.0 # via starlette -attrs==25.3.0 # via hypothesis, outcome, trio +attrs==25.4.0 # via outcome, trio babel==2.17.0 # via sphinx -beautifulsoup4==4.14.0 # via furo +beautifulsoup4==4.14.2 # via furo black==25.9.0 # via hypofuzz (pyproject.toml), hypothesis -certifi==2025.8.3 # via requests -charset-normalizer==3.4.3 # via requests -click==8.1.8 # via black, hypothesis -coverage==7.10.7 # via hypofuzz (pyproject.toml) +certifi==2025.10.5 # via requests +charset-normalizer==3.4.4 # via requests +click==8.3.0 # via black, hypothesis +coverage==7.11.0 # via hypofuzz (pyproject.toml) docutils==0.21.2 # via myst-parser, pybtex-docutils, sphinx, sphinxcontrib-bibtex exceptiongroup==1.3.0 # via anyio, hypercorn, hypothesis, pytest, taskgroup, trio furo==2025.9.25 # via -r deps/docs.in @@ -23,40 +23,39 @@ h2==4.3.0 # via hypercorn hpack==4.1.0 # via h2 hypercorn==0.17.3 # via hypofuzz (pyproject.toml) hyperframe==6.1.0 # via h2 -hypothesis[cli,watchdog]==6.140.2 # via hypofuzz (pyproject.toml) -idna==3.10 # via anyio, requests, trio +hypothesis[cli,watchdog]==6.145.1 # via hypofuzz (pyproject.toml) +idna==3.11 # via anyio, requests, trio imagesize==1.4.1 # via sphinx -importlib-metadata==8.7.0 # via pybtex, sphinx, sphinxcontrib-bibtex -iniconfig==2.1.0 # via pytest +iniconfig==2.3.0 # via pytest jinja2==3.1.6 # via myst-parser, sphinx latexcodec==3.0.1 # via pybtex -libcst==1.8.5 # via hypofuzz (pyproject.toml) +libcst==1.8.6 # via hypofuzz (pyproject.toml) markdown-it-py==3.0.0 # via mdit-py-plugins, myst-parser, rich markupsafe==3.0.3 # via jinja2 -mdit-py-plugins==0.4.2 # via myst-parser +mdit-py-plugins==0.5.0 # via myst-parser mdurl==0.1.2 # via markdown-it-py mypy-extensions==1.1.0 # via black -myst-parser==3.0.1 # via -r deps/docs.in +myst-parser==4.0.1 # via -r deps/docs.in outcome==1.3.0.post0 # via trio packaging==25.0 # via black, pytest, sphinx pathspec==0.12.1 # via black -platformdirs==4.4.0 # via black +platformdirs==4.5.0 # via black pluggy==1.6.0 # via pytest priority==2.0.0 # via hypercorn -psutil==7.1.0 # via hypofuzz (pyproject.toml) +psutil==7.1.3 # via hypofuzz (pyproject.toml) pybtex==0.25.1 # via pybtex-docutils, sphinxcontrib-bibtex pybtex-docutils==1.0.3 # via sphinxcontrib-bibtex pygments==2.19.2 # via accessible-pygments, furo, pytest, rich, sphinx pytest==8.4.2 # via hypofuzz (pyproject.toml) -pytokens==0.1.10 # via black +pytokens==0.2.0 # via black pyyaml==6.0.3 # via libcst, myst-parser, pybtex requests==2.32.5 # via sphinx -rich==14.1.0 # via hypothesis +rich==14.2.0 # via hypothesis sniffio==1.3.1 # via anyio, trio snowballstemmer==3.0.1 # via sphinx sortedcontainers==2.4.0 # via hypothesis, trio soupsieve==2.8 # via beautifulsoup4 -sphinx==7.4.7 # via -r deps/docs.in, furo, myst-parser, sphinx-basic-ng, sphinxcontrib-bibtex +sphinx==8.1.3 # via -r deps/docs.in, furo, myst-parser, sphinx-basic-ng, sphinxcontrib-bibtex sphinx-basic-ng==1.0.0b2 # via furo sphinxcontrib-applehelp==2.0.0 # via sphinx sphinxcontrib-bibtex==2.6.5 # via -r deps/docs.in @@ -65,12 +64,11 @@ sphinxcontrib-htmlhelp==2.1.0 # via sphinx sphinxcontrib-jsmath==1.0.1 # via sphinx sphinxcontrib-qthelp==2.0.0 # via sphinx sphinxcontrib-serializinghtml==2.0.0 # via sphinx -starlette==0.48.0 # via hypofuzz (pyproject.toml) +starlette==0.50.0 # via hypofuzz (pyproject.toml) taskgroup==0.2.2 # via hypercorn -tomli==2.2.1 # via black, hypercorn, pytest, sphinx -trio==0.31.0 # via hypofuzz (pyproject.toml) -typing-extensions==4.15.0 # via anyio, beautifulsoup4, black, exceptiongroup, hypercorn, libcst, starlette, taskgroup +tomli==2.3.0 # via black, hypercorn, pytest, sphinx +trio==0.32.0 # via hypofuzz (pyproject.toml) +typing-extensions==4.15.0 # via anyio, beautifulsoup4, black, exceptiongroup, hypercorn, starlette, taskgroup urllib3==2.5.0 # via requests watchdog==6.0.0 # via hypothesis wsproto==1.2.0 # via hypercorn -zipp==3.23.0 # via importlib-metadata diff --git a/deps/test.txt b/deps/test.txt index 00397fde..7646e728 100644 --- a/deps/test.txt +++ b/deps/test.txt @@ -1,16 +1,16 @@ # -# This file is autogenerated by pip-compile with Python 3.9 +# This file is autogenerated by pip-compile with Python 3.10 # by the following command: # -# pip-compile --annotation-style=line --output-file=deps/test.txt deps/test.in pyproject.toml +# pip-compile --annotation-style=line --no-strip-extras --output-file=deps/test.txt deps/test.in pyproject.toml # anyio==4.11.0 # via starlette -attrs==25.3.0 # via hypothesis, outcome, trio +attrs==25.4.0 # via outcome, trio black==25.9.0 # via hypofuzz (pyproject.toml), hypothesis -certifi==2025.8.3 # via requests -charset-normalizer==3.4.3 # via requests -click==8.1.8 # via black, hypothesis -coverage[toml]==7.10.7 # via hypofuzz (pyproject.toml), pytest-cov +certifi==2025.10.5 # via requests +charset-normalizer==3.4.4 # via requests +click==8.3.0 # via black, hypothesis +coverage[toml]==7.11.0 # via hypofuzz (pyproject.toml), pytest-cov exceptiongroup==1.3.0 # via anyio, hypercorn, hypothesis, pytest, taskgroup, trio execnet==2.1.1 # via pytest-xdist h11==0.16.0 # via hypercorn, wsproto @@ -18,35 +18,35 @@ h2==4.3.0 # via hypercorn hpack==4.1.0 # via h2 hypercorn==0.17.3 # via hypofuzz (pyproject.toml) hyperframe==6.1.0 # via h2 -hypothesis[cli,watchdog]==6.140.2 # via hypofuzz (pyproject.toml) -idna==3.10 # via anyio, requests, trio -iniconfig==2.1.0 # via pytest -libcst==1.8.5 # via hypofuzz (pyproject.toml) -markdown-it-py==3.0.0 # via rich +hypothesis[cli,watchdog]==6.145.1 # via hypofuzz (pyproject.toml) +idna==3.11 # via anyio, requests, trio +iniconfig==2.3.0 # via pytest +libcst==1.8.6 # via hypofuzz (pyproject.toml) +markdown-it-py==4.0.0 # via rich mdurl==0.1.2 # via markdown-it-py mypy-extensions==1.1.0 # via black outcome==1.3.0.post0 # via trio packaging==25.0 # via black, pytest pathspec==0.12.1 # via black -platformdirs==4.4.0 # via black +platformdirs==4.5.0 # via black pluggy==1.6.0 # via pytest, pytest-cov priority==2.0.0 # via hypercorn -psutil==7.1.0 # via hypofuzz (pyproject.toml) +psutil==7.1.3 # via hypofuzz (pyproject.toml) pygments==2.19.2 # via pytest, rich pytest==8.4.2 # via -r deps/test.in, hypofuzz (pyproject.toml), pytest-cov, pytest-xdist pytest-cov==7.0.0 # via -r deps/test.in pytest-xdist==3.8.0 # via -r deps/test.in -pytokens==0.1.10 # via black +pytokens==0.2.0 # via black pyyaml==6.0.3 # via libcst requests==2.32.5 # via -r deps/test.in -rich==14.1.0 # via hypothesis +rich==14.2.0 # via hypothesis sniffio==1.3.1 # via anyio, trio sortedcontainers==2.4.0 # via hypothesis, trio -starlette==0.48.0 # via hypofuzz (pyproject.toml) +starlette==0.50.0 # via hypofuzz (pyproject.toml) taskgroup==0.2.2 # via hypercorn -tomli==2.2.1 # via black, coverage, hypercorn, pytest -trio==0.31.0 # via hypofuzz (pyproject.toml) -typing-extensions==4.15.0 # via anyio, black, exceptiongroup, hypercorn, libcst, starlette, taskgroup +tomli==2.3.0 # via black, coverage, hypercorn, pytest +trio==0.32.0 # via hypofuzz (pyproject.toml) +typing-extensions==4.15.0 # via anyio, black, exceptiongroup, hypercorn, starlette, taskgroup urllib3==2.5.0 # via requests watchdog==6.0.0 # via hypothesis wsproto==1.2.0 # via hypercorn diff --git a/pyproject.toml b/pyproject.toml index 024cb008..8df20d99 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ dynamic = ["version"] description = "Adaptive fuzzing for property-based tests" readme = "README.md" license-files = ["LICENSE", "CONTRIBUTING.md"] -requires-python = ">=3.9" +requires-python = ">=3.10" authors = [ {name = "Zac Hatfield-Dodds", email = "zac@hypofuzz.com"} ] @@ -44,11 +44,11 @@ classifiers = [ "License :: Free for non-commercial use", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Software Development :: Testing", ] diff --git a/tox.ini b/tox.ini index 75301820..72b49776 100644 --- a/tox.ini +++ b/tox.ini @@ -33,6 +33,7 @@ commands = pytest tests/ {posargs:-n auto} [testenv:deps] +basepython = python3.10 description = Updates test corpora and the pinned dependencies in `deps/*.txt` deps = pip-tools From ce308d248174fbf1098dc8b9d7a536c3004d6500 Mon Sep 17 00:00:00 2001 From: Liam DeVoe Date: Mon, 3 Nov 2025 15:12:13 -0800 Subject: [PATCH 06/10] use license field instead of license classifier --- pyproject.toml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8df20d99..76b81ef3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,9 @@ name = "hypofuzz" dynamic = ["version"] description = "Adaptive fuzzing for property-based tests" readme = "README.md" +# custom license name. See https://spdx.github.io/spdx-spec/v2.2.2/other-licensing-information-detected/ +# and https://peps.python.org/pep-0639/ (grep "custom license") +license = "LicenseRef-HypoFuzz" license-files = ["LICENSE", "CONTRIBUTING.md"] requires-python = ">=3.10" authors = [ @@ -40,8 +43,6 @@ classifiers = [ "Development Status :: 5 - Production/Stable", "Framework :: Hypothesis", "Intended Audience :: Developers", - "License :: Other/Proprietary License", - "License :: Free for non-commercial use", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", From 9d3cd7c9dfd0d9f3770b058b4eca267fb3d5102e Mon Sep 17 00:00:00 2001 From: Liam DeVoe Date: Mon, 3 Nov 2025 15:16:54 -0800 Subject: [PATCH 07/10] use dataclasses.replace to support newly-frozen hypothesis dataclasses --- src/hypofuzz/hypofuzz.py | 15 ++++++++++----- src/hypofuzz/provider.py | 23 ++++++++++++++--------- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/src/hypofuzz/hypofuzz.py b/src/hypofuzz/hypofuzz.py index 8116dbaf..a6c6b96d 100644 --- a/src/hypofuzz/hypofuzz.py +++ b/src/hypofuzz/hypofuzz.py @@ -1,6 +1,7 @@ """Adaptive fuzzing for property-based tests using Hypothesis.""" import contextlib +import dataclasses import inspect import math import os @@ -664,11 +665,15 @@ def start(self) -> None: origin = InterestingOrigin.from_exception(e) # hypothesis just reraises the skip exception; it doesn't # think that it failed. Update the required fields - observation.status = "failed" - observation.status_reason = str(origin) - observation.metadata.interesting_origin = origin - observation.metadata.traceback = "".join( - traceback.format_exception(e) + observation = dataclasses.replace( + observation, + status="failed", + status_reason=str(origin), + metadata=dataclasses.replace( + observation.metadata, + interesting_origin=origin, + traceback="".join(traceback.format_exception(e)), + ), ) target.database.failures(state=FailureState.SHRUNK).save( target.database_key, diff --git a/src/hypofuzz/provider.py b/src/hypofuzz/provider.py index 7192a2bd..a3891723 100644 --- a/src/hypofuzz/provider.py +++ b/src/hypofuzz/provider.py @@ -1,3 +1,4 @@ +import dataclasses import math import time from base64 import b64encode @@ -617,15 +618,19 @@ def after_test_case(self, observation: TestCaseObservation) -> None: assert observation.metadata.choice_nodes is not None elapsed_time = time.perf_counter() - self._state.start_time - # run_start is normally relative to StateForActualGivenExecution, which we - # re-use per FuzzTarget. Overwrite with the current timestamp for use - # in sorting observations. This is not perfectly reliable in a - # distributed setting, but is good enough. - observation.run_start = self._state.start_time - # "arguments" duplicates part of the call repr in "representation". - # We don't use this for anything, and it can be substantial in size, so - # drop it. - observation.arguments = {} + observation = dataclasses.replace( + observation, + # run_start is normally relative to StateForActualGivenExecution, which we + # re-use per FuzzTarget. Overwrite with the current timestamp for use + # in sorting observations. This is not perfectly reliable in a + # distributed setting, but is good enough. + run_start=self._state.start_time, + # "arguments" duplicates part of the call repr in "representation". + # We don't use this for anything, and it can be substantial in size, so + # drop it. + arguments={}, + ) + # TODO this is a real type error, we need to unify the Branch namedtuple # with the real usages of `behaviors` here behaviors: Set[Behavior] = self._state.branches | ( # type: ignore From 491a7237c0bec1f8ee95fb6e8d2c1672e62e9dea Mon Sep 17 00:00:00 2001 From: Liam DeVoe Date: Mon, 3 Nov 2025 15:19:51 -0800 Subject: [PATCH 08/10] typing --- src/hypofuzz/hypofuzz.py | 2 +- src/hypofuzz/provider.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/hypofuzz/hypofuzz.py b/src/hypofuzz/hypofuzz.py index a6c6b96d..693ea685 100644 --- a/src/hypofuzz/hypofuzz.py +++ b/src/hypofuzz/hypofuzz.py @@ -661,7 +661,6 @@ def start(self) -> None: # failure card. observation = target.provider.most_recent_observation assert observation is not None - assert observation.metadata.choice_nodes origin = InterestingOrigin.from_exception(e) # hypothesis just reraises the skip exception; it doesn't # think that it failed. Update the required fields @@ -675,6 +674,7 @@ def start(self) -> None: traceback="".join(traceback.format_exception(e)), ), ) + assert observation.metadata.choice_nodes is not None target.database.failures(state=FailureState.SHRUNK).save( target.database_key, tuple(n.value for n in observation.metadata.choice_nodes), diff --git a/src/hypofuzz/provider.py b/src/hypofuzz/provider.py index a3891723..503e238d 100644 --- a/src/hypofuzz/provider.py +++ b/src/hypofuzz/provider.py @@ -782,6 +782,7 @@ def after_test_case(self, observation: TestCaseObservation) -> None: self._save_observation(observation, stability=None) if queue_for_stability: + assert observation.metadata.choice_nodes is not None self._enqueue( QueuePriority.STABILITY, choices=tuple(n.value for n in observation.metadata.choice_nodes), From 1a9e50cce91526c45b76983ffbd00bd4ef3d70d6 Mon Sep 17 00:00:00 2001 From: Liam DeVoe Date: Mon, 3 Nov 2025 15:24:50 -0800 Subject: [PATCH 09/10] skip self-reference branch on 3.14 --- tests/test_coverage.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/tests/test_coverage.py b/tests/test_coverage.py index bd491337..8693b437 100644 --- a/tests/test_coverage.py +++ b/tests/test_coverage.py @@ -201,13 +201,20 @@ def f(): collector = Collector(f) with collector: f() - assert collector.branches == { - # I'm not sure what this (2, 14) self-reference branch is for. TODO look - # at the bytecode here - ((2, 14), (2, 14)), - ((2, 14), (3, 12)), - ((2, 14), (4, 8)), - } + + # I'm not sure what this (2, 14) self-reference branch is for. It appears + # on all versions except 3.14, but it not appearing on 3.14 makes me think + # it's something weird we don't have to worry too much about. + # TODO look at the bytecode here + weird_branch = {((2, 14), (2, 14))} if sys.version_info[:2] < (3, 14) else set() + assert ( + collector.branches + == { + ((2, 14), (3, 12)), + ((2, 14), (4, 8)), + } + | weird_branch + ) def test_while_initial_false(): From 71d1488409a4d0d8f9b5961e6740ea639b5d8e71 Mon Sep 17 00:00:00 2001 From: Liam DeVoe Date: Mon, 3 Nov 2025 15:27:19 -0800 Subject: [PATCH 10/10] bump version --- src/hypofuzz/__init__.py | 2 +- src/hypofuzz/docs/changelog.md | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/hypofuzz/__init__.py b/src/hypofuzz/__init__.py index 69606156..7922e259 100644 --- a/src/hypofuzz/__init__.py +++ b/src/hypofuzz/__init__.py @@ -2,5 +2,5 @@ from hypofuzz.detection import in_hypofuzz_run -__version__ = "25.09.02" +__version__ = "25.11.01" __all__: list[str] = ["in_hypofuzz_run"] diff --git a/src/hypofuzz/docs/changelog.md b/src/hypofuzz/docs/changelog.md index 5528c348..5ef5379e 100644 --- a/src/hypofuzz/docs/changelog.md +++ b/src/hypofuzz/docs/changelog.md @@ -2,6 +2,11 @@ HypoFuzz uses [calendar-based versioning](https://calver.org/), with a `YY-MM-patch` format. +(v25-11-01)= +## 25.11.01 + +* Drop support for Python 3.9, [which reached end of life in October 2025](https://devguide.python.org/versions/). + (v25-09-02)= ## 25.09.02