Skip to content

Commit dd0f93b

Browse files
committed
print_schema: handle descriptions that are non-printable as block strings
Replicates graphql/graphql-js@be12613
1 parent 1b33f57 commit dd0f93b

File tree

11 files changed

+363
-207
lines changed

11 files changed

+363
-207
lines changed

docs/modules/utilities.rst

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,6 @@ Print a GraphQLSchema to GraphQL Schema language:
4848
.. autofunction:: print_introspection_schema
4949
.. autofunction:: print_schema
5050
.. autofunction:: print_type
51-
.. autofunction:: print_value
5251

5352
Create a GraphQLType from a GraphQL language AST:
5453

src/graphql/language/block_string.py

Lines changed: 100 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,80 +1,105 @@
1+
from typing import Collection, List
2+
from sys import maxsize
3+
14
__all__ = [
2-
"dedent_block_string_value",
5+
"dedent_block_string_lines",
6+
"is_printable_as_block_string",
37
"print_block_string",
4-
"get_block_string_indentation",
58
]
69

710

8-
def dedent_block_string_value(raw_string: str) -> str:
11+
def dedent_block_string_lines(lines: Collection[str]) -> List[str]:
912
"""Produce the value of a block string from its parsed raw value.
1013
11-
Similar to CoffeeScript's block string, Python's docstring trim or Ruby's
12-
strip_heredoc.
14+
This function works similar to CoffeeScript's block string,
15+
Python's docstring trim or Ruby's strip_heredoc.
1316
14-
This implements the GraphQL spec's BlockStringValue() static algorithm.
17+
It implements the GraphQL spec's BlockStringValue() static algorithm.
1518
1619
Note that this is very similar to Python's inspect.cleandoc() function.
17-
The differences is that the latter also expands tabs to spaces and
20+
The difference is that the latter also expands tabs to spaces and
1821
removes whitespace at the beginning of the first line. Python also has
1922
textwrap.dedent() which uses a completely different algorithm.
2023
2124
For internal use only.
2225
"""
23-
# Expand a block string's raw value into independent lines.
24-
lines = raw_string.splitlines()
26+
common_indent = maxsize
27+
first_non_empty_line = None
28+
last_non_empty_line = -1
29+
30+
for i, line in enumerate(lines):
31+
indent = leading_white_space(line)
32+
33+
if indent == len(line):
34+
continue # skip empty lines
2535

26-
# Remove common indentation from all lines but first.
27-
common_indent = get_block_string_indentation(raw_string)
36+
if first_non_empty_line is None:
37+
first_non_empty_line = i
38+
last_non_empty_line = i
2839

29-
if common_indent:
30-
lines[1:] = [line[common_indent:] for line in lines[1:]]
40+
if i and indent < common_indent:
41+
common_indent = indent
3142

32-
# Remove leading and trailing blank lines.
33-
start_line = 0
34-
end_line = len(lines)
35-
while start_line < end_line and is_blank(lines[start_line]):
36-
start_line += 1
37-
while end_line > start_line and is_blank(lines[end_line - 1]):
38-
end_line -= 1
43+
if first_non_empty_line is None:
44+
first_non_empty_line = 0
3945

40-
# Return a string of the lines joined with U+000A.
41-
return "\n".join(lines[start_line:end_line])
46+
return [ # Remove common indentation from all lines but first.
47+
line[common_indent:] if i else line for i, line in enumerate(lines)
48+
][ # Remove leading and trailing blank lines.
49+
first_non_empty_line : last_non_empty_line + 1
50+
]
4251

4352

44-
def is_blank(s: str) -> bool:
45-
"""Check whether string contains only space or tab characters."""
46-
return all(c == " " or c == "\t" for c in s)
53+
def leading_white_space(s: str) -> int:
54+
i = 0
55+
for c in s:
56+
if c not in " \t":
57+
return i
58+
i += 1
59+
return i
4760

4861

49-
def get_block_string_indentation(value: str) -> int:
50-
"""Get the amount of indentation for the given block string.
62+
def is_printable_as_block_string(value: str) -> bool:
63+
"""Check whether the given string is printable as a block string.
5164
5265
For internal use only.
5366
"""
54-
is_first_line = is_empty_line = True
55-
indent = 0
56-
common_indent = None
67+
if not isinstance(value, str):
68+
value = str(value) # resolve lazy string proxy object
69+
70+
if not value:
71+
return True # emtpy string is printable
72+
73+
is_empty_line = True
74+
has_indent = False
75+
has_common_indent = True
76+
seen_non_empty_line = False
5777

5878
for c in value:
59-
if c in "\r\n":
60-
is_first_line = False
79+
if c == "\n":
80+
if is_empty_line and not seen_non_empty_line:
81+
return False # has leading new line
82+
seen_non_empty_line = True
6183
is_empty_line = True
62-
indent = 0
63-
elif c in "\t ":
64-
indent += 1
84+
has_indent = False
85+
elif c in " \t":
86+
has_indent = has_indent or is_empty_line
87+
elif c <= "\x0f":
88+
return False
6589
else:
66-
if (
67-
is_empty_line
68-
and not is_first_line
69-
and (common_indent is None or indent < common_indent)
70-
):
71-
common_indent = indent
90+
has_common_indent = has_common_indent and has_indent
7291
is_empty_line = False
7392

74-
return common_indent or 0
93+
if is_empty_line:
94+
return False # has trailing empty lines
7595

96+
if has_common_indent and seen_non_empty_line:
97+
return False # has internal indent
7698

77-
def print_block_string(value: str, prefer_multiple_lines: bool = False) -> str:
99+
return True
100+
101+
102+
def print_block_string(value: str, minimize: bool = False) -> str:
78103
"""Print a block string in the indented block form.
79104
80105
Prints a block string in the indented block form by adding a leading and
@@ -86,24 +111,45 @@ def print_block_string(value: str, prefer_multiple_lines: bool = False) -> str:
86111
if not isinstance(value, str):
87112
value = str(value) # resolve lazy string proxy object
88113

89-
is_single_line = "\n" not in value
90-
has_leading_space = value.startswith(" ") or value.startswith("\t")
91-
has_trailing_quote = value.endswith('"')
114+
escaped_value = value.replace('"""', '\\"""')
115+
116+
# Expand a block string's raw value into independent lines.
117+
lines = escaped_value.splitlines() or [""]
118+
num_lines = len(lines)
119+
is_single_line = num_lines == 1
120+
121+
# If common indentation is found,
122+
# we can fix some of those cases by adding a leading new line.
123+
force_leading_new_line = num_lines > 1 and all(
124+
not line or line[0] in " \t" for line in lines[1:]
125+
)
126+
127+
# Trailing triple quotes just looks confusing but doesn't force trailing new line.
128+
has_trailing_triple_quotes = escaped_value.endswith('\\"""')
129+
130+
# Trailing quote (single or double) or slash forces trailing new line
131+
has_trailing_quote = value.endswith('"') and not has_trailing_triple_quotes
92132
has_trailing_slash = value.endswith("\\")
93-
print_as_multiple_lines = (
133+
force_trailing_new_line = has_trailing_quote or has_trailing_slash
134+
135+
print_as_multiple_lines = not minimize and (
136+
# add leading and trailing new lines only if it improves readability
94137
not is_single_line
95-
or has_trailing_quote
96-
or has_trailing_slash
97-
or prefer_multiple_lines
138+
or len(value) > 70
139+
or force_trailing_new_line
140+
or force_leading_new_line
141+
or has_trailing_triple_quotes
98142
)
99143

100144
# Format a multi-line block quote to account for leading space.
145+
skip_leading_new_line = is_single_line and value and value[0] in " \t"
101146
before = (
102147
"\n"
103-
if print_as_multiple_lines and not (is_single_line and has_leading_space)
148+
if print_as_multiple_lines
149+
and not skip_leading_new_line
150+
or force_leading_new_line
104151
else ""
105152
)
106-
after = "\n" if print_as_multiple_lines else ""
107-
value = value.replace('"""', '\\"""')
153+
after = "\n" if print_as_multiple_lines or force_trailing_new_line else ""
108154

109-
return f'"""{before}{value}{after}"""'
155+
return f'"""{before}{escaped_value}{after}"""'

src/graphql/language/lexer.py

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from ..error import GraphQLSyntaxError
44
from .ast import Token
5-
from .block_string import dedent_block_string_value
5+
from .block_string import dedent_block_string_lines
66
from .character_classes import is_digit, is_name_start, is_name_continue
77
from .source import Source
88
from .token_kind import TokenKind
@@ -380,40 +380,49 @@ def read_block_string(self, start: int) -> Token:
380380
"""Read a block string token from the source file."""
381381
body = self.source.body
382382
body_length = len(body)
383-
start_line = self.line
384-
start_column = 1 + start - self.line_start
383+
line_start = self.line_start
385384

386385
position = start + 3
387386
chunk_start = position
388-
raw_value = []
387+
current_line = ""
389388

389+
block_lines = []
390390
while position < body_length:
391391
char = body[position]
392392

393393
if char == '"' and body[position + 1 : position + 3] == '""':
394-
raw_value.append(body[chunk_start:position])
395-
return Token(
394+
current_line += body[chunk_start:position]
395+
block_lines.append(current_line)
396+
397+
token = self.create_token(
396398
TokenKind.BLOCK_STRING,
397399
start,
398400
position + 3,
399-
start_line,
400-
start_column,
401-
dedent_block_string_value("".join(raw_value)),
401+
# return a string of the lines joined with new lines
402+
"\n".join(dedent_block_string_lines(block_lines)),
402403
)
403404

405+
self.line += len(block_lines) - 1
406+
self.line_start = line_start
407+
return token
408+
404409
if char == "\\" and body[position + 1 : position + 4] == '"""':
405-
raw_value.extend((body[chunk_start:position], '"""'))
410+
current_line += body[chunk_start:position]
411+
chunk_start = position + 1 # skip only slash
406412
position += 4
407-
chunk_start = position
408413
continue
409414

410415
if char in "\r\n":
416+
current_line += body[chunk_start:position]
417+
block_lines.append(current_line)
418+
411419
if char == "\r" and body[position + 1 : position + 2] == "\n":
412420
position += 2
413421
else:
414422
position += 1
415-
self.line += 1
416-
self.line_start = position
423+
424+
current_line = ""
425+
chunk_start = line_start = position
417426
continue
418427

419428
if is_unicode_scalar_value(char):

src/graphql/language/print_string.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22

33

44
def print_string(s: str) -> str:
5-
""" "Print a string as a GraphQL StringValue literal.
5+
"""Print a string as a GraphQL StringValue literal.
66
77
Replaces control characters and excluded characters (" U+0022 and \\ U+005C)
88
with escape sequences.
99
"""
10+
if not isinstance(s, str):
11+
s = str(s)
1012
return f'"{s.translate(escape_sequences)}"'
1113

1214

src/graphql/utilities/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
print_introspection_schema,
3434
print_schema,
3535
print_type,
36-
print_value,
36+
print_value, # deprecated
3737
)
3838

3939
# Create a GraphQLType from a GraphQL language AST.

src/graphql/utilities/print_schema.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from typing import Any, Callable, Dict, List, Optional, Union, cast
22

33
from ..language import print_ast, StringValueNode
4-
from ..language.block_string import print_block_string
4+
from ..language.block_string import is_printable_as_block_string
55
from ..pyutils import inspect
66
from ..type import (
77
DEFAULT_DEPRECATION_REASON,
@@ -282,8 +282,11 @@ def print_description(
282282
if description is None:
283283
return ""
284284

285-
prefer_multiple_lines = len(description) > 70
286-
block_string = print_block_string(description, prefer_multiple_lines)
285+
block_string = print_ast(
286+
StringValueNode(
287+
value=description, block=is_printable_as_block_string(description)
288+
)
289+
)
287290

288291
prefix = "\n" + indentation if indentation and not first_in_block else indentation
289292

src/graphql/utilities/strip_ignored_characters.py

Lines changed: 6 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,11 @@
22

33
from ..language import Lexer, TokenKind
44
from ..language.source import Source, is_source
5-
from ..language.block_string import (
6-
dedent_block_string_value,
7-
get_block_string_indentation,
8-
)
5+
from ..language.block_string import print_block_string
96
from ..language.lexer import is_punctuator_token_kind
107

8+
__all__ = ["strip_ignored_characters"]
9+
1110

1211
def strip_ignored_characters(source: Union[str, Source]) -> str:
1312
"""Strip characters that are ignored anyway.
@@ -86,25 +85,12 @@ def strip_ignored_characters(source: Union[str, Source]) -> str:
8685

8786
token_body = body[current_token.start : current_token.end]
8887
if token_kind == TokenKind.BLOCK_STRING:
89-
stripped_body += dedent_block_string(token_body)
88+
stripped_body += print_block_string(
89+
current_token.value or "", minimize=True
90+
)
9091
else:
9192
stripped_body += token_body
9293

9394
was_last_added_token_non_punctuator = is_non_punctuator
9495

9596
return stripped_body
96-
97-
98-
def dedent_block_string(block_str: str) -> str:
99-
"""Skip leading and trailing triple quotations"""
100-
raw_str = block_str[3:-3]
101-
body = dedent_block_string_value(raw_str)
102-
103-
if get_block_string_indentation(body) > 0:
104-
body = "\n" + body
105-
106-
has_trailing_quote = body.endswith('"') and not body.endswith('\\"""')
107-
if has_trailing_quote or body.endswith("\\"):
108-
body += "\n"
109-
110-
return '"""' + body + '"""'

0 commit comments

Comments
 (0)