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/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.
-
+
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/pyproject.toml b/pyproject.toml
index 024cb008..76b81ef3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -17,8 +17,11 @@ 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.9"
+requires-python = ">=3.10"
authors = [
{name = "Zac Hatfield-Dodds", email = "zac@hypofuzz.com"}
]
@@ -40,15 +43,13 @@ 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.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/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/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 75d29dab..4c227f5f 100644
--- a/src/hypofuzz/dashboard/patching.py
+++ b/src/hypofuzz/dashboard/patching.py
@@ -2,7 +2,7 @@
from collections import defaultdict
from collections.abc import Sequence
from queue import Empty, Queue
-from typing import TYPE_CHECKING, Any, Literal, Optional
+from typing import TYPE_CHECKING, Any, Literal
from hypothesis.extra._patching import get_patch_for, make_patch as _make_patch
from sortedcontainers import SortedList
@@ -33,7 +33,7 @@
# "covering": patch,
# "failing": patch,
# }
-PATCHES: dict[str, dict[str, Optional[str]]] = defaultdict(
+PATCHES: dict[str, dict[str, str | None]] = defaultdict(
lambda: {"covering": None, "failing": None}
)
VIA = {"covering": "covering example", "failing": "discovered failure"}
@@ -44,7 +44,7 @@
ObservationTypeT: "TypeAlias" = Literal["covering", "failing"]
_queue: Queue[tuple[Any, str, Observation, ObservationTypeT]] = Queue()
-_thread: Optional[threading.Thread] = None
+_thread: threading.Thread | None = None
def add_patch(
@@ -59,7 +59,7 @@ def add_patch(
def make_patch(
function: Any, examples: Sequence[str], observation_type: ObservationTypeT
-) -> Optional[str]:
+) -> str | None:
via = VIA[observation_type]
triple = get_patch_for(function, examples=[(example, via) for example in examples])
if triple is None:
@@ -102,9 +102,9 @@ def start_patching_thread() -> None:
_thread.start()
-def failing_patch(nodeid: str) -> Optional[str]:
+def failing_patch(nodeid: str) -> str | None:
return PATCHES[nodeid]["failing"]
-def covering_patch(nodeid: str) -> Optional[str]:
+def covering_patch(nodeid: str) -> str | None:
return PATCHES[nodeid]["covering"]
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/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
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..693ea685 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
@@ -17,7 +18,7 @@
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 +109,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 +129,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 +142,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 +150,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 +171,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 +302,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 +355,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 +389,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 +478,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:
@@ -662,16 +661,20 @@ 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
- 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)),
+ ),
)
+ 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),
@@ -779,7 +782,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 +800,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..503e238d 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
@@ -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
@@ -777,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),
@@ -829,11 +835,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_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():
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(
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