2025-12-01
This commit is contained in:
@@ -0,0 +1,541 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import signal as signal_module
|
||||
import sys
|
||||
import textwrap
|
||||
from argparse import ArgumentParser, Namespace, RawTextHelpFormatter
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from graphql import GraphQLError, print_schema
|
||||
from yarl import URL
|
||||
|
||||
from gql import Client, __version__, gql
|
||||
from gql.transport import AsyncTransport
|
||||
from gql.transport.exceptions import TransportQueryError
|
||||
|
||||
description = """
|
||||
Send GraphQL queries from the command line using http(s) or websockets.
|
||||
If used interactively, write your query, then use Ctrl-D (EOF) to execute it.
|
||||
"""
|
||||
|
||||
examples = """
|
||||
EXAMPLES
|
||||
========
|
||||
|
||||
# Simple query using https
|
||||
echo 'query { continent(code:"AF") { name } }' | \
|
||||
gql-cli https://countries.trevorblades.com
|
||||
|
||||
# Simple query using websockets
|
||||
echo 'query { continent(code:"AF") { name } }' | \
|
||||
gql-cli wss://countries.trevorblades.com/graphql
|
||||
|
||||
# Query with variable
|
||||
echo 'query getContinent($code:ID!) { continent(code:$code) { name } }' | \
|
||||
gql-cli https://countries.trevorblades.com --variables code:AF
|
||||
|
||||
# Interactive usage (insert your query in the terminal, then press Ctrl-D to execute it)
|
||||
gql-cli wss://countries.trevorblades.com/graphql --variables code:AF
|
||||
|
||||
# Execute query saved in a file
|
||||
cat query.gql | gql-cli wss://countries.trevorblades.com/graphql
|
||||
|
||||
# Print the schema of the backend
|
||||
gql-cli https://countries.trevorblades.com/graphql --print-schema
|
||||
|
||||
"""
|
||||
|
||||
|
||||
def positive_int_or_none(value_str: str) -> Optional[int]:
|
||||
"""Convert a string argument value into either an int or None.
|
||||
|
||||
Raise a ValueError if the argument is negative or a string which is not "none"
|
||||
"""
|
||||
try:
|
||||
value_int = int(value_str)
|
||||
except ValueError:
|
||||
if value_str.lower() == "none":
|
||||
return None
|
||||
else:
|
||||
raise
|
||||
|
||||
if value_int < 0:
|
||||
raise ValueError
|
||||
|
||||
return value_int
|
||||
|
||||
|
||||
def get_parser(with_examples: bool = False) -> ArgumentParser:
|
||||
"""Provides an ArgumentParser for the gql-cli script.
|
||||
|
||||
This function is also used by sphinx to generate the script documentation.
|
||||
|
||||
:param with_examples: set to False by default so that the examples are not
|
||||
present in the sphinx docs (they are put there with
|
||||
a different layout)
|
||||
"""
|
||||
|
||||
parser = ArgumentParser(
|
||||
description=description,
|
||||
epilog=examples if with_examples else None,
|
||||
formatter_class=RawTextHelpFormatter,
|
||||
)
|
||||
parser.add_argument(
|
||||
"server", help="the server url starting with http://, https://, ws:// or wss://"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-V",
|
||||
"--variables",
|
||||
nargs="*",
|
||||
help="query variables in the form key:json_value",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-H", "--headers", nargs="*", help="http headers in the form key:value"
|
||||
)
|
||||
parser.add_argument("--version", action="version", version=f"v{__version__}")
|
||||
group = parser.add_mutually_exclusive_group()
|
||||
group.add_argument(
|
||||
"-d",
|
||||
"--debug",
|
||||
help="print lots of debugging statements (loglevel==DEBUG)",
|
||||
action="store_const",
|
||||
dest="loglevel",
|
||||
const=logging.DEBUG,
|
||||
)
|
||||
group.add_argument(
|
||||
"-v",
|
||||
"--verbose",
|
||||
help="show low level messages (loglevel==INFO)",
|
||||
action="store_const",
|
||||
dest="loglevel",
|
||||
const=logging.INFO,
|
||||
)
|
||||
parser.add_argument(
|
||||
"-o",
|
||||
"--operation-name",
|
||||
help="set the operation_name value",
|
||||
dest="operation_name",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--print-schema",
|
||||
help="get the schema from instrospection and print it",
|
||||
action="store_true",
|
||||
dest="print_schema",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--schema-download",
|
||||
nargs="*",
|
||||
help=textwrap.dedent(
|
||||
"""select the introspection query arguments to download the schema.
|
||||
Only useful if --print-schema is used.
|
||||
By default, it will:
|
||||
|
||||
- request field descriptions
|
||||
- not request deprecated input fields
|
||||
|
||||
Possible options:
|
||||
|
||||
- descriptions:false for a compact schema without comments
|
||||
- input_value_deprecation:true to download deprecated input fields
|
||||
- specified_by_url:true
|
||||
- schema_description:true
|
||||
- directive_is_repeatable:true"""
|
||||
),
|
||||
dest="schema_download",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--execute-timeout",
|
||||
help="set the execute_timeout argument of the Client (default: 10)",
|
||||
type=positive_int_or_none,
|
||||
default=10,
|
||||
dest="execute_timeout",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--transport",
|
||||
default="auto",
|
||||
choices=[
|
||||
"auto",
|
||||
"aiohttp",
|
||||
"phoenix",
|
||||
"websockets",
|
||||
"appsync_http",
|
||||
"appsync_websockets",
|
||||
],
|
||||
help=(
|
||||
"select the transport. 'auto' by default: "
|
||||
"aiohttp or websockets depending on url scheme"
|
||||
),
|
||||
dest="transport",
|
||||
)
|
||||
|
||||
appsync_description = """
|
||||
By default, for an AppSync backend, the IAM authentication is chosen.
|
||||
|
||||
If you want API key or JWT authentication, you can provide one of the
|
||||
following arguments:"""
|
||||
|
||||
appsync_group = parser.add_argument_group(
|
||||
"AWS AppSync options", description=appsync_description
|
||||
)
|
||||
|
||||
appsync_auth_group = appsync_group.add_mutually_exclusive_group()
|
||||
|
||||
appsync_auth_group.add_argument(
|
||||
"--api-key",
|
||||
help="Provide an API key for authentication",
|
||||
dest="api_key",
|
||||
)
|
||||
|
||||
appsync_auth_group.add_argument(
|
||||
"--jwt",
|
||||
help="Provide an JSON Web token for authentication",
|
||||
dest="jwt",
|
||||
)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def get_transport_args(args: Namespace) -> Dict[str, Any]:
|
||||
"""Extract extra arguments necessary for the transport
|
||||
from the parsed command line args
|
||||
|
||||
Will create a headers dict by splitting the colon
|
||||
in the --headers arguments
|
||||
|
||||
:param args: parsed command line arguments
|
||||
"""
|
||||
|
||||
transport_args: Dict[str, Any] = {}
|
||||
|
||||
# Parse the headers argument
|
||||
headers = {}
|
||||
if args.headers is not None:
|
||||
for header in args.headers:
|
||||
|
||||
try:
|
||||
# Split only the first colon (throw a ValueError if no colon is present)
|
||||
header_key, header_value = header.split(":", 1)
|
||||
|
||||
headers[header_key] = header_value
|
||||
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid header: {header}")
|
||||
|
||||
if args.headers is not None:
|
||||
transport_args["headers"] = headers
|
||||
|
||||
return transport_args
|
||||
|
||||
|
||||
def get_execute_args(args: Namespace) -> Dict[str, Any]:
|
||||
"""Extract extra arguments necessary for the execute or subscribe
|
||||
methods from the parsed command line args
|
||||
|
||||
Extract the operation_name
|
||||
|
||||
Extract the variable_values from the --variables argument
|
||||
by splitting the first colon, then loads the json value,
|
||||
We try to add double quotes around the value if it does not work first
|
||||
in order to simplify the passing of simple string values
|
||||
(we allow --variables KEY:VALUE instead of KEY:\"VALUE\")
|
||||
|
||||
:param args: parsed command line arguments
|
||||
"""
|
||||
|
||||
execute_args: Dict[str, Any] = {}
|
||||
|
||||
# Parse the operation_name argument
|
||||
if args.operation_name is not None:
|
||||
execute_args["operation_name"] = args.operation_name
|
||||
|
||||
# Parse the variables argument
|
||||
if args.variables is not None:
|
||||
|
||||
variables = {}
|
||||
|
||||
for var in args.variables:
|
||||
|
||||
try:
|
||||
# Split only the first colon
|
||||
# (throw a ValueError if no colon is present)
|
||||
variable_key, variable_json_value = var.split(":", 1)
|
||||
|
||||
# Extract the json value,
|
||||
# trying with double quotes if it does not work
|
||||
try:
|
||||
variable_value = json.loads(variable_json_value)
|
||||
except json.JSONDecodeError:
|
||||
try:
|
||||
variable_value = json.loads(f'"{variable_json_value}"')
|
||||
except json.JSONDecodeError:
|
||||
raise ValueError
|
||||
|
||||
# Save the value in the variables dict
|
||||
variables[variable_key] = variable_value
|
||||
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid variable: {var}")
|
||||
|
||||
execute_args["variable_values"] = variables
|
||||
|
||||
return execute_args
|
||||
|
||||
|
||||
def autodetect_transport(url: URL) -> str:
|
||||
"""Detects which transport should be used depending on url."""
|
||||
|
||||
if url.scheme in ["ws", "wss"]:
|
||||
transport_name = "websockets"
|
||||
|
||||
else:
|
||||
assert url.scheme in ["http", "https"]
|
||||
transport_name = "aiohttp"
|
||||
|
||||
return transport_name
|
||||
|
||||
|
||||
def get_transport(args: Namespace) -> Optional[AsyncTransport]:
|
||||
"""Instantiate a transport from the parsed command line arguments
|
||||
|
||||
:param args: parsed command line arguments
|
||||
"""
|
||||
|
||||
# Get the url scheme from server parameter
|
||||
url = URL(args.server)
|
||||
|
||||
# Validate scheme
|
||||
if url.scheme not in ["http", "https", "ws", "wss"]:
|
||||
raise ValueError("URL protocol should be one of: http, https, ws, wss")
|
||||
|
||||
# Get extra transport parameters from command line arguments
|
||||
# (headers)
|
||||
transport_args = get_transport_args(args)
|
||||
|
||||
# Either use the requested transport or autodetect it
|
||||
if args.transport == "auto":
|
||||
transport_name = autodetect_transport(url)
|
||||
else:
|
||||
transport_name = args.transport
|
||||
|
||||
# Import the correct transport class depending on the transport name
|
||||
if transport_name == "aiohttp":
|
||||
from gql.transport.aiohttp import AIOHTTPTransport
|
||||
|
||||
return AIOHTTPTransport(url=args.server, **transport_args)
|
||||
|
||||
elif transport_name == "phoenix":
|
||||
from gql.transport.phoenix_channel_websockets import (
|
||||
PhoenixChannelWebsocketsTransport,
|
||||
)
|
||||
|
||||
return PhoenixChannelWebsocketsTransport(url=args.server, **transport_args)
|
||||
|
||||
elif transport_name == "websockets":
|
||||
from gql.transport.websockets import WebsocketsTransport
|
||||
|
||||
transport_args["ssl"] = url.scheme == "wss"
|
||||
|
||||
return WebsocketsTransport(url=args.server, **transport_args)
|
||||
|
||||
else:
|
||||
|
||||
from gql.transport.appsync_auth import AppSyncAuthentication
|
||||
|
||||
assert transport_name in ["appsync_http", "appsync_websockets"]
|
||||
assert url.host is not None
|
||||
|
||||
auth: AppSyncAuthentication
|
||||
|
||||
if args.api_key:
|
||||
from gql.transport.appsync_auth import AppSyncApiKeyAuthentication
|
||||
|
||||
auth = AppSyncApiKeyAuthentication(host=url.host, api_key=args.api_key)
|
||||
|
||||
elif args.jwt:
|
||||
from gql.transport.appsync_auth import AppSyncJWTAuthentication
|
||||
|
||||
auth = AppSyncJWTAuthentication(host=url.host, jwt=args.jwt)
|
||||
|
||||
else:
|
||||
from gql.transport.appsync_auth import AppSyncIAMAuthentication
|
||||
from botocore.exceptions import NoRegionError
|
||||
|
||||
try:
|
||||
auth = AppSyncIAMAuthentication(host=url.host)
|
||||
except NoRegionError:
|
||||
# A warning message has been printed in the console
|
||||
return None
|
||||
|
||||
transport_args["auth"] = auth
|
||||
|
||||
if transport_name == "appsync_http":
|
||||
from gql.transport.aiohttp import AIOHTTPTransport
|
||||
|
||||
return AIOHTTPTransport(url=args.server, **transport_args)
|
||||
|
||||
else:
|
||||
from gql.transport.appsync_websockets import AppSyncWebsocketsTransport
|
||||
|
||||
try:
|
||||
return AppSyncWebsocketsTransport(url=args.server, **transport_args)
|
||||
except Exception:
|
||||
# This is for the NoCredentialsError but we cannot import it here
|
||||
return None
|
||||
|
||||
|
||||
def get_introspection_args(args: Namespace) -> Dict:
|
||||
"""Get the introspection args depending on the schema_download argument"""
|
||||
|
||||
# Parse the headers argument
|
||||
introspection_args = {}
|
||||
|
||||
possible_args = [
|
||||
"descriptions",
|
||||
"specified_by_url",
|
||||
"directive_is_repeatable",
|
||||
"schema_description",
|
||||
"input_value_deprecation",
|
||||
]
|
||||
|
||||
if args.schema_download is not None:
|
||||
for arg in args.schema_download:
|
||||
|
||||
try:
|
||||
# Split only the first colon (throw a ValueError if no colon is present)
|
||||
arg_key, arg_value = arg.split(":", 1)
|
||||
|
||||
if arg_key not in possible_args:
|
||||
raise ValueError(f"Invalid schema_download: {args.schema_download}")
|
||||
|
||||
arg_value = arg_value.lower()
|
||||
if arg_value not in ["true", "false"]:
|
||||
raise ValueError(f"Invalid schema_download: {args.schema_download}")
|
||||
|
||||
introspection_args[arg_key] = arg_value == "true"
|
||||
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid schema_download: {args.schema_download}")
|
||||
|
||||
return introspection_args
|
||||
|
||||
|
||||
async def main(args: Namespace) -> int:
|
||||
"""Main entrypoint of the gql-cli script
|
||||
|
||||
:param args: The parsed command line arguments
|
||||
:return: The script exit code (0 = ok, 1 = error)
|
||||
"""
|
||||
|
||||
# Set requested log level
|
||||
if args.loglevel is not None:
|
||||
logging.basicConfig(level=args.loglevel)
|
||||
|
||||
try:
|
||||
# Instantiate transport from command line arguments
|
||||
transport = get_transport(args)
|
||||
|
||||
if transport is None:
|
||||
return 1
|
||||
|
||||
# Get extra execute parameters from command line arguments
|
||||
# (variables, operation_name)
|
||||
execute_args = get_execute_args(args)
|
||||
|
||||
except ValueError as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# By default, the exit_code is 0 (everything is ok)
|
||||
exit_code = 0
|
||||
|
||||
# Connect to the backend and provide a session
|
||||
async with Client(
|
||||
transport=transport,
|
||||
fetch_schema_from_transport=args.print_schema,
|
||||
introspection_args=get_introspection_args(args),
|
||||
execute_timeout=args.execute_timeout,
|
||||
) as session:
|
||||
|
||||
if args.print_schema:
|
||||
schema_str = print_schema(session.client.schema)
|
||||
print(schema_str)
|
||||
|
||||
return exit_code
|
||||
|
||||
while True:
|
||||
|
||||
# Read multiple lines from input and trim whitespaces
|
||||
# Will read until EOF character is received (Ctrl-D)
|
||||
query_str = sys.stdin.read().strip()
|
||||
|
||||
# Exit if query is empty
|
||||
if len(query_str) == 0:
|
||||
break
|
||||
|
||||
# Parse query, continue on error
|
||||
try:
|
||||
query = gql(query_str)
|
||||
except GraphQLError as e:
|
||||
print(e, file=sys.stderr)
|
||||
exit_code = 1
|
||||
continue
|
||||
|
||||
# Execute or Subscribe the query depending on transport
|
||||
try:
|
||||
try:
|
||||
async for result in session.subscribe(query, **execute_args):
|
||||
print(json.dumps(result))
|
||||
except KeyboardInterrupt: # pragma: no cover
|
||||
pass
|
||||
except NotImplementedError:
|
||||
result = await session.execute(query, **execute_args)
|
||||
print(json.dumps(result))
|
||||
except (GraphQLError, TransportQueryError) as e:
|
||||
print(e, file=sys.stderr)
|
||||
exit_code = 1
|
||||
|
||||
return exit_code
|
||||
|
||||
|
||||
def gql_cli() -> None:
|
||||
"""Synchronously invoke ``main`` with the parsed command line arguments.
|
||||
|
||||
Formerly ``scripts/gql-cli``, now registered as an ``entry_point``
|
||||
"""
|
||||
# Get arguments from command line
|
||||
parser = get_parser(with_examples=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
# Create a new asyncio event loop
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
# Create a gql-cli task with the supplied arguments
|
||||
main_task = asyncio.ensure_future(main(args), loop=loop)
|
||||
|
||||
# Add signal handlers to close gql-cli cleanly on Control-C
|
||||
for signal_name in ["SIGINT", "SIGTERM", "CTRL_C_EVENT", "CTRL_BREAK_EVENT"]:
|
||||
signal = getattr(signal_module, signal_name, None)
|
||||
|
||||
if signal is None:
|
||||
continue
|
||||
|
||||
try:
|
||||
loop.add_signal_handler(signal, main_task.cancel)
|
||||
except NotImplementedError: # pragma: no cover
|
||||
# not all signals supported on all platforms
|
||||
pass
|
||||
|
||||
# Run the asyncio loop to execute the task
|
||||
exit_code = 0
|
||||
try:
|
||||
exit_code = loop.run_until_complete(main_task)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
# Return with the correct exit code
|
||||
sys.exit(exit_code)
|
||||
except KeyboardInterrupt: # pragma: no cover
|
||||
pass
|
||||
Reference in New Issue
Block a user