From c18dc0ffe2ddcd9b83bb3d435abaf8dbcf484d97 Mon Sep 17 00:00:00 2001 From: Katherine Baker Date: Sat, 23 Aug 2025 22:34:26 -0700 Subject: [PATCH 01/17] Add comprehensive directive support to DSL module Implement GraphQL directives across all executable directive locations including operations, fields, fragments, variables, and inline fragments. Add DSLDirective class with validation, DSLDirectable mixin for reusable directive functionality, and DSLFragmentSpread for fragment-specific directives. --- gql/dsl.py | 260 +++++++++++++++++++++++++++++++++++-- tests/starwars/schema.py | 79 +++++++++++ tests/starwars/test_dsl.py | 206 +++++++++++++++++++++++++++++ tests/test_cli.py | 6 + 4 files changed, 540 insertions(+), 11 deletions(-) diff --git a/gql/dsl.py b/gql/dsl.py index 1a8716c2..fb36fb4b 100644 --- a/gql/dsl.py +++ b/gql/dsl.py @@ -7,11 +7,12 @@ import re from abc import ABC, abstractmethod from math import isfinite -from typing import Any, Dict, Iterable, Mapping, Optional, Tuple, Union, cast +from typing import Any, Dict, Iterable, Mapping, Optional, Set, Tuple, Union, cast from graphql import ( ArgumentNode, BooleanValueNode, + DirectiveNode, DocumentNode, EnumValueNode, FieldNode, @@ -19,6 +20,7 @@ FragmentDefinitionNode, FragmentSpreadNode, GraphQLArgument, + GraphQLDirective, GraphQLEnumType, GraphQLError, GraphQLField, @@ -61,6 +63,7 @@ is_non_null_type, is_wrapping_type, print_ast, + specified_directives, ) from graphql.pyutils import inspect @@ -381,7 +384,155 @@ def select( log.debug(f"Added fields: {added_fields} in {self!r}") -class DSLExecutable(DSLSelector): +class DSLDirective: + """The DSLDirective represents a GraphQL directive for the DSL code. + + Directives provide a way to describe alternate runtime execution and type validation + behavior in a GraphQL document. + """ + + def __init__(self, name: str, **kwargs: Any): + r"""Initialize the DSLDirective with the given name and arguments. + + :param name: the name of the directive + :param \**kwargs: the arguments for the directive + """ + self.name = name + self.arguments = kwargs + self.directive_def: Optional[GraphQLDirective] = None + + def set_definition(self, directive_def: GraphQLDirective) -> "DSLDirective": + """Attach GraphQL directive definition from schema. + + :param directive_def: The GraphQL directive definition from the schema + :return: itself + """ + self.directive_def = directive_def + return self + + @property + def ast_directive(self) -> DirectiveNode: + """Generate DirectiveNode with validation. + + :return: DirectiveNode for the GraphQL AST + + :raises graphql.error.GraphQLError: if directive not validated + :raises KeyError: if argument doesn't exist in directive definition + """ + if not self.directive_def: + raise GraphQLError( + f"Directive '@{self.name}' definition not set. " + "Call set_definition() before converting to AST." + ) + + # Validate and convert arguments + arguments = [] + for arg_name, arg_value in self.arguments.items(): + if arg_name not in self.directive_def.args: + raise KeyError( + f"Argument '{arg_name}' does not exist in directive '@{self.name}'" + ) + arguments.append( + ArgumentNode( + name=NameNode(value=arg_name), + value=ast_from_value( + arg_value, self.directive_def.args[arg_name].type + ), + ) + ) + + return DirectiveNode(name=NameNode(value=self.name), arguments=tuple(arguments)) + + def __repr__(self) -> str: + args_str = ", ".join(f"{k}={v!r}" for k, v in self.arguments.items()) + return f"" + + +class DSLDirectable: + """Mixin class for DSL elements that can have directives. + + Provides the directives() method for adding GraphQL directives to DSL elements. + Classes that need immediate AST updates should override the directives() method. + """ + + _directives: Tuple[DSLDirective, ...] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._directives = () + + def directives( + self, *directives: DSLDirective, schema: Optional[DSLSchema] = None + ) -> Any: + r"""Add directives to this DSL element. + + :param \*directives: DSLDirective instances to add + :param schema: Optional DSLSchema for directive validation. + If None, uses built-in directives (@skip, @include) + :return: itself + + :raises graphql.error.GraphQLError: if directive not found in schema + :raises KeyError: if directive argument is invalid + + Usage: + # With explicit schema + element.directives(DSLDirective("include", **{"if": var.show}), schema=ds) + + # With built-in directives (no schema needed) + element.directives(DSLDirective("skip", **{"if": var.hide})) + """ + validated_directives = [] + + for directive in directives: + if not isinstance(directive, DSLDirective): + raise TypeError( + f"Expected DSLDirective, got {type(directive)}. " + f"Use DSLDirective(name, **args) to create directive instances." + ) + + # Find directive definition + directive_def = None + + if schema is not None: + # Try to find directive in provided schema + directive_def = schema._schema.get_directive(directive.name) + + if directive_def is None: + # Try to find in built-in directives using specified_directives + for builtin_directive in specified_directives: + if builtin_directive.name == directive.name: + directive_def = builtin_directive + + if directive_def is None: + available: Set[str] = set() + if schema: + available.update(f"@{d.name}" for d in schema._schema.directives) + available.update(f"@{d.name}" for d in specified_directives) + raise GraphQLError( + f"Directive '@{directive.name}' not found in schema or built-ins. " + f"Available directives: {', '.join(sorted(available))}" + ) + + # Set definition and validate + directive.set_definition(directive_def) + validated_directives.append(directive) + + # Update stored directives + self._directives = self._directives + tuple(validated_directives) + + log.debug( + f"Added directives {[d.name for d in validated_directives]} to {self!r}" + ) + + return self + + @property + def directives_ast(self) -> Tuple[DirectiveNode, ...]: + """Get AST directive nodes for this element.""" + return tuple(directive.ast_directive for directive in self._directives) + + +class DSLExecutable(DSLSelector, DSLDirectable): """Interface for the root elements which can be executed in the :func:`dsl_gql ` function @@ -430,6 +581,7 @@ def __init__( self.variable_definitions = DSLVariableDefinitions() DSLSelector.__init__(self, *fields, **fields_with_alias) + DSLDirectable.__init__(self) class DSLRootFieldSelector(DSLSelector): @@ -508,7 +660,7 @@ def executable_ast(self) -> OperationDefinitionNode: selection_set=self.selection_set, variable_definitions=self.variable_definitions.get_ast_definitions(), **({"name": NameNode(value=self.name)} if self.name else {}), - directives=(), + directives=self.directives_ast, ) def __repr__(self) -> str: @@ -527,7 +679,7 @@ class DSLSubscription(DSLOperation): operation_type = OperationType.SUBSCRIPTION -class DSLVariable: +class DSLVariable(DSLDirectable): """The DSLVariable represents a single variable defined in a GraphQL operation Instances of this class are generated for you automatically as attributes @@ -545,6 +697,8 @@ def __init__(self, name: str): self.default_value = None self.type: Optional[GraphQLInputType] = None + DSLDirectable.__init__(self) + def to_ast_type(self, type_: GraphQLInputType) -> TypeNode: if is_wrapping_type(type_): if isinstance(type_, GraphQLList): @@ -605,7 +759,7 @@ def get_ast_definitions(self) -> Tuple[VariableDefinitionNode, ...]: if var.default_value is None else ast_from_value(var.default_value, var.type) ), - directives=(), + directives=var.directives_ast, ) for var in self.variables.values() if var.type is not None # only variables used @@ -715,7 +869,7 @@ def is_valid_field(self, field: DSLSelectable) -> bool: assert isinstance(self, (DSLFragment, DSLInlineFragment)) - if isinstance(field, (DSLFragment, DSLInlineFragment)): + if isinstance(field, (DSLFragment, DSLFragmentSpread, DSLInlineFragment)): return True assert isinstance(field, DSLField) @@ -747,7 +901,7 @@ def is_valid_field(self, field: DSLSelectable) -> bool: assert isinstance(self, DSLField) - if isinstance(field, (DSLFragment, DSLInlineFragment)): + if isinstance(field, (DSLFragment, DSLFragmentSpread, DSLInlineFragment)): return True assert isinstance(field, DSLField) @@ -791,7 +945,7 @@ def alias(self, alias: str) -> "DSLSelectableWithAlias": return self -class DSLField(DSLSelectableWithAlias, DSLFieldSelector): +class DSLField(DSLSelectableWithAlias, DSLFieldSelector, DSLDirectable): """The DSLField represents a GraphQL field for the DSL code. Instances of this class are generated for you automatically as attributes @@ -837,6 +991,7 @@ def __init__( log.debug(f"Creating {self!r}") DSLSelector.__init__(self) + DSLDirectable.__init__(self) @property def name(self): @@ -903,6 +1058,20 @@ def select( return self + def directives( + self, *directives: DSLDirective, schema: Optional[DSLSchema] = None + ) -> "DSLField": + """Add directives to this field. + + Fields auto-supply schema through dsl_type for custom directives. + """ + if schema is None and self.dsl_type is not None: + schema = self.dsl_type._dsl_schema + super().directives(*directives, schema=schema) + self.ast_field.directives = self.directives_ast + + return self + def __repr__(self) -> str: return f"<{self.__class__.__name__} {self.parent_type.name}" f"::{self.name}>" @@ -942,7 +1111,7 @@ def __init__(self, name: str): super().__init__(name, self.meta_type, field) -class DSLInlineFragment(DSLSelectable, DSLFragmentSelector): +class DSLInlineFragment(DSLSelectable, DSLFragmentSelector, DSLDirectable): """DSLInlineFragment represents an inline fragment for the DSL code.""" _type: Union[GraphQLObjectType, GraphQLInterfaceType] @@ -966,6 +1135,7 @@ def __init__( self.ast_field = InlineFragmentNode(directives=()) DSLSelector.__init__(self, *fields, **fields_with_alias) + DSLDirectable.__init__(self) def select( self, *fields: "DSLSelectable", **fields_with_alias: "DSLSelectableWithAlias" @@ -987,6 +1157,17 @@ def on(self, type_condition: DSLType) -> "DSLInlineFragment": ) return self + def directives( + self, *directives: DSLDirective, schema: Optional[DSLSchema] = None + ) -> "DSLInlineFragment": + """Add directives to this inline fragment. + + Custom directives require explicit schema parameter. + """ + super().directives(*directives, schema=schema) + self.ast_field.directives = self.directives_ast + return self + def __repr__(self) -> str: type_info = "" @@ -998,7 +1179,50 @@ def __repr__(self) -> str: return f"<{self.__class__.__name__}{type_info}>" -class DSLFragment(DSLSelectable, DSLFragmentSelector, DSLExecutable): +class DSLFragmentSpread(DSLSelectable, DSLDirectable): + """Represents a fragment spread (usage) with its own directives. + + This class is created by calling .spread() on a DSLFragment and allows + adding directives specific to the FRAGMENT_SPREAD location. + """ + + ast_field: FragmentSpreadNode + _fragment: "DSLFragment" + + def __init__(self, fragment: "DSLFragment"): + """Initialize a fragment spread from a fragment definition. + + :param fragment: The DSLFragment to create a spread from + """ + self._fragment = fragment + self.name = fragment.name + + log.debug(f"Creating fragment spread for {fragment.name}") + + DSLDirectable.__init__(self) + + @property # type: ignore + def ast_field(self) -> FragmentSpreadNode: # type: ignore + """Generate FragmentSpreadNode with spread-specific directives.""" + spread_node = FragmentSpreadNode(directives=self.directives_ast) + spread_node.name = NameNode(value=self.name) + return spread_node + + def directives( + self, *directives: DSLDirective, schema: Optional[DSLSchema] = None + ) -> "DSLFragmentSpread": + """Add directives to this fragment spread. + + Custom directives require explicit schema parameter. + """ + super().directives(*directives, schema=schema) + return self + + def __repr__(self) -> str: + return f"" + + +class DSLFragment(DSLSelectable, DSLFragmentSelector, DSLExecutable, DSLDirectable): """DSLFragment represents a named GraphQL fragment for the DSL code.""" _type: Optional[Union[GraphQLObjectType, GraphQLInterfaceType]] @@ -1027,6 +1251,10 @@ def ast_field(self) -> FragmentSpreadNode: # type: ignore """ast_field property will generate a FragmentSpreadNode with the provided name. + For backward compatibility, when used directly without .spread(), + the fragment spread has no directives. Use .spread().directives() + to add directives to the fragment spread. + Note: We need to ignore the type because of `issue #4125 of mypy `_. """ @@ -1036,6 +1264,16 @@ def ast_field(self) -> FragmentSpreadNode: # type: ignore return spread_node + def spread(self) -> DSLFragmentSpread: + """Create a fragment spread that can have its own directives. + + This allows adding directives specific to the FRAGMENT_SPREAD location, + separate from directives on the fragment definition itself. + + :return: DSLFragmentSpread instance for this fragment + """ + return DSLFragmentSpread(self) + def select( self, *fields: "DSLSelectable", **fields_with_alias: "DSLSelectableWithAlias" ) -> "DSLFragment": @@ -1096,7 +1334,7 @@ def executable_ast(self) -> FragmentDefinitionNode: selection_set=self.selection_set, **variable_definition_kwargs, name=NameNode(value=self.name), - directives=(), + directives=self.directives_ast, ) def __repr__(self) -> str: diff --git a/tests/starwars/schema.py b/tests/starwars/schema.py index 8f1efe99..439ef2f9 100644 --- a/tests/starwars/schema.py +++ b/tests/starwars/schema.py @@ -2,7 +2,9 @@ from typing import cast from graphql import ( + DirectiveLocation, GraphQLArgument, + GraphQLDirective, GraphQLEnumType, GraphQLEnumValue, GraphQLField, @@ -19,6 +21,7 @@ get_introspection_query, graphql_sync, print_schema, + specified_directives, ) from .fixtures import ( @@ -265,11 +268,87 @@ async def resolve_review(review, _info, **_args): ) +# Custom directives for testing - simple location-specific directives +# These test that each executable directive location works correctly +query_directive = GraphQLDirective( + name="query", + description="Test directive for QUERY location", + locations=[DirectiveLocation.QUERY], + args={}, +) + +field_directive = GraphQLDirective( + name="field", + description="Test directive for FIELD location", + locations=[DirectiveLocation.FIELD], + args={}, +) + +fragment_spread_directive = GraphQLDirective( + name="fragmentSpread", + description="Test directive for FRAGMENT_SPREAD location", + locations=[DirectiveLocation.FRAGMENT_SPREAD], + args={}, +) + +inline_fragment_directive = GraphQLDirective( + name="inlineFragment", + description="Test directive for INLINE_FRAGMENT location", + locations=[DirectiveLocation.INLINE_FRAGMENT], + args={}, +) + +fragment_definition_directive = GraphQLDirective( + name="fragmentDefinition", + description="Test directive for FRAGMENT_DEFINITION location", + locations=[DirectiveLocation.FRAGMENT_DEFINITION], + args={}, +) + +mutation_directive = GraphQLDirective( + name="mutation", + description="Test directive for MUTATION location (tests keyword conflict)", + locations=[DirectiveLocation.MUTATION], + args={}, +) + +subscription_directive = GraphQLDirective( + name="subscription", + description="Test directive for SUBSCRIPTION location", + locations=[DirectiveLocation.SUBSCRIPTION], + args={}, +) + +repeat_directive = GraphQLDirective( + name="repeat", + description="Test repeatable directive for FIELD location", + locations=[DirectiveLocation.FIELD], + args={ + "value": GraphQLArgument( + GraphQLString, + description="A string value for the repeatable directive", + ) + }, + is_repeatable=True, +) + + StarWarsSchema = GraphQLSchema( query=query_type, mutation=mutation_type, subscription=subscription_type, types=[human_type, droid_type, review_type, review_input_type], + directives=[ + *specified_directives, + query_directive, + field_directive, + fragment_spread_directive, + inline_fragment_directive, + fragment_definition_directive, + mutation_directive, + subscription_directive, + repeat_directive, + ], ) diff --git a/tests/starwars/test_dsl.py b/tests/starwars/test_dsl.py index e47a97d8..d784759e 100644 --- a/tests/starwars/test_dsl.py +++ b/tests/starwars/test_dsl.py @@ -23,7 +23,9 @@ from gql import Client, gql from gql.dsl import ( + DSLDirective, DSLFragment, + DSLFragmentSpread, DSLInlineFragment, DSLMetaField, DSLMutation, @@ -47,6 +49,12 @@ def ds(): return DSLSchema(StarWarsSchema) +@pytest.fixture +def var(): + """Common DSLVariableDefinitions fixture for directive tests""" + return DSLVariableDefinitions() + + @pytest.fixture def client(): return Client(schema=StarWarsSchema) @@ -659,7 +667,23 @@ def test_fragments_repr(ds): assert repr(DSLInlineFragment()) == "" assert repr(DSLInlineFragment().on(ds.Droid)) == "" assert repr(DSLFragment("fragment_1")) == "" + assert repr(DSLFragment("fragment_1").spread()) == "" assert repr(DSLFragment("fragment_2").on(ds.Droid)) == "" + assert ( + repr(DSLFragment("fragment_2").on(ds.Droid).spread()) + == "" + ) + + +def test_fragment_spread_instances(ds): + """Test that each .spread() creates new DSLFragmentSpread instance""" + fragment = DSLFragment("Test").on(ds.Character).select(ds.Character.name) + spread1 = fragment.spread() + spread2 = fragment.spread() + + assert isinstance(spread1, DSLFragmentSpread) + assert isinstance(spread2, DSLFragmentSpread) + assert spread1 is not spread2 def test_fragments(ds): @@ -1271,3 +1295,185 @@ def test_legacy_fragment_with_variables(ds): } """.strip() assert print_ast(query.document) == expected + + +def test_executable_directives(ds, var): + """Test ALL executable directive locations and types in one document""" + + # Fragment with both built-in and custom directives + fragment = ( + DSLFragment("CharacterInfo") + .on(ds.Character) + .select(ds.Character.name, ds.Character.appearsIn) + .directives( + DSLDirective("skip", **{"if": var.skipFrag}), # built-in + DSLDirective("fragmentDefinition"), + schema=ds, # custom + ) + ) + + # Query with multiple directive types + query = DSLQuery( + ds.Query.hero.args(episode=var.episode).select( + # Field with both built-in and custom directives + ds.Character.name.directives( + DSLDirective("skip", **{"if": var.skipName}), + DSLDirective("field"), # implicit schema from DSLField + ), + # Field with repeated directives (same directive multiple times) + ds.Character.appearsIn.directives( + DSLDirective("repeat", value="first"), + DSLDirective("repeat", value="second"), + DSLDirective("repeat", value="third"), + ), + # Fragment spread with multiple directives + fragment.spread().directives( + DSLDirective("include", **{"if": var.includeSpread}), + DSLDirective("fragmentSpread"), + schema=ds, + ), + # Inline fragment with directives + DSLInlineFragment() + .on(ds.Human) + .select(ds.Human.homePlanet) + .directives( + DSLDirective("skip", **{"if": var.skipInline}), + DSLDirective("inlineFragment"), + schema=ds, + ), + # Meta field with directive + DSLMetaField("__typename").directives( + DSLDirective("include", **{"if": var.includeType}) + ), + ) + ).directives( + DSLDirective("skip", **{"if": var.skipQuery}), DSLDirective("query"), schema=ds + ) + + # Mutation with directives + mutation = DSLMutation( + ds.Mutation.createReview.args( + episode=6, review={"stars": 5, "commentary": "Great!"} + ).select(ds.Review.stars, ds.Review.commentary) + ).directives( + DSLDirective("include", **{"if": var.allowMutation}), + DSLDirective("mutation"), + schema=ds, + ) + + # Subscription with directives + subscription = DSLSubscription( + ds.Subscription.reviewAdded.args(episode=6).select( + ds.Review.stars, ds.Review.commentary + ) + ).directives( + DSLDirective("skip", **{"if": var.skipSub}), + DSLDirective("subscription"), + schema=ds, + ) + + # Variable definitions with directives + var.episode.directives(DSLDirective("skip", **{"if": True})) + query.variable_definitions = var + + # Generate ONE document with everything + doc = dsl_gql( + fragment, HeroQuery=query, CreateReview=mutation, ReviewSub=subscription + ) + + expected = """\ +fragment CharacterInfo on Character @skip(if: $skipFrag) @fragmentDefinition { + name + appearsIn +} + +query HeroQuery(\ +$skipFrag: Boolean!, \ +$episode: Episode @skip(if: true), \ +$skipName: Boolean!, \ +$includeSpread: Boolean!, \ +$skipInline: Boolean!, \ +$includeType: Boolean!\ +) @skip(if: $skipQuery) @query { + hero(episode: $episode) { + name @skip(if: $skipName) @field + appearsIn @repeat(value: "first") @repeat(value: "second") @repeat(value: "third") + ...CharacterInfo @include(if: $includeSpread) @fragmentSpread + ... on Human @skip(if: $skipInline) @inlineFragment { + homePlanet + } + __typename @include(if: $includeType) + } +} + +mutation CreateReview @include(if: $allowMutation) @mutation { + createReview(episode: JEDI, review: { stars: 5, commentary: "Great!" }) { + stars + commentary + } +} + +subscription ReviewSub @skip(if: $skipSub) @subscription { + reviewAdded(episode: JEDI) { + stars + commentary + } +}""" + + assert print_ast(doc.document) == expected + assert node_tree(doc.document) == node_tree(gql(expected).document) + + +def test_directive_repr(): + """Test DSLDirective string representation""" + directive = DSLDirective("include", **{"if": True}, reason="testing") + expected = "" + assert repr(directive) == expected + + +def test_directive_error_handling(ds): + """Test error handling for directives""" + # Invalid directive argument type + with pytest.raises(TypeError, match="Expected DSLDirective"): + ds.Query.hero.directives(123) + + # Invalid directive name + with pytest.raises(GraphQLError, match="Directive '@nonexistent' not found"): + ds.Query.hero.directives(DSLDirective("nonexistent")) + + # Invalid directive argument + with pytest.raises(KeyError, match="Argument 'invalid' does not exist"): + ds.Query.hero.directives(DSLDirective("include", invalid=True)) + + # Test unvalidated directive (covers line 309-312 in dsl.py) + unvalidated_directive = DSLDirective("include", **{"if": True}) + with pytest.raises(GraphQLError, match="Directive '@include' definition not set"): + _ = unvalidated_directive.ast_directive + + +def test_custom_directive_requires_schema(ds): + """Test that custom directives require schema access""" + # Operations require explicit schema for custom directives + with pytest.raises(GraphQLError, match="Directive '@query' not found"): + DSLQuery(ds.Query.hero.select(ds.Character.name)).directives( + DSLDirective("query") + ) + + # Fragment definitions require explicit schema + with pytest.raises(GraphQLError, match="Directive '@fragmentDefinition' not found"): + DSLFragment("test").on(ds.Character).directives( + DSLDirective("fragmentDefinition") + ) + + # Works with explicit schema + DSLQuery(ds.Query.hero.select(ds.Character.name)).directives( + DSLDirective("query"), schema=ds + ) + + # DSLField has implicit schema, so custom directives work + query = DSLQuery( + ds.Query.hero.select( + ds.Character.name.directives(DSLDirective("field")) # implicit schema + ) + ) + assert query is not None diff --git a/tests/test_cli.py b/tests/test_cli.py index 4c6b7d15..17e54694 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -18,6 +18,12 @@ def parser(): return get_parser() +@pytest.fixture(autouse=True) +def tmp_path_as_home(monkeypatch, tmp_path): + """Override $HOME so that AWS Appsync credentials aren't fetched from real $HOME""" + monkeypatch.setenv("HOME", str(tmp_path)) + + def test_cli_parser(parser): # Simple call with https server From d9615c868533c7618962a7fbfb9d15680fd5e71f Mon Sep 17 00:00:00 2001 From: Leszek Hanusz Date: Wed, 27 Aug 2025 13:11:42 +0200 Subject: [PATCH 02/17] Set ast_field in __init__ in DSLFragment and DSLFragmentSpread --- gql/dsl.py | 44 +++++++++++++++++++------------------------- 1 file changed, 19 insertions(+), 25 deletions(-) diff --git a/gql/dsl.py b/gql/dsl.py index fb36fb4b..168b5457 100644 --- a/gql/dsl.py +++ b/gql/dsl.py @@ -1195,18 +1195,18 @@ def __init__(self, fragment: "DSLFragment"): :param fragment: The DSLFragment to create a spread from """ self._fragment = fragment - self.name = fragment.name + self.ast_field = FragmentSpreadNode( + name=NameNode(value=fragment.name), directives=() + ) log.debug(f"Creating fragment spread for {fragment.name}") DSLDirectable.__init__(self) - @property # type: ignore - def ast_field(self) -> FragmentSpreadNode: # type: ignore - """Generate FragmentSpreadNode with spread-specific directives.""" - spread_node = FragmentSpreadNode(directives=self.directives_ast) - spread_node.name = NameNode(value=self.name) - return spread_node + @property + def name(self) -> str: + """:meta private:""" + return self.ast_field.name.value def directives( self, *directives: DSLDirective, schema: Optional[DSLSchema] = None @@ -1216,6 +1216,7 @@ def directives( Custom directives require explicit schema parameter. """ super().directives(*directives, schema=schema) + self.ast_field.directives = self.directives_ast return self def __repr__(self) -> str: @@ -1227,7 +1228,6 @@ class DSLFragment(DSLSelectable, DSLFragmentSelector, DSLExecutable, DSLDirectab _type: Optional[Union[GraphQLObjectType, GraphQLInterfaceType]] ast_field: FragmentSpreadNode - name: str def __init__( self, @@ -1241,28 +1241,22 @@ def __init__( DSLExecutable.__init__(self) - self.name = name + self.ast_field = FragmentSpreadNode(name=NameNode(value=name), directives=()) + self._type = None log.debug(f"Creating {self!r}") - @property # type: ignore - def ast_field(self) -> FragmentSpreadNode: # type: ignore - """ast_field property will generate a FragmentSpreadNode with the - provided name. - - For backward compatibility, when used directly without .spread(), - the fragment spread has no directives. Use .spread().directives() - to add directives to the fragment spread. - - Note: We need to ignore the type because of - `issue #4125 of mypy `_. - """ - - spread_node = FragmentSpreadNode(directives=()) - spread_node.name = NameNode(value=self.name) + @property + def name(self) -> str: + """:meta private:""" + return self.ast_field.name.value - return spread_node + @name.setter + def name(self, value: str) -> None: + """:meta private:""" + if hasattr(self, "ast_field"): + self.ast_field.name.value = value def spread(self) -> DSLFragmentSpread: """Create a fragment spread that can have its own directives. From 595608b91236fadecf8e7c37e42aeb0b496f5814 Mon Sep 17 00:00:00 2001 From: Leszek Hanusz Date: Wed, 27 Aug 2025 13:52:02 +0200 Subject: [PATCH 03/17] DSLFragment does not need to inherit DSLDirectable because it already inherits DSLExecutable --- gql/dsl.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gql/dsl.py b/gql/dsl.py index 168b5457..c003f294 100644 --- a/gql/dsl.py +++ b/gql/dsl.py @@ -448,7 +448,7 @@ def __repr__(self) -> str: return f"" -class DSLDirectable: +class DSLDirectable(ABC): """Mixin class for DSL elements that can have directives. Provides the directives() method for adding GraphQL directives to DSL elements. @@ -1223,7 +1223,7 @@ def __repr__(self) -> str: return f"" -class DSLFragment(DSLSelectable, DSLFragmentSelector, DSLExecutable, DSLDirectable): +class DSLFragment(DSLSelectable, DSLFragmentSelector, DSLExecutable): """DSLFragment represents a named GraphQL fragment for the DSL code.""" _type: Optional[Union[GraphQLObjectType, GraphQLInterfaceType]] From 275d63175198f4c4ae0d066614fb85ba67a0c4b8 Mon Sep 17 00:00:00 2001 From: Leszek Hanusz Date: Wed, 27 Aug 2025 14:01:58 +0200 Subject: [PATCH 04/17] Make DSLSelectable inherits DSLDirectable --- gql/dsl.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/gql/dsl.py b/gql/dsl.py index c003f294..92ae9aa7 100644 --- a/gql/dsl.py +++ b/gql/dsl.py @@ -819,7 +819,7 @@ def __repr__(self) -> str: return f"<{self.__class__.__name__} {self._type!r}>" -class DSLSelectable(ABC): +class DSLSelectable(DSLDirectable): """DSLSelectable is an abstract class which indicates that the subclasses can be used as arguments of the :meth:`select ` method. @@ -945,7 +945,7 @@ def alias(self, alias: str) -> "DSLSelectableWithAlias": return self -class DSLField(DSLSelectableWithAlias, DSLFieldSelector, DSLDirectable): +class DSLField(DSLSelectableWithAlias, DSLFieldSelector): """The DSLField represents a GraphQL field for the DSL code. Instances of this class are generated for you automatically as attributes @@ -1111,7 +1111,7 @@ def __init__(self, name: str): super().__init__(name, self.meta_type, field) -class DSLInlineFragment(DSLSelectable, DSLFragmentSelector, DSLDirectable): +class DSLInlineFragment(DSLSelectable, DSLFragmentSelector): """DSLInlineFragment represents an inline fragment for the DSL code.""" _type: Union[GraphQLObjectType, GraphQLInterfaceType] @@ -1179,7 +1179,7 @@ def __repr__(self) -> str: return f"<{self.__class__.__name__}{type_info}>" -class DSLFragmentSpread(DSLSelectable, DSLDirectable): +class DSLFragmentSpread(DSLSelectable): """Represents a fragment spread (usage) with its own directives. This class is created by calling .spread() on a DSLFragment and allows From 191e53444a8c43219980a75750cb4f3e92b71dbd Mon Sep 17 00:00:00 2001 From: Leszek Hanusz Date: Wed, 27 Aug 2025 14:08:17 +0200 Subject: [PATCH 05/17] Modify UML diagram --- gql/dsl.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gql/dsl.py b/gql/dsl.py index 92ae9aa7..a62cede3 100644 --- a/gql/dsl.py +++ b/gql/dsl.py @@ -1,6 +1,6 @@ """ -.. image:: http://www.plantuml.com/plantuml/png/ZLAzJWCn3Dxz51vXw1im50ag8L4XwC1OkLTJ8gMvAd4GwEYxGuC8pTbKtUxy_TZEvsaIYfAt7e1MII9rWfsdbF1cSRzWpvtq4GT0JENduX8GXr_g7brQlf5tw-MBOx_-HlS0LV_Kzp8xr1kZav9PfCsMWvolEA_1VylHoZCExKwKv4Tg2s_VkSkca2kof2JDb0yxZYIk3qMZYUe1B1uUZOROXn96pQMugEMUdRnUUqUf6DBXQyIz2zu5RlgUQAFVNYaeRfBI79_JrUTaeg9JZFQj5MmUc69PDmNGE2iU61fDgfri3x36gxHw3gDHD6xqqQ7P4vjKqz2-602xtkO7uo17SCLhVSv25VjRjUAFcUE73Sspb8ADBl8gTT7j2cFAOPst_Wi0 # noqa - :alt: UML diagram +.. image:: https://www.plantuml.com/plantuml/png/dLH1Qp8n4BtdL-IeX_q77nyMh52eb7QXFSguEzf0Dhia4wbO_tlDIiSDEsBnjibxRsRovkai47YAZLMm3kIX8brP247Fo-SIBLRKUdrGMeV-C9cUFW-_rACsORK3Q-hLng2jJ-XH2ONcnf-qiBRObwhxezbXk2PuQrjQf8hP27zhjl2mRT3H7T9xMoO9lo_p1mATfRBmyGkhA0gHaHK4IceMlNJeWKphaaOcvav8F3qOJUlMJQQyuzkl_33q-M0DXBuofA-pYBbFpXg7sG0t-kLB62d0RyDrpJjumouwQ32b33SGBQNznNIcVOUYFMNd4LB7X0vZ_--xA4Qn41cNFGgm4ESHImgkKbdb4Ky96f5q1kKQIXgFQHorV1G1b_laEP0dbgbYGJc40dEyNgLaSRxaH1ASO9XnlbyY0MCNFnX_ZUZtChICr5_8Q1dNeVAcOtSlVw92x0G2_ovoM3PpyBAY-9z9P-ZgsDWV # noqa + :alt: UML diagram - rename png to uml to edit """ import logging From afa049f1cf273af0f188922465eef23b13f9b2d7 Mon Sep 17 00:00:00 2001 From: Leszek Hanusz Date: Wed, 27 Aug 2025 14:21:15 +0200 Subject: [PATCH 06/17] Fix make docs warning --- gql/dsl.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/gql/dsl.py b/gql/dsl.py index a62cede3..9ac4f0ec 100644 --- a/gql/dsl.py +++ b/gql/dsl.py @@ -475,6 +475,9 @@ def directives( :raises KeyError: if directive argument is invalid Usage: + + .. code-block:: + # With explicit schema element.directives(DSLDirective("include", **{"if": var.show}), schema=ds) From 2cd686645cd2571062ac54c26de8dff7c8418c0f Mon Sep 17 00:00:00 2001 From: Leszek Hanusz Date: Wed, 27 Aug 2025 14:28:26 +0200 Subject: [PATCH 07/17] Modify test to make it also work with graphql-core 3.2.6 --- tests/starwars/test_dsl.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/starwars/test_dsl.py b/tests/starwars/test_dsl.py index d784759e..c1817023 100644 --- a/tests/starwars/test_dsl.py +++ b/tests/starwars/test_dsl.py @@ -1407,7 +1407,7 @@ def test_executable_directives(ds, var): } mutation CreateReview @include(if: $allowMutation) @mutation { - createReview(episode: JEDI, review: { stars: 5, commentary: "Great!" }) { + createReview(episode: JEDI, review: {stars: 5, commentary: "Great!"}) { stars commentary } @@ -1420,7 +1420,7 @@ def test_executable_directives(ds, var): } }""" - assert print_ast(doc.document) == expected + assert strip_braces_spaces(print_ast(doc.document)) == expected assert node_tree(doc.document) == node_tree(gql(expected).document) From a11da724fe8fb216ea0eefb7868dcc2e3a8e3352 Mon Sep 17 00:00:00 2001 From: Katherine Baker Date: Wed, 27 Aug 2025 10:01:15 -0700 Subject: [PATCH 08/17] Move tmp_path_as_home fixture to branch: chore_tmp_path_as_home --- tests/test_cli.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 17e54694..4c6b7d15 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -18,12 +18,6 @@ def parser(): return get_parser() -@pytest.fixture(autouse=True) -def tmp_path_as_home(monkeypatch, tmp_path): - """Override $HOME so that AWS Appsync credentials aren't fetched from real $HOME""" - monkeypatch.setenv("HOME", str(tmp_path)) - - def test_cli_parser(parser): # Simple call with https server From e09624c602badd483066d0822b937427e72c7a46 Mon Sep 17 00:00:00 2001 From: Katherine Baker Date: Wed, 27 Aug 2025 14:43:41 -0700 Subject: [PATCH 09/17] Add custom directive for variableDefinition to directives test --- tests/starwars/schema.py | 8 ++++++++ tests/starwars/test_dsl.py | 10 ++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/tests/starwars/schema.py b/tests/starwars/schema.py index 439ef2f9..ab262660 100644 --- a/tests/starwars/schema.py +++ b/tests/starwars/schema.py @@ -319,6 +319,13 @@ async def resolve_review(review, _info, **_args): args={}, ) +variable_definition_directive = GraphQLDirective( + name="variableDefinition", + description="Test directive for VARIABLE_DEFINITION location", + locations=[DirectiveLocation.VARIABLE_DEFINITION], + args={}, +) + repeat_directive = GraphQLDirective( name="repeat", description="Test repeatable directive for FIELD location", @@ -347,6 +354,7 @@ async def resolve_review(review, _info, **_args): fragment_definition_directive, mutation_directive, subscription_directive, + variable_definition_directive, repeat_directive, ], ) diff --git a/tests/starwars/test_dsl.py b/tests/starwars/test_dsl.py index c1817023..a6e6b93d 100644 --- a/tests/starwars/test_dsl.py +++ b/tests/starwars/test_dsl.py @@ -1373,7 +1373,13 @@ def test_executable_directives(ds, var): ) # Variable definitions with directives - var.episode.directives(DSLDirective("skip", **{"if": True})) + var.episode.directives( + # Note that `$episode: Episode skip(if=$skipFrag)` is INVALID GraphQL because + # variable definitions must be static, literal values defined in the query! + DSLDirective("skip", **{"if": True}), + DSLDirective("variableDefinition"), + schema=ds, + ) query.variable_definitions = var # Generate ONE document with everything @@ -1389,7 +1395,7 @@ def test_executable_directives(ds, var): query HeroQuery(\ $skipFrag: Boolean!, \ -$episode: Episode @skip(if: true), \ +$episode: Episode @skip(if: true) @variableDefinition, \ $skipName: Boolean!, \ $includeSpread: Boolean!, \ $skipInline: Boolean!, \ From 18ce4393193aa8f21c17356d7d99d8640f6e582a Mon Sep 17 00:00:00 2001 From: Katherine Baker Date: Wed, 27 Aug 2025 16:09:01 -0700 Subject: [PATCH 10/17] Update dsl_module.rst to include documentation for directives --- docs/advanced/dsl_module.rst | 323 ++++++++++++++++++++++++++++++++++- 1 file changed, 320 insertions(+), 3 deletions(-) diff --git a/docs/advanced/dsl_module.rst b/docs/advanced/dsl_module.rst index 1c2c1c82..3f921d88 100644 --- a/docs/advanced/dsl_module.rst +++ b/docs/advanced/dsl_module.rst @@ -64,11 +64,11 @@ from the :code:`ds` instance ds.Query.hero.select(ds.Character.name) -The select method return the same instance, so it is possible to chain the calls:: +The select method returns the same instance, so it is possible to chain the calls:: ds.Query.hero.select(ds.Character.name).select(ds.Character.id) -Or do it sequencially:: +Or do it sequentially:: hero_query = ds.Query.hero @@ -279,7 +279,7 @@ will generate the request:: Multiple operations in a document ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -It is possible to create an Document with multiple operations:: +It is possible to create a Document with multiple operations:: query = dsl_gql( operation_name_1=DSLQuery( ... ), @@ -384,6 +384,322 @@ you can use the :class:`DSLMetaField ` class:: DSLMetaField("__typename") ) +Directives +^^^^^^^^^^ + +`Directives`_ provide a way to describe alternate runtime execution and type validation +behavior in a GraphQL document. The DSL module supports both built-in GraphQL directives +(:code:`@skip`, :code:`@include`) and custom schema-defined directives. + +To add directives to DSL elements, you use the :class:`DSLDirective ` +class and the :meth:`directives ` method:: + + from gql.dsl import DSLDirective + + # Using built-in @skip directive + ds.Query.hero.select( + ds.Character.name.directives(DSLDirective("skip", **{"if": True})) + ) + +Directive Arguments +""""""""""""""""""" + +Directive arguments can be passed as keyword arguments to :class:`DSLDirective `. +For arguments that don't conflict with Python reserved words, you can pass them directly:: + + # Direct keyword arguments for non-reserved names + DSLDirective("custom", value="foo", reason="testing") + DSLDirective("deprecated", reason="Use newField instead") + +However, when the GraphQL directive argument name conflicts with a Python reserved word +(like :code:`if`), you need to unpack a dictionary to escape it:: + + # Dictionary unpacking for Python reserved words + DSLDirective("skip", **{"if": True}) + DSLDirective("include", **{"if": False}) + +This ensures that the exact GraphQL argument name is passed to the directive and that +no post-processing of arguments is required. + +Built-in vs Custom Directives +""""""""""""""""""""""""""""" + +**Built-in directives** (:code:`@skip`, :code:`@include`) work without any schema parameter:: + + # Built-in directives work directly + DSLQuery(ds.Query.hero.select(ds.Character.name)).directives( + DSLDirective("skip", **{"if": False}), + DSLDirective("include", **{"if": True}), + ) + +**Custom directives** require access to the schema. For fields, the schema is provided +automatically, but for other elements you need to pass it explicitly:: + + # Fields automatically have schema access + ds.Character.name.directives(DSLDirective("customDirective")) + + # Other elements need explicit schema parameter + query = DSLQuery(ds.Query.hero.select(ds.Character.name)).directives( + DSLDirective("customDirective"), schema=ds + ) + +Directive Locations +""""""""""""""""""" + +The DSL module supports all executable directive locations from the GraphQL specification: + +.. list-table:: + :header-rows: 1 + :widths: 25 35 40 + + * - GraphQL Spec Location + - DSL Class/Method + - Description + * - QUERY + - :code:`DSLQuery.directives()` + - Directives on query operations + * - MUTATION + - :code:`DSLMutation.directives()` + - Directives on mutation operations + * - SUBSCRIPTION + - :code:`DSLSubscription.directives()` + - Directives on subscription operations + * - FIELD + - :code:`DSLField.directives()` + - Directives on fields (including meta-fields) + * - FRAGMENT_DEFINITION + - :code:`DSLFragment.directives()` + - Directives on fragment definitions + * - FRAGMENT_SPREAD + - :code:`DSLFragmentSpread.directives()` + - Directives on fragment spreads (via .spread()) + * - INLINE_FRAGMENT + - :code:`DSLInlineFragment.directives()` + - Directives on inline fragments + * - VARIABLE_DEFINITION + - :code:`DSLVariable.directives()` + - Directives on variable definitions + +Examples by Location +"""""""""""""""""""" + +**Operation directives**:: + + # Query operation + query = DSLQuery(ds.Query.hero.select(ds.Character.name)).directives( + DSLDirective("customQueryDirective"), schema=ds + ) + + # Mutation operation + mutation = DSLMutation( + ds.Mutation.createReview.args(episode=6, review={"stars": 5}).select( + ds.Review.stars + ) + ).directives(DSLDirective("customMutationDirective"), schema=ds) + +**Field directives**:: + + # Single directive on field + ds.Query.hero.select( + ds.Character.name.directives(DSLDirective("customFieldDirective")) + ) + + # Multiple directives on a field + ds.Query.hero.select( + ds.Character.appearsIn.directives( + DSLDirective("repeat", value="first"), + DSLDirective("repeat", value="second"), + DSLDirective("repeat", value="third"), + ) + ) + +**Fragment directives**: + +You can add directives to fragment definitions and to fragment spread instances. +To do this, first define your fragment in the usual way:: + + name_and_appearances = ( + DSLFragment("NameAndAppearances") + .on(ds.Character) + .select(ds.Character.name, ds.Character.appearsIn) + ) + +Then, use :meth:`spread() ` when you need to add +directives to the fragment spread:: + + query_with_fragment = DSLQuery( + ds.Query.hero.select( + name_and_appearances.spread().directives( + DSLDirective("customFragmentSpreadDirective"), schema=ds + ) + ) + ) + +The :meth:`spread() ` method creates a +:class:`DSLFragmentSpread ` instance that allows you to add +directives specific to the fragment spread location, separate from directives on the +fragment definition itself. + +Example with fragment definition and spread-specific directives:: + + # Fragment definition with directive + name_and_appearances = ( + DSLFragment("CharacterInfo") + .on(ds.Character) + .select(ds.Character.name, ds.Character.appearsIn) + .directives(DSLDirective("customFragmentDefinitionDirective"), schema=ds) + ) + + # Using fragment with spread-specific directives + query_without_spread_directive = DSLQuery( + # Direct usage (no spread directives) + ds.Query.hero.select(name_and_appearances) + ) + query_with_spread_directive = DSLQuery( + # Enhanced usage with spread directives + name_and_appearances.spread().directives( + DSLDirective("customFragmentSpreadDirective"), schema=ds + ) + ) + + # Don't forget to include the fragment definition in dsl_gql + query = dsl_gql( + name_and_appearances, + BaseQuery=query_without_spread_directive, + QueryWithDirective=query_with_spread_directive, + ) + +This generates GraphQL equivalent to:: + + fragment CharacterInfo on Character @customFragmentDefinitionDirective { + name + appearsIn + } + + { + BaseQuery hero { + ...CharacterInfo + } + QueryWithDirective hero { + ...CharacterInfo @customFragmentSpreadDirective + } + } + +**Inline fragment directives**: + +Inline fragments also support directives using the +:meth:`directives ` method:: + + query_with_directive = ds.Query.hero.args(episode=6).select( + ds.Character.name, + DSLInlineFragment().on(ds.Human).select(ds.Human.homePlanet).directives( + DSLDirective("customInlineFragmentDirective"), schema=ds + ) + ) + +This generates:: + + { + hero(episode: JEDI) { + name + ... on Human @customInlineFragmentDirective { + homePlanet + } + } + } + +**Variable definition directives**:: + +You can also add directives to variable definitions using the +:meth:`directives ` method:: + + var = DSLVariableDefinitions() + var.episode.directives(DSLDirective("customVariableDirective"), schema=ds) + # Note: the directive is attached to the `.episode` variable definition (singular), + # and not the `var` variable definitions (plural) holder. + + op = DSLQuery(ds.Query.hero.args(episode=var.episode).select(ds.Character.name)) + op.variable_definitions = var + +This will generate:: + + query ($episode: Episode @customVariableDirective) { + hero(episode: $episode) { + name + } + } + +Complete Example for Directives +""""""""""""""""""""""""""""""" + +Here's a comprehensive example showing directives on multiple locations: + +.. code-block:: python + + from gql.dsl import DSLDirective, DSLFragment, DSLInlineFragment, DSLQuery, dsl_gql + + # Create variables for directive conditions + var = DSLVariableDefinitions() + + # Fragment with directive on definition + character_fragment = DSLFragment("CharacterInfo").on(ds.Character).select( + ds.Character.name, ds.Character.appearsIn + ).directives(DSLDirective("fragmentDefinition"), schema=ds) + + # Query with directives on multiple locations + query = DSLQuery( + ds.Query.hero.args(episode=var.episode).select( + # Field with directive + ds.Character.name.directives(DSLDirective("skip", **{"if": var.skipName})), + + # Fragment spread with directive + character_fragment.spread().directives( + DSLDirective("include", **{"if": var.includeFragment}) + ), + + # Inline fragment with directive + DSLInlineFragment().on(ds.Human).select(ds.Human.homePlanet).directives( + DSLDirective("skip", **{"if": var.skipHuman}) + ), + + # Meta field with directive + DSLMetaField("__typename").directives( + DSLDirective("include", **{"if": var.includeType}) + ) + ) + ).directives(DSLDirective("query"), schema=ds) # Operation directive + + # Variable definition with directive + var.episode.directives(DSLDirective("skip", **{"if": True})) + query.variable_definitions = var + + # Generate the document + document = dsl_gql(character_fragment, query) + +This generates GraphQL equivalent to:: + + fragment CharacterInfo on Character @fragmentDefinition { + name + appearsIn + } + + query ( + $episode: Episode @skip(if: true) + $skipName: Boolean! + $includeFragment: Boolean! + $skipHuman: Boolean! + $includeType: Boolean! + ) @query { + hero(episode: $episode) { + name @skip(if: $skipName) + ...CharacterInfo @include(if: $includeFragment) + ... on Human @skip(if: $skipHuman) { + homePlanet + } + __typename @include(if: $includeType) + } + } + Executable examples ------------------- @@ -399,4 +715,5 @@ Sync example .. _Fragment: https://graphql.org/learn/queries/#fragments .. _Inline Fragment: https://graphql.org/learn/queries/#inline-fragments +.. _Directives: https://graphql.org/learn/queries/#directives .. _issue #308: https://github.com/graphql-python/gql/issues/308 From b212c7166bcc798d7fb06c161b6130c112b58de6 Mon Sep 17 00:00:00 2001 From: Katherine Baker Date: Fri, 29 Aug 2025 14:42:46 -0700 Subject: [PATCH 11/17] Create DSLDirective with DSLSchema.__call__ Refactor gql/dsl.py update tests/starwars/test_dsl.py and tests/starwars/schema.py Update dsl_module.rst with new usage examples. --- docs/advanced/dsl_module.rst | 89 +++++------ gql/dsl.py | 296 ++++++++++++++++++++++++----------- tests/starwars/schema.py | 51 ++++-- tests/starwars/test_dsl.py | 212 ++++++++++++++++--------- 4 files changed, 423 insertions(+), 225 deletions(-) diff --git a/docs/advanced/dsl_module.rst b/docs/advanced/dsl_module.rst index 3f921d88..893babe0 100644 --- a/docs/advanced/dsl_module.rst +++ b/docs/advanced/dsl_module.rst @@ -391,57 +391,40 @@ Directives behavior in a GraphQL document. The DSL module supports both built-in GraphQL directives (:code:`@skip`, :code:`@include`) and custom schema-defined directives. -To add directives to DSL elements, you use the :class:`DSLDirective ` -class and the :meth:`directives ` method:: +To add directives to DSL elements, use the :meth:`DSLSchema.__call__ ` +factory method and the :meth:`directives ` method:: - from gql.dsl import DSLDirective - - # Using built-in @skip directive + # Using built-in @skip directive with DSLSchema.__call__ factory ds.Query.hero.select( - ds.Character.name.directives(DSLDirective("skip", **{"if": True})) + ds.Character.name.directives(ds("@skip").args(**{"if": True})) ) Directive Arguments """"""""""""""""""" -Directive arguments can be passed as keyword arguments to :class:`DSLDirective `. +Directive arguments can be passed using the :meth:`args ` method. For arguments that don't conflict with Python reserved words, you can pass them directly:: - # Direct keyword arguments for non-reserved names - DSLDirective("custom", value="foo", reason="testing") - DSLDirective("deprecated", reason="Use newField instead") + # Using the args method for non-reserved names + ds("@custom").args(value="foo", reason="testing") + +It can also be done by calling the directive directly:: + + ds("@custom")(value="foo", reason="testing") However, when the GraphQL directive argument name conflicts with a Python reserved word (like :code:`if`), you need to unpack a dictionary to escape it:: # Dictionary unpacking for Python reserved words - DSLDirective("skip", **{"if": True}) - DSLDirective("include", **{"if": False}) + ds("@skip").args(**{"if": True}) + ds("@include")(**{"if": False}) This ensures that the exact GraphQL argument name is passed to the directive and that no post-processing of arguments is required. -Built-in vs Custom Directives -""""""""""""""""""""""""""""" - -**Built-in directives** (:code:`@skip`, :code:`@include`) work without any schema parameter:: - - # Built-in directives work directly - DSLQuery(ds.Query.hero.select(ds.Character.name)).directives( - DSLDirective("skip", **{"if": False}), - DSLDirective("include", **{"if": True}), - ) - -**Custom directives** require access to the schema. For fields, the schema is provided -automatically, but for other elements you need to pass it explicitly:: - - # Fields automatically have schema access - ds.Character.name.directives(DSLDirective("customDirective")) - - # Other elements need explicit schema parameter - query = DSLQuery(ds.Query.hero.select(ds.Character.name)).directives( - DSLDirective("customDirective"), schema=ds - ) +The :meth:`DSLSchema.__call__ ` factory method automatically handles +schema lookup and validation for both built-in directives (:code:`@skip`, :code:`@include`) +and custom schema-defined directives using the same syntax. Directive Locations """"""""""""""""""" @@ -487,7 +470,7 @@ Examples by Location # Query operation query = DSLQuery(ds.Query.hero.select(ds.Character.name)).directives( - DSLDirective("customQueryDirective"), schema=ds + ds("@customQueryDirective") ) # Mutation operation @@ -495,21 +478,21 @@ Examples by Location ds.Mutation.createReview.args(episode=6, review={"stars": 5}).select( ds.Review.stars ) - ).directives(DSLDirective("customMutationDirective"), schema=ds) + ).directives(ds("@customMutationDirective")) **Field directives**:: # Single directive on field ds.Query.hero.select( - ds.Character.name.directives(DSLDirective("customFieldDirective")) + ds.Character.name.directives(ds("@customFieldDirective")) ) # Multiple directives on a field ds.Query.hero.select( ds.Character.appearsIn.directives( - DSLDirective("repeat", value="first"), - DSLDirective("repeat", value="second"), - DSLDirective("repeat", value="third"), + ds("@repeat").args(value="first"), + ds("@repeat").args(value="second"), + ds("@repeat").args(value="third"), ) ) @@ -530,7 +513,7 @@ directives to the fragment spread:: query_with_fragment = DSLQuery( ds.Query.hero.select( name_and_appearances.spread().directives( - DSLDirective("customFragmentSpreadDirective"), schema=ds + ds("@customFragmentSpreadDirective") ) ) ) @@ -547,7 +530,7 @@ Example with fragment definition and spread-specific directives:: DSLFragment("CharacterInfo") .on(ds.Character) .select(ds.Character.name, ds.Character.appearsIn) - .directives(DSLDirective("customFragmentDefinitionDirective"), schema=ds) + .directives(ds("@customFragmentDefinitionDirective")) ) # Using fragment with spread-specific directives @@ -558,7 +541,7 @@ Example with fragment definition and spread-specific directives:: query_with_spread_directive = DSLQuery( # Enhanced usage with spread directives name_and_appearances.spread().directives( - DSLDirective("customFragmentSpreadDirective"), schema=ds + ds("@customFragmentSpreadDirective") ) ) @@ -593,7 +576,7 @@ Inline fragments also support directives using the query_with_directive = ds.Query.hero.args(episode=6).select( ds.Character.name, DSLInlineFragment().on(ds.Human).select(ds.Human.homePlanet).directives( - DSLDirective("customInlineFragmentDirective"), schema=ds + ds("@customInlineFragmentDirective") ) ) @@ -614,7 +597,7 @@ You can also add directives to variable definitions using the :meth:`directives ` method:: var = DSLVariableDefinitions() - var.episode.directives(DSLDirective("customVariableDirective"), schema=ds) + var.episode.directives(ds("@customVariableDirective")) # Note: the directive is attached to the `.episode` variable definition (singular), # and not the `var` variable definitions (plural) holder. @@ -636,7 +619,7 @@ Here's a comprehensive example showing directives on multiple locations: .. code-block:: python - from gql.dsl import DSLDirective, DSLFragment, DSLInlineFragment, DSLQuery, dsl_gql + from gql.dsl import DSLFragment, DSLInlineFragment, DSLQuery, dsl_gql # Create variables for directive conditions var = DSLVariableDefinitions() @@ -644,33 +627,33 @@ Here's a comprehensive example showing directives on multiple locations: # Fragment with directive on definition character_fragment = DSLFragment("CharacterInfo").on(ds.Character).select( ds.Character.name, ds.Character.appearsIn - ).directives(DSLDirective("fragmentDefinition"), schema=ds) + ).directives(ds("@fragmentDefinition")) # Query with directives on multiple locations query = DSLQuery( ds.Query.hero.args(episode=var.episode).select( # Field with directive - ds.Character.name.directives(DSLDirective("skip", **{"if": var.skipName})), + ds.Character.name.directives(ds("@skip").args(**{"if": var.skipName})), # Fragment spread with directive character_fragment.spread().directives( - DSLDirective("include", **{"if": var.includeFragment}) + ds("@include").args(**{"if": var.includeFragment}) ), # Inline fragment with directive DSLInlineFragment().on(ds.Human).select(ds.Human.homePlanet).directives( - DSLDirective("skip", **{"if": var.skipHuman}) + ds("@skip").args(**{"if": var.skipHuman}) ), # Meta field with directive DSLMetaField("__typename").directives( - DSLDirective("include", **{"if": var.includeType}) + ds("@include").args(**{"if": var.includeType}) ) ) - ).directives(DSLDirective("query"), schema=ds) # Operation directive + ).directives(ds("@query")) # Operation directive # Variable definition with directive - var.episode.directives(DSLDirective("skip", **{"if": True})) + var.episode.directives(ds("@variableDefinition")) query.variable_definitions = var # Generate the document @@ -684,7 +667,7 @@ This generates GraphQL equivalent to:: } query ( - $episode: Episode @skip(if: true) + $episode: Episode @variableDefinition $skipName: Boolean! $includeFragment: Boolean! $skipHuman: Boolean! diff --git a/gql/dsl.py b/gql/dsl.py index 9ac4f0ec..976ee8a6 100644 --- a/gql/dsl.py +++ b/gql/dsl.py @@ -12,6 +12,7 @@ from graphql import ( ArgumentNode, BooleanValueNode, + DirectiveLocation, DirectiveNode, DocumentNode, EnumValueNode, @@ -296,6 +297,29 @@ def __init__(self, schema: GraphQLSchema): self._schema: GraphQLSchema = schema + def __call__(self, name: str) -> "DSLDirective": + """Factory method for creating DSL objects. + + Currently, supports creating DSLDirective instances when name starts with '@'. + Future support planned for meta-fields (__typename), inline fragments (...), + and fragment definitions (fragment). + + :param name: the name of the object to create + :type name: str + + :return: DSLDirective instance + + :raises ValueError: if name format is not supported + """ + if name.startswith("@"): + return DSLDirective(name=name[1:], dsl_schema=self) + # Future support: + # if name.startswith("__"): return DSLMetaField(name) + # if name == "...": return DSLInlineFragment() + # if name.startswith("fragment "): return DSLFragment(name[9:]) + + raise ValueError(f"Unsupported name: {name}") + def __getattr__(self, name: str) -> "DSLType": type_def: Optional[GraphQLNamedType] = self._schema.get_type(name) @@ -391,60 +415,115 @@ class DSLDirective: behavior in a GraphQL document. """ - def __init__(self, name: str, **kwargs: Any): + def __init__(self, name: str, dsl_schema: "DSLSchema"): r"""Initialize the DSLDirective with the given name and arguments. :param name: the name of the directive - :param \**kwargs: the arguments for the directive + :param dsl_schema: DSLSchema for directive validation and definition lookup + + :raises GraphQLError: if directive not found or not executable """ - self.name = name - self.arguments = kwargs - self.directive_def: Optional[GraphQLDirective] = None + self._dsl_schema = dsl_schema - def set_definition(self, directive_def: GraphQLDirective) -> "DSLDirective": - """Attach GraphQL directive definition from schema. + # Find directive definition in schema or built-ins + directive_def = self._dsl_schema._schema.get_directive(name) - :param directive_def: The GraphQL directive definition from the schema - :return: itself - """ - self.directive_def = directive_def - return self + if directive_def is None: + # Try to find in built-in directives using specified_directives + builtins = {builtin.name: builtin for builtin in specified_directives} + directive_def = builtins.get(name) + + if directive_def is None: + available: Set[str] = set() + available.update(f"@{d.name}" for d in self._dsl_schema._schema.directives) + available.update(f"@{d.name}" for d in specified_directives) + raise GraphQLError( + f"Directive '@{name}' not found in schema or built-ins. " + f"Available directives: {', '.join(sorted(available))}" + ) + + # Check directive has at least one executable location + executable_locations = { + DirectiveLocation.QUERY, + DirectiveLocation.MUTATION, + DirectiveLocation.SUBSCRIPTION, + DirectiveLocation.FIELD, + DirectiveLocation.FRAGMENT_DEFINITION, + DirectiveLocation.FRAGMENT_SPREAD, + DirectiveLocation.INLINE_FRAGMENT, + DirectiveLocation.VARIABLE_DEFINITION, + } + + if not any(loc in executable_locations for loc in directive_def.locations): + raise GraphQLError( + f"Directive '@{name}' is not a valid request executable directive. " + f"It can only be used in type system locations, not in requests." + ) + + self.directive_def: GraphQLDirective = directive_def + self.ast_directive = DirectiveNode(name=NameNode(value=name), arguments=()) @property - def ast_directive(self) -> DirectiveNode: - """Generate DirectiveNode with validation. + def name(self) -> str: + """Get the directive name.""" + return self.ast_directive.name.value - :return: DirectiveNode for the GraphQL AST + def __call__(self, **kwargs: Any) -> "DSLDirective": + """Add arguments by calling the directive like a function. - :raises graphql.error.GraphQLError: if directive not validated - :raises KeyError: if argument doesn't exist in directive definition + :param kwargs: directive arguments + :return: itself """ - if not self.directive_def: - raise GraphQLError( - f"Directive '@{self.name}' definition not set. " - "Call set_definition() before converting to AST." - ) + return self.args(**kwargs) + + def args(self, **kwargs: Any) -> "DSLDirective": + r"""Set the arguments of a directive + + The arguments are parsed to be stored in the AST of this field. + + .. note:: + You can also call the field directly with your arguments. + :code: ds("@someDirective").args(value="foo")` is equivalent to: + :code: ds("@someDirective")(value="foo")` + + :param \**kwargs: the arguments (keyword=value) + + :return: itself - # Validate and convert arguments - arguments = [] - for arg_name, arg_value in self.arguments.items(): - if arg_name not in self.directive_def.args: - raise KeyError( - f"Argument '{arg_name}' does not exist in directive '@{self.name}'" + :raises AttributeError: if arguments already set for this directive + :raises GraphQLError: if argument doesn't exist in directive definition + """ + if len(self.ast_directive.arguments) > 0: + raise AttributeError(f"Arguments for directive @{self.name} already set.") + + errs = [] + for key, value in kwargs.items(): + if key not in self.directive_def.args: + errs.append( + f"Argument '{key}' does not exist in directive '@{self.name}'" ) - arguments.append( + if errs: + raise GraphQLError("\n".join(errs)) + + # Update AST directive with arguments + self.ast_directive = DirectiveNode( + name=NameNode(value=self.name), + arguments=tuple( ArgumentNode( - name=NameNode(value=arg_name), - value=ast_from_value( - arg_value, self.directive_def.args[arg_name].type - ), + name=NameNode(value=key), + value=ast_from_value(value, self.directive_def.args[key].type), ) - ) + for key, value in kwargs.items() + ), + ) - return DirectiveNode(name=NameNode(value=self.name), arguments=tuple(arguments)) + return self def __repr__(self) -> str: - args_str = ", ".join(f"{k}={v!r}" for k, v in self.arguments.items()) + args_str = ", ".join( + f"{arg.name.value}={arg.value.value}" + for arg in self.ast_directive.arguments + ) return f"" @@ -461,28 +540,33 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._directives = () - def directives( - self, *directives: DSLDirective, schema: Optional[DSLSchema] = None - ) -> Any: + @abstractmethod + def is_valid_directive(self, directive: "DSLDirective") -> bool: + """Check if a directive is valid for this DSL element. + + :param directive: The DSLDirective to validate + :return: True if the directive can be used at this location + """ + raise NotImplementedError( + "Any DSLDirectable concrete class must have an is_valid_directive method" + ) + + def directives(self, *directives: DSLDirective) -> Any: r"""Add directives to this DSL element. :param \*directives: DSLDirective instances to add - :param schema: Optional DSLSchema for directive validation. - If None, uses built-in directives (@skip, @include) :return: itself - :raises graphql.error.GraphQLError: if directive not found in schema - :raises KeyError: if directive argument is invalid + :raises graphql.error.GraphQLError: if directive location is invalid + :raises TypeError: if argument is not a DSLDirective Usage: .. code-block:: - # With explicit schema - element.directives(DSLDirective("include", **{"if": var.show}), schema=ds) - - # With built-in directives (no schema needed) - element.directives(DSLDirective("skip", **{"if": var.hide})) + # Using new factory method + element.directives(ds("@include")(**{"if": var.show})) + element.directives(ds("@skip")(**{"if": var.hide})) """ validated_directives = [] @@ -490,34 +574,32 @@ def directives( if not isinstance(directive, DSLDirective): raise TypeError( f"Expected DSLDirective, got {type(directive)}. " - f"Use DSLDirective(name, **args) to create directive instances." + f"Use ds('@directiveName') factory method to create directive instances." ) - # Find directive definition - directive_def = None - - if schema is not None: - # Try to find directive in provided schema - directive_def = schema._schema.get_directive(directive.name) - - if directive_def is None: - # Try to find in built-in directives using specified_directives - for builtin_directive in specified_directives: - if builtin_directive.name == directive.name: - directive_def = builtin_directive - - if directive_def is None: - available: Set[str] = set() - if schema: - available.update(f"@{d.name}" for d in schema._schema.directives) - available.update(f"@{d.name}" for d in specified_directives) + # Validate directive location using the abstract method + if not self.is_valid_directive(directive): + # Get valid locations for error message + valid_locations = [ + loc.name + for loc in directive.directive_def.locations + if loc + in { + DirectiveLocation.QUERY, + DirectiveLocation.MUTATION, + DirectiveLocation.SUBSCRIPTION, + DirectiveLocation.FIELD, + DirectiveLocation.FRAGMENT_DEFINITION, + DirectiveLocation.FRAGMENT_SPREAD, + DirectiveLocation.INLINE_FRAGMENT, + DirectiveLocation.VARIABLE_DEFINITION, + } + ] raise GraphQLError( - f"Directive '@{directive.name}' not found in schema or built-ins. " - f"Available directives: {', '.join(sorted(available))}" + f"Invalid directive location: '@{directive.name}' cannot be used on {self.__class__.__name__}. " + f"Valid locations for this directive: {', '.join(valid_locations)}" ) - # Set definition and validate - directive.set_definition(directive_def) validated_directives.append(directive) # Update stored directives @@ -673,14 +755,26 @@ def __repr__(self) -> str: class DSLQuery(DSLOperation): operation_type = OperationType.QUERY + def is_valid_directive(self, directive: "DSLDirective") -> bool: + """Check if directive is valid for Query operations.""" + return DirectiveLocation.QUERY in directive.directive_def.locations + class DSLMutation(DSLOperation): operation_type = OperationType.MUTATION + def is_valid_directive(self, directive: "DSLDirective") -> bool: + """Check if directive is valid for Mutation operations.""" + return DirectiveLocation.MUTATION in directive.directive_def.locations + class DSLSubscription(DSLOperation): operation_type = OperationType.SUBSCRIPTION + def is_valid_directive(self, directive: "DSLDirective") -> bool: + """Check if directive is valid for Subscription operations.""" + return DirectiveLocation.SUBSCRIPTION in directive.directive_def.locations + class DSLVariable(DSLDirectable): """The DSLVariable represents a single variable defined in a GraphQL operation @@ -725,6 +819,18 @@ def default(self, default_value: Any) -> "DSLVariable": self.default_value = default_value return self + def is_valid_directive(self, directive: "DSLDirective") -> bool: + """Check if directive is valid for Variable definitions.""" + for arg in directive.ast_directive.arguments: + if isinstance(arg.value, VariableNode): + raise GraphQLError( + f"Directive @{directive.name} argument value has " + f"unexpected variable '${arg.value.name}' in constant location." + ) + return ( + DirectiveLocation.VARIABLE_DEFINITION in directive.directive_def.locations + ) + class DSLVariableDefinitions: """The DSLVariableDefinitions represents variable definitions in a GraphQL operation @@ -1061,20 +1167,20 @@ def select( return self - def directives( - self, *directives: DSLDirective, schema: Optional[DSLSchema] = None - ) -> "DSLField": + def directives(self, *directives: DSLDirective) -> "DSLField": """Add directives to this field. - Fields auto-supply schema through dsl_type for custom directives. + Fields support all directive types since they auto-validate through is_valid_directive. """ - if schema is None and self.dsl_type is not None: - schema = self.dsl_type._dsl_schema - super().directives(*directives, schema=schema) + super().directives(*directives) self.ast_field.directives = self.directives_ast return self + def is_valid_directive(self, directive: "DSLDirective") -> bool: + """Check if directive is valid for Field locations.""" + return DirectiveLocation.FIELD in directive.directive_def.locations + def __repr__(self) -> str: return f"<{self.__class__.__name__} {self.parent_type.name}" f"::{self.name}>" @@ -1113,6 +1219,10 @@ def __init__(self, name: str): super().__init__(name, self.meta_type, field) + def is_valid_directive(self, directive: "DSLDirective") -> bool: + """Check if directive is valid for MetaField locations (same as Field).""" + return DirectiveLocation.FIELD in directive.directive_def.locations + class DSLInlineFragment(DSLSelectable, DSLFragmentSelector): """DSLInlineFragment represents an inline fragment for the DSL code.""" @@ -1160,14 +1270,12 @@ def on(self, type_condition: DSLType) -> "DSLInlineFragment": ) return self - def directives( - self, *directives: DSLDirective, schema: Optional[DSLSchema] = None - ) -> "DSLInlineFragment": + def directives(self, *directives: DSLDirective) -> "DSLInlineFragment": """Add directives to this inline fragment. - Custom directives require explicit schema parameter. + Inline fragments support all directive types through auto-validation. """ - super().directives(*directives, schema=schema) + super().directives(*directives) self.ast_field.directives = self.directives_ast return self @@ -1181,6 +1289,10 @@ def __repr__(self) -> str: return f"<{self.__class__.__name__}{type_info}>" + def is_valid_directive(self, directive: "DSLDirective") -> bool: + """Check if directive is valid for Inline Fragment locations.""" + return DirectiveLocation.INLINE_FRAGMENT in directive.directive_def.locations + class DSLFragmentSpread(DSLSelectable): """Represents a fragment spread (usage) with its own directives. @@ -1211,17 +1323,19 @@ def name(self) -> str: """:meta private:""" return self.ast_field.name.value - def directives( - self, *directives: DSLDirective, schema: Optional[DSLSchema] = None - ) -> "DSLFragmentSpread": + def directives(self, *directives: DSLDirective) -> "DSLFragmentSpread": """Add directives to this fragment spread. - Custom directives require explicit schema parameter. + Fragment spreads support all directive types through auto-validation. """ - super().directives(*directives, schema=schema) + super().directives(*directives) self.ast_field.directives = self.directives_ast return self + def is_valid_directive(self, directive: "DSLDirective") -> bool: + """Check if directive is valid for Fragment Spread locations.""" + return DirectiveLocation.FRAGMENT_SPREAD in directive.directive_def.locations + def __repr__(self) -> str: return f"" @@ -1334,5 +1448,11 @@ def executable_ast(self) -> FragmentDefinitionNode: directives=self.directives_ast, ) + def is_valid_directive(self, directive: "DSLDirective") -> bool: + """Check if directive is valid for Fragment Definition locations.""" + return ( + DirectiveLocation.FRAGMENT_DEFINITION in directive.directive_def.locations + ) + def __repr__(self) -> str: return f"<{self.__class__.__name__} {self.name!s}>" diff --git a/tests/starwars/schema.py b/tests/starwars/schema.py index ab262660..f14a4ea1 100644 --- a/tests/starwars/schema.py +++ b/tests/starwars/schema.py @@ -267,63 +267,92 @@ async def resolve_review(review, _info, **_args): }, ) - -# Custom directives for testing - simple location-specific directives -# These test that each executable directive location works correctly query_directive = GraphQLDirective( name="query", description="Test directive for QUERY location", locations=[DirectiveLocation.QUERY], - args={}, + args={ + "value": GraphQLArgument( + GraphQLString, description="A string value for the variable" + ) + }, ) field_directive = GraphQLDirective( name="field", description="Test directive for FIELD location", locations=[DirectiveLocation.FIELD], - args={}, + args={ + "value": GraphQLArgument( + GraphQLString, description="A string value for the variable" + ) + }, ) fragment_spread_directive = GraphQLDirective( name="fragmentSpread", description="Test directive for FRAGMENT_SPREAD location", locations=[DirectiveLocation.FRAGMENT_SPREAD], - args={}, + args={ + "value": GraphQLArgument( + GraphQLString, description="A string value for the variable" + ) + }, ) inline_fragment_directive = GraphQLDirective( name="inlineFragment", description="Test directive for INLINE_FRAGMENT location", locations=[DirectiveLocation.INLINE_FRAGMENT], - args={}, + args={ + "value": GraphQLArgument( + GraphQLString, description="A string value for the variable" + ) + }, ) fragment_definition_directive = GraphQLDirective( name="fragmentDefinition", description="Test directive for FRAGMENT_DEFINITION location", locations=[DirectiveLocation.FRAGMENT_DEFINITION], - args={}, + args={ + "value": GraphQLArgument( + GraphQLString, description="A string value for the variable" + ) + }, ) mutation_directive = GraphQLDirective( name="mutation", description="Test directive for MUTATION location (tests keyword conflict)", locations=[DirectiveLocation.MUTATION], - args={}, + args={ + "value": GraphQLArgument( + GraphQLString, description="A string value for the variable" + ) + }, ) subscription_directive = GraphQLDirective( name="subscription", description="Test directive for SUBSCRIPTION location", locations=[DirectiveLocation.SUBSCRIPTION], - args={}, + args={ + "value": GraphQLArgument( + GraphQLString, description="A string value for the variable" + ) + }, ) variable_definition_directive = GraphQLDirective( name="variableDefinition", description="Test directive for VARIABLE_DEFINITION location", locations=[DirectiveLocation.VARIABLE_DEFINITION], - args={}, + args={ + "value": GraphQLArgument( + GraphQLString, description="A string value for the variable" + ) + }, ) repeat_directive = GraphQLDirective( diff --git a/tests/starwars/test_dsl.py b/tests/starwars/test_dsl.py index a6e6b93d..ad11e69e 100644 --- a/tests/starwars/test_dsl.py +++ b/tests/starwars/test_dsl.py @@ -1,5 +1,6 @@ import pytest from graphql import ( + DirectiveLocation, FloatValueNode, GraphQLError, GraphQLFloat, @@ -24,6 +25,7 @@ from gql import Client, gql from gql.dsl import ( DSLDirective, + DSLField, DSLFragment, DSLFragmentSpread, DSLInlineFragment, @@ -1305,11 +1307,7 @@ def test_executable_directives(ds, var): DSLFragment("CharacterInfo") .on(ds.Character) .select(ds.Character.name, ds.Character.appearsIn) - .directives( - DSLDirective("skip", **{"if": var.skipFrag}), # built-in - DSLDirective("fragmentDefinition"), - schema=ds, # custom - ) + .directives(ds("@fragmentDefinition")) ) # Query with multiple directive types @@ -1317,68 +1315,54 @@ def test_executable_directives(ds, var): ds.Query.hero.args(episode=var.episode).select( # Field with both built-in and custom directives ds.Character.name.directives( - DSLDirective("skip", **{"if": var.skipName}), - DSLDirective("field"), # implicit schema from DSLField + ds("@skip")(**{"if": var.skipName}), + ds("@field"), # custom field directive ), # Field with repeated directives (same directive multiple times) ds.Character.appearsIn.directives( - DSLDirective("repeat", value="first"), - DSLDirective("repeat", value="second"), - DSLDirective("repeat", value="third"), + ds("@repeat")(value="first"), + ds("@repeat")(value="second"), + ds("@repeat")(value="third"), ), # Fragment spread with multiple directives fragment.spread().directives( - DSLDirective("include", **{"if": var.includeSpread}), - DSLDirective("fragmentSpread"), - schema=ds, + ds("@include")(**{"if": var.includeSpread}), + ds("@fragmentSpread"), ), # Inline fragment with directives DSLInlineFragment() .on(ds.Human) .select(ds.Human.homePlanet) .directives( - DSLDirective("skip", **{"if": var.skipInline}), - DSLDirective("inlineFragment"), - schema=ds, + ds("@skip")(**{"if": var.skipInline}), + ds("@inlineFragment"), ), # Meta field with directive DSLMetaField("__typename").directives( - DSLDirective("include", **{"if": var.includeType}) + ds("@include")(**{"if": var.includeType}) ), ) - ).directives( - DSLDirective("skip", **{"if": var.skipQuery}), DSLDirective("query"), schema=ds - ) + ).directives(ds("@query")) # Mutation with directives mutation = DSLMutation( ds.Mutation.createReview.args( episode=6, review={"stars": 5, "commentary": "Great!"} ).select(ds.Review.stars, ds.Review.commentary) - ).directives( - DSLDirective("include", **{"if": var.allowMutation}), - DSLDirective("mutation"), - schema=ds, - ) + ).directives(ds("@mutation")) # Subscription with directives subscription = DSLSubscription( ds.Subscription.reviewAdded.args(episode=6).select( ds.Review.stars, ds.Review.commentary ) - ).directives( - DSLDirective("skip", **{"if": var.skipSub}), - DSLDirective("subscription"), - schema=ds, - ) + ).directives(ds("@subscription")) # Variable definitions with directives var.episode.directives( - # Note that `$episode: Episode skip(if=$skipFrag)` is INVALID GraphQL because + # Note that `$episode: Episode @someDirective(value=$someValue)` is INVALID GraphQL because # variable definitions must be static, literal values defined in the query! - DSLDirective("skip", **{"if": True}), - DSLDirective("variableDefinition"), - schema=ds, + ds("@variableDefinition"), ) query.variable_definitions = var @@ -1388,19 +1372,18 @@ def test_executable_directives(ds, var): ) expected = """\ -fragment CharacterInfo on Character @skip(if: $skipFrag) @fragmentDefinition { +fragment CharacterInfo on Character @fragmentDefinition { name appearsIn } query HeroQuery(\ -$skipFrag: Boolean!, \ -$episode: Episode @skip(if: true) @variableDefinition, \ +$episode: Episode @variableDefinition, \ $skipName: Boolean!, \ $includeSpread: Boolean!, \ $skipInline: Boolean!, \ $includeType: Boolean!\ -) @skip(if: $skipQuery) @query { +) @query { hero(episode: $episode) { name @skip(if: $skipName) @field appearsIn @repeat(value: "first") @repeat(value: "second") @repeat(value: "third") @@ -1412,14 +1395,14 @@ def test_executable_directives(ds, var): } } -mutation CreateReview @include(if: $allowMutation) @mutation { +mutation CreateReview @mutation { createReview(episode: JEDI, review: {stars: 5, commentary: "Great!"}) { stars commentary } } -subscription ReviewSub @skip(if: $skipSub) @subscription { +subscription ReviewSub @subscription { reviewAdded(episode: JEDI) { stars commentary @@ -1430,10 +1413,10 @@ def test_executable_directives(ds, var): assert node_tree(doc.document) == node_tree(gql(expected).document) -def test_directive_repr(): +def test_directive_repr(ds): """Test DSLDirective string representation""" - directive = DSLDirective("include", **{"if": True}, reason="testing") - expected = "" + directive = ds("@include")(**{"if": True}) + expected = "" assert repr(directive) == expected @@ -1445,41 +1428,124 @@ def test_directive_error_handling(ds): # Invalid directive name with pytest.raises(GraphQLError, match="Directive '@nonexistent' not found"): - ds.Query.hero.directives(DSLDirective("nonexistent")) + ds("@nonexistent") # Invalid directive argument - with pytest.raises(KeyError, match="Argument 'invalid' does not exist"): - ds.Query.hero.directives(DSLDirective("include", invalid=True)) - - # Test unvalidated directive (covers line 309-312 in dsl.py) - unvalidated_directive = DSLDirective("include", **{"if": True}) - with pytest.raises(GraphQLError, match="Directive '@include' definition not set"): - _ = unvalidated_directive.ast_directive + with pytest.raises(GraphQLError, match="Argument 'invalid' does not exist"): + ds("@include")(invalid=True) - -def test_custom_directive_requires_schema(ds): - """Test that custom directives require schema access""" - # Operations require explicit schema for custom directives - with pytest.raises(GraphQLError, match="Directive '@query' not found"): - DSLQuery(ds.Query.hero.select(ds.Character.name)).directives( - DSLDirective("query") + with pytest.raises(GraphQLError, match="unexpected variable"): + # variable definitions must be static, literal values defined in the query! + var = DSLVariableDefinitions() + query = DSLQuery( + ds.Query.hero.args(episode=var.episode).select(ds.Character.name) ) - - # Fragment definitions require explicit schema - with pytest.raises(GraphQLError, match="Directive '@fragmentDefinition' not found"): - DSLFragment("test").on(ds.Character).directives( - DSLDirective("fragmentDefinition") + var.episode.directives( + ds("@variableDefinition").args(value=var.nonStatic), ) + query.variable_definitions = var + invalid = print_ast(dsl_gql(query).document) + print(invalid) + + +# Parametrized tests for comprehensive directive location validation +@pytest.fixture( + params=[ + "@query", + "@mutation", + "@subscription", + "@field", + "@fragmentDefinition", + "@fragmentSpread", + "@inlineFragment", + "@variableDefinition", + ] +) +def directive_name(request): + return request.param + + +@pytest.fixture( + params=[ + (DSLQuery, "QUERY"), + (DSLMutation, "MUTATION"), + (DSLSubscription, "SUBSCRIPTION"), + (DSLField, "FIELD"), + (DSLMetaField, "FIELD"), + (DSLFragment, "FRAGMENT_DEFINITION"), + (DSLFragmentSpread, "FRAGMENT_SPREAD"), + (DSLInlineFragment, "INLINE_FRAGMENT"), + (DSLVariable, "VARIABLE_DEFINITION"), + ] +) +def dsl_class_and_location(request): + return request.param - # Works with explicit schema - DSLQuery(ds.Query.hero.select(ds.Character.name)).directives( - DSLDirective("query"), schema=ds - ) - # DSLField has implicit schema, so custom directives work - query = DSLQuery( - ds.Query.hero.select( - ds.Character.name.directives(DSLDirective("field")) # implicit schema +@pytest.fixture +def is_valid_combination(directive_name, dsl_class_and_location): + # Map directive names to their expected locations + directive_to_location = { + "@query": "QUERY", + "@mutation": "MUTATION", + "@subscription": "SUBSCRIPTION", + "@field": "FIELD", + "@fragmentDefinition": "FRAGMENT_DEFINITION", + "@fragmentSpread": "FRAGMENT_SPREAD", + "@inlineFragment": "INLINE_FRAGMENT", + "@variableDefinition": "VARIABLE_DEFINITION", + } + expected_location = directive_to_location[directive_name] + _, actual_location = dsl_class_and_location + return expected_location == actual_location + + +def create_dsl_instance(dsl_class, ds): + """Helper function to create DSL instances for testing""" + if dsl_class == DSLQuery: + return DSLQuery(ds.Query.hero.select(ds.Character.name)) + elif dsl_class == DSLMutation: + return DSLMutation( + ds.Mutation.createReview.args(episode=6, review={"stars": 5}).select( + ds.Review.stars + ) ) - ) - assert query is not None + elif dsl_class == DSLSubscription: + return DSLSubscription( + ds.Subscription.reviewAdded.args(episode=6).select(ds.Review.stars) + ) + elif dsl_class == DSLField: + return ds.Query.hero + elif dsl_class == DSLMetaField: + return DSLMetaField("__typename") + elif dsl_class == DSLFragment: + return DSLFragment("test").on(ds.Character).select(ds.Character.name) + elif dsl_class == DSLFragmentSpread: + fragment = DSLFragment("test").on(ds.Character).select(ds.Character.name) + return fragment.spread() + elif dsl_class == DSLInlineFragment: + return DSLInlineFragment().on(ds.Human).select(ds.Human.homePlanet) + elif dsl_class == DSLVariable: + var = DSLVariableDefinitions() + return var.testVar + else: + raise ValueError(f"Unknown DSL class: {dsl_class}") + + +def test_directive_location_validation( + ds, directive_name, dsl_class_and_location, is_valid_combination +): + """Test all 64 combinations of 8 directives × 8 DSL classes""" + dsl_class, _ = dsl_class_and_location + directive = ds(directive_name) + + # Create instance of DSL class and try to apply directive + instance = create_dsl_instance(dsl_class, ds) + + if is_valid_combination: + # Should work without error + instance.directives(directive) + else: + # Should raise GraphQLError for invalid location + with pytest.raises(GraphQLError, match="Invalid directive location"): + instance.directives(directive) From 9d66beb15e256b90d89f23f14581ac99909a1928 Mon Sep 17 00:00:00 2001 From: Katherine Baker Date: Sat, 30 Aug 2025 18:07:22 -0700 Subject: [PATCH 12/17] Update UML for DSL module --- gql/dsl.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gql/dsl.py b/gql/dsl.py index 976ee8a6..8d89a048 100644 --- a/gql/dsl.py +++ b/gql/dsl.py @@ -1,5 +1,5 @@ """ -.. image:: https://www.plantuml.com/plantuml/png/dLH1Qp8n4BtdL-IeX_q77nyMh52eb7QXFSguEzf0Dhia4wbO_tlDIiSDEsBnjibxRsRovkai47YAZLMm3kIX8brP247Fo-SIBLRKUdrGMeV-C9cUFW-_rACsORK3Q-hLng2jJ-XH2ONcnf-qiBRObwhxezbXk2PuQrjQf8hP27zhjl2mRT3H7T9xMoO9lo_p1mATfRBmyGkhA0gHaHK4IceMlNJeWKphaaOcvav8F3qOJUlMJQQyuzkl_33q-M0DXBuofA-pYBbFpXg7sG0t-kLB62d0RyDrpJjumouwQ32b33SGBQNznNIcVOUYFMNd4LB7X0vZ_--xA4Qn41cNFGgm4ESHImgkKbdb4Ky96f5q1kKQIXgFQHorV1G1b_laEP0dbgbYGJc40dEyNgLaSRxaH1ASO9XnlbyY0MCNFnX_ZUZtChICr5_8Q1dNeVAcOtSlVw92x0G2_ovoM3PpyBAY-9z9P-ZgsDWV # noqa +.. image:: https://www.plantuml.com/plantuml/png/hLZXJkGs4FwVft1_NLXOfBR_Lcrrz3Wg93WA2rTL24Kc6LYtMITdErpf5QdFqaVharp6tincS8ZsLlTd8PxnpESltun7UMsTDAvPbichRzm2bY3gKYgT9Bfo8AGLfrNHb73KwDofIjjaCWahWfOca-J_V_yJXIsp-mzbEgbgCD9RziIazvHzL6wHQRc4dPdunSXwSNvo0HyQiCu7aDPbTwPQPW-oR23rltl2FTQGjHlEQWmYo-ltkFwkAk26xx9Wb2pLtr2405cZSM-HhWqlX05T23nkakIbj5OSpa_cUSk559yI8QRJzcStot9PbbcM8lwPiCxipD3nK1d8dNg0u7GFJZfdOh_B5ahoH1d20iKVtNgae2pONahg0-mMtMDMm1rHov0XI-Gs4sH30j1EAUC3JoP_VfJctWwS5vTViZF0xwLHyhQ4GxXJMdar1EWFAuD5JBcxjixizJVSR40GEQDRwvJvmwupfQtNPLENS1t3mFFlYVtz_Hl4As_Rc39tOgq3A25tbGbeBJxXjio2cubvzpW7Xu48wwSkq9DG5jMeYkmEtsBgVriyjrLLhYEc4x_kwoNy5sgbtIYHrmFzoE5n8U2HdYd18WdTiTdR3gSTXKfHKlglWynof1FwVnJbHLKvBsB6PiW_nizWi2CZxvUWtLU9zRL0OGnw3vnLQLq8CnDNMbNwsYSDR-9Obqf3TwAmHkUh3KZlrtjPracdyYU1AlVYW1L6ctOAYlH3wcSunqJ_zY_86-_5YxHVLBCNofgQ2NLQhEcRZQg7yGO40gNiAM0jvQoxLm96kcOoRFepGMRii-Z0u_KSU3E84vqtO1w7aeWVUPRzywkt5xzp4OsN4yjpsZWVQgDKfrUN1vV7P--spZPlRcrkLBrnnldLp_Ct5yU_RfsL14EweZRUtL0aD4JGKn02w2g1EuOGNTXEHgrEPLEwC0VuneIhpuAkhibZNJSE4wpBp5Ke4GyYxSQF3a8GCZVoEuZIfmm6Tzk2FEfyWRnUNubR1cStLZzj6H8_dj17IWDc7dx3MujlzVhIWQ-yqeNFo5qsPsIq__xM8ZX0035B-8UTqWDD_IzD4uEns6lWJJjAmysKRtFQU8fnyhZZwEqSUsyZGSGxokokNwCXr9jmkPO6T2YRxY9SkPpT_W6vhy0zGJNfmDp97Bgwt2ri-Rmfj738lF7uIdXmQS2skRnfnpZhvBJ5XG1EzWYdot_Phg_8Y2ZSkZFp8j-YnM3QSI9uZ2y0-KeSwmKOvQJEGHWe_Qra5wgsINz6_-6VwJGQws8FDk74PXfOnuF4asYIy8ayJZRWm2w5sCmRKfAmS16IP01LxCH2nkPaY01oew5W20gp9_qdRwTfQj140z2WbGqioV0PU8CRPuEx3WSSlWi6F6Dn9yERkKJHYRFCpMIdTMe9M1HlgcLTMNyRyA8GKt4Y7y68RyMgdWH-8H6cgjnEilwwCPt-H5yYPY8t81rORkTV6yXfi_JVYTJd3PiAKVasPJq4J8e9wBGCmU070-zDfYz6yxr86ollGIWjQDQrErp7F0dBZ_agxQJIbXVg44-D1TlNd_U9somTGJmeARgfAtaDkcYMvMS0 # noqa :alt: UML diagram - rename png to uml to edit """ From 0f4c3a78b2ca2551046983e20777b0f4962d3d0d Mon Sep 17 00:00:00 2001 From: Katherine Baker Date: Sat, 30 Aug 2025 18:23:20 -0700 Subject: [PATCH 13/17] Fix mypy and flake8 errors --- gql/dsl.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/gql/dsl.py b/gql/dsl.py index 8d89a048..3f47d0c4 100644 --- a/gql/dsl.py +++ b/gql/dsl.py @@ -521,7 +521,7 @@ def args(self, **kwargs: Any) -> "DSLDirective": def __repr__(self) -> str: args_str = ", ".join( - f"{arg.name.value}={arg.value.value}" + f"{arg.name.value}={getattr(arg.value, 'value')}" for arg in self.ast_directive.arguments ) return f"" @@ -574,7 +574,7 @@ def directives(self, *directives: DSLDirective) -> Any: if not isinstance(directive, DSLDirective): raise TypeError( f"Expected DSLDirective, got {type(directive)}. " - f"Use ds('@directiveName') factory method to create directive instances." + f"Use ds('@directiveName') to create directive instances." ) # Validate directive location using the abstract method @@ -596,7 +596,8 @@ def directives(self, *directives: DSLDirective) -> Any: } ] raise GraphQLError( - f"Invalid directive location: '@{directive.name}' cannot be used on {self.__class__.__name__}. " + f"Invalid directive location: '@{directive.name}' " + f"cannot be used on {self.__class__.__name__}. " f"Valid locations for this directive: {', '.join(valid_locations)}" ) @@ -1168,10 +1169,7 @@ def select( return self def directives(self, *directives: DSLDirective) -> "DSLField": - """Add directives to this field. - - Fields support all directive types since they auto-validate through is_valid_directive. - """ + """Add directives to this field.""" super().directives(*directives) self.ast_field.directives = self.directives_ast From d11d00c3f76c50dfd22e9f51aa64110b45be9760 Mon Sep 17 00:00:00 2001 From: Katherine Baker Date: Sun, 31 Aug 2025 13:21:02 -0700 Subject: [PATCH 14/17] fix flake8 errors --- tests/starwars/test_dsl.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/starwars/test_dsl.py b/tests/starwars/test_dsl.py index ad11e69e..e232725d 100644 --- a/tests/starwars/test_dsl.py +++ b/tests/starwars/test_dsl.py @@ -1,6 +1,5 @@ import pytest from graphql import ( - DirectiveLocation, FloatValueNode, GraphQLError, GraphQLFloat, @@ -24,7 +23,6 @@ from gql import Client, gql from gql.dsl import ( - DSLDirective, DSLField, DSLFragment, DSLFragmentSpread, @@ -1360,8 +1358,8 @@ def test_executable_directives(ds, var): # Variable definitions with directives var.episode.directives( - # Note that `$episode: Episode @someDirective(value=$someValue)` is INVALID GraphQL because - # variable definitions must be static, literal values defined in the query! + # Note that `$episode: Episode @someDirective(value=$someValue)` + # is INVALID GraphQL because variable definitions must be literal values ds("@variableDefinition"), ) query.variable_definitions = var From b6c88ee430e81be5c1e0da2ca298e3a5b355eea4 Mon Sep 17 00:00:00 2001 From: Katherine Baker Date: Sun, 31 Aug 2025 14:08:57 -0700 Subject: [PATCH 15/17] fix coverage and add overloads --- gql/dsl.py | 50 +++++++++++++++++++++++++++++++------- tests/starwars/test_dsl.py | 22 ++++++++++++++--- 2 files changed, 60 insertions(+), 12 deletions(-) diff --git a/gql/dsl.py b/gql/dsl.py index 3f47d0c4..15e6b03d 100644 --- a/gql/dsl.py +++ b/gql/dsl.py @@ -7,7 +7,19 @@ import re from abc import ABC, abstractmethod from math import isfinite -from typing import Any, Dict, Iterable, Mapping, Optional, Set, Tuple, Union, cast +from typing import ( + Any, + Dict, + Iterable, + Literal, + Mapping, + Optional, + Set, + Tuple, + Union, + cast, + overload, +) from graphql import ( ArgumentNode, @@ -297,28 +309,48 @@ def __init__(self, schema: GraphQLSchema): self._schema: GraphQLSchema = schema - def __call__(self, name: str) -> "DSLDirective": + @overload + def __call__( + self, shortcut: Literal["__typename", "__schema", "__type"] + ) -> "DSLMetaField": ... # pragma: no cover + + @overload + def __call__( + self, shortcut: Literal["..."] + ) -> "DSLInlineFragment": ... # pragma: no cover + + @overload + def __call__( + self, shortcut: Literal["fragment"], name: str + ) -> "DSLFragment": ... # pragma: no cover + + @overload + def __call__(self, shortcut: Any) -> "DSLDirective": ... # pragma: no cover + + def __call__( + self, shortcut: str, name: Optional[str] = None + ) -> Union["DSLMetaField", "DSLInlineFragment", "DSLFragment", "DSLDirective"]: """Factory method for creating DSL objects. Currently, supports creating DSLDirective instances when name starts with '@'. Future support planned for meta-fields (__typename), inline fragments (...), and fragment definitions (fragment). - :param name: the name of the object to create - :type name: str + :param shortcut: the name of the object to create + :type shortcut: LiteralString :return: DSLDirective instance - :raises ValueError: if name format is not supported + :raises ValueError: if shortcut format is not supported """ - if name.startswith("@"): - return DSLDirective(name=name[1:], dsl_schema=self) + if shortcut.startswith("@"): + return DSLDirective(name=shortcut[1:], dsl_schema=self) # Future support: # if name.startswith("__"): return DSLMetaField(name) # if name == "...": return DSLInlineFragment() # if name.startswith("fragment "): return DSLFragment(name[9:]) - raise ValueError(f"Unsupported name: {name}") + raise ValueError(f"Unsupported shortcut: {shortcut}") def __getattr__(self, name: str) -> "DSLType": @@ -549,7 +581,7 @@ def is_valid_directive(self, directive: "DSLDirective") -> bool: """ raise NotImplementedError( "Any DSLDirectable concrete class must have an is_valid_directive method" - ) + ) # pragma: no cover def directives(self, *directives: DSLDirective) -> Any: r"""Add directives to this DSL element. diff --git a/tests/starwars/test_dsl.py b/tests/starwars/test_dsl.py index e232725d..a3d1ef8c 100644 --- a/tests/starwars/test_dsl.py +++ b/tests/starwars/test_dsl.py @@ -1297,6 +1297,11 @@ def test_legacy_fragment_with_variables(ds): assert print_ast(query.document) == expected +def test_dsl_schema_call_validation(ds): + with pytest.raises(ValueError, match="(?i)unsupported shortcut"): + ds("foo") + + def test_executable_directives(ds, var): """Test ALL executable directive locations and types in one document""" @@ -1424,7 +1429,7 @@ def test_directive_error_handling(ds): with pytest.raises(TypeError, match="Expected DSLDirective"): ds.Query.hero.directives(123) - # Invalid directive name + # Invalid directive name from `__call__ with pytest.raises(GraphQLError, match="Directive '@nonexistent' not found"): ds("@nonexistent") @@ -1432,6 +1437,18 @@ def test_directive_error_handling(ds): with pytest.raises(GraphQLError, match="Argument 'invalid' does not exist"): ds("@include")(invalid=True) + # Tried to set arguments twice + with pytest.raises( + AttributeError, match="Arguments for directive @field already set." + ): + ds("@field").args(value="foo").args(value="bar") + + with pytest.raises( + GraphQLError, + match="(?i)Directive '@deprecated' is not a valid request executable directive", + ): + ds("@deprecated") + with pytest.raises(GraphQLError, match="unexpected variable"): # variable definitions must be static, literal values defined in the query! var = DSLVariableDefinitions() @@ -1442,8 +1459,7 @@ def test_directive_error_handling(ds): ds("@variableDefinition").args(value=var.nonStatic), ) query.variable_definitions = var - invalid = print_ast(dsl_gql(query).document) - print(invalid) + _ = dsl_gql(query).document # Parametrized tests for comprehensive directive location validation From e8dab27dd444d8eea2f95134fb3e41cb42b7b69f Mon Sep 17 00:00:00 2001 From: Leszek Hanusz Date: Mon, 1 Sep 2025 14:56:54 +0200 Subject: [PATCH 16/17] Fix make docs warnings --- docs/advanced/dsl_module.rst | 2 +- gql/dsl.py | 18 +++++++++++------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/docs/advanced/dsl_module.rst b/docs/advanced/dsl_module.rst index 893babe0..c6ee035a 100644 --- a/docs/advanced/dsl_module.rst +++ b/docs/advanced/dsl_module.rst @@ -591,7 +591,7 @@ This generates:: } } -**Variable definition directives**:: +**Variable definition directives**: You can also add directives to variable definitions using the :meth:`directives ` method:: diff --git a/gql/dsl.py b/gql/dsl.py index 15e6b03d..e89c6786 100644 --- a/gql/dsl.py +++ b/gql/dsl.py @@ -148,8 +148,9 @@ def ast_from_value(value: Any, type_: GraphQLInputType) -> Optional[ValueNode]: Produce a GraphQL Value AST given a Python object. - Raises a GraphQLError instead of returning None if we receive an Undefined - of if we receive a Null value for a Non-Null type. + :raises graphql.error.GraphQLError: + instead of returning None if we receive an Undefined + of if we receive a Null value for a Non-Null type. """ if isinstance(value, DSLVariable): return value.set_type(type_).ast_variable_name @@ -290,6 +291,8 @@ class DSLSchema: Attributes of the DSLSchema class are generated automatically with the `__getattr__` dunder method in order to generate instances of :class:`DSLType` + + .. automethod:: __call__ """ def __init__(self, schema: GraphQLSchema): @@ -453,7 +456,7 @@ def __init__(self, name: str, dsl_schema: "DSLSchema"): :param name: the name of the directive :param dsl_schema: DSLSchema for directive validation and definition lookup - :raises GraphQLError: if directive not found or not executable + :raises graphql.error.GraphQLError: if directive not found or not executable """ self._dsl_schema = dsl_schema @@ -515,15 +518,16 @@ def args(self, **kwargs: Any) -> "DSLDirective": .. note:: You can also call the field directly with your arguments. - :code: ds("@someDirective").args(value="foo")` is equivalent to: - :code: ds("@someDirective")(value="foo")` + :code:`ds("@someDirective").args(value="foo")` is equivalent to: + :code:`ds("@someDirective")(value="foo")` :param \**kwargs: the arguments (keyword=value) :return: itself :raises AttributeError: if arguments already set for this directive - :raises GraphQLError: if argument doesn't exist in directive definition + :raises graphql.error.GraphQLError: + if argument doesn't exist in directive definition """ if len(self.ast_directive.arguments) > 0: raise AttributeError(f"Arguments for directive @{self.name} already set.") @@ -594,7 +598,7 @@ def directives(self, *directives: DSLDirective) -> Any: Usage: - .. code-block:: + .. code-block:: python # Using new factory method element.directives(ds("@include")(**{"if": var.show})) From 0edd164bcefd53c2c1009c8fdb87fee4630694fe Mon Sep 17 00:00:00 2001 From: Leszek Hanusz Date: Mon, 1 Sep 2025 15:37:40 +0200 Subject: [PATCH 17/17] Add __getattr__ documentation --- gql/dsl.py | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/gql/dsl.py b/gql/dsl.py index e89c6786..da4cf64c 100644 --- a/gql/dsl.py +++ b/gql/dsl.py @@ -293,6 +293,7 @@ class DSLSchema: instances of :class:`DSLType` .. automethod:: __call__ + .. automethod:: __getattr__ """ def __init__(self, schema: GraphQLSchema): @@ -340,9 +341,9 @@ def __call__( and fragment definitions (fragment). :param shortcut: the name of the object to create - :type shortcut: LiteralString + :type shortcut: str - :return: DSLDirective instance + :return: :class:`DSLDirective` instance :raises ValueError: if shortcut format is not supported """ @@ -356,6 +357,13 @@ def __call__( raise ValueError(f"Unsupported shortcut: {shortcut}") def __getattr__(self, name: str) -> "DSLType": + """Attributes of the DSLSchema class are generated automatically + with this dunder method in order to generate + instances of :class:`DSLType` + + :return: :class:`DSLType` instance + :raises AttributeError: if the name is not valid + """ type_def: Optional[GraphQLNamedType] = self._schema.get_type(name) @@ -879,6 +887,8 @@ class DSLVariableDefinitions: with the `__getattr__` dunder method in order to generate instances of :class:`DSLVariable`, that can then be used as values in the :meth:`args ` method. + + .. automethod:: __getattr__ """ def __init__(self): @@ -886,6 +896,12 @@ def __init__(self): self.variables: Dict[str, DSLVariable] = {} def __getattr__(self, name: str) -> "DSLVariable": + """Attributes of the DSLVariableDefinitions class are generated automatically + with this dunder method in order to generate + instances of :class:`DSLVariable` + + :return: :class:`DSLVariable` instance + """ if name not in self.variables: self.variables[name] = DSLVariable(name) return self.variables[name] @@ -925,6 +941,8 @@ class DSLType: Attributes of the DSLType class are generated automatically with the `__getattr__` dunder method in order to generate instances of :class:`DSLField` + + .. automethod:: __getattr__ """ def __init__( @@ -946,6 +964,13 @@ def __init__( log.debug(f"Creating {self!r})") def __getattr__(self, name: str) -> "DSLField": + """Attributes of the DSLType class are generated automatically + with this dunder method in order to generate + instances of :class:`DSLField` + + :return: :class:`DSLField` instance + :raises AttributeError: if the field name does not exist in the type + """ camel_cased_name = to_camel_case(name) if name in self._type.fields: