Skip to content

Commit f191bba

Browse files
authored
improved gql-cli script (#148)
Breaking changes: Now printing result as json Now working with multi-lines queries, using Ctrl-D (EOF) to execute queries Using --variables instead of --params Improvements of the gql-cli script: add --version flag add --verbose and --debug arguments add --operation-name argument add --headers argument examples added in --help moved most of the script content in the gql module added sphinx docs, usage automagically set using sphinx-argparse added tests for the gql-cli script
1 parent 92c8d70 commit f191bba

File tree

12 files changed

+737
-85
lines changed

12 files changed

+737
-85
lines changed

Makefile

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ all_tests:
1010
pytest tests --cov=gql --cov-report=term-missing --run-online -vv
1111

1212
check:
13-
isort --recursive gql tests
14-
black gql tests
15-
flake8 gql tests
16-
mypy gql tests
13+
isort --recursive gql tests scripts/gql-cli
14+
black gql tests scripts/gql-cli
15+
flake8 gql tests scripts/gql-cli
16+
mypy gql tests scripts/gql-cli
1717
check-manifest
1818

1919
docs:

docs/conf.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
3333
# ones.
3434
extensions = [
35+
'sphinxarg.ext',
3536
'sphinx.ext.autodoc',
3637
'sphinx_rtd_theme'
3738
]

docs/gql-cli/intro.rst

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
gql-cli
2+
=======
3+
4+
GQL provides a python 3.6+ script, called `gql-cli` which allows you to execute
5+
GraphQL queries directly from the terminal.
6+
7+
This script supports http(s) or websockets protocols.
8+
9+
Usage
10+
-----
11+
12+
.. argparse::
13+
:module: gql.cli
14+
:func: get_parser
15+
:prog: gql-cli
16+
17+
Examples
18+
--------
19+
20+
Simple query using https
21+
^^^^^^^^^^^^^^^^^^^^^^^^^
22+
23+
.. code-block:: shell
24+
25+
$ echo 'query { continent(code:"AF") { name } }' | gql-cli https://countries.trevorblades.com
26+
{"continent": {"name": "Africa"}}
27+
28+
Simple query using websockets
29+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
30+
31+
.. code-block:: shell
32+
33+
$ echo 'query { continent(code:"AF") { name } }' | gql-cli wss://countries.trevorblades.com/graphql
34+
{"continent": {"name": "Africa"}}
35+
36+
Query with variable
37+
^^^^^^^^^^^^^^^^^^^
38+
39+
.. code-block:: shell
40+
41+
$ echo 'query getContinent($code:ID!) { continent(code:$code) { name } }' | gql-cli https://countries.trevorblades.com --variables code:AF
42+
{"continent": {"name": "Africa"}}
43+
44+
Interactive usage
45+
^^^^^^^^^^^^^^^^^
46+
47+
Insert your query in the terminal, then press Ctrl-D to execute it.
48+
49+
.. code-block:: shell
50+
51+
$ gql-cli wss://countries.trevorblades.com/graphql --variables code:AF
52+
53+
Execute query saved in a file
54+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
55+
56+
Put the query in a file:
57+
58+
.. code-block:: shell
59+
60+
$ echo 'query {
61+
continent(code:"AF") {
62+
name
63+
}
64+
}' > query.gql
65+
66+
Then execute query from the file:
67+
68+
.. code-block:: shell
69+
70+
$ cat query.gql | gql-cli wss://countries.trevorblades.com/graphql
71+
{"continent": {"name": "Africa"}}

docs/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ Contents
1717
async/index
1818
transports/index
1919
advanced/index
20+
gql-cli/intro
2021
modules/gql
2122

2223

gql/cli.py

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
import json
2+
import logging
3+
import sys
4+
from argparse import ArgumentParser, Namespace, RawDescriptionHelpFormatter
5+
from typing import Any, Dict
6+
7+
from graphql import GraphQLError
8+
from yarl import URL
9+
10+
from gql import Client, __version__, gql
11+
from gql.transport import AsyncTransport
12+
from gql.transport.aiohttp import AIOHTTPTransport
13+
from gql.transport.exceptions import TransportQueryError
14+
from gql.transport.websockets import WebsocketsTransport
15+
16+
description = """
17+
Send GraphQL queries from the command line using http(s) or websockets.
18+
If used interactively, write your query, then use Ctrl-D (EOF) to execute it.
19+
"""
20+
21+
examples = """
22+
EXAMPLES
23+
========
24+
25+
# Simple query using https
26+
echo 'query { continent(code:"AF") { name } }' | \
27+
gql-cli https://countries.trevorblades.com
28+
29+
# Simple query using websockets
30+
echo 'query { continent(code:"AF") { name } }' | \
31+
gql-cli wss://countries.trevorblades.com/graphql
32+
33+
# Query with variable
34+
echo 'query getContinent($code:ID!) { continent(code:$code) { name } }' | \
35+
gql-cli https://countries.trevorblades.com --variables code:AF
36+
37+
# Interactive usage (insert your query in the terminal, then press Ctrl-D to execute it)
38+
gql-cli wss://countries.trevorblades.com/graphql --variables code:AF
39+
40+
# Execute query saved in a file
41+
cat query.gql | gql-cli wss://countries.trevorblades.com/graphql
42+
43+
"""
44+
45+
46+
def get_parser(with_examples: bool = False) -> ArgumentParser:
47+
"""Provides an ArgumentParser for the gql-cli script.
48+
49+
This function is also used by sphinx to generate the script documentation.
50+
51+
:param with_examples: set to False by default so that the examples are not
52+
present in the sphinx docs (they are put there with
53+
a different layout)
54+
"""
55+
56+
parser = ArgumentParser(
57+
description=description,
58+
epilog=examples if with_examples else None,
59+
formatter_class=RawDescriptionHelpFormatter,
60+
)
61+
parser.add_argument(
62+
"server", help="the server url starting with http://, https://, ws:// or wss://"
63+
)
64+
parser.add_argument(
65+
"-V",
66+
"--variables",
67+
nargs="*",
68+
help="query variables in the form key:json_value",
69+
)
70+
parser.add_argument(
71+
"-H", "--headers", nargs="*", help="http headers in the form key:value"
72+
)
73+
parser.add_argument("--version", action="version", version=f"v{__version__}")
74+
group = parser.add_mutually_exclusive_group()
75+
group.add_argument(
76+
"-d",
77+
"--debug",
78+
help="print lots of debugging statements (loglevel==DEBUG)",
79+
action="store_const",
80+
dest="loglevel",
81+
const=logging.DEBUG,
82+
)
83+
group.add_argument(
84+
"-v",
85+
"--verbose",
86+
help="show low level messages (loglevel==INFO)",
87+
action="store_const",
88+
dest="loglevel",
89+
const=logging.INFO,
90+
)
91+
parser.add_argument(
92+
"-o",
93+
"--operation-name",
94+
help="set the operation_name value",
95+
dest="operation_name",
96+
)
97+
98+
return parser
99+
100+
101+
def get_transport_args(args: Namespace) -> Dict[str, Any]:
102+
"""Extract extra arguments necessary for the transport
103+
from the parsed command line args
104+
105+
Will create a headers dict by splitting the colon
106+
in the --headers arguments
107+
108+
:param args: parsed command line arguments
109+
"""
110+
111+
transport_args: Dict[str, Any] = {}
112+
113+
# Parse the headers argument
114+
headers = {}
115+
if args.headers is not None:
116+
for header in args.headers:
117+
118+
try:
119+
# Split only the first colon (throw a ValueError if no colon is present)
120+
header_key, header_value = header.split(":", 1)
121+
122+
headers[header_key] = header_value
123+
124+
except ValueError:
125+
raise ValueError(f"Invalid header: {header}")
126+
127+
if args.headers is not None:
128+
transport_args["headers"] = headers
129+
130+
return transport_args
131+
132+
133+
def get_execute_args(args: Namespace) -> Dict[str, Any]:
134+
"""Extract extra arguments necessary for the execute or subscribe
135+
methods from the parsed command line args
136+
137+
Extract the operation_name
138+
139+
Extract the variable_values from the --variables argument
140+
by splitting the first colon, then loads the json value,
141+
We try to add double quotes around the value if it does not work first
142+
in order to simplify the passing of simple string values
143+
(we allow --variables KEY:VALUE instead of KEY:\"VALUE\")
144+
145+
:param args: parsed command line arguments
146+
"""
147+
148+
execute_args: Dict[str, Any] = {}
149+
150+
# Parse the operation_name argument
151+
if args.operation_name is not None:
152+
execute_args["operation_name"] = args.operation_name
153+
154+
# Parse the variables argument
155+
if args.variables is not None:
156+
157+
variables = {}
158+
159+
for var in args.variables:
160+
161+
try:
162+
# Split only the first colon
163+
# (throw a ValueError if no colon is present)
164+
variable_key, variable_json_value = var.split(":", 1)
165+
166+
# Extract the json value,
167+
# trying with double quotes if it does not work
168+
try:
169+
variable_value = json.loads(variable_json_value)
170+
except json.JSONDecodeError:
171+
try:
172+
variable_value = json.loads(f'"{variable_json_value}"')
173+
except json.JSONDecodeError:
174+
raise ValueError
175+
176+
# Save the value in the variables dict
177+
variables[variable_key] = variable_value
178+
179+
except ValueError:
180+
raise ValueError(f"Invalid variable: {var}")
181+
182+
execute_args["variable_values"] = variables
183+
184+
return execute_args
185+
186+
187+
def get_transport(args: Namespace) -> AsyncTransport:
188+
"""Instanciate a transport from the parsed command line arguments
189+
190+
:param args: parsed command line arguments
191+
"""
192+
193+
# Get the url scheme from server parameter
194+
url = URL(args.server)
195+
scheme = url.scheme
196+
197+
# Get extra transport parameters from command line arguments
198+
# (headers)
199+
transport_args = get_transport_args(args)
200+
201+
# Instanciate transport depending on url scheme
202+
transport: AsyncTransport
203+
if scheme in ["ws", "wss"]:
204+
transport = WebsocketsTransport(
205+
url=args.server, ssl=(scheme == "wss"), **transport_args
206+
)
207+
elif scheme in ["http", "https"]:
208+
transport = AIOHTTPTransport(url=args.server, **transport_args)
209+
else:
210+
raise ValueError("URL protocol should be one of: http, https, ws, wss")
211+
212+
return transport
213+
214+
215+
async def main(args: Namespace) -> int:
216+
"""Main entrypoint of the gql-cli script
217+
218+
:param args: The parsed command line arguments
219+
:return: The script exit code (0 = ok, 1 = error)
220+
"""
221+
222+
# Set requested log level
223+
if args.loglevel is not None:
224+
logging.basicConfig(level=args.loglevel)
225+
226+
try:
227+
# Instanciate transport from command line arguments
228+
transport = get_transport(args)
229+
230+
# Get extra execute parameters from command line arguments
231+
# (variables, operation_name)
232+
execute_args = get_execute_args(args)
233+
234+
except ValueError as e:
235+
print(f"Error: {e}", file=sys.stderr)
236+
sys.exit(1)
237+
238+
# By default, the exit_code is 0 (everything is ok)
239+
exit_code = 0
240+
241+
# Connect to the backend and provide a session
242+
async with Client(transport=transport) as session:
243+
244+
while True:
245+
246+
# Read multiple lines from input and trim whitespaces
247+
# Will read until EOF character is received (Ctrl-D)
248+
query_str = sys.stdin.read().strip()
249+
250+
# Exit if query is empty
251+
if len(query_str) == 0:
252+
break
253+
254+
# Parse query, continue on error
255+
try:
256+
query = gql(query_str)
257+
except GraphQLError as e:
258+
print(e, file=sys.stderr)
259+
exit_code = 1
260+
continue
261+
262+
# Execute or Subscribe the query depending on transport
263+
try:
264+
if isinstance(transport, WebsocketsTransport):
265+
try:
266+
async for result in session.subscribe(query, **execute_args):
267+
print(json.dumps(result))
268+
except KeyboardInterrupt: # pragma: no cover
269+
pass
270+
else:
271+
result = await session.execute(query, **execute_args)
272+
print(json.dumps(result))
273+
except (GraphQLError, TransportQueryError) as e:
274+
print(e, file=sys.stderr)
275+
exit_code = 1
276+
277+
return exit_code

0 commit comments

Comments
 (0)