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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ jobs:

- uses: actions/setup-python@v6.2.0
with:
python-version: 3.9
python-version: "3.14"

- uses: j178/prek-action@v2.0.4
with:
Expand All @@ -41,7 +41,7 @@ jobs:

- uses: actions/setup-python@v6.2.0
with:
python-version: 3.9
python-version: "3.14"

- uses: j178/prek-action@v2.0.4
with:
Expand All @@ -51,7 +51,7 @@ jobs:
strategy:
matrix:
os: [ubuntu-24.04]
python: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
python: ["3.10", "3.11", "3.12", "3.13", "3.14"]

name: ${{ matrix.os }}/tests_${{ matrix.python }}
runs-on: ${{ matrix.os }}
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ jobs:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6.0.2
- name: Set up Python 3.9
- name: Set up Python
uses: actions/setup-python@v6.2.0
with:
python-version: 3.9
python-version: "3.14"

- run: pip install hatch

Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@

- Much faster generation - up to ~180x on large and negative-mode cases.

### Removed

- Python 3.8 and 3.9 support.

## [0.12.0] - 2026-02-04

### Added
Expand Down
7 changes: 3 additions & 4 deletions benchmarks/_schemas.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import json
import pathlib
from typing import Dict, Optional

import graphql
from hypothesis import strategies as st
Expand Down Expand Up @@ -38,12 +37,12 @@
PLACEHOLDER_STRATEGY = st.just("placeholder").map(nodes.String)


def _load_corpus() -> Dict[str, str]:
def _load_corpus() -> dict[str, str]:
with open(_CORPUS_PATH) as fd:
return json.load(fd)


def custom_scalars_for(schema: str) -> Optional[Dict[str, st.SearchStrategy]]:
def custom_scalars_for(schema: str) -> dict[str, st.SearchStrategy] | None:
parsed = graphql.build_schema(schema)
custom = {
name: PLACEHOLDER_STRATEGY
Expand All @@ -53,7 +52,7 @@ def custom_scalars_for(schema: str) -> Optional[Dict[str, st.SearchStrategy]]:
return custom or None


def load_schemas() -> Dict[str, str]:
def load_schemas() -> dict[str, str]:
corpus = _load_corpus()
schemas = {"recursive": RECURSIVE_SCHEMA}
for label, name in _CORPUS_SELECTION.items():
Expand Down
2 changes: 1 addition & 1 deletion mypy.ini
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[mypy]
python_version = 3.9
python_version = 3.10
show_error_context = true
verbosity = 0
ignore_missing_imports = true
Expand Down
6 changes: 2 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@ classifiers = [
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
Expand All @@ -30,7 +28,7 @@ maintainers = [{ name = "Dmitry Dygalo", email = "dmitry@dygalo.dev" }]
readme = "README.md"
license = "MIT"
include = ["src/hypothesis_graphql/py.typed"]
requires-python = ">=3.8"
requires-python = ">=3.10"
dependencies = ["hypothesis>=6.84.3,<7.0", "graphql-core>=3.1.0,<3.3.0"]

[project.optional-dependencies]
Expand Down Expand Up @@ -64,7 +62,7 @@ exclude_lines = ["pragma: no cover", "raise NotImplementedError", "if TYPE_CHECK

[tool.ruff]
line-length = 120
target-version = "py38"
target-version = "py310"

[tool.ruff.lint]
select = [
Expand Down
45 changes: 0 additions & 45 deletions src/hypothesis_graphql/_strategies/aliases.py

This file was deleted.

2 changes: 2 additions & 0 deletions src/hypothesis_graphql/_strategies/ast.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

import graphql

from ..types import SelectionNodes
Expand Down
18 changes: 10 additions & 8 deletions src/hypothesis_graphql/_strategies/builder.py
Original file line number Diff line number Diff line change
@@ -1,40 +1,42 @@
from typing import Dict, List, Optional, Sequence, Tuple
from __future__ import annotations

from collections.abc import Sequence

import graphql
from graphql import is_equal_type

from .strategy import make_type_name

# (field_name, on_type, children, arguments)
SelectionNode = Tuple[str, Optional[str], "List[SelectionNode]", List[graphql.ArgumentNode]]
SelectionNode = tuple[str, str | None, "list[SelectionNode]", list[graphql.ArgumentNode]]


def _field_node(
name: str, children: Sequence[graphql.SelectionNode], args: List[graphql.ArgumentNode]
name: str, children: Sequence[graphql.SelectionNode], args: list[graphql.ArgumentNode]
) -> graphql.FieldNode:
selection_set = graphql.SelectionSetNode(selections=tuple(children)) if children else None
return graphql.FieldNode(name=graphql.NameNode(value=name), arguments=tuple(args), selection_set=selection_set)


def build_selection_set(
nodes: "List[SelectionNode]", type_map: Optional[Dict[str, graphql.GraphQLNamedType]] = None
nodes: list[SelectionNode], type_map: dict[str, graphql.GraphQLNamedType] | None = None
) -> graphql.SelectionSetNode:
selections: List[graphql.SelectionNode] = []
selections: list[graphql.SelectionNode] = []
for name, on, children, args in nodes:
if on is not None:
continue
child = list(build_selection_set(children, type_map).selections) if children else []
selections.append(_field_node(name, child, args))

seen: Dict[str, graphql.GraphQLType] = {}
by_type: Dict[str, List[SelectionNode]] = {}
seen: dict[str, graphql.GraphQLType] = {}
by_type: dict[str, list[SelectionNode]] = {}
for node in nodes:
if node[1] is not None:
by_type.setdefault(node[1], []).append(node)
for on_type in sorted(by_type):
assert type_map is not None
fragment_type = type_map[on_type]
frag_fields: List[graphql.SelectionNode] = []
frag_fields: list[graphql.SelectionNode] = []
for name, _on_type, children, args in by_type[on_type]:
field_type = fragment_type.fields[name].type
child = list(build_selection_set(children, type_map).selections) if children else []
Expand Down
9 changes: 0 additions & 9 deletions src/hypothesis_graphql/_strategies/containers.py

This file was deleted.

34 changes: 3 additions & 31 deletions src/hypothesis_graphql/_strategies/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,13 @@
Most of them exist to avoid using lambdas, which might become expensive in Hypothesis in some cases.
"""

from __future__ import annotations

from collections.abc import Callable
from functools import lru_cache
from typing import Callable, List, Optional, Tuple

import graphql

from ..types import SelectionNodes
from .aliases import add_selection_aliases

FieldNodeInput = Tuple[List[graphql.ArgumentNode], Optional[SelectionNodes]]


@lru_cache
def inline_fragment(type_name: str) -> Callable[[SelectionNodes], graphql.InlineFragmentNode]:
def factory(nodes: SelectionNodes) -> graphql.InlineFragmentNode:
return graphql.InlineFragmentNode(
type_condition=graphql.NamedTypeNode(
name=graphql.NameNode(value=type_name),
),
selection_set=graphql.SelectionSetNode(kind="selection_set", selections=nodes),
)

return factory


@lru_cache
def argument(name: str) -> Callable[[graphql.ValueNode], graphql.ArgumentNode]:
Expand All @@ -35,18 +19,6 @@ def factory(value: graphql.ValueNode) -> graphql.ArgumentNode:
return factory


@lru_cache
def field(name: str) -> Callable[[FieldNodeInput], graphql.FieldNode]:
def factory(tup: FieldNodeInput) -> graphql.FieldNode:
return graphql.FieldNode(
name=graphql.NameNode(value=name),
arguments=tup[0],
selection_set=graphql.SelectionSetNode(kind="selection_set", selections=add_selection_aliases(tup[1])),
)

return factory


@lru_cache
def object_field(name: str) -> Callable[[graphql.ValueNode], graphql.ObjectFieldNode]:
def factory(value: graphql.ValueNode) -> graphql.ObjectFieldNode:
Expand Down
40 changes: 21 additions & 19 deletions src/hypothesis_graphql/_strategies/negative_sites.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

import dataclasses
from typing import List, Optional, Sequence, Tuple
from collections.abc import Sequence

import graphql
from hypothesis import strategies as st
Expand All @@ -15,29 +17,29 @@
MAX_DEPTH = 10


@dataclasses.dataclass(frozen=True)
@dataclasses.dataclass(frozen=True, slots=True)
class PathStep:
field_name: str
on_type: Optional[str]
on_type: str | None


@dataclasses.dataclass(frozen=True)
@dataclasses.dataclass(frozen=True, slots=True)
class ViolationSite:
path: Tuple[PathStep, ...]
path: tuple[PathStep, ...]
arg_name: str
kinds: Tuple[str, ...]
kinds: tuple[str, ...]

@property
def field_path(self) -> Tuple[str, ...]:
def field_path(self) -> tuple[str, ...]:
return tuple(step.field_name for step in self.path)

@property
def depth(self) -> int:
return len(self.path)


def _arg_kinds(arg: graphql.GraphQLArgument) -> Tuple[str, ...]:
kinds: List[str] = []
def _arg_kinds(arg: graphql.GraphQLArgument) -> tuple[str, ...]:
kinds: list[str] = []
required = isinstance(arg.type, graphql.GraphQLNonNull)
inner = unwrap(arg.type)
if required:
Expand All @@ -55,13 +57,13 @@ def _arg_kinds(arg: graphql.GraphQLArgument) -> Tuple[str, ...]:
return tuple(kinds)


def enumerate_violation_sites(schema: graphql.GraphQLSchema, root: graphql.GraphQLNamedType) -> List[ViolationSite]:
def enumerate_violation_sites(schema: graphql.GraphQLSchema, root: graphql.GraphQLNamedType) -> list[ViolationSite]:
# One representative path per type. Enumerating every route explodes combinatorially on
# dense recursive schemas; we only need a reachable path to each violatable field.
sites: List[ViolationSite] = []
sites: list[ViolationSite] = []
walked: set = set()

def walk(type_: graphql.GraphQLNamedType, path: Tuple[PathStep, ...]) -> None:
def walk(type_: graphql.GraphQLNamedType, path: tuple[PathStep, ...]) -> None:
if type_.name in walked or len(path) > MAX_DEPTH:
return
walked.add(type_.name)
Expand All @@ -80,7 +82,7 @@ def walk(type_: graphql.GraphQLNamedType, path: Tuple[PathStep, ...]) -> None:


def _find_field(
schema: graphql.GraphQLSchema, type_: graphql.GraphQLNamedType, name: str, on_type: Optional[str]
schema: graphql.GraphQLSchema, type_: graphql.GraphQLNamedType, name: str, on_type: str | None
) -> graphql.GraphQLField:
# The (name, on_type) pair always comes from a previously enumerated path, so it is present.
return next(
Expand Down Expand Up @@ -124,7 +126,7 @@ def _violation_value(

def _corrupt_args(
draw: st.DrawFn, gql: GraphQLStrategy, field: graphql.GraphQLField, target_arg: str, kind: str
) -> List[graphql.ArgumentNode]:
) -> list[graphql.ArgumentNode]:
others = {name: arg for name, arg in field.args.items() if name != target_arg}
args = list(draw(gql.list_of_arguments(others)))
if kind == "missing_required":
Expand All @@ -141,7 +143,7 @@ def _extend_to_leaf(
gql: GraphQLStrategy,
type_: graphql.GraphQLNamedType,
seen: frozenset,
) -> Optional[List[SelectionNode]]:
) -> list[SelectionNode] | None:
if type_.name in seen:
return None
seen = seen | {type_.name}
Expand All @@ -159,10 +161,10 @@ def negative_selection(
schema: graphql.GraphQLSchema,
root: graphql.GraphQLObjectType,
alphabet: st.SearchStrategy[str],
custom_scalars: Optional[dict] = None,
custom_scalars: dict | None = None,
allow_null: bool = True,
fields: Optional[Sequence[str]] = None,
) -> st.SearchStrategy[List[SelectionNode]]:
fields: Sequence[str] | None = None,
) -> st.SearchStrategy[list[SelectionNode]]:
# Enumerated once per strategy build; the strategy itself is constructed once.
sites = enumerate_violation_sites(schema, root)
if fields is not None:
Expand All @@ -171,7 +173,7 @@ def negative_selection(
gql = GraphQLStrategy(schema=schema, alphabet=alphabet, custom_scalars=custom_scalars or {}, allow_null=allow_null)

@st.composite # type: ignore
def _generate(draw: st.DrawFn) -> List[SelectionNode]:
def _generate(draw: st.DrawFn) -> list[SelectionNode]:
if not sites:
raise InvalidArgument(
"Cannot generate invalid queries in NEGATIVE mode: schema has no required "
Expand Down
Loading