Skip to content

Commit

Permalink
feat: A way to control what characters are used for string generation
Browse files Browse the repository at this point in the history
  • Loading branch information
Stranger6667 committed Nov 29, 2023
1 parent ec91bfa commit 06b3b67
Show file tree
Hide file tree
Showing 5 changed files with 70 additions and 22 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@

- Support for Python 3.12.
- Include tests in the source tarball. #82
- A way to control what characters are used for string generation via the `allow_x00` and `codec` arguments to `queries`, `mutations` and `from_schema`.

### Changed

- Bump the minimum supported Hypothesis version to ``6.84.3``.

## [0.10.0] - 2023-04-12

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ license = "MIT"
include = ["src/hypothesis_graphql/py.typed"]
requires-python = ">=3.7"
dependencies = [
"hypothesis>=5.8.0,<7.0",
"hypothesis>=6.84.3,<7.0",
"graphql-core>=3.1.0,<3.3.0",
]

Expand Down
32 changes: 18 additions & 14 deletions src/hypothesis_graphql/_strategies/primitives.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ def _string(
return StringValueNode(value=value)


STRING_STRATEGY = st.text(alphabet=st.characters(blacklist_categories=("Cs",), max_codepoint=0xFFFF)).map(_string)
INTEGER_STRATEGY = st.integers(min_value=MIN_INT, max_value=MAX_INT).map(nodes.Int)
FLOAT_STRATEGY = st.floats(allow_infinity=False, allow_nan=False).map(nodes.Float)
BOOLEAN_STRATEGY = st.booleans().map(nodes.Boolean)
Expand All @@ -30,51 +29,56 @@ def _string(

@lru_cache(maxsize=16)
def scalar(
type_name: str, nullable: bool = True, default: Optional[graphql.ValueNode] = None
alphabet: st.SearchStrategy[str],
type_name: str,
nullable: bool = True,
default: Optional[graphql.ValueNode] = None,
) -> st.SearchStrategy[ScalarValueNode]:
if type_name == "Int":
return int_(nullable, default)
return int_(nullable=nullable, default=default)
if type_name == "Float":
return float_(nullable, default)
return float_(nullable=nullable, default=default)
if type_name == "String":
return string(nullable, default)
return string(nullable=nullable, default=default, alphabet=alphabet)
if type_name == "ID":
return id_(nullable, default)
return id_(nullable=nullable, default=default, alphabet=alphabet)
if type_name == "Boolean":
return boolean(nullable, default)
return boolean(nullable=nullable, default=default)
raise InvalidArgument(
f"Scalar {type_name!r} is not supported. "
"Provide a Hypothesis strategy via the `custom_scalars` argument to generate it."
)


def int_(nullable: bool = True, default: Optional[graphql.ValueNode] = None) -> st.SearchStrategy[graphql.IntValueNode]:
def int_(
*, nullable: bool = True, default: Optional[graphql.ValueNode] = 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: Optional[graphql.ValueNode] = 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
*, nullable: bool = True, default: Optional[graphql.ValueNode] = None, alphabet: st.SearchStrategy[str]
) -> st.SearchStrategy[graphql.StringValueNode]:
return maybe_default(
maybe_null(STRING_STRATEGY, nullable),
maybe_null(st.text(alphabet=alphabet).map(_string), nullable),
default=default,
)


def id_(
nullable: bool = True, default: Optional[graphql.ValueNode] = None
*, nullable: bool = True, default: Optional[graphql.ValueNode] = None, alphabet: st.SearchStrategy[str]
) -> st.SearchStrategy[Union[graphql.StringValueNode, graphql.IntValueNode]]:
return maybe_default(string(nullable) | int_(nullable), default=default)
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: Optional[graphql.ValueNode] = None
) -> st.SearchStrategy[graphql.BooleanValueNode]:
return maybe_default(maybe_null(BOOLEAN_STRATEGY, nullable), default=default)

Expand Down
39 changes: 33 additions & 6 deletions src/hypothesis_graphql/_strategies/strategy.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ class GraphQLStrategy:
"""Strategy for generating various GraphQL nodes."""

schema: graphql.GraphQLSchema
alphabet: st.SearchStrategy[str]
custom_scalars: CustomScalarStrategies = dataclasses.field(default_factory=dict)
# 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
Expand Down Expand Up @@ -79,7 +80,7 @@ def values(
type_name = type_.name
if type_name in self.custom_scalars:
return primitives.custom(self.custom_scalars[type_name], nullable, default=default)
return primitives.scalar(type_name, nullable, default=default)
return primitives.scalar(alphabet=self.alphabet, type_name=type_name, nullable=nullable, default=default)
if isinstance(type_, graphql.GraphQLEnumType):
values = tuple(type_.values)
return primitives.enum(values, nullable, default=default)
Expand Down Expand Up @@ -372,13 +373,22 @@ def _make_strategy(
type_: graphql.GraphQLObjectType,
fields: Optional[Iterable[str]] = None,
custom_scalars: Optional[CustomScalarStrategies] = None,
alphabet: st.SearchStrategy[str],
) -> st.SearchStrategy[List[graphql.FieldNode]]:
if fields is not None:
fields = tuple(fields)
validation.validate_fields(fields, list(type_.fields))
if custom_scalars:
validation.validate_custom_scalars(custom_scalars)
return GraphQLStrategy(schema, custom_scalars or {}).selections(type_, fields=fields)
return GraphQLStrategy(schema=schema, alphabet=alphabet, custom_scalars=custom_scalars or {}).selections(
type_, fields=fields
)


def _build_alphabet(allow_x00: bool = True, codec: Optional[str] = "utf-8") -> st.SearchStrategy[str]:
return st.characters(
codec=codec, min_codepoint=0 if allow_x00 else 1, max_codepoint=0xFFFF, blacklist_categories=["Cs"]
)


@cacheable # type: ignore
Expand All @@ -388,25 +398,31 @@ def queries(
fields: Optional[Iterable[str]] = None,
custom_scalars: Optional[CustomScalarStrategies] = None,
print_ast: AstPrinter = graphql.print_ast,
allow_x00: bool = True,
codec: Optional[str] = "utf-8",
) -> st.SearchStrategy[str]:
"""A strategy for generating valid queries for the given GraphQL schema.
r"""A strategy for generating valid queries for the given GraphQL schema.
The output query will contain a subset of fields defined in the `Query` type.
:param schema: GraphQL schema as a string or `graphql.GraphQLSchema`.
:param fields: Restrict generated fields to ones in this list.
:param custom_scalars: Strategies for generating custom scalars.
:param print_ast: A function to convert the generated AST to a string.
:param allow_x00: Determines whether to allow the generation of `\x00` bytes within strings.
:param codec: Specifies the codec used for generating strings.
"""
parsed_schema = validation.maybe_parse_schema(schema)
if parsed_schema.query_type is None:
raise InvalidArgument("Query type is not defined in the schema")
alphabet = _build_alphabet(allow_x00=allow_x00, codec=codec)
return (
_make_strategy(
parsed_schema,
type_=parsed_schema.query_type,
fields=fields,
custom_scalars=custom_scalars,
alphabet=alphabet,
)
.map(make_query)
.map(print_ast)
Expand All @@ -420,25 +436,31 @@ def mutations(
fields: Optional[Iterable[str]] = None,
custom_scalars: Optional[CustomScalarStrategies] = None,
print_ast: AstPrinter = graphql.print_ast,
allow_x00: bool = True,
codec: Optional[str] = "utf-8",
) -> st.SearchStrategy[str]:
"""A strategy for generating valid mutations for the given GraphQL schema.
r"""A strategy for generating valid mutations for the given GraphQL schema.
The output mutation will contain a subset of fields defined in the `Mutation` type.
:param schema: GraphQL schema as a string or `graphql.GraphQLSchema`.
:param fields: Restrict generated fields to ones in this list.
:param custom_scalars: Strategies for generating custom scalars.
:param print_ast: A function to convert the generated AST to a string.
:param allow_x00: Determines whether to allow the generation of `\x00` bytes within strings.
:param codec: Specifies the codec used for generating strings.
"""
parsed_schema = validation.maybe_parse_schema(schema)
if parsed_schema.mutation_type is None:
raise InvalidArgument("Mutation type is not defined in the schema")
alphabet = _build_alphabet(allow_x00=allow_x00, codec=codec)
return (
_make_strategy(
parsed_schema,
type_=parsed_schema.mutation_type,
fields=fields,
custom_scalars=custom_scalars,
alphabet=alphabet,
)
.map(make_mutation)
.map(print_ast)
Expand All @@ -452,13 +474,17 @@ def from_schema(
fields: Optional[Iterable[str]] = None,
custom_scalars: Optional[CustomScalarStrategies] = None,
print_ast: AstPrinter = graphql.print_ast,
allow_x00: bool = True,
codec: Optional[str] = "utf-8",
) -> st.SearchStrategy[str]:
"""A strategy for generating valid queries and mutations for the given GraphQL schema.
r"""A strategy for generating valid queries and mutations for the given GraphQL schema.
:param schema: GraphQL schema as a string or `graphql.GraphQLSchema`.
:param fields: Restrict generated fields to ones in this list.
:param custom_scalars: Strategies for generating custom scalars.
:param print_ast: A function to convert the generated AST to a string.
:param allow_x00: Determines whether to allow the generation of `\x00` bytes within strings.
:param codec: Specifies the codec used for generating strings.
"""
parsed_schema = validation.maybe_parse_schema(schema)
if custom_scalars:
Expand All @@ -479,7 +505,8 @@ def from_schema(
available_fields.extend(mutation.fields)
validation.validate_fields(fields, available_fields)

strategy = GraphQLStrategy(parsed_schema, custom_scalars or {})
alphabet = _build_alphabet(allow_x00=allow_x00, codec=codec)
strategy = GraphQLStrategy(parsed_schema, alphabet=alphabet, custom_scalars=custom_scalars or {})
strategies = [
strategy.selections(type_, fields=type_fields).map(node_factory).map(print_ast)
for (type_, type_fields, node_factory) in (
Expand Down
14 changes: 13 additions & 1 deletion test/test_queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ class NewType(GraphQLNamedType):
pass

with pytest.raises(TypeError, match="Type NewType is not supported."):
GraphQLStrategy(schema).values(NewType("Test"))
GraphQLStrategy(schema, alphabet=st.characters()).values(NewType("Test"))


@given(data=st.data())
Expand Down Expand Up @@ -473,3 +473,15 @@ def test_empty_interface(data, validate_operation):
# And then schema validation should fail instead
with pytest.raises(TypeError, match="Type Empty must define one or more fields"):
validate_operation(schema, query)


@given(data=st.data())
def test_custom_strings(data, validate_operation):
schema = """
type Query {
getExample(name: String): String
}"""
query = data.draw(queries(schema, allow_x00=False, codec="ascii"))
validate_operation(schema, query)
assert "\0" not in query
query.encode("ascii")

0 comments on commit 06b3b67

Please sign in to comment.