diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f2e4ba2..f36a3a1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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: @@ -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: @@ -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 }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c1c0bc0..3f1b95e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index d2fbcdb..40a9989 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/benchmarks/_schemas.py b/benchmarks/_schemas.py index 0d97550..c10d9b9 100644 --- a/benchmarks/_schemas.py +++ b/benchmarks/_schemas.py @@ -1,6 +1,5 @@ import json import pathlib -from typing import Dict, Optional import graphql from hypothesis import strategies as st @@ -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 @@ -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(): diff --git a/mypy.ini b/mypy.ini index 14b296b..b721e75 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,5 +1,5 @@ [mypy] -python_version = 3.9 +python_version = 3.10 show_error_context = true verbosity = 0 ignore_missing_imports = true diff --git a/pyproject.toml b/pyproject.toml index fe07c7b..8ba4311 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", @@ -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] @@ -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 = [ diff --git a/src/hypothesis_graphql/_strategies/aliases.py b/src/hypothesis_graphql/_strategies/aliases.py deleted file mode 100644 index 69b2a17..0000000 --- a/src/hypothesis_graphql/_strategies/aliases.py +++ /dev/null @@ -1,45 +0,0 @@ -from typing import Dict, List, Optional, Tuple - -import graphql - -from ..types import SelectionNodes - - -def maybe_add_alias_to_node(node: graphql.SelectionNode, seen: Dict[Tuple[str, str], List]) -> None: - if isinstance(node, graphql.FieldNode): - maybe_add_alias(node, node.arguments, seen) - if node.selection_set.selections: - for selection in node.selection_set.selections: - maybe_add_alias_to_node(selection, seen) - if isinstance(node, graphql.InlineFragmentNode): - for selection in node.selection_set.selections: - maybe_add_alias(selection, selection.arguments, seen) - if selection.selection_set.selections: - for sub_selection in selection.selection_set.selections: - maybe_add_alias_to_node(sub_selection, seen) - - -def maybe_add_alias( - field_node: graphql.FieldNode, - arguments: List[graphql.ArgumentNode], - seen: Dict[Tuple[str, str], List], -) -> None: - for argument in arguments: - key = (field_node.name.value, argument.name.value) - value = argument.value - if key in seen: - # Simply add an alias, the values could be the same, so it not technically necessary, but this is safe - # and simpler, but a bit reduces the possible input variety - field_node.alias = graphql.NameNode(value=f"{field_node.name.value}_{len(seen[key])}") - seen[key].append(value) - else: - seen[key] = [value] - - -def add_selection_aliases(nodes: Optional[SelectionNodes]) -> Optional[SelectionNodes]: - """Add aliases to fields that have conflicting argument types.""" - if nodes and len(nodes) > 1: - seen: Dict[Tuple[str, str], List] = {} - for node in nodes: - maybe_add_alias_to_node(node, seen) - return nodes diff --git a/src/hypothesis_graphql/_strategies/ast.py b/src/hypothesis_graphql/_strategies/ast.py index 34d8cd5..efd6cb4 100644 --- a/src/hypothesis_graphql/_strategies/ast.py +++ b/src/hypothesis_graphql/_strategies/ast.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import graphql from ..types import SelectionNodes diff --git a/src/hypothesis_graphql/_strategies/builder.py b/src/hypothesis_graphql/_strategies/builder.py index 877a205..c05508e 100644 --- a/src/hypothesis_graphql/_strategies/builder.py +++ b/src/hypothesis_graphql/_strategies/builder.py @@ -1,4 +1,6 @@ -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 @@ -6,35 +8,35 @@ 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 [] diff --git a/src/hypothesis_graphql/_strategies/containers.py b/src/hypothesis_graphql/_strategies/containers.py deleted file mode 100644 index b761b15..0000000 --- a/src/hypothesis_graphql/_strategies/containers.py +++ /dev/null @@ -1,9 +0,0 @@ -from typing import List, Tuple, TypeVar - -T = TypeVar("T") - - -def flatten(items: Tuple[List[T], T]) -> List[T]: - output = items[0] - output.extend(items[1:]) - return output diff --git a/src/hypothesis_graphql/_strategies/factories.py b/src/hypothesis_graphql/_strategies/factories.py index 6531a6d..2bcb918 100644 --- a/src/hypothesis_graphql/_strategies/factories.py +++ b/src/hypothesis_graphql/_strategies/factories.py @@ -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]: @@ -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: diff --git a/src/hypothesis_graphql/_strategies/negative_sites.py b/src/hypothesis_graphql/_strategies/negative_sites.py index 4a42d2e..67f1120 100644 --- a/src/hypothesis_graphql/_strategies/negative_sites.py +++ b/src/hypothesis_graphql/_strategies/negative_sites.py @@ -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 @@ -15,20 +17,20 @@ 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 @@ -36,8 +38,8 @@ 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: @@ -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) @@ -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( @@ -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": @@ -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} @@ -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: @@ -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 " diff --git a/src/hypothesis_graphql/_strategies/oracle.py b/src/hypothesis_graphql/_strategies/oracle.py index 38f45a0..626c430 100644 --- a/src/hypothesis_graphql/_strategies/oracle.py +++ b/src/hypothesis_graphql/_strategies/oracle.py @@ -1,10 +1,11 @@ +from __future__ import annotations + import dataclasses import math -from typing import Dict, List, Optional, Tuple import graphql -Selectable = Tuple[str, graphql.GraphQLField, Optional[str]] +Selectable = tuple[str, graphql.GraphQLField, str | None] def unwrap(type_: graphql.GraphQLType) -> graphql.GraphQLNamedType: @@ -17,8 +18,8 @@ def is_leaf(type_: graphql.GraphQLNamedType) -> bool: return isinstance(type_, (graphql.GraphQLScalarType, graphql.GraphQLEnumType)) -def selectable_fields(schema: graphql.GraphQLSchema, type_: graphql.GraphQLNamedType) -> List[Selectable]: - out: List[Selectable] = [] +def selectable_fields(schema: graphql.GraphQLSchema, type_: graphql.GraphQLNamedType) -> list[Selectable]: + out: list[Selectable] = [] if isinstance(type_, graphql.GraphQLObjectType): for name, field in type_.fields.items(): out.append((name, field, None)) @@ -57,13 +58,13 @@ def _reachable_composites(schema: graphql.GraphQLSchema, roots: frozenset) -> se # type name -> list of (is_leaf, target type name) for each selectable field -FieldTerms = Dict[str, List[Tuple[bool, str]]] +FieldTerms = dict[str, list[tuple[bool, str]]] def _field_terms(schema: graphql.GraphQLSchema, types: set) -> FieldTerms: terms: FieldTerms = {} for name in types: - row: List[Tuple[bool, str]] = [] + row: list[tuple[bool, str]] = [] for _name, field, _on_type in selectable_fields(schema, schema.get_type(name)): target = unwrap(field.type) row.append((True, "") if is_leaf(target) else (False, target.name)) @@ -71,7 +72,7 @@ def _field_terms(schema: graphql.GraphQLSchema, types: set) -> FieldTerms: return terms -def _solve(types: set, terms: FieldTerms, x: float) -> Dict[str, float]: +def _solve(types: set, terms: FieldTerms, x: float) -> dict[str, float]: # Fixpoint of y_T = product over selectable fields of (1 + phi); non-convergence within # MAX_ITERS is treated as past the singularity (OverflowError), so callers back x off. y = dict.fromkeys(types, 0.0) @@ -91,12 +92,12 @@ def _solve(types: set, terms: FieldTerms, x: float) -> Dict[str, float]: raise OverflowError("no convergence") -def selset_values(schema: graphql.GraphQLSchema, roots: frozenset, x: float) -> Dict[str, float]: +def selset_values(schema: graphql.GraphQLSchema, roots: frozenset, x: float) -> dict[str, float]: types = _reachable_composites(schema, roots) return _solve(types, _field_terms(schema, types), x) -def min_depths(schema: graphql.GraphQLSchema) -> Dict[str, Optional[int]]: +def min_depths(schema: graphql.GraphQLSchema) -> dict[str, int | None]: # Shortest number of selection levels from a type to a leaf field; None if unreachable. # Bellman-Ford-style fixpoint over the type graph -- order-independent. composites = [ @@ -104,7 +105,7 @@ def min_depths(schema: graphql.GraphQLSchema) -> Dict[str, Optional[int]]: for name, type_ in schema.type_map.items() if isinstance(type_, (graphql.GraphQLObjectType, graphql.GraphQLInterfaceType, graphql.GraphQLUnionType)) ] - depth: Dict[str, Optional[int]] = dict.fromkeys(composites) + depth: dict[str, int | None] = dict.fromkeys(composites) changed = True while changed: changed = False @@ -113,7 +114,7 @@ def min_depths(schema: graphql.GraphQLSchema) -> Dict[str, Optional[int]]: for _name, field, _on_type in selectable_fields(schema, schema.get_type(name)): target = unwrap(field.type) if is_leaf(target): - candidate: Optional[int] = 1 + candidate: int | None = 1 else: child_depth = depth.get(target.name) candidate = None if child_depth is None else child_depth + 1 @@ -125,14 +126,14 @@ def min_depths(schema: graphql.GraphQLSchema) -> Dict[str, Optional[int]]: return depth -@dataclasses.dataclass +@dataclasses.dataclass(slots=True) class Oracle: schema: graphql.GraphQLSchema x: float - y: Dict[str, float] + y: dict[str, float] - def inclusion_probabilities(self, type_name: str) -> Dict[Tuple[str, Optional[str]], float]: - out: Dict[Tuple[str, Optional[str]], float] = {} + def inclusion_probabilities(self, type_name: str) -> dict[tuple[str, str | None], float]: + out: dict[tuple[str, str | None], float] = {} for name, field, on_type in selectable_fields(self.schema, self.schema.get_type(type_name)): target = unwrap(field.type) phi = self.x if is_leaf(target) else self.x * (self.y.get(target.name, 0.0) - 1.0) diff --git a/src/hypothesis_graphql/_strategies/primitives.py b/src/hypothesis_graphql/_strategies/primitives.py index d22e473..0055c3f 100644 --- a/src/hypothesis_graphql/_strategies/primitives.py +++ b/src/hypothesis_graphql/_strategies/primitives.py @@ -1,7 +1,9 @@ """Strategies for simple types like scalars or enums.""" +from __future__ import annotations + from functools import lru_cache -from typing import Optional, Tuple, Type, TypeVar, Union +from typing import TypeVar import graphql from hypothesis import strategies as st @@ -17,7 +19,7 @@ # `String` version without extra `str` call def _string( - value: str, StringValueNode: Type[graphql.StringValueNode] = graphql.StringValueNode + value: str, StringValueNode: type[graphql.StringValueNode] = graphql.StringValueNode ) -> graphql.StringValueNode: return StringValueNode(value=value) @@ -49,7 +51,7 @@ def scalar( alphabet: st.SearchStrategy[str], type_name: str, nullable: bool = True, - default: Optional[graphql.ValueNode] = None, + default: graphql.ValueNode | None = None, ) -> st.SearchStrategy[ScalarValueNode]: if type_name == "Int": return int_(nullable=nullable, default=default) @@ -67,20 +69,18 @@ def scalar( ) -def int_( - *, nullable: bool = True, default: Optional[graphql.ValueNode] = None -) -> st.SearchStrategy[graphql.IntValueNode]: +def int_(*, nullable: bool = True, default: graphql.ValueNode | None = None) -> st.SearchStrategy[graphql.IntValueNode]: return maybe_default(maybe_null(INTEGER_STRATEGY, nullable), default=default) def float_( - *, nullable: bool = True, default: Optional[graphql.ValueNode] = None + *, nullable: bool = True, default: graphql.ValueNode | None = None ) -> st.SearchStrategy[graphql.FloatValueNode]: return maybe_default(maybe_null(FLOAT_STRATEGY, nullable), default=default) def string( - *, nullable: bool = True, default: Optional[graphql.ValueNode] = None, alphabet: st.SearchStrategy[str] + *, nullable: bool = True, default: graphql.ValueNode | None = None, alphabet: st.SearchStrategy[str] ) -> st.SearchStrategy[graphql.StringValueNode]: return maybe_default( maybe_null(st.text(alphabet=alphabet).map(_string), nullable), @@ -89,13 +89,13 @@ def string( def id_( - *, nullable: bool = True, default: Optional[graphql.ValueNode] = None, alphabet: st.SearchStrategy[str] -) -> st.SearchStrategy[Union[graphql.StringValueNode, graphql.IntValueNode]]: + *, nullable: bool = True, default: graphql.ValueNode | None = None, alphabet: st.SearchStrategy[str] +) -> st.SearchStrategy[graphql.StringValueNode | graphql.IntValueNode]: return maybe_default(string(nullable=nullable, alphabet=alphabet) | int_(nullable=nullable), default=default) def boolean( - *, nullable: bool = True, default: Optional[graphql.ValueNode] = None + *, nullable: bool = True, default: graphql.ValueNode | None = None ) -> st.SearchStrategy[graphql.BooleanValueNode]: return maybe_default(maybe_null(BOOLEAN_STRATEGY, nullable), default=default) @@ -109,14 +109,12 @@ def maybe_null(strategy: st.SearchStrategy[T], nullable: bool) -> st.SearchStrat def custom( strategy: st.SearchStrategy, nullable: bool = True, - default: Optional[graphql.ValueNode] = None, + default: graphql.ValueNode | None = None, ) -> st.SearchStrategy: return maybe_default(maybe_null(strategy, nullable), default=default) -def maybe_default( - strategy: st.SearchStrategy[T], *, default: Optional[graphql.ValueNode] = None -) -> st.SearchStrategy[T]: +def maybe_default(strategy: st.SearchStrategy[T], *, default: graphql.ValueNode | None = None) -> st.SearchStrategy[T]: if default is not None: strategy |= st.just(default) return strategy @@ -124,9 +122,9 @@ def maybe_default( @lru_cache(maxsize=64) def enum( - values: Tuple[str], + values: tuple[str], nullable: bool = True, - default: Optional[graphql.ValueNode] = None, + default: graphql.ValueNode | None = None, ) -> st.SearchStrategy[graphql.EnumValueNode]: return maybe_default(maybe_null(st.sampled_from(values).map(nodes.Enum), nullable), default=default) @@ -134,7 +132,7 @@ def enum( def list_( strategy: st.SearchStrategy[graphql.ListValueNode], nullable: bool = True, - default: Optional[graphql.ValueNode] = None, + default: graphql.ValueNode | None = None, ) -> st.SearchStrategy[graphql.ListValueNode]: return maybe_default(maybe_null(strategy.map(nodes.List), nullable), default=default) @@ -179,7 +177,7 @@ def out_of_range_int() -> st.SearchStrategy[graphql.IntValueNode]: ).map(nodes.Int) -def invalid_enum(valid_values: Tuple[str, ...]) -> st.SearchStrategy[graphql.EnumValueNode]: +def invalid_enum(valid_values: tuple[str, ...]) -> st.SearchStrategy[graphql.EnumValueNode]: # Use only ASCII letters, digits, and underscore to match GraphQL enum syntax # Enum values must match /[_A-Za-z][_0-9A-Za-z]*/ pattern alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_" diff --git a/src/hypothesis_graphql/_strategies/sampler.py b/src/hypothesis_graphql/_strategies/sampler.py index 3870385..1907118 100644 --- a/src/hypothesis_graphql/_strategies/sampler.py +++ b/src/hypothesis_graphql/_strategies/sampler.py @@ -1,4 +1,6 @@ -from typing import List, Optional, Sequence +from __future__ import annotations + +from collections.abc import Sequence import graphql from hypothesis import strategies as st @@ -17,10 +19,10 @@ def positive_selection( schema: graphql.GraphQLSchema, root: graphql.GraphQLObjectType, alphabet: st.SearchStrategy[str], - custom_scalars: Optional[CustomScalarStrategies] = None, + custom_scalars: CustomScalarStrategies | None = None, allow_null: bool = True, - fields: Optional[Sequence[str]] = None, -) -> st.SearchStrategy[List[SelectionNode]]: + fields: Sequence[str] | None = None, +) -> st.SearchStrategy[list[SelectionNode]]: depths = min_depths(schema) # Adaptive expected size: richer queries on larger schemas so big graphs are not under-explored. target_size = max(8.0, min(40.0, len(depths) * 0.4)) @@ -32,19 +34,19 @@ def ok_composite(target: graphql.GraphQLNamedType, depth: int) -> bool: md = depths.get(target.name) return md is not None and depth + md <= MAX_DEPTH - def fields_at(type_: graphql.GraphQLNamedType, depth: int) -> List[Selectable]: + def fields_at(type_: graphql.GraphQLNamedType, depth: int) -> list[Selectable]: items = selectable_fields(schema, type_) if depth == 0 and allowed_root is not None: items = [item for item in items if item[0] in allowed_root] return items @st.composite # type: ignore - def _generate(draw: st.DrawFn) -> List[SelectionNode]: + def _generate(draw: st.DrawFn) -> list[SelectionNode]: counter = [0] - def select(type_: graphql.GraphQLNamedType, depth: int) -> List[SelectionNode]: + def select(type_: graphql.GraphQLNamedType, depth: int) -> list[SelectionNode]: probs = oracle.inclusion_probabilities(type_.name) - chosen: List[Selectable] = [] + chosen: list[Selectable] = [] for name, field, on_type in fields_at(type_, depth): target = unwrap(field.type) if not is_leaf(target) and not ok_composite(target, depth): @@ -56,7 +58,7 @@ def select(type_: graphql.GraphQLNamedType, depth: int) -> List[SelectionNode]: chosen.append((name, field, on_type)) counter[0] += 1 if not chosen: - valid: List[Selectable] = [] + valid: list[Selectable] = [] for name, field, on_type in fields_at(type_, depth): target = unwrap(field.type) if is_leaf(target): @@ -69,7 +71,7 @@ def select(type_: graphql.GraphQLNamedType, depth: int) -> List[SelectionNode]: return [] chosen = [valid[0]] counter[0] += 1 - nodes: List[SelectionNode] = [] + nodes: list[SelectionNode] = [] for name, field, on_type in chosen: target = unwrap(field.type) children = [] if is_leaf(target) else select(target, depth + 1) diff --git a/src/hypothesis_graphql/_strategies/strategy.py b/src/hypothesis_graphql/_strategies/strategy.py index 8947867..ca3a7ff 100644 --- a/src/hypothesis_graphql/_strategies/strategy.py +++ b/src/hypothesis_graphql/_strategies/strategy.py @@ -1,8 +1,11 @@ # pylint: disable=unused-import +from __future__ import annotations + import dataclasses +from collections.abc import Callable, Iterable from functools import reduce, wraps from operator import or_ -from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, Union +from typing import Any import graphql from hypothesis import strategies as st @@ -27,7 +30,7 @@ def instance_cache(key_func: Callable) -> Callable: def decorator(method: Callable) -> Callable: @wraps(method) - def wrapped(self: "GraphQLStrategy", *args: Any, **kwargs: Any) -> st.SearchStrategy: + def wrapped(self: GraphQLStrategy, *args: Any, **kwargs: Any) -> st.SearchStrategy: key = key_func(*args, **kwargs) memo = self._cache.setdefault(method.__name__, {}) cached = memo.get(key) @@ -42,7 +45,7 @@ def wrapped(self: "GraphQLStrategy", *args: Any, **kwargs: Any) -> st.SearchStra return decorator -@dataclasses.dataclass +@dataclasses.dataclass(slots=True) class GraphQLStrategy: """Strategy for generating various GraphQL nodes.""" @@ -52,12 +55,12 @@ class GraphQLStrategy: allow_null: bool = True # As the schema is assumed to be immutable, there are a few strategy caches possible for internal components # This is a per-method cache without limits as they are proportionate to the schema size - _cache: Dict[str, Dict] = dataclasses.field(default_factory=dict) + _cache: dict[str, dict] = dataclasses.field(default_factory=dict) def values( self, type_: graphql.GraphQLInputType, - default: Optional[graphql.ValueNode] = None, + default: graphql.ValueNode | None = None, ) -> st.SearchStrategy[InputTypeNode]: """Generate value nodes for a `GraphQLInputType` (scalar/enum/list/input object).""" type_, nullable = check_nullable(type_) @@ -91,7 +94,7 @@ def lists( self, type_: graphql.GraphQLList, nullable: bool = True, - default: Optional[graphql.ValueNode] = None, + default: graphql.ValueNode | None = None, ) -> st.SearchStrategy[graphql.ListValueNode]: """Generate a `graphql.ListValueNode`.""" strategy = st.lists(self.values(type_.of_type)) @@ -126,8 +129,8 @@ def can_generate_field(self, field: graphql.GraphQLInputField) -> bool: ) def lists_of_object_fields( - self, items: List[Tuple[str, graphql.GraphQLInputField]] - ) -> st.SearchStrategy[List[graphql.ObjectFieldNode]]: + self, items: list[tuple[str, graphql.GraphQLInputField]] + ) -> st.SearchStrategy[list[graphql.ObjectFieldNode]]: return st.tuples( *( self.values( @@ -139,14 +142,14 @@ def lists_of_object_fields( ).map(list) def list_of_arguments( - self, arguments: Dict[str, graphql.GraphQLArgument] - ) -> st.SearchStrategy[List[graphql.ArgumentNode]]: + self, arguments: dict[str, graphql.GraphQLArgument] + ) -> st.SearchStrategy[list[graphql.ArgumentNode]]: """Generate a list `graphql.ArgumentNode` for a field.""" if not arguments: return st.just([]) @st.composite # type: ignore - def inner(draw: st.DrawFn) -> List[graphql.ArgumentNode]: + def inner(draw: st.DrawFn) -> list[graphql.ArgumentNode]: args = [] for name, argument in arguments.items(): default = argument.ast_node.default_value if argument.ast_node is not None else None @@ -165,7 +168,7 @@ def inner(draw: st.DrawFn) -> List[graphql.ArgumentNode]: return inner() -def check_nullable(type_: graphql.GraphQLInputType) -> Tuple[graphql.GraphQLInputType, bool]: +def check_nullable(type_: graphql.GraphQLInputType) -> tuple[graphql.GraphQLInputType, bool]: """Get the wrapped type and detect if it is nullable.""" nullable = True if isinstance(type_, graphql.GraphQLNonNull): @@ -192,12 +195,11 @@ def make_type_name(type_: graphql.GraphQLType) -> str: def subset_of_fields( - fields: Dict[str, graphql.GraphQLInputField], *, force_required: bool = False -) -> st.SearchStrategy[List[Tuple[str, graphql.GraphQLInputField]]]: + fields: dict[str, graphql.GraphQLInputField], *, force_required: bool = False +) -> st.SearchStrategy[list[tuple[str, graphql.GraphQLInputField]]]: """A helper to select a subset of fields.""" if not fields: - # The schema is invalid as there should be at least one field - # But there should not be an internal error because of it + # Nothing selectable, e.g. an input object whose fields are all optional and not generatable return EMPTY_LISTS_STRATEGY field_pairs = sorted(fields.items()) # if we need to always generate required fields, then return them and extend with a subset of optional fields @@ -221,12 +223,12 @@ def _make_strategy( schema: graphql.GraphQLSchema, *, type_: graphql.GraphQLObjectType, - fields: Optional[Iterable[str]] = None, - custom_scalars: Optional[CustomScalarStrategies] = None, + fields: Iterable[str] | None = None, + custom_scalars: CustomScalarStrategies | None = None, alphabet: st.SearchStrategy[str], allow_null: bool = True, mode: Mode = Mode.POSITIVE, -) -> st.SearchStrategy[List[graphql.FieldNode]]: +) -> st.SearchStrategy[list[graphql.FieldNode]]: """Create strategy for GraphQL selections (query/mutation fields).""" from .builder import build_selection_set from .negative_sites import negative_selection @@ -249,7 +251,7 @@ def _make_strategy( return selection.map(lambda sel_nodes: build_selection_set(sel_nodes, type_map=schema.type_map).selections) -def _build_alphabet(allow_x00: bool = True, codec: Optional[str] = "utf-8") -> st.SearchStrategy[str]: +def _build_alphabet(allow_x00: bool = True, codec: str | None = "utf-8") -> st.SearchStrategy[str]: return st.characters( codec=codec, min_codepoint=0 if allow_x00 else 1, max_codepoint=0xFFFF, blacklist_categories=["Cs"] ) @@ -257,14 +259,14 @@ def _build_alphabet(allow_x00: bool = True, codec: Optional[str] = "utf-8") -> s @cacheable # type: ignore def queries( - schema: Union[str, graphql.GraphQLSchema], + schema: str | graphql.GraphQLSchema, *, - fields: Optional[Iterable[str]] = None, - custom_scalars: Optional[CustomScalarStrategies] = None, + fields: Iterable[str] | None = None, + custom_scalars: CustomScalarStrategies | None = None, print_ast: AstPrinter = graphql.print_ast, allow_x00: bool = True, allow_null: bool = True, - codec: Optional[str] = "utf-8", + codec: str | None = "utf-8", mode: Mode = Mode.POSITIVE, ) -> st.SearchStrategy[str]: r"""A strategy for generating queries for the given GraphQL schema. @@ -302,14 +304,14 @@ def queries( @cacheable # type: ignore def mutations( - schema: Union[str, graphql.GraphQLSchema], + schema: str | graphql.GraphQLSchema, *, - fields: Optional[Iterable[str]] = None, - custom_scalars: Optional[CustomScalarStrategies] = None, + fields: Iterable[str] | None = None, + custom_scalars: CustomScalarStrategies | None = None, print_ast: AstPrinter = graphql.print_ast, allow_x00: bool = True, allow_null: bool = True, - codec: Optional[str] = "utf-8", + codec: str | None = "utf-8", mode: Mode = Mode.POSITIVE, ) -> st.SearchStrategy[str]: r"""A strategy for generating mutations for the given GraphQL schema. @@ -347,14 +349,14 @@ def mutations( @cacheable # type: ignore def from_schema( - schema: Union[str, graphql.GraphQLSchema], + schema: str | graphql.GraphQLSchema, *, - fields: Optional[Iterable[str]] = None, - custom_scalars: Optional[CustomScalarStrategies] = None, + fields: Iterable[str] | None = None, + custom_scalars: CustomScalarStrategies | None = None, print_ast: AstPrinter = graphql.print_ast, allow_x00: bool = True, allow_null: bool = True, - codec: Optional[str] = "utf-8", + codec: str | None = "utf-8", mode: Mode = Mode.POSITIVE, ) -> st.SearchStrategy[str]: r"""A strategy for generating queries and mutations for the given GraphQL schema. diff --git a/src/hypothesis_graphql/_strategies/validation.py b/src/hypothesis_graphql/_strategies/validation.py index 5262569..391c2c6 100644 --- a/src/hypothesis_graphql/_strategies/validation.py +++ b/src/hypothesis_graphql/_strategies/validation.py @@ -1,4 +1,4 @@ -from typing import List, Tuple, Union +from __future__ import annotations import graphql from hypothesis import strategies as st @@ -8,13 +8,13 @@ from ..types import CustomScalarStrategies -def maybe_parse_schema(schema: Union[str, graphql.GraphQLSchema]) -> graphql.GraphQLSchema: +def maybe_parse_schema(schema: str | graphql.GraphQLSchema) -> graphql.GraphQLSchema: if isinstance(schema, str): return cached_build_schema(schema) return schema -def validate_fields(fields_: Tuple[str, ...], available_fields: List[str]) -> None: +def validate_fields(fields_: tuple[str, ...], available_fields: list[str]) -> None: if not fields_: raise ValueError("If you pass `fields`, it should not be empty") invalid_fields = tuple(field for field in fields_ if field not in available_fields) diff --git a/src/hypothesis_graphql/_strategies/weighted.py b/src/hypothesis_graphql/_strategies/weighted.py index fee3691..dcb5cfd 100644 --- a/src/hypothesis_graphql/_strategies/weighted.py +++ b/src/hypothesis_graphql/_strategies/weighted.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING from hypothesis import strategies as st @@ -11,7 +13,7 @@ def __init__(self, p: float) -> None: super().__init__() self.p = p - def do_draw(self, data: "ConjectureData") -> bool: + def do_draw(self, data: ConjectureData) -> bool: return data.draw_boolean(self.p) diff --git a/src/hypothesis_graphql/cache.py b/src/hypothesis_graphql/cache.py index 2143c37..3a1aa49 100644 --- a/src/hypothesis_graphql/cache.py +++ b/src/hypothesis_graphql/cache.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from functools import lru_cache import graphql diff --git a/src/hypothesis_graphql/nodes.py b/src/hypothesis_graphql/nodes.py index 6393926..1a624c2 100644 --- a/src/hypothesis_graphql/nodes.py +++ b/src/hypothesis_graphql/nodes.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import typing from functools import lru_cache @@ -9,32 +11,32 @@ def String( value: typing.Any, - StringValueNode: typing.Type[graphql.StringValueNode] = graphql.StringValueNode, + StringValueNode: type[graphql.StringValueNode] = graphql.StringValueNode, ) -> graphql.StringValueNode: return StringValueNode(value=str(value)) def Float( value: float, - FloatValueNode: typing.Type[graphql.FloatValueNode] = graphql.FloatValueNode, + FloatValueNode: type[graphql.FloatValueNode] = graphql.FloatValueNode, ) -> graphql.FloatValueNode: return FloatValueNode(value=str(value)) -def Int(value: int, IntValueNode: typing.Type[graphql.IntValueNode] = graphql.IntValueNode) -> graphql.IntValueNode: +def Int(value: int, IntValueNode: type[graphql.IntValueNode] = graphql.IntValueNode) -> graphql.IntValueNode: return IntValueNode(value=str(value)) def Object( - fields: typing.List[graphql.ObjectFieldNode], - ObjectValueNode: typing.Type[graphql.ObjectValueNode] = graphql.ObjectValueNode, + fields: list[graphql.ObjectFieldNode], + ObjectValueNode: type[graphql.ObjectValueNode] = graphql.ObjectValueNode, ) -> graphql.ObjectValueNode: return ObjectValueNode(fields=fields) def List( - values: typing.List[graphql.ValueNode], - ListValueNode: typing.Type[graphql.ListValueNode] = graphql.ListValueNode, + values: list[graphql.ValueNode], + ListValueNode: type[graphql.ListValueNode] = graphql.ListValueNode, ) -> graphql.ListValueNode: return ListValueNode(values=values) diff --git a/src/hypothesis_graphql/types.py b/src/hypothesis_graphql/types.py index 7e90342..c98aed4 100644 --- a/src/hypothesis_graphql/types.py +++ b/src/hypothesis_graphql/types.py @@ -1,23 +1,13 @@ -from typing import Callable, Dict, List, Union +from collections.abc import Callable import graphql from hypothesis import strategies as st # Leaf nodes, that don't have children -ScalarValueNode = Union[ - graphql.IntValueNode, - graphql.FloatValueNode, - graphql.StringValueNode, - graphql.BooleanValueNode, -] -InputTypeNode = Union[ - ScalarValueNode, - graphql.EnumValueNode, - graphql.ListValueNode, - graphql.ObjectValueNode, -] -Field = Union[graphql.GraphQLField, graphql.GraphQLInputField] -InterfaceOrObject = Union[graphql.GraphQLObjectType, graphql.GraphQLInterfaceType] -SelectionNodes = List[graphql.SelectionNode] +ScalarValueNode = graphql.IntValueNode | graphql.FloatValueNode | graphql.StringValueNode | graphql.BooleanValueNode +InputTypeNode = ScalarValueNode | graphql.EnumValueNode | graphql.ListValueNode | graphql.ObjectValueNode +Field = graphql.GraphQLField | graphql.GraphQLInputField +InterfaceOrObject = graphql.GraphQLObjectType | graphql.GraphQLInterfaceType +SelectionNodes = list[graphql.SelectionNode] AstPrinter = Callable[[graphql.Node], str] -CustomScalarStrategies = Dict[str, st.SearchStrategy[graphql.ValueNode]] +CustomScalarStrategies = dict[str, st.SearchStrategy[graphql.ValueNode]] diff --git a/test/fetch_corpus.py b/test/fetch_corpus.py index 51a3785..42d128a 100644 --- a/test/fetch_corpus.py +++ b/test/fetch_corpus.py @@ -3,7 +3,7 @@ import urllib.request from concurrent.futures._base import Future from contextlib import suppress -from typing import Any, Dict, List +from typing import Any import graphql @@ -60,12 +60,12 @@ def load_schema(url: str) -> str: return introspection_result_to_sdl(data["data"]) -def introspection_result_to_sdl(data: Dict[str, Any]) -> str: +def introspection_result_to_sdl(data: dict[str, Any]) -> str: client_schema = graphql.build_client_schema(data) return graphql.print_schema(client_schema).strip() -def load_corpus(path: str = CORPUS_PATH) -> Dict[str, Any]: +def load_corpus(path: str = CORPUS_PATH) -> dict[str, Any]: try: with open(path) as fd: return json.load(fd) @@ -73,12 +73,12 @@ def load_corpus(path: str = CORPUS_PATH) -> Dict[str, Any]: return {} -def store_corpus(data: Dict[str, Any], path: str = CORPUS_PATH) -> None: +def store_corpus(data: dict[str, Any], path: str = CORPUS_PATH) -> None: with open(path, mode="w") as fd: json.dump(data, fd, indent=4, sort_keys=True) -def update_corpus(futures: List[Future], corpus_path: str = CORPUS_PATH) -> Dict[str, Any]: +def update_corpus(futures: list[Future], corpus_path: str = CORPUS_PATH) -> dict[str, Any]: corpus = load_corpus(corpus_path) with suppress(concurrent.futures.TimeoutError): for future in concurrent.futures.as_completed(futures, timeout=FETCH_TIMEOUT): diff --git a/test/test_corpus.py b/test/test_corpus.py index d3b3403..baaf302 100644 --- a/test/test_corpus.py +++ b/test/test_corpus.py @@ -1,14 +1,13 @@ import json import pathlib from dataclasses import dataclass -from typing import Optional import graphql import pytest from hypothesis import HealthCheck, Phase, Verbosity, given, settings from hypothesis import strategies as st -from hypothesis_graphql import from_schema, nodes, Mode +from hypothesis_graphql import Mode, from_schema, nodes from hypothesis_graphql._strategies.strategy import BUILT_IN_SCALAR_TYPE_NAMES from hypothesis_graphql.cache import cached_build_schema @@ -26,7 +25,7 @@ @dataclass class Schema: raw: dict - custom_scalars: Optional[dict] + custom_scalars: dict | None PLACEHOLDER_STRATEGY = st.just("placeholder").map(nodes.String) diff --git a/test/test_negative.py b/test/test_negative.py index ccddf01..3b96d61 100644 --- a/test/test_negative.py +++ b/test/test_negative.py @@ -1,10 +1,10 @@ import graphql import pytest -from hypothesis import given, settings, find, HealthCheck +from hypothesis import HealthCheck, find, given, settings from hypothesis import strategies as st from hypothesis.errors import InvalidArgument -from hypothesis_graphql import queries, mutations, from_schema, Mode, nodes +from hypothesis_graphql import Mode, from_schema, mutations, nodes, queries def assert_query_invalid(query: str, schema: str) -> None: diff --git a/test/test_queries.py b/test/test_queries.py index 957208d..f5140a5 100644 --- a/test/test_queries.py +++ b/test/test_queries.py @@ -180,6 +180,18 @@ def test_missing_query(): queries(schema) +def test_input_object_with_no_generatable_fields(validate_operation): + # An input object whose fields are all optional and reference an unknown custom scalar has no + # generatable fields, so it is emitted as an empty object literal rather than failing. + schema = """ +scalar Custom +input Filter { a: Custom b: [Custom] } +type Query { search(filter: Filter): Int } +""" + query = find(queries(schema), lambda q: "filter: {}" in q) + validate_operation(schema, query) + + def test_unknown_type(simple_schema): # If there will be a new input type in `graphql` diff --git a/tox.ini b/tox.ini index 592053a..0d756a5 100644 --- a/tox.ini +++ b/tox.ini @@ -1,11 +1,9 @@ [tox] isolated_build = true -envlist = py{38,39,310,311,312,313,314},coverage-report +envlist = py{310,311,312,313,314},coverage-report [gh-actions] python = - 3.8: py38 - 3.9: py39 3.10: py310 3.11: py311 3.12: py312 @@ -29,7 +27,7 @@ allowlist_externals = description = Report coverage over all measured test runs. basepython = python3.10 skip_install = true -depends = py{38,39,310,311,312,313,314} +depends = py{310,311,312,313,314} commands = coverage combine coverage report