Skip to content

Commit ac497ca

Browse files
committed
feat: A way to control what characters are used for string generation
1 parent 29b15aa commit ac497ca

File tree

4 files changed

+69
-25
lines changed

4 files changed

+69
-25
lines changed

CHANGELOG.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,16 @@
66

77
- Support for Python 3.12.
88
- Include tests in the source tarball. #82
9-
10-
### Removed
11-
12-
- Python 3.7 support.
9+
- A way to control what characters are used for string generation via the `allow_x00` and `codec` arguments to `queries`, `mutations` and `from_schema`.
1310

1411
### Changed
1512

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

15+
### Removed
16+
17+
- Python 3.7 support.
18+
1819
## [0.10.0] - 2023-04-12
1920

2021
### Changed

src/hypothesis_graphql/_strategies/primitives.py

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ def _string(
2121
return StringValueNode(value=value)
2222

2323

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

3130
@lru_cache(maxsize=16)
3231
def scalar(
33-
type_name: str, nullable: bool = True, default: Optional[graphql.ValueNode] = None
32+
alphabet: st.SearchStrategy[str],
33+
type_name: str,
34+
nullable: bool = True,
35+
default: Optional[graphql.ValueNode] = None,
3436
) -> st.SearchStrategy[ScalarValueNode]:
3537
if type_name == "Int":
36-
return int_(nullable, default)
38+
return int_(nullable=nullable, default=default)
3739
if type_name == "Float":
38-
return float_(nullable, default)
40+
return float_(nullable=nullable, default=default)
3941
if type_name == "String":
40-
return string(nullable, default)
42+
return string(nullable=nullable, default=default, alphabet=alphabet)
4143
if type_name == "ID":
42-
return id_(nullable, default)
44+
return id_(nullable=nullable, default=default, alphabet=alphabet)
4345
if type_name == "Boolean":
44-
return boolean(nullable, default)
46+
return boolean(nullable=nullable, default=default)
4547
raise InvalidArgument(
4648
f"Scalar {type_name!r} is not supported. "
4749
"Provide a Hypothesis strategy via the `custom_scalars` argument to generate it."
4850
)
4951

5052

51-
def int_(nullable: bool = True, default: Optional[graphql.ValueNode] = None) -> st.SearchStrategy[graphql.IntValueNode]:
53+
def int_(
54+
*, nullable: bool = True, default: Optional[graphql.ValueNode] = None
55+
) -> st.SearchStrategy[graphql.IntValueNode]:
5256
return maybe_default(maybe_null(INTEGER_STRATEGY, nullable), default=default)
5357

5458

5559
def float_(
56-
nullable: bool = True, default: Optional[graphql.ValueNode] = None
60+
*, nullable: bool = True, default: Optional[graphql.ValueNode] = None
5761
) -> st.SearchStrategy[graphql.FloatValueNode]:
5862
return maybe_default(maybe_null(FLOAT_STRATEGY, nullable), default=default)
5963

6064

6165
def string(
62-
nullable: bool = True, default: Optional[graphql.ValueNode] = None
66+
*, nullable: bool = True, default: Optional[graphql.ValueNode] = None, alphabet: st.SearchStrategy[str]
6367
) -> st.SearchStrategy[graphql.StringValueNode]:
6468
return maybe_default(
65-
maybe_null(STRING_STRATEGY, nullable),
69+
maybe_null(st.text(alphabet=alphabet).map(_string), nullable),
6670
default=default,
6771
)
6872

6973

7074
def id_(
71-
nullable: bool = True, default: Optional[graphql.ValueNode] = None
75+
*, nullable: bool = True, default: Optional[graphql.ValueNode] = None, alphabet: st.SearchStrategy[str]
7276
) -> st.SearchStrategy[Union[graphql.StringValueNode, graphql.IntValueNode]]:
73-
return maybe_default(string(nullable) | int_(nullable), default=default)
77+
return maybe_default(string(nullable=nullable, alphabet=alphabet) | int_(nullable=nullable), default=default)
7478

7579

7680
def boolean(
77-
nullable: bool = True, default: Optional[graphql.ValueNode] = None
81+
*, nullable: bool = True, default: Optional[graphql.ValueNode] = None
7882
) -> st.SearchStrategy[graphql.BooleanValueNode]:
7983
return maybe_default(maybe_null(BOOLEAN_STRATEGY, nullable), default=default)
8084

src/hypothesis_graphql/_strategies/strategy.py

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ class GraphQLStrategy:
5252
"""Strategy for generating various GraphQL nodes."""
5353

5454
schema: graphql.GraphQLSchema
55+
alphabet: st.SearchStrategy[str]
5556
custom_scalars: CustomScalarStrategies = dataclasses.field(default_factory=dict)
5657
# As the schema is assumed to be immutable, there are a few strategy caches possible for internal components
5758
# This is a per-method cache without limits as they are proportionate to the schema size
@@ -79,7 +80,7 @@ def values(
7980
type_name = type_.name
8081
if type_name in self.custom_scalars:
8182
return primitives.custom(self.custom_scalars[type_name], nullable, default=default)
82-
return primitives.scalar(type_name, nullable, default=default)
83+
return primitives.scalar(alphabet=self.alphabet, type_name=type_name, nullable=nullable, default=default)
8384
if isinstance(type_, graphql.GraphQLEnumType):
8485
values = tuple(type_.values)
8586
return primitives.enum(values, nullable, default=default)
@@ -372,13 +373,22 @@ def _make_strategy(
372373
type_: graphql.GraphQLObjectType,
373374
fields: Optional[Iterable[str]] = None,
374375
custom_scalars: Optional[CustomScalarStrategies] = None,
376+
alphabet: st.SearchStrategy[str],
375377
) -> st.SearchStrategy[List[graphql.FieldNode]]:
376378
if fields is not None:
377379
fields = tuple(fields)
378380
validation.validate_fields(fields, list(type_.fields))
379381
if custom_scalars:
380382
validation.validate_custom_scalars(custom_scalars)
381-
return GraphQLStrategy(schema, custom_scalars or {}).selections(type_, fields=fields)
383+
return GraphQLStrategy(schema=schema, alphabet=alphabet, custom_scalars=custom_scalars or {}).selections(
384+
type_, fields=fields
385+
)
386+
387+
388+
def _build_alphabet(allow_x00: bool = True, codec: Optional[str] = "utf-8") -> st.SearchStrategy[str]:
389+
return st.characters(
390+
codec=codec, min_codepoint=0 if allow_x00 else 1, max_codepoint=0xFFFF, blacklist_categories=["Cs"]
391+
)
382392

383393

384394
@cacheable # type: ignore
@@ -388,25 +398,31 @@ def queries(
388398
fields: Optional[Iterable[str]] = None,
389399
custom_scalars: Optional[CustomScalarStrategies] = None,
390400
print_ast: AstPrinter = graphql.print_ast,
401+
allow_x00: bool = True,
402+
codec: Optional[str] = "utf-8",
391403
) -> st.SearchStrategy[str]:
392-
"""A strategy for generating valid queries for the given GraphQL schema.
404+
r"""A strategy for generating valid queries for the given GraphQL schema.
393405
394406
The output query will contain a subset of fields defined in the `Query` type.
395407
396408
:param schema: GraphQL schema as a string or `graphql.GraphQLSchema`.
397409
:param fields: Restrict generated fields to ones in this list.
398410
:param custom_scalars: Strategies for generating custom scalars.
399411
:param print_ast: A function to convert the generated AST to a string.
412+
:param allow_x00: Determines whether to allow the generation of `\x00` bytes within strings.
413+
:param codec: Specifies the codec used for generating strings.
400414
"""
401415
parsed_schema = validation.maybe_parse_schema(schema)
402416
if parsed_schema.query_type is None:
403417
raise InvalidArgument("Query type is not defined in the schema")
418+
alphabet = _build_alphabet(allow_x00=allow_x00, codec=codec)
404419
return (
405420
_make_strategy(
406421
parsed_schema,
407422
type_=parsed_schema.query_type,
408423
fields=fields,
409424
custom_scalars=custom_scalars,
425+
alphabet=alphabet,
410426
)
411427
.map(make_query)
412428
.map(print_ast)
@@ -420,25 +436,31 @@ def mutations(
420436
fields: Optional[Iterable[str]] = None,
421437
custom_scalars: Optional[CustomScalarStrategies] = None,
422438
print_ast: AstPrinter = graphql.print_ast,
439+
allow_x00: bool = True,
440+
codec: Optional[str] = "utf-8",
423441
) -> st.SearchStrategy[str]:
424-
"""A strategy for generating valid mutations for the given GraphQL schema.
442+
r"""A strategy for generating valid mutations for the given GraphQL schema.
425443
426444
The output mutation will contain a subset of fields defined in the `Mutation` type.
427445
428446
:param schema: GraphQL schema as a string or `graphql.GraphQLSchema`.
429447
:param fields: Restrict generated fields to ones in this list.
430448
:param custom_scalars: Strategies for generating custom scalars.
431449
:param print_ast: A function to convert the generated AST to a string.
450+
:param allow_x00: Determines whether to allow the generation of `\x00` bytes within strings.
451+
:param codec: Specifies the codec used for generating strings.
432452
"""
433453
parsed_schema = validation.maybe_parse_schema(schema)
434454
if parsed_schema.mutation_type is None:
435455
raise InvalidArgument("Mutation type is not defined in the schema")
456+
alphabet = _build_alphabet(allow_x00=allow_x00, codec=codec)
436457
return (
437458
_make_strategy(
438459
parsed_schema,
439460
type_=parsed_schema.mutation_type,
440461
fields=fields,
441462
custom_scalars=custom_scalars,
463+
alphabet=alphabet,
442464
)
443465
.map(make_mutation)
444466
.map(print_ast)
@@ -452,13 +474,17 @@ def from_schema(
452474
fields: Optional[Iterable[str]] = None,
453475
custom_scalars: Optional[CustomScalarStrategies] = None,
454476
print_ast: AstPrinter = graphql.print_ast,
477+
allow_x00: bool = True,
478+
codec: Optional[str] = "utf-8",
455479
) -> st.SearchStrategy[str]:
456-
"""A strategy for generating valid queries and mutations for the given GraphQL schema.
480+
r"""A strategy for generating valid queries and mutations for the given GraphQL schema.
457481
458482
:param schema: GraphQL schema as a string or `graphql.GraphQLSchema`.
459483
:param fields: Restrict generated fields to ones in this list.
460484
:param custom_scalars: Strategies for generating custom scalars.
461485
:param print_ast: A function to convert the generated AST to a string.
486+
:param allow_x00: Determines whether to allow the generation of `\x00` bytes within strings.
487+
:param codec: Specifies the codec used for generating strings.
462488
"""
463489
parsed_schema = validation.maybe_parse_schema(schema)
464490
if custom_scalars:
@@ -479,7 +505,8 @@ def from_schema(
479505
available_fields.extend(mutation.fields)
480506
validation.validate_fields(fields, available_fields)
481507

482-
strategy = GraphQLStrategy(parsed_schema, custom_scalars or {})
508+
alphabet = _build_alphabet(allow_x00=allow_x00, codec=codec)
509+
strategy = GraphQLStrategy(parsed_schema, alphabet=alphabet, custom_scalars=custom_scalars or {})
483510
strategies = [
484511
strategy.selections(type_, fields=type_fields).map(node_factory).map(print_ast)
485512
for (type_, type_fields, node_factory) in (

test/test_queries.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ class NewType(GraphQLNamedType):
186186
pass
187187

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

191191

192192
@given(data=st.data())
@@ -473,3 +473,15 @@ def test_empty_interface(data, validate_operation):
473473
# And then schema validation should fail instead
474474
with pytest.raises(TypeError, match="Type Empty must define one or more fields"):
475475
validate_operation(schema, query)
476+
477+
478+
@given(data=st.data())
479+
def test_custom_strings(data, validate_operation):
480+
schema = """
481+
type Query {
482+
getExample(name: String): String
483+
}"""
484+
query = data.draw(queries(schema, allow_x00=False, codec="ascii"))
485+
validate_operation(schema, query)
486+
assert "\0" not in query
487+
query.encode("ascii")

0 commit comments

Comments
 (0)