Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 38 additions & 11 deletions docs/docs/python-sdk/guides/python-typing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ When working with GraphQL queries, you can generate type-safe Pydantic models th
Generated Pydantic models from GraphQL queries offer several important benefits:

- **Type Safety**: Catch type errors during development time instead of at runtime
- **IDE Support**: Get autocomplete, type hints, and better IntelliSense in your IDE
- **IDE Support**: Get autocomplete, type hints, and better IntelliSense in your IDE
- **Documentation**: Generated models serve as living documentation of your GraphQL API
- **Validation**: Automatic validation of query responses against the expected schema

Expand All @@ -120,32 +120,59 @@ Generated Pydantic models from GraphQL queries offer several important benefits:
Use the `infrahubctl graphql generate-return-types` command to create Pydantic models from your GraphQL queries:

```shell
# Generate models for queries in current directory
infrahubctl graphql generate-return-types
# Generate models for queries in a directory
infrahubctl graphql generate-return-types queries/

# Generate models for specific query files
infrahubctl graphql generate-return-types queries/get_devices.gql
infrahubctl graphql generate-return-types queries/get_tags.gql
```

> You can also export the GraphQL schema first using the `infrahubctl graphql export-schema` command:
> You can also export the GraphQL schema first using the `infrahubctl graphql export-schema` command.

### Example workflow

1. **Create your GraphQL queries** in `.gql` files:
1. **Create your GraphQL queries** in `.gql` files preferably in a directory (e.g., `queries/`):

```graphql
# queries/get_tags.gql
query GetAllTags {
BuiltinTag {
edges {
node {
__typename
name {
value
}
}
}
}
}
```

2. **Generate the Pydantic models**:
2. **Export the GraphQL schema**:

```shell
infrahubctl graphql export-schema
```

3. **Generate the Pydantic models**:

```shell
infrahubctl graphql generate-return-types queries/
```

The command will generate the Python file per query based on the name of the query.
:::warning Query names

Ensure each of your GraphQL queries has a unique name, as the generated Python files will be named based on these query names.
Two queries with the same name will land in the same file, leading to potential overrides.

:::

3. **Use the generated models** in your Python code
4. **Use the generated models** in your Python code

```python
from .queries.get_devices import GetDevicesQuery
from .queries.get_tags import GetAllTagsQuery

response = await client.execute_graphql(query=MY_QUERY)
data = GetDevicesQuery(**response)
data = GetAllTagsQuery(**response)
```
19 changes: 15 additions & 4 deletions infrahub_sdk/ctl/graphql.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,12 @@
from ..async_typer import AsyncTyper
from ..ctl.client import initialize_client
from ..ctl.utils import catch_exception
from ..graphql.utils import insert_fragments_inline, remove_fragment_import
from ..graphql.utils import (
insert_fragments_inline,
remove_fragment_import,
strip_typename_from_fragment,
strip_typename_from_operation,
)
from .parameters import CONFIG_PARAM

app = AsyncTyper()
Expand Down Expand Up @@ -152,12 +157,18 @@ async def generate_return_types(
queries = filter_operations_definitions(definitions)
fragments = filter_fragments_definitions(definitions)

# Strip __typename fields from operations and fragments before code generation.
# __typename is a GraphQL introspection meta-field that isn't part of the schema's
# type definitions, causing ariadne-codegen to fail with "Redefinition of reserved type 'String'"
stripped_queries = [strip_typename_from_operation(q) for q in queries]
stripped_fragments = [strip_typename_from_fragment(f) for f in fragments]

package_generator = get_package_generator(
schema=graphql_schema,
fragments=fragments,
fragments=stripped_fragments,
settings=ClientSettings(
schema_path=str(schema),
target_package_name=directory.name,
target_package_name=directory.name or "graphql_client",
queries_path=str(directory),
include_comments=CommentsStrategy.NONE,
),
Expand All @@ -166,7 +177,7 @@ async def generate_return_types(

parsing_failed = False
try:
for query_operation in queries:
for query_operation in stripped_queries:
package_generator.add_operation(query_operation)
except ParsingError as exc:
console.print(f"[red]Unable to process {gql_file.name}: {exc}")
Expand Down
85 changes: 85 additions & 0 deletions infrahub_sdk/graphql/utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,90 @@
import ast

from graphql import (
FieldNode,
FragmentDefinitionNode,
FragmentSpreadNode,
InlineFragmentNode,
OperationDefinitionNode,
SelectionNode,
SelectionSetNode,
)


def strip_typename_from_selection_set(selection_set: SelectionSetNode | None) -> SelectionSetNode | None:
"""Recursively strip __typename fields from a SelectionSetNode.

The __typename meta-field is an introspection field that is not part of the schema's
type definitions. When code generation tools like ariadne-codegen try to look up
__typename in the schema, they fail because it's a reserved introspection field.

This function removes all __typename fields from the selection set, allowing
code generation to proceed without errors.
"""
if selection_set is None:
return None

new_selections: list[SelectionNode] = []
for selection in selection_set.selections:
if isinstance(selection, FieldNode):
# Skip __typename fields
if selection.name.value == "__typename":
continue
# Recursively process nested selection sets
new_field = FieldNode(
alias=selection.alias,
name=selection.name,
arguments=selection.arguments,
directives=selection.directives,
selection_set=strip_typename_from_selection_set(selection.selection_set),
)
new_selections.append(new_field)
elif isinstance(selection, InlineFragmentNode):
# Process inline fragments
new_inline = InlineFragmentNode(
type_condition=selection.type_condition,
directives=selection.directives,
selection_set=strip_typename_from_selection_set(selection.selection_set),
)
new_selections.append(new_inline)
elif isinstance(selection, FragmentSpreadNode):
# FragmentSpread references a named fragment - keep as-is
new_selections.append(selection)
else:
raise TypeError(f"Unexpected GraphQL selection node type '{type(selection).__name__}'.")

return SelectionSetNode(selections=tuple(new_selections))


def strip_typename_from_operation(operation: OperationDefinitionNode) -> OperationDefinitionNode:
"""Strip __typename fields from an operation definition.

Returns a new OperationDefinitionNode with all __typename fields removed
from its selection set and any nested selection sets.
"""
return OperationDefinitionNode(
operation=operation.operation,
name=operation.name,
variable_definitions=operation.variable_definitions,
directives=operation.directives,
selection_set=strip_typename_from_selection_set(operation.selection_set),
)


def strip_typename_from_fragment(fragment: FragmentDefinitionNode) -> FragmentDefinitionNode:
"""Strip __typename fields from a fragment definition.

Returns a new FragmentDefinitionNode with all __typename fields removed
from its selection set and any nested selection sets.
"""
return FragmentDefinitionNode(
name=fragment.name,
type_condition=fragment.type_condition,
variable_definitions=fragment.variable_definitions,
directives=fragment.directives,
selection_set=strip_typename_from_selection_set(fragment.selection_set),
)


def get_class_def_index(module: ast.Module) -> int:
"""Get the index of the first class definition in the module.
Expand Down
3 changes: 1 addition & 2 deletions infrahub_sdk/schema/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,7 @@ class InfrahubRepositoryGraphQLConfig(InfrahubRepositoryConfigElement):

def load_query(self, relative_path: str = ".") -> str:
file_name = Path(f"{relative_path}/{self.file_path}")
with file_name.open("r", encoding="UTF-8") as file:
return file.read()
return file_name.read_text(encoding="UTF-8")


class InfrahubObjectConfig(InfrahubRepositoryConfigElement):
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ tests = [
lint = [
"yamllint",
"mypy==1.11.2",
"ruff==0.14.5",
"ruff==0.14.10",
"astroid>=3.1,<4.0",
"ty==0.0.8",
]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
query InvalidQuery {
NonExistentType {
edges {
node {
id
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
query GetTagsWithTypename($name: String!) {
BuiltinTag(name__value: $name) {
__typename
edges {
__typename
node {
__typename
id
name {
__typename
value
}
}
}
}
}
47 changes: 47 additions & 0 deletions tests/fixtures/unit/test_infrahubctl/graphql/test_schema.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Attribute of type Text"""
type TextAttribute implements AttributeInterface {
is_default: Boolean
is_inherited: Boolean
is_protected: Boolean
is_visible: Boolean
updated_at: DateTime
id: String
value: String
}

interface AttributeInterface {
is_default: Boolean
is_inherited: Boolean
is_protected: Boolean
is_visible: Boolean
updated_at: DateTime
}

scalar DateTime

type BuiltinTag implements CoreNode {
"""Unique identifier"""
id: String!
display_label: String
"""Description"""
description: TextAttribute
"""Name (required)"""
name: TextAttribute
}

interface CoreNode {
id: String!
}

type EdgedBuiltinTag {
node: BuiltinTag
}

type PaginatedBuiltinTag {
count: Int!
edges: [EdgedBuiltinTag!]!
}

type Query {
BuiltinTag(name__value: String, ids: [ID]): PaginatedBuiltinTag
}
15 changes: 15 additions & 0 deletions tests/fixtures/unit/test_infrahubctl/graphql/valid_query.gql
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
query GetTags($name: String!) {
BuiltinTag(name__value: $name) {
edges {
node {
id
name {
value
}
description {
value
}
}
}
}
}
Loading