diff --git a/.pylintrc b/.pylintrc index 46abd67b1..8175adc75 100644 --- a/.pylintrc +++ b/.pylintrc @@ -3,7 +3,7 @@ ignore=snapshots load-plugins=pylint.extensions.bad_builtin, pylint.extensions.mccabe [MESSAGES CONTROL] -disable=C0103, C0111, C0209, C0412, I0011, R0101, R0801, R0901, R0902, R0903, R0912, R0913, R0914, R0915, R1260, W0231, W0511, W0621, W0703 +disable=C0103, C0111, C0209, C0412, I0011, R0101, R0801, R0901, R0902, R0903, R0912, R0913, R0914, R0915, R1260, W0105, W0231, W0511, W0621, W0703 [SIMILARITIES] ignore-imports=yes diff --git a/ariadne/__init__.py b/ariadne/__init__.py index fe2016db3..eb1eb3bf2 100644 --- a/ariadne/__init__.py +++ b/ariadne/__init__.py @@ -1,5 +1,3 @@ -__version__ = "0.17.0.dev1" - from .enums import ( EnumType, set_default_enum_values_on_schema, @@ -30,7 +28,7 @@ from .schema_names import SchemaNameConverter, convert_schema_names from .schema_visitor import SchemaDirectiveVisitor from .subscriptions import SubscriptionType -from .types import SchemaBindable +from .types import Extension, ExtensionSync, SchemaBindable from .unions import UnionType from .utils import ( convert_camel_case_to_snake, @@ -41,7 +39,9 @@ __all__ = [ "EnumType", + "Extension", "ExtensionManager", + "ExtensionSync", "FallbackResolversSetter", "InterfaceType", "MutationType", diff --git a/ariadne/enums.py b/ariadne/enums.py index 125ead832..a702e3e86 100644 --- a/ariadne/enums.py +++ b/ariadne/enums.py @@ -45,16 +45,65 @@ class EnumType(SchemaBindable): + """Bindable mapping Python values to enumeration members in a GraphQL schema. + + # Example + + Given following GraphQL enum: + + ```graphql + enum UserRole { + MEMBER + MODERATOR + ADMIN + } + ``` + + You can use `EnumType` to map it's members to Python `Enum`: + + ```python + user_role_type = EnumType( + "UserRole", + { + "MEMBER": 0, + "MODERATOR": 1, + "ADMIN": 2, + } + ) + ``` + + `EnumType` also works with dictionaries: + + ```python + user_role_type = EnumType( + "UserRole", + { + "MEMBER": 0, + "MODERATOR": 1, + "ADMIN": 2, + } + ) + ``` + """ + def __init__( - self, name: str, values=Union[Dict[str, Any], enum.Enum, enum.IntEnum] + self, name: str, values: Union[Dict[str, Any], enum.Enum, enum.IntEnum] ) -> None: + """Initializes the `EnumType` with `name` and `values` mapping. + + # Required arguments + + `name`: a `str` with the name of GraphQL enum type in GraphQL schema to + bind to. + + `values`: a `dict` or `enums.Enum` with values to use to represent GraphQL + enum's in Python logic. + """ self.name = name - try: - self.values = values.__members__ # pylint: disable=no-member - except AttributeError: - self.values = values + self.values = cast(Dict[str, Any], getattr(values, "__members__", values)) def bind_to_schema(self, schema: GraphQLSchema) -> None: + """Binds this `EnumType` instance to the instance of GraphQL schema.""" graphql_type = schema.type_map.get(self.name) self.validate_graphql_type(graphql_type) graphql_type = cast(GraphQLEnumType, graphql_type) @@ -67,6 +116,13 @@ def bind_to_schema(self, schema: GraphQLSchema) -> None: graphql_type.values[key].value = value def bind_to_default_values(self, schema: GraphQLSchema) -> None: + """Populates default values of input fields and args in the GraphQL schema. + + This step is required because GraphQL query executor doesn't perform a + lookup for default values defined in schema. Instead it simply pulls the + value from fields and arguments `default_value` attribute, which is + `None` by default. + """ for _, _, arg, key_list in find_enum_values_in_schema(schema): type_ = resolve_null_type(arg.type) type_ = cast(GraphQLNamedInputType, type_) @@ -89,6 +145,8 @@ def bind_to_default_values(self, schema: GraphQLSchema) -> None: ) def validate_graphql_type(self, graphql_type: Optional[GraphQLNamedType]) -> None: + """Validates that schema's GraphQL type associated with this `EnumType` + is an `enum`.""" if not graphql_type: raise ValueError("Enum %s is not defined in the schema" % self.name) if not isinstance(graphql_type, GraphQLEnumType): @@ -99,6 +157,17 @@ def validate_graphql_type(self, graphql_type: Optional[GraphQLNamedType]) -> Non def set_default_enum_values_on_schema(schema: GraphQLSchema): + """Sets missing Python values for GraphQL enums in schema. + + Recursively scans GraphQL schema for enums and their values. If `value` + attribute is empty, its populated with with a string of its GraphQL name. + + This string is then used to represent enum's value in Python instead of `None`. + + # Requires arguments + + `schema`: a GraphQL schema to set enums default values in. + """ for type_object in schema.type_map.values(): if isinstance(type_object, GraphQLEnumType): set_default_enum_values(type_object) @@ -111,6 +180,59 @@ def set_default_enum_values(graphql_type: GraphQLEnumType): def validate_schema_enum_values(schema: GraphQLSchema) -> None: + """Raises `ValueError` if GraphQL schema has input fields or arguments with + default values that are undefined enum values. + + # Example schema with invalid field argument + + This schema fails to validate because argument `role` on field `users` + specifies `REVIEWER` as default value and `REVIEWER` is not a member of + the `UserRole` enum: + + ```graphql + type Query { + users(role: UserRole = REVIEWER): [User!]! + } + + enum UserRole { + MEMBER + MODERATOR + ADMIN + } + + type User { + id: ID! + } + ``` + + # Example schema with invalid input field + + This schema fails to validate because field `role` on input `UserFilters` + specifies `REVIEWER` as default value and `REVIEWER` is not a member of + the `UserRole` enum: + + ```graphql + type Query { + users(filter: UserFilters): [User!]! + } + + input UserFilters { + name: String + role: UserRole = REVIEWER + } + + enum UserRole { + MEMBER + MODERATOR + ADMIN + } + + type User { + id: ID! + } + ``` + """ + for type_name, field_name, arg, _ in find_enum_values_in_schema(schema): if is_invalid_enum_value(arg): raise ValueError( diff --git a/ariadne/exceptions.py b/ariadne/exceptions.py index 39e4cb428..450b53fb4 100644 --- a/ariadne/exceptions.py +++ b/ariadne/exceptions.py @@ -1,25 +1,64 @@ +import os +from typing import Optional, Union + from .constants import HTTP_STATUS_400_BAD_REQUEST class HttpError(Exception): + """Base class for HTTP errors raised inside the ASGI and WSGI servers.""" + status = "" - def __init__(self, message=None) -> None: + def __init__(self, message: Optional[str] = None) -> None: + """Initializes the `HttpError` with optional error message. + + # Optional arguments + + `message`: a `str` with error message to return in response body or + `None`. + """ super().__init__() self.message = message class HttpBadRequestError(HttpError): + """Raised when request did not contain the data required to execute + the GraphQL query.""" + status = HTTP_STATUS_400_BAD_REQUEST class GraphQLFileSyntaxError(Exception): - def __init__(self, schema_file, message) -> None: + """Raised by `load_schema_from_path` when loaded GraphQL file has invalid syntax.""" + + def __init__(self, file_path: Union[str, os.PathLike], message: str) -> None: + """Initializes the `GraphQLFileSyntaxError` with file name and error. + + # Required arguments + + `file_path`: a `str` or `PathLike` object pointing to a file that + failed to validate. + + `message`: a `str` with validation message. + """ super().__init__() - self.message = self.format_message(schema_file, message) - def format_message(self, schema_file, message): - return f"Could not load {schema_file}:\n{message}" + self.message = self.format_message(file_path, message) + + def format_message(self, file_path: Union[str, os.PathLike], message: str): + """Builds final error message from path to schema file and error message. + + Returns `str` with final error message. + + # Required arguments + + `file_path`: a `str` or `PathLike` object pointing to a file that + failed to validate. + + `message`: a `str` with validation message. + """ + return f"Could not load {file_path}:\n{message}" def __str__(self): + """Returns error message.""" return self.message diff --git a/ariadne/executable_schema.py b/ariadne/executable_schema.py index 3bf0dfb6d..f3897ae86 100644 --- a/ariadne/executable_schema.py +++ b/ariadne/executable_schema.py @@ -23,6 +23,292 @@ def make_executable_schema( directives: Optional[Dict[str, Type[SchemaDirectiveVisitor]]] = None, convert_names_case: Union[bool, SchemaNameConverter] = False, ) -> GraphQLSchema: + """Create a `GraphQLSchema` instance that can be used to execute queries. + + Returns a `GraphQLSchema` instance with attributes populated with Python + values and functions. + + # Required arguments + + `type_defs`: a `str` or list of `str` with GraphQL types definitions in + schema definition language (`SDL`). + + # Optional arguments + + `bindables`: instances or lists of instances of schema bindables. Order in + which bindables are passed to `make_executable_schema` matters depending on + individual bindable's implementation. + + `directives`: a dict of GraphQL directives to apply to schema. Dict's keys must + correspond to directives names in GraphQL schema and values should be + `SchemaDirectiveVisitor` classes (_not_ instances) implementing their logic. + + `convert_names_case`: a `bool` or function of `SchemaNameConverter` type to + use to convert names in GraphQL schema between `camelCase` used by GraphQL + and `snake_case` used by Python. Defaults to `False`, making all conversion + explicit and up to developer to implement. Set `True` to use + default strategy using `convert_camel_case_to_snake` for name conversions or + set to custom function to customize this behavior. + + # Example with minimal schema + + Below code creates minimal executable schema that doesn't implement any Python + logic, but still executes queries using `root_value`: + + ```python + from ariadne import graphql_sync, make_executable_schema + + schema = make_executable_schema( + \"\"\" + type Query { + helloWorld: String! + } + \"\"\" + ) + + no_errors, result = graphql_sync( + schema, + {"query": "{ helloWorld }"}, + root_value={"helloWorld": "Hello world!"}, + ) + + assert no_errors + assert result == { + "data": { + "helloWorld": "Hello world!", + }, + } + ``` + + # Example with bindables + + Below code creates executable schema that combines different ways of passing + bindables to add Python logic to schema: + + ```python + from dataclasses import dataclass + from ariadne import ObjectType, QueryType, UnionType, graphql_sync, make_executable_schema + + # Define some types representing database models in real applications + @dataclass + class UserModel: + id: str + name: str + + @dataclass + class PostModel: + id: str + body: str + + # Create fake "database" + results = ( + UserModel(id=1, name="Bob"), + UserModel(id=2, name="Alice"), + UserModel(id=3, name="Jon"), + PostModel(id=1, body="Hello world!"), + PostModel(id=2, body="How's going?"), + PostModel(id=3, body="Sure thing!"), + ) + + + # Resolve username field in GraphQL schema to user.name attribute + user_type = ObjectType("User") + user_type.set_alias("username", "name") + + + # Resolve message field in GraphQL schema to post.body attribute + post_type = ObjectType("Post") + post_type.set_alias("message", "body") + + + # Resolve results field in GraphQL schema to results array + query_type = QueryType() + + @query_type.field("results") + def resolve_results(*_): + return results + + + # Resolve GraphQL type of individual result from it's Python class + result_type = UnionType("Result") + + @result_type.type_resolver + def resolve_result_type(obj: UserModel | PostModel | dict, *_) -> str: + if isinstance(obj, UserModel): + return "User" + + if isinstance(obj, PostModel): + return "Post" + + raise ValueError(f"Don't know GraphQL type for '{obj}'!") + + + # Create executable schema that returns list of results + schema = make_executable_schema( + \"\"\" + type Query { + results: [Result!]! + } + + union Result = User | Post + + type User { + id: ID! + username: String! + } + + type Post { + id: ID! + message: String! + } + \"\"\", + # Bindables *args accept single instances: + query_type, + result_type, + # Bindables *args accepts lists of instances: + [user_type, post_type], + # Both approaches can be mixed + ) + + # Query the schema for results + no_errors, result = graphql_sync( + schema, + { + "query": ( + \"\"\" + { + results { + ... on Post { + id + message + } + ... on User { + id + username + } + } + } + \"\"\" + ), + }, + ) + + # Verify that it works + assert no_errors + assert result == { + "data": { + "results": [ + { + "id": "1", + "username": "Bob", + }, + { + "id": "2", + "username": "Alice", + }, + { + "id": "3", + "username": "Jon", + }, + { + "id": "1", + "message": "Hello world!", + }, + { + "id": "2", + "message": "How's going?", + }, + { + "id": "3", + "message": "Sure thing!", + }, + ], + }, + } + ``` + + # Example with directive + + Below code uses `directives` option to set custom directive on schema: + + ```python + from functools import wraps + from ariadne import SchemaDirectiveVisitor, graphql_sync, make_executable_schema + from graphql import default_field_resolver + + class UppercaseDirective(SchemaDirectiveVisitor): + def visit_field_definition(self, field, object_type): + org_resolver = field.resolve or default_field_resolver + + @wraps(org_resolver) + def uppercase_resolved_value(*args, **kwargs): + value = org_resolver(*args, **kwargs) + if isinstance(value, str): + return value.upper() + return value + + # Extend field's behavior by wrapping it's resolver in custom one + field.resolve = uppercase_resolved_value + return field + + + schema = make_executable_schema( + \"\"\" + directive @uppercase on FIELD_DEFINITION + + type Query { + helloWorld: String! @uppercase + } + \"\"\", + directives={"uppercase": UppercaseDirective}, + ) + + no_errors, result = graphql_sync( + schema, + {"query": "{ helloWorld }"}, + root_value={"helloWorld": "Hello world!"}, + ) + + assert no_errors + assert result == { + "data": { + "helloWorld": "HELLO WORLD!", + }, + } + ``` + + # Example with converted names + + Below code uses `convert_names_case=True` option to resolve `helloWorld` + field to `hello_world` key from `root_value`: + + ```python + from ariadne import graphql_sync, make_executable_schema + + schema = make_executable_schema( + \"\"\" + type Query { + helloWorld: String! + } + \"\"\", + convert_names_case=True, + ) + + no_errors, result = graphql_sync( + schema, + {"query": "{ helloWorld }"}, + root_value={"hello_world": "Hello world!"}, + ) + + assert no_errors + assert result == { + "data": { + "helloWorld": "Hello world!", + }, + } + ``` + """ + if isinstance(type_defs, list): type_defs = join_type_defs(type_defs) diff --git a/ariadne/extensions.py b/ariadne/extensions.py index d48dbc28e..cb2957a84 100644 --- a/ariadne/extensions.py +++ b/ariadne/extensions.py @@ -8,6 +8,17 @@ class ExtensionManager: + """Container and runner for extensions and middleware, used by the GraphQL servers. + + # Attributes + + `context`: the `ContextValue` of type specific to the server. + + `extensions`: a `tuple` with instances of initialized extensions. + + `extensions_reversed`: a `tuple` created from reversing `extensions`. + """ + __slots__ = ("context", "extensions", "extensions_reversed") def __init__( @@ -15,6 +26,14 @@ def __init__( extensions: Optional[ExtensionList] = None, context: Optional[ContextValue] = None, ) -> None: + """Initializes extensions and stores them with context on instance. + + # Optional arguments + + `extensions`: a `list` of `Extension` types to initialize. + + `context`: the `ContextValue` of type specific to the server. + """ self.context = context if extensions: @@ -28,6 +47,18 @@ def as_middleware_manager( middleware: MiddlewareList = None, manager_class: Optional[Type[MiddlewareManager]] = None, ) -> Optional[MiddlewareManager]: + """Creates middleware manager instance combining middleware and extensions. + + Returns instance of the type passed in `manager_class` argument + or `MiddlewareManager` that query executor then uses. + + # Optional arguments + + `middleware`: a `list` of `Middleware` instances + + `manager_class` a `type` of middleware manager to use. `MiddlewareManager` + is used if this argument is passed `None` or omitted. + """ if not middleware and not self.extensions: return None @@ -39,6 +70,11 @@ def as_middleware_manager( @contextmanager def request(self): + """A context manager that should wrap request processing. + + Runs `request_started` hook at beginning and `request_finished` at + the end of request processing, enabling APM extensions like ApolloTracing. + """ for ext in self.extensions: ext.request_started(self.context) try: @@ -48,10 +84,20 @@ def request(self): ext.request_finished(self.context) def has_errors(self, errors: List[GraphQLError]): + """Propagates GraphQL errors returned by GraphQL server to extensions. + + Should be called only when there are errors. + """ for ext in self.extensions: ext.has_errors(errors, self.context) def format(self) -> dict: + """Gathers data from extensions for inclusion in server's response JSON. + + This data can be retrieved from the `extensions` key in response JSON. + + Returns `dict` with JSON-serializable data. + """ data = {} for ext in self.extensions: ext_data = ext.format(self.context) diff --git a/ariadne/file_uploads.py b/ariadne/file_uploads.py index 5924a3c67..90514a1e6 100644 --- a/ariadne/file_uploads.py +++ b/ariadne/file_uploads.py @@ -15,6 +15,56 @@ def __getitem__(self, key): def combine_multipart_data( operations: Union[dict, list], files_map: dict, files: FilesDict ) -> Union[dict, list]: + """Populates `operations` variables with `files` using the `files_map`. + + Utility function for integration developers. + + Mutates `operations` in place, but also returns it. + + # Requires arguments + + `operations`: a `list` or `dict` with GraphQL operations to populate the file + variables in. It contains `operationName`, `query` and `variables` keys, but + implementation only cares about `variables` being present. + + `files_map`: a `dict` with mapping of `files` to `operations`. Keys correspond + to keys in `files dict`, values are lists of strings with paths (eg.: + `variables.key.0` maps to `operations["variables"]["key"]["0"]`). + + `files`: a `dict` of files. Keys are strings, values are environment specific + representations of uploaded files. + + # Example + + Following example uses `combine_multipart_data` to populate the `image` + variable with file object from `files`, using the `files_map` to know + which variable to replace. + + ```python + # Single GraphQL operation + operations = { + "operationName": "AvatarUpload", + "query": \"\"\" + mutation AvatarUpload($type: String!, $image: Upload!) { + avatarUpload(type: $type, image: $image) { + success + errors + } + } + \"\"\", + "variables": {"type": "SQUARE", "image": None} + } + files_map = {"0": ["variables.image"]} + files = {"0": UploadedFile(....)} + + combine_multipart_data(operations, files_map, files + + assert operations == { + "variables": {"type": "SQUARE", "image": UploadedFile(....)} + } + ``` + """ + if not isinstance(operations, (dict, list)): raise HttpBadRequestError( "Invalid type for the 'operations' multipart field ({}).".format(SPEC_URL) @@ -87,6 +137,43 @@ def add_files_to_variables( variables[i] = files_map.get(variable_path) +"""Optional Python logic for `Upload` scalar. + +`Upload` scalar doesn't require any custom Python logic to work, but this utility +sets `serializer` and `literal_parser` to raise ValueErrors when `Upload` is used +either as return type for field or passed as literal value in GraphQL query. + +# Example + +Below code defines a schema with `Upload` scalar using `upload_scalar` utility: + +```python +from ariadne import MutationType, make_executable_schema, upload_scalar + +mutation_type = MutationType() + +@mutation_type.field("handleUpload") +def resolve_handle_upload(*_, upload): + return repr(upload) + + +schema = make_executable_schema( + \"\"\" + scalar Upload + + type Query { + empty: String + } + + type Mutation { + handleUpload(upload: Upload!): String + } + \"\"\", + upload_scalar, + mutation_type, +) +``` +""" upload_scalar = ScalarType("Upload") diff --git a/ariadne/format_error.py b/ariadne/format_error.py index e18a3596c..f880a2ed7 100644 --- a/ariadne/format_error.py +++ b/ariadne/format_error.py @@ -9,6 +9,23 @@ def format_error(error: GraphQLError, debug: bool = False) -> dict: + """Format the GraphQL error into JSON serializable format. + + If `debug` is set to `True`, error's JSON will also include the `extensions` + key with `exception` object containing error's `context` and `stacktrace`. + + Returns a JSON-serializable `dict` with error representation. + + # Required arguments + + `error`: an `GraphQLError` to convert into JSON serializable format. + + # Optional arguments + + `debug`: a `bool` that controls if debug data should be included in + result `dict`. Defaults to `False`. + """ + formatted = cast(dict, error.formatted) if debug: if "extensions" not in formatted: @@ -18,6 +35,16 @@ def format_error(error: GraphQLError, debug: bool = False) -> dict: def get_error_extension(error: GraphQLError) -> Optional[dict]: + """Get a JSON-serializable `dict` containing error's stacktrace and context. + + Returns a JSON-serializable `dict` with `stacktrace` and `context` to include + under error's `extensions` key in JSON response. Returns `None` if `error` + has no stacktrace or wraps no exception. + + # Required arguments + + `error`: an `GraphQLError` to return context and stacktrace for. + """ unwrapped_error = unwrap_graphql_error(error) if unwrapped_error is None or not error.__traceback__: return None @@ -30,6 +57,18 @@ def get_error_extension(error: GraphQLError) -> Optional[dict]: def get_formatted_error_traceback(error: Exception) -> List[str]: + """Get JSON-serializable stacktrace from `Exception`. + + Returns list of strings, with every item being separate line from stacktrace. + + This approach produces better results in GraphQL explorers which display every + line under previous one but not always format linebreak characters for blocks + of text. + + # Required arguments + + `error`: an `Exception` to return formatted stacktrace for. + """ formatted = [] for line in format_exception(type(error), error, error.__traceback__): formatted.extend(line.rstrip().splitlines()) @@ -37,6 +76,17 @@ def get_formatted_error_traceback(error: Exception) -> List[str]: def get_formatted_error_context(error: Exception) -> Optional[dict]: + """Get JSON-serializable context from `Exception`. + + Returns a `dict` of strings, with every key being value name and value + being `repr()` of it's Python value. Returns `None` if context is not + available. + + # Required arguments + + `error`: an `Exception` to return formatted context for. + """ + tb_last = error.__traceback__ while tb_last and tb_last.tb_next: tb_last = tb_last.tb_next diff --git a/ariadne/graphql.py b/ariadne/graphql.py index 699d56791..285282cdc 100644 --- a/ariadne/graphql.py +++ b/ariadne/graphql.py @@ -76,6 +76,66 @@ async def graphql( execution_context_class: Optional[Type[ExecutionContext]] = None, **kwargs, ) -> GraphQLResult: + """Execute GraphQL query asynchronously. + + Returns a tuple with two items: + + `bool`: `True` when no errors occurred, `False` otherwise. + + `dict`: an JSON-serializable `dict` with query result + (defining either `data`, `error`, or both keys) that should be returned to + client. + + # Required arguments + + 'schema': a GraphQL schema instance that defines `Query` type. + + `data`: a `dict` with query data (`query` string, optionally `operationName` + string and `variables` dictionary). + + # Optional arguments + + `context_value`: a context value to make accessible as 'context' attribute + of second argument (`info`) passed to resolvers. + + `root_value`: an root value to pass as first argument to resolvers set on + `Query` and `Mutation` types. + + `query_parser`: a `QueryParser` to use instead of default one. Is called + with two arguments: `context_value`, and `data` dict. + + `query_document`: an already parsed GraphQL query. Setting this option will + prevent `graphql` from parsing `query` string from `data` second time. + + `debug`: a `bool` for enabling debug mode. Controls presence of debug data + in errors reported to client. + + `introspection`: a `bool` for disabling introspection queries. + + `logger`: a `str` with name of logger or logger instance to use for logging + errors. + + `validation_rules`: a `list` of or callable returning list of custom + validation rules to use to validate query before it's executed. + + `error_formatter`: an `ErrorFormatter` callable to use to convert GraphQL + errors encountered during query execution to JSON-serializable format. + + `middleware`: a `list` of or callable returning list of GraphQL middleware + to use by query executor. + + `middleware_manager_class`: a `MiddlewareManager` class to use by query + executor. + + `extensions`: a `list` of or callable returning list of extensions + to use during query execution. + + `execution_context_class`: `ExecutionContext` class to use by query + executor. + + `**kwargs`: any kwargs not used by `graphql` are passed to + `graphql.graphql`. + """ extension_manager = ExtensionManager(extensions, context_value) with extension_manager.request(): @@ -174,6 +234,66 @@ def graphql_sync( execution_context_class: Optional[Type[ExecutionContext]] = None, **kwargs, ) -> GraphQLResult: + """Execute GraphQL query synchronously. + + Returns a tuple with two items: + + `bool`: `True` when no errors occurred, `False` otherwise. + + `dict`: an JSON-serializable `dict` with query result + (defining either `data`, `error`, or both keys) that should be returned to + client. + + # Required arguments + + 'schema': a GraphQL schema instance that defines `Query` type. + + `data`: a `dict` with query data (`query` string, optionally `operationName` + string and `variables` dictionary). + + # Optional arguments + + `context_value`: a context value to make accessible as 'context' attribute + of second argument (`info`) passed to resolvers. + + `root_value`: an root value to pass as first argument to resolvers set on + `Query` and `Mutation` types. + + `query_parser`: a `QueryParser` to use instead of default one. Is called + with two arguments: `context_value`, and `data` dict. + + `query_document`: an already parsed GraphQL query. Setting this option will + prevent `graphql_sync` from parsing `query` string from `data` second time. + + `debug`: a `bool` for enabling debug mode. Controls presence of debug data + in errors reported to client. + + `introspection`: a `bool` for disabling introspection queries. + + `logger`: a `str` with name of logger or logger instance to use for logging + errors. + + `validation_rules`: a `list` of or callable returning list of custom + validation rules to use to validate query before it's executed. + + `error_formatter`: an `ErrorFormatter` callable to use to convert GraphQL + errors encountered during query execution to JSON-serializable format. + + `middleware`: a `list` of or callable returning list of GraphQL middleware + to use by query executor. + + `middleware_manager_class`: a `MiddlewareManager` class to use by query + executor. + + `extensions`: a `list` of or callable returning list of extensions + to use during query execution. + + `execution_context_class`: `ExecutionContext` class to use by query + executor. + + `**kwargs`: any kwargs not used by `graphql_sync` are passed to + `graphql.graphql_sync`. + """ extension_manager = ExtensionManager(extensions, context_value) with extension_manager.request(): @@ -275,6 +395,53 @@ async def subscribe( error_formatter: ErrorFormatter = format_error, **kwargs, ) -> SubscriptionResult: + """Subscribe to GraphQL updates. + + Returns a tuple with two items: + + `bool`: `True` when no errors occurred, `False` otherwise. + + `AsyncGenerator`: an async generator that server implementation should + consume to retrieve messages to send to client. + + # Required arguments + + 'schema': a GraphQL schema instance that defines `Subscription` type. + + `data`: a `dict` with query data (`query` string, optionally `operationName` + string and `variables` dictionary). + + # Optional arguments + + `context_value`: a context value to make accessible as 'context' attribute + of second argument (`info`) passed to resolvers and source functions. + + `root_value`: an root value to pass as first argument to resolvers and + source functions set on `Subscription` type. + + `query_parser`: a `QueryParser` to use instead of default one. Is called + with two arguments: `context_value`, and `data` dict. + + `query_document`: an already parsed GraphQL query. Setting this option will + prevent `subscribe` from parsing `query` string from `data` second time. + + `debug`: a `bool` for enabling debug mode. Controls presence of debug data + in errors reported to client. + + `introspection`: a `bool` for disabling introspection queries. + + `logger`: a `str` with name of logger or logger instance to use for logging + errors. + + `validation_rules`: a `list` of or callable returning list of custom + validation rules to use to validate query before it's executed. + + `error_formatter`: an `ErrorFormatter` callable to use to convert GraphQL + errors encountered during query execution to JSON-serializable format. + + `**kwargs`: any kwargs not used by `subscribe` are passed to + `graphql.subscribe`. + """ try: validate_data(data) variables, operation_name = ( diff --git a/ariadne/interfaces.py b/ariadne/interfaces.py index b6ab1bb93..b6e18e641 100644 --- a/ariadne/interfaces.py +++ b/ariadne/interfaces.py @@ -12,13 +12,166 @@ class InterfaceType(ObjectType): + """Bindable populating interfaces in a GraphQL schema with Python logic. + + Extends `ObjectType`, providing `field` decorator and `set_field` and `set_alias` + methods. If those are used to set resolvers for interface's fields, those + resolvers will instead be set on fields of GraphQL types implementing this + interface, but only if those fields don't already have resolver of their own set + by the `ObjectType`. + + # Type resolver + + Because GraphQL fields using interface as their returning type can return any + Python value from their resolver, GraphQL interfaces require special type of + resolver called "type resolver" to function. + + This resolver is called with the value returned by field's resolver and is + required to return a string with a name of GraphQL type represented by Python + value from the field: + + ```python + def example_type_resolver(obj: Any, *_) -> str: + if isinstance(obj, PythonReprOfUser): + return "User" + + if isinstance(obj, PythonReprOfComment): + return "Comment" + + raise ValueError(f"Don't know GraphQL type for '{obj}'!") + ``` + + This resolver is not required if the GraphQL field returns a value that has + the `__typename` attribute or `dict` key with a name of the GraphQL type: + + ```python + user_data_dict = {"__typename": "User", ...} + + # or... + + class UserRepr: + __typename: str = "User" + ``` + + # Example + + Following code creates a GraphQL schema with a field that returns random + result of either `User` or `Post` GraphQL type. It also supports dict with + `__typename` key that explicitly declares its GraphQL type: + + ```python + import random + from dataclasses import dataclass + from ariadne import QueryType, InterfaceType, make_executable_schema + + @dataclass + class UserModel: + id: str + name: str + + @dataclass + class PostModel: + id: str + message: str + + results = ( + UserModel(id=1, name="Bob"), + UserModel(id=2, name="Alice"), + UserModel(id=3, name="Jon"), + PostModel(id=1, message="Hello world!"), + PostModel(id=2, message="How's going?"), + PostModel(id=3, message="Sure thing!"), + {"__typename": "User", "id": 4, "name": "Polito"}, + {"__typename": "User", "id": 5, "name": "Aerith"}, + {"__typename": "Post", "id": 4, "message": "Good day!"}, + {"__typename": "Post", "id": 5, "message": "Whats up?"}, + ) + + query_type = QueryType() + + @query_type.field("result") + def resolve_random_result(*_): + return random.choice(results) + + + result_type = InterfaceType("Result") + + @result_type.type_resolver + def resolve_result_type(obj: UserModel | PostModel | dict, *_) -> str: + if isinstance(obj, UserModel): + return "User" + + if isinstance(obj, PostModel): + return "Post" + + if isinstance(obj, dict) and obj.get("__typename"): + return obj["__typename"] + + raise ValueError(f"Don't know GraphQL type for '{obj}'!") + + + schema = make_executable_schema( + \"\"\" + type Query { + result: Result! + } + + interface Result { + id: ID! + } + + type User implements Result { + id: ID! + name: String! + } + + type Post implements Result { + id: ID! + message: String! + } + \"\"\", + query_type, + result_type, + ) + ``` + """ + _resolve_type: Optional[Resolver] def __init__(self, name: str, type_resolver: Optional[Resolver] = None) -> None: + """Initializes the `InterfaceType` with a `name` and optional `type_resolver`. + + Type resolver is required by `InterfaceType` to function properly, but can + be set later using either `set_type_resolver(type_resolver)` + setter or `type_resolver` decorator. + + # Required arguments + + `name`: a `str` with the name of GraphQL interface type in GraphQL schema to + bind to. + + # Optional arguments + + `type_resolver`: a `Resolver` used to resolve a str with name of GraphQL type + from it's Python representation. + """ + super().__init__(name) self._resolve_type = type_resolver def set_type_resolver(self, type_resolver: Resolver) -> Resolver: + """Sets function as type resolver for this interface. + + Can be used as a decorator. Also available through `type_resolver` alias: + + ```python + interface_type = InterfaceType("MyInterface") + + @interface_type.type_resolver + def type_resolver(obj: Any, *_) -> str: + ... + ``` + """ self._resolve_type = type_resolver return type_resolver @@ -26,6 +179,15 @@ def set_type_resolver(self, type_resolver: Resolver) -> Resolver: type_resolver = set_type_resolver def bind_to_schema(self, schema: GraphQLSchema) -> None: + """Binds this `InterfaceType` instance to the instance of GraphQL schema. + + Sets `resolve_type` attribute on GraphQL interface. If this attribute was + previously set, it will be replaced to new value. + + If this interface has any resolvers set, it also scans GraphQL schema for + types implementing this interface and sets those resolvers on those types + fields, but only if those fields don't already have other resolver set. + """ graphql_type = schema.type_map.get(self.name) self.validate_graphql_type(graphql_type) graphql_type = cast(GraphQLInterfaceType, graphql_type) @@ -38,6 +200,8 @@ def bind_to_schema(self, schema: GraphQLSchema) -> None: self.bind_resolvers_to_graphql_type(object_type, replace_existing=False) def validate_graphql_type(self, graphql_type: Optional[GraphQLNamedType]) -> None: + """Validates that schema's GraphQL type associated with this `InterfaceType` + is an `interface`.""" if not graphql_type: raise ValueError(f"Interface {self.name} is not defined in the schema") if not isinstance(graphql_type, GraphQLInterfaceType): diff --git a/ariadne/load_schema.py b/ariadne/load_schema.py index 61d9ebe91..43fdfd8b6 100644 --- a/ariadne/load_schema.py +++ b/ariadne/load_schema.py @@ -8,6 +8,22 @@ def load_schema_from_path(path: Union[str, os.PathLike]) -> str: + """Load schema definition in Schema Definition Language from file or directory. + + If `path` argument points to a file, this file's contents are read, validated + and returned as `str`. If its a directory, its walked recursively and every + file with `.graphql`, `.graphqls` or `.gql` extension is read and validated, + and all files are then concatenated into single `str` that is then returned. + + Returns a `str` with schema definition that was already validated to be valid + GraphQL SDL. Raises `GraphQLFileSyntaxError` is any of loaded files fails to + parse. + + # Required arguments + + `path`: a `str` or `PathLike` object pointing to either file or directory + with files to load. + """ if os.path.isdir(path): schema_list = [read_graphql_file(f) for f in sorted(walk_graphql_files(path))] return "\n".join(schema_list) diff --git a/ariadne/objects.py b/ariadne/objects.py index 8b6e1b2f7..ee5a8188d 100644 --- a/ariadne/objects.py +++ b/ariadne/objects.py @@ -7,13 +7,174 @@ class ObjectType(SchemaBindable): + """Bindable populating object types in a GraphQL schema with Python logic. + + # Example + + Following code creates a GraphQL schema with single object type named `Query` + and uses `ObjectType` to set resolvers on its fields: + + ```python + import random + from datetime import datetime + + from ariadne import ObjectType, make_executable_schema + + query_type = ObjectType("Query") + + @query_type.field("diceRoll") + def resolve_dice_roll(*_): + return random.int(1, 6) + + + @query_type.field("year") + def resolve_year(*_): + return datetime.today().year + + + schema = make_executable_schema( + \"\"\" + type Query { + diceRoll: Int! + year: Int! + } + \"\"\", + query_type, + ) + ``` + + + # Example with objects in objects + + When a field in the schema returns other GraphQL object, this object's + resolvers are called with value returned from field's resolver. For example + if there's an `user` field on the `Query` type that returns the `User` type, + you don't have to resolve `User` fields in `user` resolver. In below example + `fullName` field on `User` type is resolved from data on `UserModel` object + that `user` field resolver on `Query` type returned: + + ```python + import dataclasses + from ariadne import ObjectType, make_executable_schema + + @dataclasses.dataclass + class UserModel: + id: int + username: str + first_name: str + last_name: str + + + users = [ + UserModel( + id=1, + username="Dany", + first_name="Daenerys", + last_name="Targaryen", + ), + UserModel( + id=2, + username="BlackKnight19", + first_name="Cahir", + last_name="Mawr Dyffryn aep Ceallach", + ), + UserModel( + id=3, + username="TheLady", + first_name="Dorotea", + last_name="Senjak", + ), + ] + + + # Query type resolvers return users, but don't care about fields + # of User type + query_type = ObjectType("Query") + + @query_type.field("users") + def resolve_users(*_) -> list[UserModel]: + # In real world applications this would be a database query + # returning iterable with user results + return users + + + @query_type.field("user") + def resolve_user(*_, id: str) -> UserModel | None: + # In real world applications this would be a database query + # returning single user or None + + try: + # GraphQL ids are always strings + clean_id = int(id) + except (ValueError, TypeError): + # We could raise "ValueError" instead + return None + + for user in users: + if user.id == id: + return user + + return None + + + # User type resolvers don't know how to retrieve User, but know how to + # resolve User type fields from UserModel instance + user_type = ObjectType("User") + + # Resolve "name" GraphQL field to "username" attribute + user_type.set_alias("name", "username") + + # Resolve "fullName" field to combined first and last name + # `obj` argument will be populated by GraphQL with a value from + # resolver for field returning "User" type + @user_type.field("fullName") + def resolve_user_full_name(obj: UserModel, *_): + return f"{obj.first_name} {obj.last_name}" + + + schema = make_executable_schema( + \"\"\" + type Query { + users: [User!]! + user(id: ID!): User + } + + type User { + id: ID! + name: String! + fullName: String! + } + \"\"\", + query_type, + user_type, + ) + ``` + """ + _resolvers: Dict[str, Resolver] def __init__(self, name: str) -> None: + """Initializes the `ObjectType` with a `name`. + + # Required arguments + + `name`: a `str` with the name of GraphQL object type in GraphQL schema to + bind to. + """ self.name = name self._resolvers = {} def field(self, name: str) -> Callable[[Resolver], Resolver]: + """Return a decorator that sets decorated function as a resolver for named field. + + Wrapper for `create_register_resolver` that on runtime validates `name` to be a + string. + + # Required arguments + + `name`: a `str` with a name of the GraphQL object's field in GraphQL schema to + bind decorated resolver to. + """ if not isinstance(name, str): raise ValueError( 'field decorator should be passed a field name: @foo.field("name")' @@ -21,6 +182,14 @@ def field(self, name: str) -> Callable[[Resolver], Resolver]: return self.create_register_resolver(name) def create_register_resolver(self, name: str) -> Callable[[Resolver], Resolver]: + """Return a decorator that sets decorated function as a resolver for named field. + + # Required arguments + + `name`: a `str` with a name of the GraphQL object's field in GraphQL schema to + bind decorated resolver to. + """ + def register_resolver(f: Resolver) -> Resolver: self._resolvers[name] = f return f @@ -28,19 +197,45 @@ def register_resolver(f: Resolver) -> Resolver: return register_resolver def set_field(self, name, resolver: Resolver) -> Resolver: + """Set a resolver for the field name. + + # Required arguments + + `name`: a `str` with a name of the GraphQL object's field in GraphQL schema to + set this resolver for. + + `resolver`: a `Resolver` function to use. + """ self._resolvers[name] = resolver return resolver def set_alias(self, name: str, to: str) -> None: + """Set an alias resolver for the field name to given Python nme. + + # Required arguments + + `name`: a `str` with a name of the GraphQL object's field in GraphQL schema to + set this resolver for. + + `to`: a `str` of an attribute or dict key to resolve this field to. + """ self._resolvers[name] = resolve_to(to) def bind_to_schema(self, schema: GraphQLSchema) -> None: + """Binds this `ObjectType` instance to the instance of GraphQL schema. + + If it has any resolver functions set, it assigns those to GraphQL type's + fields `resolve` attributes. If field already has other resolver set on + its `resolve` attribute, this resolver is replaced with the new one. + """ graphql_type = schema.type_map.get(self.name) self.validate_graphql_type(graphql_type) graphql_type = cast(GraphQLObjectType, graphql_type) self.bind_resolvers_to_graphql_type(graphql_type) def validate_graphql_type(self, graphql_type: Optional[GraphQLNamedType]) -> None: + """Validates that schema's GraphQL type associated with this `ObjectType` + is a `type`.""" if not graphql_type: raise ValueError("Type %s is not defined in the schema" % self.name) if not isinstance(graphql_type, GraphQLObjectType): @@ -50,6 +245,7 @@ def validate_graphql_type(self, graphql_type: Optional[GraphQLNamedType]) -> Non ) def bind_resolvers_to_graphql_type(self, graphql_type, replace_existing=True): + """Binds this `ObjectType` instance to the instance of GraphQL schema.""" for field, resolver in self._resolvers.items(): if field not in graphql_type.fields: raise ValueError( @@ -60,14 +256,42 @@ def bind_resolvers_to_graphql_type(self, graphql_type, replace_existing=True): class QueryType(ObjectType): - """Convenience class for defining Query type""" + """An convenience class for defining Query type. + + # Example + + Both of those code samples have same effect: + + ```python + query_type = QueryType() + ``` + + ```python + query_type = ObjectType("Query") + ``` + """ def __init__(self) -> None: + """Initializes the `QueryType` with a GraphQL name set to `Query`.""" super().__init__("Query") class MutationType(ObjectType): - """Convenience class for defining Mutation type""" + """An convenience class for defining Mutation type. + + # Example + + Both of those code samples have same result: + + ```python + mutation_type = MutationType() + ``` + + ```python + mutation_type = ObjectType("Mutation") + ``` + """ def __init__(self) -> None: + """Initializes the `MutationType` with a GraphQL name set to `Mutation`.""" super().__init__("Mutation") diff --git a/ariadne/resolvers.py b/ariadne/resolvers.py index f6a787914..21a3014a6 100644 --- a/ariadne/resolvers.py +++ b/ariadne/resolvers.py @@ -14,30 +14,67 @@ class FallbackResolversSetter(SchemaBindable): + """Bindable that recursively scans GraphQL schema for fields and explicitly + sets their resolver to `graphql.default_field_resolver` package if + they don't have any resolver set yet. + + > **Deprecated:** This class doesn't provide any utility for developers and + only serves as a base for `SnakeCaseFallbackResolversSetter` which is being + replaced by what we believe to be a better solution. + > + > Because of this we are deprecating this utility. It will be removed in future + Ariadne release. + """ + def bind_to_schema(self, schema: GraphQLSchema) -> None: + """Scans GraphQL schema for types with fields that don't have set resolver.""" for type_object in schema.type_map.values(): if isinstance(type_object, GraphQLObjectType): self.add_resolvers_to_object_fields(type_object) - def add_resolvers_to_object_fields(self, type_object) -> None: + def add_resolvers_to_object_fields(self, type_object: GraphQLObjectType) -> None: + """Sets explicit default resolver on a fields of an object that don't have any.""" for field_name, field_object in type_object.fields.items(): self.add_resolver_to_field(field_name, field_object) def add_resolver_to_field(self, _: str, field_object: GraphQLField) -> None: + """Sets `default_field_resolver` as a resolver on a field that doesn't have any.""" if field_object.resolve is None: field_object.resolve = default_field_resolver class SnakeCaseFallbackResolversSetter(FallbackResolversSetter): + """Subclass of `FallbackResolversSetter` that uses case-converting resolver + instead of `default_field_resolver`. + + > **Deprecated:** Use `convert_names_case` from `make_executable_schema` + instead. + """ + def add_resolver_to_field( self, field_name: str, field_object: GraphQLField ) -> None: + """Sets case converting resolver on a field that doesn't have any.""" if field_object.resolve is None: field_name = convert_camel_case_to_snake(field_name) field_object.resolve = resolve_to(field_name) +""" +Bindable instance of `FallbackResolversSetter`. + +> **Deprecated:** This utility will be removed in future Ariadne release. +> +> See `FallbackResolversSetter` for details. +""" fallback_resolvers = FallbackResolversSetter() + +""" +Bindable instance of `SnakeCaseFallbackResolversSetter`. + +> **Deprecated:** Use `convert_names_case` from `make_executable_schema` +instead. +""" snake_case_fallback_resolvers = SnakeCaseFallbackResolversSetter() @@ -47,9 +84,22 @@ def resolve_parent_field(parent: Any, field_name: str) -> Any: return getattr(parent, field_name, None) -def resolve_to(field_name: str) -> Resolver: +def resolve_to(attr_name: str) -> Resolver: + """Create a resolver that resolves to given attribute or dict key. + + Returns a resolver function that can be used as resolver. + + Usually not used directly but through higher level features like aliases + or schema names conversion. + + # Required arguments + + `attr_name`: a `str` with name of attribute or `dict` key to return from + resolved object. + """ + def resolver(parent: Any, info: GraphQLResolveInfo, **kwargs) -> Any: - value = resolve_parent_field(parent, field_name) + value = resolve_parent_field(parent, attr_name) if callable(value): return value(info, **kwargs) return value @@ -60,6 +110,20 @@ def resolver(parent: Any, info: GraphQLResolveInfo, **kwargs) -> Any: def is_default_resolver(resolver: Optional[Resolver]) -> bool: + """Test if resolver function is default resolver implemented by + `graphql-core` or Ariadne. + + Returns `True` if resolver function is `None`, `graphql.default_field_resolver` + or was created by Ariadne's `resolve_to` utility. Returns `False` otherwise. + + `True` is returned for `None` because query executor defaults to the + `graphql.default_field_resolver` is there's no resolver function set on a + field. + + # Required arguments + + `resolver`: an function `None` to test or `None`. + """ # pylint: disable=comparison-with-callable if not resolver or resolver == default_field_resolver: return True diff --git a/ariadne/scalars.py b/ariadne/scalars.py index e0bcd5a72..0a786d470 100644 --- a/ariadne/scalars.py +++ b/ariadne/scalars.py @@ -13,6 +13,175 @@ class ScalarType(SchemaBindable): + """Bindable populating scalars in a GraphQL schema with Python logic. + + GraphQL scalars implement default serialization and deserialization logic. + This class is only useful when custom logic is needed, most commonly + when Python representation of scalar's value is not JSON-serializable by + default. + + This logic can be customized for three steps: + + # Serialization + + Serialization step converts Python representation of scalar's value to a + JSON serializable format. + + Serializer function takes single argument and returns a single, + JSON serializable value: + + ```python + def serialize_date(value: date) -> str: + # Serialize dates as "YYYY-MM-DD" string + return date.strftime("%Y-%m-%d") + ``` + + # Value parsing + + Value parsing step converts value from deserialized JSON + to Python representation. + + Value parser function takes single argument and returns a single value: + + ```python + def parse_date_str(value: str) -> date: + try: + # Parse "YYYY-MM-DD" string into date + return datetime.strptime(value, "%Y-%m-%d").date() + except (ValueError, TypeError): + raise ValueError( + f'"{value}" is not a date string in YYYY-MM-DD format.' + ) + ``` + + # Literal parsing + + Literal parsing step converts value from GraphQL abstract syntax tree (AST) + to Python representation. + + Literal parser function takes two arguments, an AST node and a dict with + query's variables and returns Python value: + + ```python + def parse_date_literal( + value: str, variable_values: dict[str, Any] = None + ) -> date: + if not isinstance(ast, StringValueNode): + raise ValueError() + + try: + # Parse "YYYY-MM-DD" string into date + return datetime.strptime(ast.value, "%Y-%m-%d").date() + except (ValueError, TypeError): + raise ValueError( + f'"{value}" is not a date string in YYYY-MM-DD format.' + ) + ``` + + When scalar has custom value parser set, but not the literal parser, the + GraphQL query executor will use default literal parser, and then call the + value parser with it's return value. This mechanism makes custom literal + parser unnecessary for majority of scalar implementations. + + Scalar literals are always parsed twice: on query validation and during + query execution. + + # Example datetime scalar + + Following code defines a datetime scalar which converts Python datetime + object to and from a string. Note that it without custom literal scalar: + + ```python + from datetime import datetime + + from ariadne import QueryType, ScalarType, make_executable_schema + + scalar_type = ScalarType("DateTime") + + @scalar_type.serializer + def serialize_value(val: datetime) -> str: + return datetime.strftime(val, "%Y-%m-%d %H:%M:%S") + + + @scalar_type.value_parser + def parse_value(val) -> datetime: + if not isinstance(val, str): + raise ValueError( + f"'{val}' is not a valid JSON representation " + ) + + return datetime.strptime(val, "%Y-%m-%d %H:%M:%S") + + + query_type = QueryType() + + @query_type.field("now") + def resolve_now(*_): + return datetime.now() + + + @query_type.field("diff") + def resolve_diff(*_, value): + delta = datetime.now() - value + return int(delta.total_seconds()) + + + schema = make_executable_schema( + \"\"\" + scalar DateTime + + type Query { + now: DateTime! + diff(value: DateTime): Int! + } + \"\"\", + scalar_type, + query_type, + ) + ``` + + # Example generic scalar + + Generic scalar is a pass-through scalar that doesn't perform any value + conversion. Most common use case for those is for GraphQL fields that + return unstructured JSON to the client. To create a scalar like this, + you can simply include `scalar Generic` in your GraphQL schema: + + ```python + from ariadne import QueryType, make_executable_schema + + query_type = QueryType() + + @query_type.field("rawJSON") + def resolve_raw_json(*_): + # Note: this value needs to be JSON serializable + return { + "map": { + "0": "Hello!", + "1": "World!", + }, + "list": [ + 2, + 1, + 3, + 7, + ], + } + + + schema = make_executable_schema( + \"\"\" + scalar Generic + + type Query { + rawJSON: Generic! + } + \"\"\", + query_type, + ) + ``` + """ + _serialize: Optional[GraphQLScalarSerializer] _parse_value: Optional[GraphQLScalarValueParser] _parse_literal: Optional[GraphQLScalarLiteralParser] @@ -25,22 +194,94 @@ def __init__( value_parser: Optional[GraphQLScalarValueParser] = None, literal_parser: Optional[GraphQLScalarLiteralParser] = None, ) -> None: + """Initializes the `ScalarType` with a `name`. + + # Required arguments + + `name`: a `str` with the name of GraphQL scalar in GraphQL schema to + bind to. + + # Optional arguments + + `serializer`: a function called to convert Python representation of + scalar's value to JSON serializable format. + + `value_parser`: a function called to convert a JSON deserialized value + from query's "variables" JSON into scalar's Python representation. + + `literal_parser`: a function called to convert an AST value + from parsed query into scalar's Python representation. + """ self.name = name self._serialize = serializer self._parse_value = value_parser self._parse_literal = literal_parser def set_serializer(self, f: GraphQLScalarSerializer) -> GraphQLScalarSerializer: + """Sets function as serializer for this scalar. + + Can be used as a decorator. Also available through `serializer` alias: + + ```python + date_scalar = ScalarType("Date") + + @date_scalar.serializer + def serialize_date(value: date) -> str: + # Serialize dates as "YYYY-MM-DD" string + return date.strftime("%Y-%m-%d") + ``` + """ self._serialize = f return f def set_value_parser(self, f: GraphQLScalarValueParser) -> GraphQLScalarValueParser: + """Sets function as value parser for this scalar. + + Can be used as a decorator. Also available through `value_parser` alias: + + ```python + date_scalar = ScalarType("Date") + + @date_scalar.value_parser + def parse_date_str(value: str) -> date: + try: + # Parse "YYYY-MM-DD" string into date + return datetime.strptime(value, "%Y-%m-%d").date() + except (ValueError, TypeError): + raise ValueError( + f'"{value}" is not a date string in YYYY-MM-DD format.' + ) + ``` + """ self._parse_value = f return f def set_literal_parser( self, f: GraphQLScalarLiteralParser ) -> GraphQLScalarLiteralParser: + """Sets function as literal parser for this scalar. + + Can be used as a decorator. Also available through `literal_parser` alias: + + ```python + date_scalar = ScalarType("Date") + + @date_scalar.literal_parser + def parse_date_literal( + value: str, variable_values: Optional[dict[str, Any]] = None + ) -> date: + if not isinstance(ast, StringValueNode): + raise ValueError() + + try: + # Parse "YYYY-MM-DD" string into date + return datetime.strptime(ast.value, "%Y-%m-%d").date() + except (ValueError, TypeError): + raise ValueError( + f'"{value}" is not a date string in YYYY-MM-DD format.' + ) + ``` + """ self._parse_literal = f return f @@ -50,6 +291,12 @@ def set_literal_parser( literal_parser = set_literal_parser def bind_to_schema(self, schema: GraphQLSchema) -> None: + """Binds this `ScalarType` instance to the instance of GraphQL schema. + + If it has serializer or parser functions set, it assigns those to GraphQL + scalar's attributes. If scalar's attribute already has other function + set, this function is replaced with the new one. + """ graphql_type = schema.type_map.get(self.name) self.validate_graphql_type(graphql_type) graphql_type = cast(GraphQLScalarType, graphql_type) @@ -63,6 +310,8 @@ def bind_to_schema(self, schema: GraphQLSchema) -> None: graphql_type.parse_literal = self._parse_literal # type: ignore def validate_graphql_type(self, graphql_type: Optional[GraphQLNamedType]) -> None: + """Validates that schema's GraphQL type associated with this `ScalarType` + is a `scalar`.""" if not graphql_type: raise ValueError("Scalar %s is not defined in the schema" % self.name) if not isinstance(graphql_type, GraphQLScalarType): diff --git a/ariadne/schema_names.py b/ariadne/schema_names.py index 6f699cc65..73441b6e8 100644 --- a/ariadne/schema_names.py +++ b/ariadne/schema_names.py @@ -10,6 +10,22 @@ from .resolvers import resolve_to from .utils import convert_camel_case_to_snake +""" +A type of a function implementing a strategy for names conversion in schema. +Passed as an option to `make_executable_schema` and `convert_schema_names` +functions. + +Takes three arguments: + +`name`: a `str` with schema name to convert. + +`schema`: the GraphQL schema in which names are converted. + +`path`: a tuple of `str` representing a path to the schema item which name +is being converted. + +Returns a string with the Python name to use. +""" SchemaNameConverter = Callable[[str, GraphQLSchema, Tuple[str, ...]], str] GRAPHQL_SPEC_TYPES = ( @@ -26,6 +42,37 @@ def convert_schema_names( schema: GraphQLSchema, name_converter: Optional[SchemaNameConverter], ) -> None: + """Set mappings in GraphQL schema from `camelCase` names to `snake_case`. + + This function scans GraphQL schema and: + + If objects field has name in `camelCase` and this field doesn't have a + resolver already set on it, new resolver is assigned to it that resolves + it's value from object attribute or dict key named like `snake_case` + version of field's name. + + If object's field has argument in `camelCase` and this argument doesn't have + the `out_name` attribute already set, this attribute is populated with + argument's name converted to `snake_case` + + If input's field has name in `camelCase` and it's `out_name` attribute is + not already set, this attribute is populated with field's name converted + to `snake_case`. + + Schema is mutated in place. + + Generally you shouldn't call this function yourself, as its part of + `make_executable_schema` logic, but its part of public API for other + libraries to use. + + # Required arguments + + `schema`: a GraphQL schema to update. + + `name_converter`: an `SchemaNameConverter` function to use to convert the + names from `camelCase` to `snake_case`. If not provided, default one + based on `convert_camel_case_to_snake` is used. + """ name_converter = name_converter or default_schema_name_converter for type_name, graphql_type in schema.type_map.items(): diff --git a/ariadne/schema_visitor.py b/ariadne/schema_visitor.py index 1b2df03a2..5300c88f9 100644 --- a/ariadne/schema_visitor.py +++ b/ariadne/schema_visitor.py @@ -318,7 +318,106 @@ def directive_location_to_visitor_method_name(loc: DirectiveLocation): class SchemaDirectiveVisitor(SchemaVisitor): + """Base class for custom GraphQL directives. + + Also implements class methods with container and management logic for + directives at schema creation time, used by `make_executable_schema`. + + # Lifecycle + + Separate instances of the GraphQL directive are created for each GraphQL + schema item with the directive set on it. If directive is set on two + fields, two separate instances of a directive will be created. + + # Example schema visitors + + `SchemaDirectiveVisitor` subclasses can implement any of below methods + that will be called when directive is applied to different elements of + GraphQL schema: + + ```python + from ariadne import SchemaDirectiveVisitor + from graphql import ( + GraphQLArgument, + GraphQLEnumType, + GraphQLEnumValue, + GraphQLField, + GraphQLInputField, + GraphQLInputObjectType, + GraphQLInterfaceType, + GraphQLObjectType, + GraphQLScalarType, + GraphQLSchema, + GraphQLUnionType, + ) + + class MyDirective(SchemaDirectiveVisitor): + def visit_schema(self, schema: GraphQLSchema) -> None: + pass + + def visit_scalar(self, scalar: GraphQLScalarType) -> GraphQLScalarType: + pass + + def visit_object(self, object_: GraphQLObjectType) -> GraphQLObjectType: + pass + + def visit_field_definition( + self, + field: GraphQLField, + object_type: Union[GraphQLObjectType, GraphQLInterfaceType], + ) -> GraphQLField: + pass + + def visit_argument_definition( + self, + argument: GraphQLArgument, + field: GraphQLField, + object_type: Union[GraphQLObjectType, GraphQLInterfaceType], + ) -> GraphQLArgument: + pass + + def visit_interface(self, interface: GraphQLInterfaceType) -> GraphQLInterfaceType: + pass + + def visit_union(self, union: GraphQLUnionType) -> GraphQLUnionType: + pass + + def visit_enum(self, type_: GraphQLEnumType) -> GraphQLEnumType: + pass + + def visit_enum_value( + self, value: GraphQLEnumValue, enum_type: GraphQLEnumType + ) -> GraphQLEnumValue: + pass + + def visit_input_object( + self, object_: GraphQLInputObjectType + ) -> GraphQLInputObjectType: + pass + + def visit_input_field_definition( + self, field: GraphQLInputField, object_type: GraphQLInputObjectType + ) -> GraphQLInputField: + pass + ``` + """ + def __init__(self, name, args, visited_type, schema, context) -> None: + """Instantiates the directive for schema object. + + # Required arguments + + `name`: a `str` with directive's name. + + `args`: a `dict` with directive's arguments. + + `visited_type`: an GraphQL type this directive is set on. + + `schema`: the GraphQL schema instance. + + `context`: `None`, unused but present for historic reasons. + """ + self.name = name self.args = args self.visited_type = visited_type @@ -326,7 +425,20 @@ def __init__(self, name, args, visited_type, schema, context) -> None: self.context = context @classmethod - def get_directive_declaration(cls, directive_name: str, schema: GraphQLSchema): + def get_directive_declaration( + cls, directive_name: str, schema: GraphQLSchema + ) -> Optional[GraphQLDirective]: + """Get GraphQL directive declaration from GraphQL schema by it's name. + + Returns `GraphQLDirective` object or `None`. + + # Required arguments + + `directive_name`: a `str` with name of directive in the GraphQL schema. + + `schema`: a `GraphQLSchema` instance to retrieve the directive + declaration from. + """ return schema.get_directive(directive_name) @classmethod @@ -334,7 +446,21 @@ def get_declared_directives( cls, schema: GraphQLSchema, directive_visitors: Dict[str, Type["SchemaDirectiveVisitor"]], - ): + ) -> Dict[str, GraphQLDirective]: + """Get GraphQL directives declaration from GraphQL schema by their names. + + Returns a `dict` where keys are strings with directive names in schema + and values are `GraphQLDirective` objects with their declarations in the + GraphQL schema. + + # Required arguments + + `directive_name`: a `str` with name of directive in the GraphQL schema. + + `schema`: a `GraphQLSchema` instance to retrieve the directive + declaration from. + """ + declared_directives: Dict[str, GraphQLDirective] = {} def _add_directive(decl): @@ -393,6 +519,25 @@ def visit_schema_directives( *, context: Optional[Dict[str, Any]] = None, ) -> Mapping[str, List["SchemaDirectiveVisitor"]]: + """Apply directives to the GraphQL schema. + + Applied directives mutate the GraphQL schema in place. + + Returns dict with names of GraphQL directives as keys and list of + directive instances created for each directive name. + + # Required arguments + + `schema`: a GraphQL schema to which directives should be applied. + + `directive_visitors`: a `dict` with `str` and + `Type[SchemaDirectiveVisitor]` pairs defining mapping of + `SchemaDirectiveVisitor` types to their names in the GraphQL schema. + + # Optional arguments + + `context`: `None`, unused but present for historic reasons. + """ declared_directives = cls.get_declared_directives(schema, directive_visitors) # Map from directive names to lists of SchemaDirectiveVisitor instances diff --git a/ariadne/subscriptions.py b/ariadne/subscriptions.py index 8a180e310..d9d24a277 100644 --- a/ariadne/subscriptions.py +++ b/ariadne/subscriptions.py @@ -7,13 +7,124 @@ class SubscriptionType(ObjectType): + """Bindable populating the Subscription type in a GraphQL schema with Python logic. + + Extends `ObjectType`, providing `source` decorator and `set_source` method, used + to set subscription sources for it's fields. + + # Subscription sources ("subscribers") + + Subscription source is a function that is an async generator. This function is used + to subscribe to source of events or messages. It can also filter the messages + by not yielding them. + + Its signature is same as resolver: + + ```python + async def source_fn( + root_value: Any, info: GraphQLResolveInfo, **field_args + ) -> Any: + yield ... + ``` + + # Subscription resolvers + + Subscription resolvers are called with message returned from the source. Their role + is to convert this message into Python representation of a type associated with + subscription's field in GraphQL schema. Its called with message yielded from + source function as first argument. + + ```python + def resolver_fn( + message: Any, info: GraphQLResolveInfo, **field_args + ) -> Any: + # Subscription resolver can be sync and async. + return ... + ``` + + # GraphQL arguments + + When subscription field has arguments those arguments values are passed + to both source and resolver functions. + + # Example source and resolver + + ```python + from ariadne import SubscriptionType, make_executable_schema + from broadcast import broadcast + + from .models import Post + + + subscription_type = SubscriptionType() + + + @subscription_type.source("post") + async def source_post(*_, category: Optional[str] = None) -> dict: + async with broadcast.subscribe(channel="NEW_POSTS") as subscriber: + async for event in subscriber: + message = json.loads(event.message) + # Send message to resolver if we don't filter + if not category or message["category"] == category: + yield message + + + @subscription_type.field("post") + async def resolve_post( + message: dict, *_, category: Optional[str] = None + ) -> Post: + # Convert message to Post object that resolvers for Post type in + # GraphQL schema understand. + return await Post.get_one(id=message["post_id"]) + + + schema = make_executable_schema( + \"\"\" + type Query { + \"Valid schema must define the Query type\" + none: Int + } + + type Subscription { + post(category: ID): Post! + } + + type Post { + id: ID! + author: String! + text: String! + } + \"\"\", + subscription_type + ) + ``` + + # Example chat + + [Ariadne GraphQL Chat Example](https://github.com/mirumee/ariadne-graphql-chat-example) + is the Github repository with GraphQL chat application, using Redis message backend, + Broadcaster library for publishing and subscribing to messages and React.js client + using Apollo-Client subscriptions. + """ + _subscribers: Dict[str, Subscriber] def __init__(self) -> None: + """Initializes the `SubscriptionType` with a GraphQL name set to `Subscription`.""" super().__init__("Subscription") self._subscribers = {} def source(self, name: str) -> Callable[[Subscriber], Subscriber]: + """Return a decorator that sets decorated function as a source for named field. + + Wrapper for `create_register_subscriber` that on runtime validates `name` to be a + string. + + # Required arguments + + `name`: a `str` with a name of the GraphQL object's field in GraphQL schema to + bind decorated source to. + """ if not isinstance(name, str): raise ValueError( 'source decorator should be passed a field name: @foo.source("name")' @@ -23,6 +134,14 @@ def source(self, name: str) -> Callable[[Subscriber], Subscriber]: def create_register_subscriber( self, name: str ) -> Callable[[Subscriber], Subscriber]: + """Return a decorator that sets decorated function as a source for named field. + + # Required arguments + + `name`: a `str` with a name of the GraphQL object's field in GraphQL schema to + bind decorated source to. + """ + def register_subscriber(generator: Subscriber) -> Subscriber: self._subscribers[name] = generator return generator @@ -30,16 +149,34 @@ def register_subscriber(generator: Subscriber) -> Subscriber: return register_subscriber def set_source(self, name, generator: Subscriber) -> Subscriber: + """Set a source for the field name. + + # Required arguments + + `name`: a `str` with a name of the GraphQL object's field in GraphQL schema to + set this source for. + + `generator`: a `Subscriber` function to use as an source. + """ self._subscribers[name] = generator return generator def bind_to_schema(self, schema: GraphQLSchema) -> None: + """Binds this `SubscriptionType` instance to the instance of GraphQL schema. + + If it has any previously set subscription resolvers or source functions, + those will be replaced with new ones from this instance. + """ graphql_type = schema.type_map.get(self.name) self.validate_graphql_type(graphql_type) self.bind_resolvers_to_graphql_type(graphql_type) self.bind_subscribers_to_graphql_type(graphql_type) def bind_subscribers_to_graphql_type(self, graphql_type): + """Binds this `SubscriptionType` instance's source functions. + + Source functions are set to fields `subscribe` attributes. + """ for field, subscriber in self._subscribers.items(): if field not in graphql_type.fields: raise ValueError( diff --git a/ariadne/types.py b/ariadne/types.py index 3ee26ac6c..eeea7b6a6 100644 --- a/ariadne/types.py +++ b/ariadne/types.py @@ -103,38 +103,169 @@ def __init__(self, payload: Optional[Union[dict, str]] = None) -> None: @runtime_checkable class Extension(Protocol): - def request_started(self, context: ContextValue): - pass # pragma: no cover + """Base class for async extensions. - def request_finished(self, context: ContextValue): - pass # pragma: no cover + Subclasses of this this class should override default methods to run + custom logic during Query execution. + """ + + def request_started(self, context: ContextValue) -> None: + """Extension hook executed at request's start.""" + + def request_finished(self, context: ContextValue) -> None: + """Extension hook executed at request's end.""" async def resolve( self, next_: Resolver, obj: Any, info: GraphQLResolveInfo, **kwargs - ): + ) -> Any: + """Async extension hook wrapping field's value resolution. + + # Arguments + + `next_`: a `resolver` or next extension's `resolve` method. + + `obj`: a Python data structure to resolve value from. + + `info`: a `GraphQLResolveInfo` instance for executed resolver. + + `**kwargs`: extra arguments from GraphQL to pass to resolver. + """ result = next_(obj, info, **kwargs) if isawaitable(result): result = await result return result - def has_errors(self, errors: List[GraphQLError], context: ContextValue): - pass # pragma: no cover + def has_errors(self, errors: List[GraphQLError], context: ContextValue) -> None: + """Extension hook executed when GraphQL encountered errors.""" def format(self, context: ContextValue) -> Optional[dict]: - pass # pragma: no cover + """Extension hook executed to retrieve extra data to include in result's + `extensions` data.""" class ExtensionSync(Extension): - def resolve( + """Base class for sync extensions, extends `Extension`. + + Subclasses of this this class should override default methods to run + custom logic during Query execution. + """ + + def resolve( # pylint: disable=invalid-overridden-method self, next_: Resolver, obj: Any, info: GraphQLResolveInfo, **kwargs - ): # pylint: disable=invalid-overridden-method + ) -> Any: + """Sync extension hook wrapping field's value resolution. + + # Arguments + + `next_`: a `resolver` or next extension's `resolve` method. + + `obj`: a Python data structure to resolve value from. + + `info`: a `GraphQLResolveInfo` instance for executed resolver. + + `**kwargs`: extra arguments from GraphQL to pass to resolver. + """ return next_(obj, info, **kwargs) @runtime_checkable class SchemaBindable(Protocol): + """Base class for bindable types. + + Subclasses should extend the `bind_to_schema` method with custom logic for + populating an instance of GraphQL schema with Python logic and values. + + # Example + + Example `InputType` bindable that sets Python names for fields of GraphQL input: + + ```python + from ariadne import SchemaBindable + from graphql import GraphQLInputType + + class InputType(SchemaBindable): + _name: str + _fields: dict[str, str] + + def __init__(self, name: str, fields: dict[str, str] | None): + self._name = name + self._fields = fields or {} + + def set_field_out_name(self, field: str, out_name: str): + self._fields[field] = out_name + + def bind_to_schema(self, schema: GraphQLSchema) -> None: + graphql_type = schema.get_type(self._name) + if not graphql_type: + raise ValueError( + "Type %s is not defined in the schema" % self.name + ) + if not isinstance(graphql_type, GraphQLInputType): + raise ValueError( + "%s is defined in the schema, but it is instance of %s (expected %s)" + % (self.name, type(graphql_type).__name__, GraphQLInputType.__name__) + ) + + for field, out_name in self._fields.items(): + schema_field = graphql_type.fields.get(field) + if not schema_field: + raise ValueError( + "Type %s does not define the %s field" % (self.name, field) + ) + + schema_field.out_name = out_name + ``` + + Usage: + + ```python + from ariadne import QueryType, make_executable_schema + + from .input_type import InputType + from .users.models import User + + input_type = InputType( + "UserInput", + { + "fullName": "full_name", + "yearOfBirth": "year_of_birth", + } + ) + + query_type = QueryType() + + @query_type.field("countUsers") + def resolve_count_users(*_, input): + qs = User.objects + + if input: + if input["full_name"]: + qs = qs.filter(full_name__ilike=input["full_name"]) + if input["year_of_birth"]: + qs = qs.filter(dob__year=input["year_of_birth"]) + + return qs.count() + + + schema = make_executable_schema( + \"\"\" + type Query { + countUsers(input: UserInput!): Int! + } + + input UserInput { + fullName: String + yearOfBirth: Int + } + \"\"\", + query_type, + input_type, + ) + ``` + """ + def bind_to_schema(self, schema: GraphQLSchema) -> None: - pass # pragma: no cover + """Binds this `SchemaBindable` instance to the instance of GraphQL schema.""" SubscriptionHandler = TypeVar("SubscriptionHandler") diff --git a/ariadne/unions.py b/ariadne/unions.py index 97c283809..6edb42658 100644 --- a/ariadne/unions.py +++ b/ariadne/unions.py @@ -6,13 +6,158 @@ class UnionType(SchemaBindable): + """Bindable populating interfaces in a GraphQL schema with Python logic. + + + # Type resolver + + Because GraphQL fields using union as their returning type can return any + Python value from their resolver, GraphQL unions require special type of + resolver called "type resolver" to function. + + This resolver is called with the value returned by field's resolver and is + required to return a string with a name of GraphQL type represented by Python + value from the field: + + ```python + def example_type_resolver(obj: Any, *_) -> str: + if isinstance(obj, PythonReprOfUser): + return "USer" + + if isinstance(obj, PythonReprOfComment): + return "Comment" + + raise ValueError(f"Don't know GraphQL type for '{obj}'!") + ``` + + This resolver is not required if the GraphQL field returns a value that has + the `__typename` attribute or `dict` key with a name of the GraphQL type: + + ```python + user_data_dict = {"__typename": "User", ...} + + # or... + + class UserRepr: + __typename: str = "User" + ``` + + # Example + + Following code creates a GraphQL schema with a field that returns random + result of either `User` or `Post` GraphQL type. It also supports dict with + `__typename` key that explicitly declares its GraphQL type: + + ```python + import random + from dataclasses import dataclass + from ariadne import QueryType, UnionType, make_executable_schema + + @dataclass + class UserModel: + id: str + name: str + + @dataclass + class PostModel: + id: str + message: str + + results = ( + UserModel(id=1, name="Bob"), + UserModel(id=2, name="Alice"), + UserModel(id=3, name="Jon"), + PostModel(id=1, message="Hello world!"), + PostModel(id=2, message="How's going?"), + PostModel(id=3, message="Sure thing!"), + {"__typename": "User", "id": 4, "name": "Polito"}, + {"__typename": "User", "id": 5, "name": "Aerith"}, + {"__typename": "Post", "id": 4, "message": "Good day!"}, + {"__typename": "Post", "id": 5, "message": "Whats up?"}, + ) + + query_type = QueryType() + + @query_type.field("result") + def resolve_random_result(*_): + return random.choice(results) + + + result_type = UnionType("Result") + + @result_type.type_resolver + def resolve_result_type(obj: UserModel | PostModel | dict, *_) -> str: + if isinstance(obj, UserModel): + return "User" + + if isinstance(obj, PostModel): + return "Post" + + if isinstance(obj, dict) and obj.get("__typename"): + return obj["__typename"] + + raise ValueError(f"Don't know GraphQL type for '{obj}'!") + + + schema = make_executable_schema( + \"\"\" + type Query { + result: Result! + } + + union Result = User | Post + + type User { + id: ID! + name: String! + } + + type Post { + id: ID! + message: String! + } + \"\"\", + query_type, + result_type, + ) + ``` + """ + _resolve_type: Optional[Resolver] def __init__(self, name: str, type_resolver: Optional[Resolver] = None) -> None: + """Initializes the `UnionType` with a `name` and optional `type_resolver`. + + Type resolver is required by `UnionType` to function properly, but can + be set later using either `set_type_resolver(type_resolver)` + setter or `type_resolver` decorator. + + # Required arguments + + `name`: a `str` with the name of GraphQL union type in GraphQL schema to + bind to. + + # Optional arguments + + `type_resolver`: a `Resolver` used to resolve a str with name of GraphQL type + from it's Python representation. + """ self.name = name self._resolve_type = type_resolver def set_type_resolver(self, type_resolver: Resolver) -> Resolver: + """Sets function as type resolver for this union. + + Can be used as a decorator. Also available through `type_resolver` alias: + + ```python + union_type = UnionType("MyUnion") + + @union_type.type_resolver + def type_resolver(obj: Any, *_) -> str: + ... + ``` + """ self._resolve_type = type_resolver return type_resolver @@ -20,12 +165,19 @@ def set_type_resolver(self, type_resolver: Resolver) -> Resolver: type_resolver = set_type_resolver def bind_to_schema(self, schema: GraphQLSchema) -> None: + """Binds this `UnionType` instance to the instance of GraphQL schema. + + Sets `resolve_type` attribute on GraphQL union. If this attribute was + previously set, it will be replaced to new value. + """ graphql_type = schema.type_map.get(self.name) self.validate_graphql_type(graphql_type) graphql_type = cast(GraphQLUnionType, graphql_type) graphql_type.resolve_type = self._resolve_type def validate_graphql_type(self, graphql_type: Optional[GraphQLNamedType]) -> None: + """Validates that schema's GraphQL type associated with this `UnionType` + is an `union`.""" if not graphql_type: raise ValueError("Type %s is not defined in the schema" % self.name) if not isinstance(graphql_type, GraphQLUnionType): diff --git a/ariadne/utils.py b/ariadne/utils.py index 08efb4615..b45366d58 100644 --- a/ariadne/utils.py +++ b/ariadne/utils.py @@ -8,6 +8,47 @@ def convert_camel_case_to_snake(graphql_name: str) -> str: + """Converts a string with `camelCase` name to `snake_case`. + + Utility function used by Ariadne's name conversion logic for mapping GraphQL + names using the `camelCase` convention to Python counterparts in `snake_case`. + + Returns a string with converted name. + + # Required arguments + + `graphql_name`: a `str` with name to convert. + + # Example + + All characters in converted name are lowercased: + + ```python + assert convert_camel_case_to_snake("URL") == "url" + ``` + + `_` is inserted before every uppercase character that's not first and is not + preceded by other uppercase character: + + ```python + assert convert_camel_case_to_snake("testURL") == "test_url" + ``` + + `_` is inserted before every uppercase character succeeded by lowercased + character: + + ```python + assert convert_camel_case_to_snake("URLTest") == "url_test" + ``` + + `_` is inserted before every digit that's not first and is not preceded by + other digit: + + ```python + assert convert_camel_case_to_snake("Rfc123") == "rfc_123" + ``` + """ + # pylint: disable=too-many-boolean-expressions max_index = len(graphql_name) - 1 lowered_name = graphql_name.lower() @@ -38,6 +79,39 @@ def convert_camel_case_to_snake(graphql_name: str) -> str: def gql(value: str) -> str: + """Verifies that given string is a valid GraphQL. + + Provides definition time validation for GraphQL strings. Returns unmodified + string. Some IDEs provide GraphQL syntax for highlighting those strings. + + + # Examples + + Python string in this code snippet will use GraphQL's syntax highlighting when + viewed in VSCode: + + ```python + type_defs = gql( + \"\"\" + type Query { + hello: String! + } + \"\"\" + ) + ``` + + This code will raise a GraphQL parsing error: + + ```python + type_defs = gql( + \"\"\" + type Query { + hello String! + } + \"\"\" + ) + ``` + """ parse(value) return value @@ -45,12 +119,65 @@ def gql(value: str) -> str: def unwrap_graphql_error( error: Union[GraphQLError, Optional[Exception]] ) -> Optional[Exception]: + """Recursively unwrap exception when its instance of GraphQLError. + + GraphQL query executor wraps exceptions in `GraphQLError` instances which + contain information about exception's origin in GraphQL query or it's result. + + Original exception is available through `GraphQLError`'s `original_error` + attribute, but sometimes `GraphQLError` can be wrapped in other `GraphQLError`. + + Returns unwrapped exception or `None` if no original exception was found. + + # Example + + Below code unwraps original `KeyError` from multiple `GraphQLError` instances: + + ```python + error = KeyError("I am a test!") + + assert unwrap_graphql_error( + GraphQLError( + "Error 1", + GraphQLError( + "Error 2", + GraphQLError( + "Error 3", + original_error=error + ) + ) + ) + ) == error + ``` + + Passing other exception to `unwrap_graphql_error` results in same exception + being returned: + + ```python + error = ValueError("I am a test!") + assert unwrap_graphql_error(error) == error + ``` + """ + if isinstance(error, GraphQLError): return unwrap_graphql_error(error.original_error) return error def convert_kwargs_to_snake_case(func: Callable) -> Callable: + """Decorator for resolvers recursively converting their kwargs to `snake_case`. + + Converts keys in `kwargs` dict from `camelCase` to `snake_case` using the + `convert_camel_case_to_snake` function. Walks values recursively, applying + same conversion to keys of nested dicts and dicts in lists of elements. + + Returns decorated resolver function. + + > **Deprecated:** This decorator is deprecated and will be deleted in future + version of Ariadne. Set `out_name`s explicitly in your GraphQL schema or use + the `convert_schema_names` option on `make_executable_schema`. + """ + def convert_to_snake_case(m: Mapping) -> Dict: converted: Dict = {} for k, v in m.items(): @@ -79,6 +206,18 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: def type_implements_interface(interface: str, graphql_type: GraphQLType) -> bool: + """Test if type definition from GraphQL schema implements an interface. + + Returns `True` if type implements interface and `False` if it doesn't. + + # Required arguments + + `interface`: a `str` with name of interface in GraphQL schema. + + `graphql_type`: a `GraphQLType` interface to test. It may or may not have + the `interfaces` attribute. + """ + try: return interface in [i.name for i in graphql_type.interfaces] # type: ignore except AttributeError: diff --git a/generate_reference.py b/generate_reference.py new file mode 100644 index 000000000..8c7327cb1 --- /dev/null +++ b/generate_reference.py @@ -0,0 +1,551 @@ +"""Introspect public API and generate reference from it.""" +import ast +import re +from dataclasses import dataclass +from importlib import import_module +from textwrap import dedent, indent + +import ariadne +from ariadne import constants, exceptions + + +URL_KEYWORDS = [ + ( + r"(bindables?)", + "bindables.md", + ), + ( + r"(graphql schemas?)", + "https://graphql-core-3.readthedocs.io/en/latest/modules/type.html#graphql.type.GraphQLSchema", + ), +] + + +def main(): + generate_ariadne_reference() + generate_constants_reference() + generate_exceptions_reference() + + +def generate_ariadne_reference(): + text = dedent( + """ + --- + id: api-reference + title: API reference + sidebar_label: ariadne + --- + + Following items are importable directly from `ariadne` package: + """ + ).strip() + + reference_items = [] + + all_names = sorted(ariadne.__all__) + ast_definitions = get_all_ast_definitions(all_names, ariadne) + + for item_name in all_names: + item_doc = f"## `{item_name}`" + item_doc += "\n\n" + + if item_name in ast_definitions: + item = getattr(ariadne, item_name) + item_ast = ast_definitions[item_name] + if isinstance(item_ast, ast.ClassDef): + item_doc += get_class_reference(item, item_ast) + if isinstance(item_ast, (ast.AsyncFunctionDef, ast.FunctionDef)): + item_doc += get_function_reference(item, item_ast) + if isinstance(item_ast, ast.Assign): + item_doc += get_varname_reference( + item, item_ast, ast_definitions.get(f"doc:{item_name}") + ) + + reference_items.append(item_doc) + + text += "\n\n\n" + text += "\n\n\n- - - - -\n\n\n".join(reference_items) + + with open("api-reference.md", "w+") as fp: + fp.write(text.strip()) + + +def generate_constants_reference(): + text = dedent( + """ + --- + id: constants-reference + title: Constants reference + sidebar_label: ariadne.constants + --- + + Following constants are importable from `ariadne.constants` module: + """ + ).strip() + + reference_items = [] + + all_names = [name for name in dir(constants) if not name.startswith("_")] + ast_definitions = get_all_ast_definitions(all_names, constants) + + for item_name in sorted(all_names): + item_doc = f"## `{item_name}`" + item_doc += "\n\n" + + if item_name in ast_definitions: + item = getattr(constants, item_name) + item_ast = ast_definitions[item_name] + if isinstance(item_ast, ast.ClassDef): + continue + + item_doc += get_varname_reference( + item, item_ast, ast_definitions.get(f"doc:{item_name}") + ) + reference_items.append(item_doc) + + text += "\n\n\n" + text += "\n\n\n- - - - -\n\n\n".join(reference_items) + + with open("constants-reference.md", "w+") as fp: + fp.write(text.strip()) + + +def generate_exceptions_reference(): + text = dedent( + """ + --- + id: exceptions-reference + title: Exceptions reference + sidebar_label: ariadne.exceptions + --- + + Ariadne defines some custom exception types that can be imported from `ariadne.exceptions` module: + """ + ) + + reference_items = [] + + all_names = [name for name in dir(exceptions) if not name.startswith("_")] + ast_definitions = get_all_ast_definitions(all_names, exceptions) + + for item_name in sorted(all_names): + if item_name not in ast_definitions: + continue + + item_ast = ast_definitions[item_name] + if not isinstance(item_ast, ast.ClassDef): + continue + + item_doc = f"## `{item_name}`" + item_doc += "\n\n" + + if item_name in ast_definitions: + item = getattr(exceptions, item_name) + item_doc += get_class_reference(item, item_ast) + reference_items.append(item_doc) + + text += "\n\n\n" + text += "\n\n\n- - - - -\n\n\n".join(reference_items) + + with open("exceptions-reference.md", "w+") as fp: + fp.write(text.strip()) + + +def get_all_ast_definitions(all_names, root_module): + names_set = set(all_names) + checked_modules = [] + definitions = {} + + def visit_node(ast_node): + if isinstance(ast_node, ast.Module): + for i, node in enumerate(ast_node.body): + visit_node(node) + + # Extract documentation from prepending string + if isinstance(node, ast.Assign) and i: + name = node.targets[0].id + previous_node = ast_node.body[i - 1] + if isinstance(previous_node, ast.Expr): + obj_name_key = f"doc:{name}" + if obj_name_key in definitions: + continue + + node_extra_documentation = previous_node.value.value.strip() + definitions[obj_name_key] = node_extra_documentation + + elif isinstance(ast_node, ast.ImportFrom) and ast_node.level: + if ast_node.module in checked_modules: + return + + checked_modules.append(ast_node.module) + + imported_names = set([alias.name for alias in ast_node.names]) + if names_set.intersection(imported_names): + module = import_module(f"ariadne.{ast_node.module}") + with open(module.__file__, "r") as fp: + module_ast = ast.parse(fp.read()) + visit_node(module_ast) + + elif isinstance( + ast_node, (ast.AsyncFunctionDef, ast.FunctionDef, ast.ClassDef) + ): + if ast_node.name in names_set: + if ast_node.name not in definitions: + definitions[ast_node.name] = ast_node + + elif isinstance(ast_node, ast.Assign): + name = ast_node.targets[0].id + if name in names_set and name not in definitions: + definitions[name] = ast_node + + with open(root_module.__file__, "r") as fp: + module_ast = ast.parse(fp.read()) + visit_node(module_ast) + + return definitions + + +def get_class_reference(obj, obj_ast: ast.ClassDef): + reference = "```python\n" + reference += f"class {obj_ast.name}" + + bases = [base.id for base in obj_ast.bases] + if bases: + reference += "(%s)" % (", ".join(bases)) + + reference += ":\n ...\n```" + + doc = parse_docstring(obj.__doc__) + methods = get_class_methods(obj, obj_ast) + constructor = methods.pop("__init__", None) + + if doc: + if doc.lead: + reference += "\n\n" + reference += doc.lead + + for section in doc.sections: + reference += "\n\n\n" + reference += "##" + section + + if constructor: + reference += "\n\n\n" + reference += "### Constructor" + reference += "\n\n" + reference += constructor["code"] + + if constructor["doc"]: + if constructor["doc"].lead: + reference += "\n\n" + reference += constructor["doc"].lead + + for section in constructor["doc"].sections: + reference += "\n\n\n" + reference += "###" + section + + if methods: + methods_list = [] + + for method_name, method in methods.items(): + method_reference = f"#### `{method_name}`" + method_reference += "\n\n" + method_reference += method["code"] + + if method["doc"]: + if method["doc"].lead: + method_reference += "\n\n" + method_reference += method["doc"].lead + + for section in method["doc"].sections: + method_reference += "\n\n\n" + method_reference += "####" + section + + methods_list.append(method_reference) + + reference += "\n\n\n" + reference += "### Methods" + reference += "\n\n" + reference += "\n\n\n".join(methods_list) + + if doc and doc.examples: + reference += "\n\n\n##" + reference += "\n\n\n##".join(doc.examples) + + return reference + + +def get_class_methods(obj, obj_ast: ast.FunctionDef): + methods = {} + for node in obj_ast.body: + if not isinstance(node, (ast.AsyncFunctionDef, ast.FunctionDef)): + continue + + if node.name.startswith("_") and not node.name.startswith("__"): + continue + + code = "```python\n" + code += "async " if isinstance(node, ast.AsyncFunctionDef) else "" + code += f"def {node.name}" + code += get_function_signature(node) + code += ":" + code += "\n ..." + code += "\n```" + + doc = parse_docstring(getattr(obj, node.name).__doc__, 1) + + methods[node.name] = { + "code": code, + "doc": doc, + } + + return methods + + +def skip_init_method(obj_ast: ast.FunctionDef): + if obj_ast.name != "__init__": + return False + + if obj_ast.args.vararg or obj_ast.args.kwarg: + return True + + args = len(obj_ast.args.args) + args += len(obj_ast.args.posonlyargs) + args += len(obj_ast.args.kwonlyargs) + + return args == 1 + + +def get_function_reference(obj, obj_ast: ast.AsyncFunctionDef | ast.FunctionDef): + reference = "```python\n" + + if isinstance(obj_ast, ast.AsyncFunctionDef): + reference += "async " + + reference += f"def {obj_ast.name}" + reference += get_function_signature(obj_ast) + reference += ":\n" + reference += " ..." + reference += "\n```" + + doc = parse_docstring(obj.__doc__) + + if doc: + if doc.lead: + reference += "\n\n" + reference += doc.lead + + for section in doc.sections: + reference += "\n\n\n" + reference += "##" + section + + if doc.examples: + reference += "\n\n\n##" + reference += "\n\n\n##".join(doc.examples) + + return reference + + +def get_function_signature(obj_ast): + returns = ast.unparse(obj_ast.returns) if obj_ast.returns else "None" + + params = [] + + args_count = len(obj_ast.args.args) + pargs_count = len(obj_ast.args.posonlyargs) + defaults = obj_ast.args.defaults + defaults_counts = len(defaults) + kw_defaults = obj_ast.args.kw_defaults + + for i, arg in enumerate(obj_ast.args.posonlyargs): + param = ast.unparse(arg) + if defaults: + index = defaults_counts - (pargs_count + args_count - i) + try: + if index >= 0: + default = defaults[index] + if default: + param += " = " + ast.unparse(default) + except IndexError: + pass + params.append(param) + + if obj_ast.args.posonlyargs: + params.append("/") + + for i, arg in enumerate(obj_ast.args.args): + param = ast.unparse(arg) + if defaults: + index = defaults_counts - (args_count - i) + try: + if index >= 0: + default = defaults[index] + if default: + param += " = " + ast.unparse(default) + except IndexError: + pass + params.append(param) + + if obj_ast.args.vararg: + params.append("*" + ast.unparse(obj_ast.args.vararg)) + elif obj_ast.args.kwonlyargs: + params.append("*") + + for index, arg in enumerate(obj_ast.args.kwonlyargs): + param = ast.unparse(arg) + if defaults: + try: + if index >= 0: + default = kw_defaults[index] + if default: + param += " = " + ast.unparse(default) + except IndexError: + pass + params.append(param) + + if obj_ast.args.kwarg: + params.append("**" + ast.unparse(obj_ast.args.kwarg)) + + signature_str = "(" + + params_str = ", ".join(params) + if len(params_str) + len(signature_str) + len(returns) + len(obj_ast.name) < 70: + signature_str += params_str + else: + params_str = ",\n ".join(params) + signature_str += f"\n {params_str},\n" + + signature_str += ")" + + if obj_ast.name != "__init__": + signature_str += f" -> {returns}" + + return signature_str + + +def get_varname_reference(obj, obj_ast, doc): + reference = "```python\n" + reference += ast.unparse(obj_ast) + reference += "\n```" + + if doc: + doc = parse_docstring(doc) + + reference += "\n\n" + reference += doc.lead + + if doc.sections: + reference += "\n\n\n##" + reference += "\n\n\n##".join(doc.sections) + + if doc.examples: + reference += "\n\n\n##" + reference += "\n\n\n##".join(doc.examples) + + return reference + + +@dataclass +class ParsedDoc: + lead: str + sections: list[str] + examples: list[str] + + +def parse_docstring(doc: str | None, depth: int = 0) -> ParsedDoc: + if not str(doc or "").strip(): + return + + doc = dedent(((depth + 1) * " ") + doc) + doc = collapse_lines(doc) + doc = urlify_keywords(doc) + + lead = "" + + if "\n#" in doc: + lead = doc[: doc.find("\n#")].strip() + doc = doc[doc.find("\n#") :].strip() + else: + lead = doc + doc = None + + if doc: + sections = split_sections(doc) + else: + sections = [] + + other_sections = [] + examples = [] + for section in sections: + if section.lower().lstrip("# ").startswith("example"): + examples.append(section) + else: + other_sections.append(section) + + return ParsedDoc( + lead=lead, + sections=other_sections, + examples=examples, + ) + + +def collapse_lines(text: str) -> str: + lines = [] + in_code = False + for line in text.splitlines(): + if line.startswith("```"): + in_code = not in_code + + if in_code: + line = f"{line}\n" + elif not line.endswith(" ") or line.strip() == ">": + line = f"{line}\n" + + lines.append(line) + + return ("".join(lines)).strip() + + +def urlify_keywords(text: str) -> str: + lines = [] + in_code = False + for line in text.splitlines(): + if line.startswith("```"): + in_code = not in_code + + if not in_code and not line.strip().startswith("#"): + line = urlify_keywords_in_text(line) + + lines.append(line) + + return "\n".join(lines) + + +def urlify_keywords_in_text(text: str) -> str: + for pattern, url in URL_KEYWORDS: + text = re.sub(f"{pattern}", f"[\\1]({url})", text, flags=re.IGNORECASE) + return text + + +def split_sections(text: str) -> list[str]: + sections = [] + lines = [] + in_code = False + for line in text.splitlines(): + if line.startswith("```"): + in_code = not in_code + + if not in_code and line.startswith("#"): + if lines: + sections.append(("\n".join(lines)).strip()) + lines = [] + + lines.append(line) + + if lines: + sections.append(("\n".join(lines)).strip()) + + return sections + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 770c14fe6..f2827ab2f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,9 +4,9 @@ build-backend = "hatchling.build" [project] name = "ariadne" +version = "0.18.0dev1" description = "Ariadne is a Python library for implementing GraphQL servers." authors = [{ name = "Mirumee Software", email = "hello@mirumee.com" }] -dynamic = ["version"] readme = "README.md" classifiers = [ "Development Status :: 4 - Beta",