From 3338aaae8dfa5bb96f6dd8d1368c2cdf3d7ddce7 Mon Sep 17 00:00:00 2001 From: Phil Aquilina Date: Thu, 29 Nov 2018 15:52:36 -0800 Subject: [PATCH] Add description to schema parser and printer Descriptions are now part of the [graphql spec](https://facebook.github.io/graphql/June2018/#sec-Descriptions). This commit brings graphql-core inline with the spec. --- graphql/language/ast.py | 48 ++++-- graphql/language/lexer.py | 96 +++++++++++- graphql/language/parser.py | 53 ++++++- graphql/language/printer.py | 73 ++++++--- graphql/language/tests/fixtures.py | 31 +++- .../language/tests/test_block_string_value.py | 108 +++++++++++++ graphql/language/tests/test_schema_printer.py | 33 +++- graphql/language/visitor_meta.py | 20 +-- graphql/type/directives.py | 2 +- graphql/utils/build_ast_schema.py | 22 ++- graphql/utils/schema_printer.py | 142 +++++++++++++++--- graphql/utils/tests/test_build_ast_schema.py | 31 ++++ graphql/utils/tests/test_schema_printer.py | 141 ++++++++++++++++- 13 files changed, 715 insertions(+), 85 deletions(-) create mode 100644 graphql/language/tests/test_block_string_value.py diff --git a/graphql/language/ast.py b/graphql/language/ast.py index f7f407ea..cd616313 100644 --- a/graphql/language/ast.py +++ b/graphql/language/ast.py @@ -543,13 +543,14 @@ def __hash__(self): class StringValue(Value): - __slots__ = ("loc", "value") + __slots__ = ("loc", "value", "is_block_string") _fields = ("value",) - def __init__(self, value, loc=None): + def __init__(self, value, loc=None, is_block_string=False): # type: (str, Optional[Loc]) -> None self.loc = loc self.value = value + self.is_block_string = is_block_string def __eq__(self, other): # type: (Any) -> bool @@ -989,7 +990,7 @@ def __hash__(self): class ObjectTypeDefinition(TypeDefinition): - __slots__ = ("loc", "name", "interfaces", "directives", "fields") + __slots__ = ("loc", "name", "interfaces", "directives", "fields", "description") _fields = ("name", "interfaces", "fields") def __init__( @@ -999,6 +1000,7 @@ def __init__( interfaces=None, # type: Optional[List[NamedType]] loc=None, # type: Optional[Loc] directives=None, # type: Optional[List[Directive]] + description=None, # type: Optional[String] ): # type: (...) -> None self.loc = loc @@ -1006,12 +1008,12 @@ def __init__( self.interfaces = interfaces self.fields = fields self.directives = directives + self.description = description def __eq__(self, other): # type: (Any) -> bool return self is other or ( - isinstance(other, ObjectTypeDefinition) - and + isinstance(other, ObjectTypeDefinition) and # self.loc == other.loc and self.name == other.name and self.interfaces == other.interfaces @@ -1042,7 +1044,7 @@ def __hash__(self): class FieldDefinition(Node): - __slots__ = ("loc", "name", "arguments", "type", "directives") + __slots__ = ("loc", "name", "arguments", "type", "directives", "description") _fields = ("name", "arguments", "type") def __init__( @@ -1052,6 +1054,7 @@ def __init__( type, # type: Union[NamedType, NonNullType, ListType] loc=None, # type: Optional[Loc] directives=None, # type: Optional[List] + description=None, # type: Optional[String] ): # type: (...) -> None self.loc = loc @@ -1059,6 +1062,7 @@ def __init__( self.arguments = arguments self.type = type self.directives = directives + self.description = description def __eq__(self, other): # type: (Any) -> bool @@ -1094,7 +1098,7 @@ def __hash__(self): class InputValueDefinition(Node): - __slots__ = ("loc", "name", "type", "default_value", "directives") + __slots__ = ("loc", "name", "type", "default_value", "directives", "description") _fields = ("name", "type", "default_value") def __init__( @@ -1104,6 +1108,7 @@ def __init__( default_value=None, # type: Any loc=None, # type: Optional[Loc] directives=None, # type: Optional[List] + description=None, # type: Optional[String] ): # type: (...) -> None self.loc = loc @@ -1111,6 +1116,7 @@ def __init__( self.type = type self.default_value = default_value self.directives = directives + self.description = description def __eq__(self, other): # type: (Any) -> bool @@ -1147,7 +1153,7 @@ def __hash__(self): class InterfaceTypeDefinition(TypeDefinition): - __slots__ = ("loc", "name", "fields", "directives") + __slots__ = ("loc", "name", "fields", "directives", "description") _fields = ("name", "fields") def __init__( @@ -1156,12 +1162,14 @@ def __init__( fields, # type: List[FieldDefinition] loc=None, # type: Optional[Loc] directives=None, # type: Optional[List[Directive]] + description=None, # type: Optional[String] ): # type: (...) -> None self.loc = loc self.name = name self.fields = fields self.directives = directives + self.description = description def __eq__(self, other): # type: (Any) -> bool @@ -1194,7 +1202,7 @@ def __hash__(self): class UnionTypeDefinition(TypeDefinition): - __slots__ = ("loc", "name", "types", "directives") + __slots__ = ("loc", "name", "types", "directives", "description") _fields = ("name", "types") def __init__( @@ -1203,12 +1211,14 @@ def __init__( types, # type: List[NamedType] loc=None, # type: Optional[Loc] directives=None, # type: Optional[List[Directive]] + description=None, # type: Optional[String] ): # type: (...) -> None self.loc = loc self.name = name self.types = types self.directives = directives + self.description = description def __eq__(self, other): # type: (Any) -> bool @@ -1241,7 +1251,7 @@ def __hash__(self): class ScalarTypeDefinition(TypeDefinition): - __slots__ = ("loc", "name", "directives") + __slots__ = ("loc", "name", "directives", "description") _fields = ("name",) def __init__( @@ -1249,11 +1259,13 @@ def __init__( name, # type: Name loc=None, # type: Optional[Loc] directives=None, # type: Optional[List[Directive]] + description=None, # type: Optional[String] ): # type: (...) -> None self.loc = loc self.name = name self.directives = directives + self.description = description def __eq__(self, other): # type: (Any) -> bool @@ -1284,7 +1296,7 @@ def __hash__(self): class EnumTypeDefinition(TypeDefinition): - __slots__ = ("loc", "name", "values", "directives") + __slots__ = ("loc", "name", "values", "directives", "description") _fields = ("name", "values") def __init__( @@ -1293,12 +1305,14 @@ def __init__( values, # type: List[EnumValueDefinition] loc=None, # type: Optional[Loc] directives=None, # type: Optional[List[Directive]] + description=None, # type: Optional[String] ): # type: (...) -> None self.loc = loc self.name = name self.values = values self.directives = directives + self.description = description def __eq__(self, other): # type: (Any) -> bool @@ -1331,7 +1345,7 @@ def __hash__(self): class EnumValueDefinition(Node): - __slots__ = ("loc", "name", "directives") + __slots__ = ("loc", "name", "directives", "description") _fields = ("name",) def __init__( @@ -1339,11 +1353,13 @@ def __init__( name, # type: Name loc=None, # type: Optional[Loc] directives=None, # type: Optional[List[Directive]] + description=None, # type: Optional[String] ): # type: (...) -> None self.loc = loc self.name = name self.directives = directives + self.description = description def __eq__(self, other): # type: (Any) -> bool @@ -1374,7 +1390,7 @@ def __hash__(self): class InputObjectTypeDefinition(TypeDefinition): - __slots__ = ("loc", "name", "fields", "directives") + __slots__ = ("loc", "name", "fields", "directives", "description") _fields = ("name", "fields") def __init__( @@ -1383,12 +1399,14 @@ def __init__( fields, # type: List[InputValueDefinition] loc=None, # type: Optional[Loc] directives=None, # type: Optional[List[Directive]] + description=None, # type: Optional[String] ): # type: (...) -> None self.loc = loc self.name = name self.fields = fields self.directives = directives + self.description = description def __eq__(self, other): # type: (Any) -> bool @@ -1454,7 +1472,7 @@ def __hash__(self): class DirectiveDefinition(TypeSystemDefinition): - __slots__ = ("loc", "name", "arguments", "locations") + __slots__ = ("loc", "name", "arguments", "locations", "description") _fields = ("name", "locations") def __init__( @@ -1463,12 +1481,14 @@ def __init__( locations, # type: List[Name] arguments=None, # type: Optional[List[InputValueDefinition]] loc=None, # type: Optional[Loc] + description=None, # type: Optional[String] ): # type: (...) -> None self.name = name self.locations = locations self.loc = loc self.arguments = arguments + self.description = description def __eq__(self, other): # type: (Any) -> bool diff --git a/graphql/language/lexer.py b/graphql/language/lexer.py index a60bc6e2..b1451dbc 100644 --- a/graphql/language/lexer.py +++ b/graphql/language/lexer.py @@ -1,3 +1,4 @@ +import re import json from six import unichr @@ -55,6 +56,10 @@ def next_token(self, reset_position=None): self.prev_position = token.end return token + def look_ahead(self): + skip_token = read_token(self.source, self.prev_position) + return read_token(self.source, skip_token.start) + class TokenKind(object): EOF = 1 @@ -76,6 +81,7 @@ class TokenKind(object): INT = 17 FLOAT = 18 STRING = 19 + BLOCK_STRING = 20 def get_token_desc(token): @@ -111,6 +117,7 @@ def get_token_kind_desc(kind): TokenKind.INT: "Int", TokenKind.FLOAT: "Float", TokenKind.STRING: "String", + TokenKind.BLOCK_STRING: "Block string", } @@ -155,7 +162,7 @@ def read_token(source, from_position): This skips over whitespace and comments until it finds the next lexable token, then lexes punctuators immediately or calls the appropriate - helper fucntion for more complicated tokens.""" + helper function for more complicated tokens.""" body = source.body body_length = len(body) @@ -191,6 +198,11 @@ def read_token(source, from_position): return read_number(source, position, code) elif code == 34: # " + if ( + char_code_at(body, position + 1) == 34 and + char_code_at(body, position + 2) == 34 + ): + return read_block_string(source, position) return read_string(source, position) raise GraphQLSyntaxError( @@ -417,6 +429,55 @@ def read_string(source, start): return Token(TokenKind.STRING, start, position + 1, u"".join(value)) +def read_block_string(source, from_position): + body = source.body + position = from_position + 3 + + chunk_start = position + code = 0 # type: Optional[int] + value = [] # type: List[str] + + while position < len(body) and code is not None: + code = char_code_at(body, position) + + # Closing triple quote + if ( + code == 34 and + char_code_at(body, position + 1) == 34 and + char_code_at(body, position + 2) == 34 + ): + value.append(body[chunk_start:position]) + return Token( + TokenKind.BLOCK_STRING, + from_position, + position + 3, + block_string_value(u"".join(value)), + ) + + if code < 0x0020 and code not in (0x0009, 0x000a, 0x000d): + raise GraphQLSyntaxError( + source, + position, + "Invalid character within str: %s." % print_char_code(code), + ) + + # Escaped triple quote (\""") + if ( + code == 92 and + char_code_at(body, position + 1) == 34 and + char_code_at(body, position + 2) == 34 and + char_code_at(body, position + 3) == 34 + ): + value.append(body[chunk_start, position] + '"""') + position += 4 + chunk_start = position + else: + position += 1 + + raise GraphQLSyntaxError(source, position, "Unterminated string") + + + def uni_char_code(a, b, c, d): # type: (int, int, int, int) -> int """Converts four hexidecimal chars to the integer that the @@ -473,3 +534,36 @@ def read_name(source, position): end += 1 return Token(TokenKind.NAME, position, end, body[position:end]) + + + +SPLIT_RE = re.compile("\r\n|[\n\r]") +WHITESPACE_RE = re.compile("(^[ |\t]*)") +EMPTY_LINE_RE = re.compile("^\s*$") + +def block_string_value(value): + lines = SPLIT_RE.split(value) + + common_indent = None + for line in lines[1:]: + match = WHITESPACE_RE.match(line) + indent = len(match.groups()[0]) + + if indent < len(line) and (common_indent is None or indent < common_indent): + common_indent = indent + if common_indent == 0: + break + + if common_indent: + new_lines = [lines[0]] + for line in lines[1:]: + new_lines.append(line[common_indent:]) + lines = new_lines + + while len(lines) and EMPTY_LINE_RE.match(lines[0]): + lines = lines[1:] + + while len(lines) and EMPTY_LINE_RE.match(lines[-1]): + lines = lines[:-1] + + return '\n'.join(lines) diff --git a/graphql/language/parser.py b/graphql/language/parser.py index 8b658e50..406abef5 100644 --- a/graphql/language/parser.py +++ b/graphql/language/parser.py @@ -140,6 +140,10 @@ def peek(parser, kind): return parser.token.kind == kind +def peek_description(parser): + return peek(parser, TokenKind.STRING) or peek(parser, TokenKind.BLOCK_STRING) + + def skip(parser, kind): # type: (Parser, int) -> bool """If the next token is of the given kind, return true after advancing @@ -254,6 +258,9 @@ def parse_definition(parser): if peek(parser, TokenKind.BRACE_L): return parse_operation_definition(parser) + if peek_description(parser): + return parse_type_system_definition(parser) + if peek(parser, TokenKind.NAME): name = parser.token.value @@ -277,6 +284,11 @@ def parse_definition(parser): raise unexpected(parser) +def parse_description(parser): + if peek_description(parser): + return parse_value_literal(parser, False) + + # Implements the parsing rules in the Operations section. def parse_operation_definition(parser): # type: (Parser) -> OperationDefinition @@ -494,6 +506,14 @@ def parse_value_literal(parser, is_const): value=token.value, loc=loc(parser, token.start) ) + elif token.kind == TokenKind.BLOCK_STRING: + advance(parser) + return ast.StringValue( # type: ignore + value=token.value, + loc=loc(parser, token.start), + is_block_string=True, + ) + elif token.kind == TokenKind.NAME: if token.value in ("true", "false"): advance(parser) @@ -624,10 +644,10 @@ def parse_type_system_definition(parser): - EnumTypeDefinition - InputObjectTypeDefinition """ - if not peek(parser, TokenKind.NAME): - raise unexpected(parser) - - name = parser.token.value + if peek_description(parser): + name = parser.lexer.look_ahead().value + else: + name = parser.token.value if name == "schema": return parse_schema_definition(parser) @@ -688,9 +708,11 @@ def parse_scalar_type_definition(parser): # type: (Parser) -> ScalarTypeDefinition start = parser.token.start expect_keyword(parser, "scalar") + description = parse_description(parser) return ast.ScalarTypeDefinition( name=parse_name(parser), + description=description, directives=parse_directives(parser), loc=loc(parser, start), ) @@ -699,9 +721,11 @@ def parse_scalar_type_definition(parser): def parse_object_type_definition(parser): # type: (Parser) -> ObjectTypeDefinition start = parser.token.start + description = parse_description(parser) expect_keyword(parser, "type") return ast.ObjectTypeDefinition( name=parse_name(parser), + description=description, interfaces=parse_implements_interfaces(parser), directives=parse_directives(parser), fields=any( @@ -729,9 +753,11 @@ def parse_implements_interfaces(parser): def parse_field_definition(parser): # type: (Parser) -> FieldDefinition start = parser.token.start + description = parse_description(parser) return ast.FieldDefinition( # type: ignore name=parse_name(parser), + description=description, arguments=parse_argument_defs(parser), type=expect(parser, TokenKind.COLON) and parse_type(parser), directives=parse_directives(parser), @@ -750,9 +776,11 @@ def parse_argument_defs(parser): def parse_input_value_def(parser): # type: (Parser) -> InputValueDefinition start = parser.token.start + description = parse_description(parser) return ast.InputValueDefinition( # type: ignore name=parse_name(parser), + description=description, type=expect(parser, TokenKind.COLON) and parse_type(parser), default_value=parse_const_value(parser) if skip(parser, TokenKind.EQUALS) @@ -765,10 +793,12 @@ def parse_input_value_def(parser): def parse_interface_type_definition(parser): # type: (Parser) -> InterfaceTypeDefinition start = parser.token.start + description = parse_description(parser) expect_keyword(parser, "interface") return ast.InterfaceTypeDefinition( name=parse_name(parser), + description=description, directives=parse_directives(parser), fields=any( parser, TokenKind.BRACE_L, parse_field_definition, TokenKind.BRACE_R @@ -780,10 +810,12 @@ def parse_interface_type_definition(parser): def parse_union_type_definition(parser): # type: (Parser) -> UnionTypeDefinition start = parser.token.start + description = parse_description(parser) expect_keyword(parser, "union") return ast.UnionTypeDefinition( # type: ignore name=parse_name(parser), + description=description, directives=parse_directives(parser), types=expect(parser, TokenKind.EQUALS) and parse_union_members(parser), loc=loc(parser, start), @@ -806,10 +838,12 @@ def parse_union_members(parser): def parse_enum_type_definition(parser): # type: (Parser) -> EnumTypeDefinition start = parser.token.start + description = parse_description(parser) expect_keyword(parser, "enum") return ast.EnumTypeDefinition( name=parse_name(parser), + description=description, directives=parse_directives(parser), values=many( parser, TokenKind.BRACE_L, parse_enum_value_definition, TokenKind.BRACE_R @@ -821,9 +855,11 @@ def parse_enum_type_definition(parser): def parse_enum_value_definition(parser): # type: (Parser) -> EnumValueDefinition start = parser.token.start + description = parse_description(parser) return ast.EnumValueDefinition( name=parse_name(parser), + description=description, directives=parse_directives(parser), loc=loc(parser, start), ) @@ -832,10 +868,12 @@ def parse_enum_value_definition(parser): def parse_input_object_type_definition(parser): # type: (Parser) -> InputObjectTypeDefinition start = parser.token.start + description = parse_description(parser) expect_keyword(parser, "input") return ast.InputObjectTypeDefinition( name=parse_name(parser), + description=description, directives=parse_directives(parser), fields=any(parser, TokenKind.BRACE_L, parse_input_value_def, TokenKind.BRACE_R), loc=loc(parser, start), @@ -855,6 +893,7 @@ def parse_type_extension_definition(parser): def parse_directive_definition(parser): # type: (Parser) -> DirectiveDefinition start = parser.token.start + description = parse_description(parser) expect_keyword(parser, "directive") expect(parser, TokenKind.AT) @@ -864,7 +903,11 @@ def parse_directive_definition(parser): locations = parse_directive_locations(parser) return ast.DirectiveDefinition( - name=name, locations=locations, arguments=args, loc=loc(parser, start) + name=name, + description=description, + locations=locations, + arguments=args, + loc=loc(parser, start), ) diff --git a/graphql/language/printer.py b/graphql/language/printer.py index 676af0a8..67cde409 100644 --- a/graphql/language/printer.py +++ b/graphql/language/printer.py @@ -140,8 +140,10 @@ def leave_IntValue(self, node, *args): def leave_FloatValue(self, node, *args): return node.value - def leave_StringValue(self, node, *args): + def leave_StringValue(self, node, key, *args): # type: (Any, *Any) -> str + if node.is_block_string: + return print_block_string(node.value, key == 'description') return json.dumps(node.value) def leave_BooleanValue(self, node, *args): @@ -198,84 +200,102 @@ def leave_OperationTypeDefinition(self, node, *args): def leave_ScalarTypeDefinition(self, node, *args): # type: (Any, *Any) -> str - return "scalar " + node.name + wrap(" ", join(node.directives, " ")) + return join([ + node.description, + join(["scalar", node.name, join(node.directives, " ")], " "), + ], "\n") def leave_ObjectTypeDefinition(self, node, *args): # type: (Any, *Any) -> str - return join( - [ + return join([ + node.description, + join([ "type", node.name, wrap("implements ", join(node.interfaces, ", ")), join(node.directives, " "), block(node.fields), - ], - " ", - ) + ], " "), + ], "\n") def leave_FieldDefinition(self, node, *args): # type: (Any, *Any) -> str - return ( + has_multiline_item = any("\n" in arg for arg in node.arguments) + if has_multiline_item: + arguments_str = wrap("(\n", indent(join(node.arguments, "\n")), "\n)") + else: + arguments_str = wrap("(", join(node.arguments, ", "), ")") + + definition_str = ( node.name - + wrap("(", join(node.arguments, ", "), ")") + + arguments_str + ": " + node.type + wrap(" ", join(node.directives, " ")) ) + return join([node.description, definition_str], "\n") def leave_InputValueDefinition(self, node, *args): # type: (Any, *Any) -> str - return ( + definition_str = ( node.name + ": " + node.type + wrap(" = ", node.default_value) + wrap(" ", join(node.directives, " ")) ) + return join([node.description, definition_str], "\n") def leave_InterfaceTypeDefinition(self, node, *args): # type: (Any, *Any) -> str - return ( + definition_str = ( "interface " + node.name + wrap(" ", join(node.directives, " ")) + " " + block(node.fields) ) + return join([node.description, definition_str], "\n") def leave_UnionTypeDefinition(self, node, *args): # type: (Any, *Any) -> str - return ( + definition_str = ( "union " + node.name + wrap(" ", join(node.directives, " ")) + " = " + join(node.types, " | ") ) + return join([node.description, definition_str], "\n") def leave_EnumTypeDefinition(self, node, *args): # type: (Any, *Any) -> str - return ( + definition_str = ( "enum " + node.name + wrap(" ", join(node.directives, " ")) + " " + block(node.values) ) + return join([node.description, definition_str], "\n") def leave_EnumValueDefinition(self, node, *args): # type: (Any, *Any) -> str - return node.name + wrap(" ", join(node.directives, " ")) + return join([ + node.description, + join([node.name, join(node.directives, " ")], " "), + ], "\n") def leave_InputObjectTypeDefinition(self, node, *args): # type: (Any, *Any) -> str - return ( + definition_str = ( "input " + node.name + wrap(" ", join(node.directives, " ")) + " " + block(node.fields) ) + return join([node.description, definition_str], "\n") def leave_TypeExtensionDefinition(self, node, *args): # type: (Any, *Any) -> str @@ -283,11 +303,14 @@ def leave_TypeExtensionDefinition(self, node, *args): def leave_DirectiveDefinition(self, node, *args): # type: (Any, *Any) -> str - return "directive @{}{} on {}".format( - node.name, - wrap("(", join(node.arguments, ", "), ")"), - " | ".join(node.locations), - ) + return join([ + node.description, + "directive @{}{} on {}".format( + node.name, + wrap("(", join(node.arguments, ", "), ")"), + " | ".join(node.locations), + ) + ], "\n") def join(maybe_list, separator=""): @@ -317,3 +340,13 @@ def indent(maybe_str): if maybe_str: return ' ' + maybe_str.replace("\n", "\n ") return "" + + +def print_block_string(value, is_description): + escaped = value.replace('"""', '\\"""') + if "\n" in value or (value[0] != " " and value[0] != "\t"): + if is_description: + return '"""\n' + escaped + '\n"""' + else: + return '"""\n' + indent(escaped) + '\n"""' + return '"""' + escaped.replace(r'"$', '"\n') + '"""' diff --git a/graphql/language/tests/fixtures.py b/graphql/language/tests/fixtures.py index b16653c4..290dc98d 100644 --- a/graphql/language/tests/fixtures.py +++ b/graphql/language/tests/fixtures.py @@ -58,7 +58,7 @@ } """ -SCHEMA_KITCHEN_SINK = """ +SCHEMA_KITCHEN_SINK = ''' # Copyright (c) 2015, Facebook, Inc. # All rights reserved. @@ -72,9 +72,23 @@ mutation: MutationType } +""" +This is a description +of the `Foo` type. +""" type Foo implements Bar { + "Description of the `one` field." one: Type - two(argument: InputType!): Type + """ + This is a description of the `two` field. + """ + two( + """ + This is a description of the `argument` argument. + """ + argument: InputType! + ): Type + """This is a description of the `three` field.""" three(argument: InputType, other: String): Int four(argument: String = "string"): String five(argument: [String] = ["string", "string"]): String @@ -103,8 +117,16 @@ scalar AnnotatedScalar @onScalar enum Site { + """ + This is a description of the `DESKTOP` value + """ + DESKTOP + """This is a description of the `MOBILE` value""" MOBILE + + "This is a description of the `WEB` value" + WEB } enum AnnotatedEnum @onEnum { @@ -129,7 +151,10 @@ type NoFields {} +""" +This is a description of the `@skip` directive +""" directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT directive @include(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT -""" +''' diff --git a/graphql/language/tests/test_block_string_value.py b/graphql/language/tests/test_block_string_value.py new file mode 100644 index 00000000..cbbaacf6 --- /dev/null +++ b/graphql/language/tests/test_block_string_value.py @@ -0,0 +1,108 @@ +from graphql.language.lexer import block_string_value + + +def test_uniform_indentation(): + _input = [ + '', + ' Hello,', + ' World!', + '', + ' Yours,', + ' GraphQL.', + ] + expectation = [ + 'Hello,', + ' World!', + '', + 'Yours,', + ' GraphQL.', + ] + _test_harness(_input, expectation) + + +def test_empty_leading_and_trailing_lines(): + _input = [ + '', + '', + ' Hello,', + ' World!', + '', + ' Yours,', + ' GraphQL.', + '', + '', + ] + expectation = [ + 'Hello,', + ' World!', + '', + 'Yours,', + ' GraphQL.', + ] + _test_harness(_input, expectation) + + +def remove_blank_and_leading_lines(): + _input = [ + ' ', + ' ', + ' Hello,', + ' World!', + '', + ' Yours,', + ' GraphQL.', + ' ', + ' ', + ] + expectation = [ + 'Hello,', + ' World!', + '', + 'Yours,', + ' GraphQL.', + ] + _test_harness(_input, expectation) + + +def test_retain_indentation_from_first_line(): + _input = [ + ' Hello,', + ' World!', + '', + ' Yours,', + ' GraphQL.', + ] + expectation = [ + ' Hello,', + ' World!', + '', + 'Yours,', + ' GraphQL.', + ] + _test_harness(_input, expectation) + + +def test_does_not_alter_trailing_spaces(): + _input = [ + ' ', + ' Hello, ', + ' World! ', + ' ', + ' Yours, ', + ' GraphQL. ', + ' ', + ] + expectation = [ + 'Hello, ', + ' World! ', + ' ', + 'Yours, ', + ' GraphQL. ', + ] + _test_harness(_input, expectation) + + +def _test_harness(_input, expectation): + _input = "\n".join(_input) + expectation = "\n".join(expectation) + assert block_string_value(_input) == expectation diff --git a/graphql/language/tests/test_schema_printer.py b/graphql/language/tests/test_schema_printer.py index afa979cb..310fef55 100644 --- a/graphql/language/tests/test_schema_printer.py +++ b/graphql/language/tests/test_schema_printer.py @@ -38,14 +38,30 @@ def test_prints_kitchen_sink(): ast = parse(SCHEMA_KITCHEN_SINK) printed = print_ast(ast) - expected = """schema { + expected = '''schema { query: QueryType mutation: MutationType } +""" +This is a description +of the `Foo` type. +""" type Foo implements Bar { + "Description of the `one` field." one: Type - two(argument: InputType!): Type + """ + This is a description of the `two` field. + """ + two( + """ + This is a description of the `argument` argument. + """ + argument: InputType! + ): Type + """ + This is a description of the `three` field. + """ three(argument: InputType, other: String): Int four(argument: String = "string"): String five(argument: [String] = ["string", "string"]): String @@ -74,8 +90,16 @@ def test_prints_kitchen_sink(): scalar AnnotatedScalar @onScalar enum Site { + """ + This is a description of the `DESKTOP` value + """ DESKTOP + """ + This is a description of the `MOBILE` value + """ MOBILE + "This is a description of the `WEB` value" + WEB } enum AnnotatedEnum @onEnum { @@ -100,9 +124,12 @@ def test_prints_kitchen_sink(): type NoFields {} +""" +This is a description of the `@skip` directive +""" directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT directive @include(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT -""" +''' assert printed == expected diff --git a/graphql/language/visitor_meta.py b/graphql/language/visitor_meta.py index 37372c48..87f5ad37 100644 --- a/graphql/language/visitor_meta.py +++ b/graphql/language/visitor_meta.py @@ -31,17 +31,17 @@ ast.NonNullType: ("type",), ast.SchemaDefinition: ("directives", "operation_types"), ast.OperationTypeDefinition: ("type",), - ast.ScalarTypeDefinition: ("name", "directives"), - ast.ObjectTypeDefinition: ("name", "interfaces", "directives", "fields"), - ast.FieldDefinition: ("name", "arguments", "directives", "type"), - ast.InputValueDefinition: ("name", "type", "directives", "default_value"), - ast.InterfaceTypeDefinition: ("name", "directives", "fields"), - ast.UnionTypeDefinition: ("name", "directives", "types"), - ast.EnumTypeDefinition: ("name", "directives", "values"), - ast.EnumValueDefinition: ("name", "directives"), - ast.InputObjectTypeDefinition: ("name", "directives", "fields"), + ast.ScalarTypeDefinition: ("description", "name", "directives"), + ast.ObjectTypeDefinition: ("description", "name", "interfaces", "directives", "fields"), + ast.FieldDefinition: ("description", "name", "arguments", "directives", "type"), + ast.InputValueDefinition: ("description", "name", "type", "directives", "default_value"), + ast.InterfaceTypeDefinition: ("description", "name", "directives", "fields"), + ast.UnionTypeDefinition: ("description", "name", "directives", "types"), + ast.EnumTypeDefinition: ("description", "name", "directives", "values"), + ast.EnumValueDefinition: ("description", "name", "directives"), + ast.InputObjectTypeDefinition: ("description", "name", "directives", "fields"), ast.TypeExtensionDefinition: ("definition",), - ast.DirectiveDefinition: ("name", "arguments", "locations"), + ast.DirectiveDefinition: ("description", "name", "arguments", "locations"), } AST_KIND_TO_TYPE = {c.__name__: c for c in QUERY_DOCUMENT_KEYS.keys()} diff --git a/graphql/type/directives.py b/graphql/type/directives.py index ef7417c4..1a7bf701 100644 --- a/graphql/type/directives.py +++ b/graphql/type/directives.py @@ -107,7 +107,7 @@ def __init__(self, name, description=None, args=None, locations=None): "reason": GraphQLArgument( type=GraphQLString, description=( - "Explains why this element was deprecated, usually also including a suggestion for how to" + "Explains why this element was deprecated, usually also including a suggestion for how to " "access supported similar data. Formatted in [Markdown]" "(https://daringfireball.net/projects/markdown/)." ), diff --git a/graphql/utils/build_ast_schema.py b/graphql/utils/build_ast_schema.py index 8d28f4be..541be45b 100644 --- a/graphql/utils/build_ast_schema.py +++ b/graphql/utils/build_ast_schema.py @@ -167,6 +167,7 @@ def get_directive(directive_ast): name=directive_ast.name.value, locations=[node.value for node in directive_ast.locations], args=make_input_values(directive_ast.arguments, GraphQLArgument), + description=get_description(directive_ast), ) def get_object_type(type_ast): @@ -210,6 +211,7 @@ def make_type_def(definition): name=definition.name.value, fields=lambda: make_field_def_map(definition), interfaces=make_implemented_interfaces(definition), + description=get_description(definition), ) def make_field_def_map(definition): @@ -220,6 +222,7 @@ def make_field_def_map(definition): type=produce_type_def(f.type), args=make_input_values(f.arguments, GraphQLArgument), deprecation_reason=get_deprecation_reason(f.directives), + description=get_description(f), ), ) for f in definition.fields @@ -237,6 +240,7 @@ def make_input_values(values, cls): default_value=value_from_ast( value.default_value, produce_type_def(value.type) ), + description=get_description(value), ), ) for value in values @@ -247,6 +251,7 @@ def make_interface_def(definition): name=definition.name.value, resolve_type=_none, fields=lambda: make_field_def_map(definition), + description=get_description(definition), ) def make_enum_def(definition): @@ -254,18 +259,24 @@ def make_enum_def(definition): ( v.name.value, GraphQLEnumValue( - deprecation_reason=get_deprecation_reason(v.directives) + deprecation_reason=get_deprecation_reason(v.directives), + description=get_description(v), ), ) for v in definition.values ) - return GraphQLEnumType(name=definition.name.value, values=values) + return GraphQLEnumType( + name=definition.name.value, + values=values, + description=get_description(definition), + ) def make_union_def(definition): return GraphQLUnionType( name=definition.name.value, resolve_type=_none, types=[produce_type_def(t) for t in definition.types], + description=get_description(definition), ) def make_scalar_def(definition): @@ -277,6 +288,7 @@ def make_scalar_def(definition): # will cause them to pass. parse_literal=_false, parse_value=_false, + description=get_description(definition), ) def make_input_object_def(definition): @@ -285,6 +297,7 @@ def make_input_object_def(definition): fields=lambda: make_input_values( definition.fields, GraphQLInputObjectField ), + description=get_description(definition), ) _schema_def_handlers = { @@ -352,3 +365,8 @@ def get_deprecation_reason(directives): return args["reason"] else: return None + + +def get_description(node): + if node.description: + return node.description.value diff --git a/graphql/utils/schema_printer.py b/graphql/utils/schema_printer.py index 30a28abd..18356a75 100644 --- a/graphql/utils/schema_printer.py +++ b/graphql/utils/schema_printer.py @@ -1,3 +1,5 @@ +import re + from ..language.printer import print_ast from ..type.definition import ( GraphQLEnumType, @@ -25,6 +27,9 @@ from typing import Any, Union, Callable +MAX_DESC_LEN = 120 + + def print_schema(schema): # type: (GraphQLSchema) -> str return _print_filtered_schema( @@ -122,7 +127,7 @@ def _print_type(type): def _print_scalar(type): # type: (GraphQLScalarType) -> str - return "scalar {}".format(type.name) + return _print_description(type) + "scalar {}".format(type.name) def _print_object(type): @@ -134,44 +139,69 @@ def _print_object(type): else "" ) - return ("type {}{} {{\n" "{}\n" "}}").format( - type.name, implemented_interfaces, _print_fields(type) + return ("{}type {}{} {{\n" "{}\n" "}}").format( + _print_description(type), + type.name, + implemented_interfaces, + _print_fields(type), ) def _print_interface(type): # type: (GraphQLInterfaceType) -> str - return ("interface {} {{\n" "{}\n" "}}").format(type.name, _print_fields(type)) + return ("{}interface {} {{\n" "{}\n" "}}").format( + _print_description(type), + type.name, + _print_fields(type), + ) def _print_union(type): # type: (GraphQLUnionType) -> str - return "union {} = {}".format(type.name, " | ".join(str(t) for t in type.types)) + return "{}union {} = {}".format( + _print_description(type), + type.name, + " | ".join(str(t) for t in type.types), + ) def _print_enum(type): # type: (GraphQLEnumType) -> str - return ("enum {} {{\n" "{}\n" "}}").format( - type.name, "\n".join(" " + v.name + _print_deprecated(v) for v in type.values) + enum_values_str = "\n".join( + _print_description(v, ' ', not idx) + ' ' + v.name + _print_deprecated(v) + for idx, v in enumerate(type.values) + ) + return ("{}enum {} {{\n" "{}\n" "}}").format( + _print_description(type), + type.name, + enum_values_str, ) def _print_input_object(type): # type: (GraphQLInputObjectType) -> str - return ("input {} {{\n" "{}\n" "}}").format( + fields_str = "\n".join( + _print_description(f, " ", not idx) + " " + _print_input_value(name, f) + for idx, (name, f) in enumerate(type.fields.items()) + ) + return ("{}input {} {{\n" "{}\n" "}}").format( + _print_description(type), type.name, - "\n".join( - " " + _print_input_value(name, field) - for name, field in type.fields.items() - ), + fields_str, ) def _print_fields(type): # type: (Union[GraphQLObjectType, GraphQLInterfaceType]) -> str return "\n".join( - " {}{}: {}{}".format(f_name, _print_args(f), f.type, _print_deprecated(f)) - for f_name, f in type.fields.items() + "{} {}{}: {}{}".format( + _print_description(f, ' ', not idx), + f_name, + _print_args(f), + f.type, + _print_deprecated(f), + ) + for idx, (f_name, f) in enumerate(type.fields.items()) ) @@ -189,15 +219,24 @@ def _print_deprecated(field_or_enum_value): def _print_args(field_or_directives): # type: (Union[GraphQLField, GraphQLDirective]) -> str - if not field_or_directives.args: + args = field_or_directives.args + + if not args: return "" - return "({})".format( - ", ".join( - _print_input_value(arg_name, arg) - for arg_name, arg in field_or_directives.args.items() + if all(not arg.description for arg in args.values()): + return "({})".format( + ", ".join( + _print_input_value(arg_name, arg) + for arg_name, arg in args.items() + ) ) + + args_description = "\n".join( + _print_description(arg, ' ', not idx) + " " + _print_input_value(arg_name, arg) + for idx, (arg_name, arg) in enumerate(args.items()) ) + return "(\n" + args_description + "\n)" def _print_input_value(name, arg): @@ -212,9 +251,70 @@ def _print_input_value(name, arg): def _print_directive(directive): # type: (GraphQLDirective) -> str - return "directive @{}{} on {}".format( - directive.name, _print_args(directive), " | ".join(directive.locations) + return "{}directive @{}{} on {}".format( + _print_description(directive), + directive.name, + _print_args(directive), + " | ".join(directive.locations), ) +def _print_description(definition, indentation="", first_in_block=True): + if not definition.description: + return "" + + lines = _description_lines(definition.description, MAX_DESC_LEN - len(indentation)) + if indentation and not first_in_block: + description = "\n" + indentation + '"""' + else: + description = indentation + '"""' + + if len(lines) == 1 and len(lines[0]) < 70 and lines[0][-1] != '"': + return description + _escape_quote(lines[0]) + '"""\n' + + has_leading_space = lines[0][0] == " " or lines[0][0] == "\t"; + if not has_leading_space: + description += "\n"; + + for idx, line in enumerate(lines): + if idx != 0 or not has_leading_space: + description += indentation; + + description += _escape_quote(line) + "\n"; + + return description + indentation + '"""\n'; + + +def _description_lines(description, max_len): + lines = [] + raw_lines = description.split("\n") + for line in raw_lines: + if line == "": + lines.append(line) + else: + lines = lines + _break_lines(line, max_len) + return lines + + +def _break_lines(line, max_len): + if len(line) < max_len + 5: + return [line] + + line_split_re = r"((?: |^).{15,%s}(?= |$))" % str(max_len - 40) + parts = re.split(line_split_re, line) + + if len(parts) < 4: + return [line] + + sublines = [parts[0] + parts[1] + parts[2]] + for idx in range(3, len(parts), 2): + sublines.append(parts[idx][1:] + parts[idx + 1]) + + return sublines + + +def _escape_quote(line): + return line.replace('"""', '\\"""') + + __all__ = ["print_schema", "print_introspection_schema"] diff --git a/graphql/utils/tests/test_build_ast_schema.py b/graphql/utils/tests/test_build_ast_schema.py index 6f84aa64..bf9bcf0f 100644 --- a/graphql/utils/tests/test_build_ast_schema.py +++ b/graphql/utils/tests/test_build_ast_schema.py @@ -54,6 +54,37 @@ def test_with_directives(): assert output == body +def test_supports_descriptions(): + body = ''' +schema { + query: Query +} + +"""This is a directive""" +directive @foo( + """It has an argument""" + arg: Int +) on FIELD + +"""With an enum""" +enum Color { + RED + + """Not a creative color""" + GREEN + BLUE +} + +"""What a great type""" +type Query { + """And a field to boot""" + str: String +} +''' + output = cycle_output(body) + assert output == body + + def test_maintains_skip_and_include_directives(): body = """ schema { diff --git a/graphql/utils/tests/test_schema_printer.py b/graphql/utils/tests/test_schema_printer.py index 0d61facf..fbaf3a5d 100644 --- a/graphql/utils/tests/test_schema_printer.py +++ b/graphql/utils/tests/test_schema_printer.py @@ -569,17 +569,45 @@ def test_print_introspection_schema(): assert ( output - == """ + == ''' schema { query: Root } -directive @include(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT +""" +Directs the executor to include this field or fragment only when the `if` argument is true. +""" +directive @include( + """Included when true.""" + if: Boolean! +) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT -directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT +""" +Directs the executor to skip this field or fragment when the `if` argument is true. +""" +directive @skip( + """Skipped when true.""" + if: Boolean! +) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT + +"""Marks an element of a GraphQL schema as no longer supported.""" +directive @deprecated( + """ + Explains why this element was deprecated, usually also including a suggestion + for how to access supported similar data. Formatted in + [Markdown](https://daringfireball.net/projects/markdown/). + """ + reason: String = "No longer supported" +) on FIELD_DEFINITION | ENUM_VALUE -directive @deprecated(reason: String = "No longer supported") on FIELD_DEFINITION | ENUM_VALUE +""" +A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document. +In some cases, you need to provide options to alter GraphQL's execution behavior +in ways field arguments will not suffice, such as conditionally including or +skipping a field. Directives provide this by describing additional information +to the executor. +""" type __Directive { name: String! description: String @@ -590,27 +618,71 @@ def test_print_introspection_schema(): onField: Boolean! @deprecated(reason: "Use `locations`.") } +""" +A Directive can be adjacent to many parts of the GraphQL language, a +__DirectiveLocation describes one such possible adjacencies. +""" enum __DirectiveLocation { + """Location adjacent to a query operation.""" QUERY + + """Location adjacent to a mutation operation.""" MUTATION + + """Location adjacent to a subscription operation.""" SUBSCRIPTION + + """Location adjacent to a field.""" FIELD + + """Location adjacent to a fragment definition.""" FRAGMENT_DEFINITION + + """Location adjacent to a fragment spread.""" FRAGMENT_SPREAD + + """Location adjacent to an inline fragment.""" INLINE_FRAGMENT + + """Location adjacent to a schema definition.""" SCHEMA + + """Location adjacent to a scalar definition.""" SCALAR + + """Location adjacent to an object definition.""" OBJECT + + """Location adjacent to a field definition.""" FIELD_DEFINITION + + """Location adjacent to an argument definition.""" ARGUMENT_DEFINITION + + """Location adjacent to an interface definition.""" INTERFACE + + """Location adjacent to a union definition.""" UNION + + """Location adjacent to an enum definition.""" ENUM + + """Location adjacent to an enum value definition.""" ENUM_VALUE + + """Location adjacent to an input object definition.""" INPUT_OBJECT + + """Location adjacent to an input object field definition.""" INPUT_FIELD_DEFINITION } +""" +One possible value for a given Enum. Enum values are unique values, not a +placeholder for a string or numeric value. However an Enum value is returned in +a JSON response as a string. +""" type __EnumValue { name: String! description: String @@ -618,6 +690,10 @@ def test_print_introspection_schema(): deprecationReason: String } +""" +Object and Interface types are described by a list of Fields, each of which has +a name, potentially a list of arguments, and a return type. +""" type __Field { name: String! description: String @@ -627,6 +703,11 @@ def test_print_introspection_schema(): deprecationReason: String } +""" +Arguments provided to Fields or Directives and the input fields of an +InputObject are represented as Input Values which describe their type and +optionally a default value. +""" type __InputValue { name: String! description: String @@ -634,14 +715,42 @@ def test_print_introspection_schema(): defaultValue: String } +""" +A GraphQL Schema defines the capabilities of a GraphQL server. It exposes all +available types and directives on the server, as well as the entry points for +query, mutation and subscription operations. +""" type __Schema { + """A list of all types supported by this server.""" types: [__Type!]! + + """The type that query operations will be rooted at.""" queryType: __Type! + + """ + If this server supports mutation, the type that mutation operations will be rooted at. + """ mutationType: __Type + + """ + If this server support subscription, the type that subscription operations will be rooted at. + """ subscriptionType: __Type + + """A list of all directives supported by this server.""" directives: [__Directive!]! } +""" +The fundamental unit of any GraphQL Schema is the type. There are many kinds of +types in GraphQL as represented by the `__TypeKind` enum. + +Depending on the kind of a type, certain fields describe information about that +type. Scalar types provide no information beyond a name and description, while +Enum types provide their values. Object and Interface types provide the fields +they describe. Abstract types, Union and Interface, provide the Object types +possible at runtime. List and NonNull types compose other types. +""" type __Type { kind: __TypeKind! name: String @@ -654,15 +763,37 @@ def test_print_introspection_schema(): ofType: __Type } +"""An enum describing what kind of type a given `__Type` is""" enum __TypeKind { + """Indicates this type is a scalar.""" SCALAR + + """ + Indicates this type is an object. `fields` and `interfaces` are valid fields. + """ OBJECT + + """ + Indicates this type is an interface. `fields` and `possibleTypes` are valid fields. + """ INTERFACE + + """Indicates this type is a union. `possibleTypes` is a valid field.""" UNION + + """Indicates this type is an enum. `enumValues` is a valid field.""" ENUM + + """ + Indicates this type is an input object. `inputFields` is a valid field. + """ INPUT_OBJECT + + """Indicates this type is a list. `ofType` is a valid field.""" LIST + + """Indicates this type is a non-null. `ofType` is a valid field.""" NON_NULL } -""" +''' )