diff --git a/CHANGELOG.md b/CHANGELOG.md index eb3b066..98de611 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,15 +6,16 @@ - Support for Python 3.12. - Include tests in the source tarball. #82 - -### Removed - -- Python 3.7 support. +- 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`. +### Removed + +- Python 3.7 support. + ## [0.10.0] - 2023-04-12 ### Changed diff --git a/src/hypothesis_graphql/_strategies/primitives.py b/src/hypothesis_graphql/_strategies/primitives.py index 371df3f..07e1c84 100644 --- a/src/hypothesis_graphql/_strategies/primitives.py +++ b/src/hypothesis_graphql/_strategies/primitives.py @@ -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) @@ -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) diff --git a/src/hypothesis_graphql/_strategies/strategy.py b/src/hypothesis_graphql/_strategies/strategy.py index 173451f..8fcdd0b 100644 --- a/src/hypothesis_graphql/_strategies/strategy.py +++ b/src/hypothesis_graphql/_strategies/strategy.py @@ -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 @@ -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) @@ -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 @@ -388,8 +398,10 @@ 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. @@ -397,16 +409,20 @@ def queries( :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) @@ -420,8 +436,10 @@ 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. @@ -429,16 +447,20 @@ def mutations( :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) @@ -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: @@ -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 ( diff --git a/test/test_queries.py b/test/test_queries.py index f6f02cd..5b3b93b 100644 --- a/test/test_queries.py +++ b/test/test_queries.py @@ -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()) @@ -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")