Skip to content

Commit 29c84d4

Browse files
authored
Merge pull request #719 from opsmill/stable
Merge stable into develop
2 parents a2ecdda + 25ead97 commit 29c84d4

File tree

9 files changed

+733
-15
lines changed

9 files changed

+733
-15
lines changed

docs/docs/python-sdk/guides/python-typing.mdx

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ When working with GraphQL queries, you can generate type-safe Pydantic models th
111111
Generated Pydantic models from GraphQL queries offer several important benefits:
112112

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

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

122122
```shell
123-
# Generate models for queries in current directory
124-
infrahubctl graphql generate-return-types
123+
# Generate models for queries in a directory
124+
infrahubctl graphql generate-return-types queries/
125125

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

130-
> You can also export the GraphQL schema first using the `infrahubctl graphql export-schema` command:
130+
> You can also export the GraphQL schema first using the `infrahubctl graphql export-schema` command.
131131
132132
### Example workflow
133133

134-
1. **Create your GraphQL queries** in `.gql` files:
134+
1. **Create your GraphQL queries** in `.gql` files preferably in a directory (e.g., `queries/`):
135+
136+
```graphql
137+
# queries/get_tags.gql
138+
query GetAllTags {
139+
BuiltinTag {
140+
edges {
141+
node {
142+
__typename
143+
name {
144+
value
145+
}
146+
}
147+
}
148+
}
149+
}
150+
```
135151

136-
2. **Generate the Pydantic models**:
152+
2. **Export the GraphQL schema**:
153+
154+
```shell
155+
infrahubctl graphql export-schema
156+
```
157+
158+
3. **Generate the Pydantic models**:
137159

138160
```shell
139161
infrahubctl graphql generate-return-types queries/
140162
```
141163

142-
The command will generate the Python file per query based on the name of the query.
164+
:::warning Query names
165+
166+
Ensure each of your GraphQL queries has a unique name, as the generated Python files will be named based on these query names.
167+
Two queries with the same name will land in the same file, leading to potential overrides.
168+
169+
:::
143170

144-
3. **Use the generated models** in your Python code
171+
4. **Use the generated models** in your Python code
145172

146173
```python
147-
from .queries.get_devices import GetDevicesQuery
174+
from .queries.get_tags import GetAllTagsQuery
148175

149176
response = await client.execute_graphql(query=MY_QUERY)
150-
data = GetDevicesQuery(**response)
177+
data = GetAllTagsQuery(**response)
151178
```

infrahub_sdk/ctl/graphql.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,12 @@
2222
from ..async_typer import AsyncTyper
2323
from ..ctl.client import initialize_client
2424
from ..ctl.utils import catch_exception
25-
from ..graphql.utils import insert_fragments_inline, remove_fragment_import
25+
from ..graphql.utils import (
26+
insert_fragments_inline,
27+
remove_fragment_import,
28+
strip_typename_from_fragment,
29+
strip_typename_from_operation,
30+
)
2631
from .parameters import CONFIG_PARAM
2732

2833
app = AsyncTyper()
@@ -152,12 +157,18 @@ async def generate_return_types(
152157
queries = filter_operations_definitions(definitions)
153158
fragments = filter_fragments_definitions(definitions)
154159

160+
# Strip __typename fields from operations and fragments before code generation.
161+
# __typename is a GraphQL introspection meta-field that isn't part of the schema's
162+
# type definitions, causing ariadne-codegen to fail with "Redefinition of reserved type 'String'"
163+
stripped_queries = [strip_typename_from_operation(q) for q in queries]
164+
stripped_fragments = [strip_typename_from_fragment(f) for f in fragments]
165+
155166
package_generator = get_package_generator(
156167
schema=graphql_schema,
157-
fragments=fragments,
168+
fragments=stripped_fragments,
158169
settings=ClientSettings(
159170
schema_path=str(schema),
160-
target_package_name=directory.name,
171+
target_package_name=directory.name or "graphql_client",
161172
queries_path=str(directory),
162173
include_comments=CommentsStrategy.NONE,
163174
),
@@ -166,7 +177,7 @@ async def generate_return_types(
166177

167178
parsing_failed = False
168179
try:
169-
for query_operation in queries:
180+
for query_operation in stripped_queries:
170181
package_generator.add_operation(query_operation)
171182
except ParsingError as exc:
172183
console.print(f"[red]Unable to process {gql_file.name}: {exc}")

infrahub_sdk/graphql/utils.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,90 @@
11
import ast
22

3+
from graphql import (
4+
FieldNode,
5+
FragmentDefinitionNode,
6+
FragmentSpreadNode,
7+
InlineFragmentNode,
8+
OperationDefinitionNode,
9+
SelectionNode,
10+
SelectionSetNode,
11+
)
12+
13+
14+
def strip_typename_from_selection_set(selection_set: SelectionSetNode | None) -> SelectionSetNode | None:
15+
"""Recursively strip __typename fields from a SelectionSetNode.
16+
17+
The __typename meta-field is an introspection field that is not part of the schema's
18+
type definitions. When code generation tools like ariadne-codegen try to look up
19+
__typename in the schema, they fail because it's a reserved introspection field.
20+
21+
This function removes all __typename fields from the selection set, allowing
22+
code generation to proceed without errors.
23+
"""
24+
if selection_set is None:
25+
return None
26+
27+
new_selections: list[SelectionNode] = []
28+
for selection in selection_set.selections:
29+
if isinstance(selection, FieldNode):
30+
# Skip __typename fields
31+
if selection.name.value == "__typename":
32+
continue
33+
# Recursively process nested selection sets
34+
new_field = FieldNode(
35+
alias=selection.alias,
36+
name=selection.name,
37+
arguments=selection.arguments,
38+
directives=selection.directives,
39+
selection_set=strip_typename_from_selection_set(selection.selection_set),
40+
)
41+
new_selections.append(new_field)
42+
elif isinstance(selection, InlineFragmentNode):
43+
# Process inline fragments
44+
new_inline = InlineFragmentNode(
45+
type_condition=selection.type_condition,
46+
directives=selection.directives,
47+
selection_set=strip_typename_from_selection_set(selection.selection_set),
48+
)
49+
new_selections.append(new_inline)
50+
elif isinstance(selection, FragmentSpreadNode):
51+
# FragmentSpread references a named fragment - keep as-is
52+
new_selections.append(selection)
53+
else:
54+
raise TypeError(f"Unexpected GraphQL selection node type '{type(selection).__name__}'.")
55+
56+
return SelectionSetNode(selections=tuple(new_selections))
57+
58+
59+
def strip_typename_from_operation(operation: OperationDefinitionNode) -> OperationDefinitionNode:
60+
"""Strip __typename fields from an operation definition.
61+
62+
Returns a new OperationDefinitionNode with all __typename fields removed
63+
from its selection set and any nested selection sets.
64+
"""
65+
return OperationDefinitionNode(
66+
operation=operation.operation,
67+
name=operation.name,
68+
variable_definitions=operation.variable_definitions,
69+
directives=operation.directives,
70+
selection_set=strip_typename_from_selection_set(operation.selection_set),
71+
)
72+
73+
74+
def strip_typename_from_fragment(fragment: FragmentDefinitionNode) -> FragmentDefinitionNode:
75+
"""Strip __typename fields from a fragment definition.
76+
77+
Returns a new FragmentDefinitionNode with all __typename fields removed
78+
from its selection set and any nested selection sets.
79+
"""
80+
return FragmentDefinitionNode(
81+
name=fragment.name,
82+
type_condition=fragment.type_condition,
83+
variable_definitions=fragment.variable_definitions,
84+
directives=fragment.directives,
85+
selection_set=strip_typename_from_selection_set(fragment.selection_set),
86+
)
87+
388

489
def get_class_def_index(module: ast.Module) -> int:
590
"""Get the index of the first class definition in the module.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
query InvalidQuery {
2+
NonExistentType {
3+
edges {
4+
node {
5+
id
6+
}
7+
}
8+
}
9+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
query GetTagsWithTypename($name: String!) {
2+
BuiltinTag(name__value: $name) {
3+
__typename
4+
edges {
5+
__typename
6+
node {
7+
__typename
8+
id
9+
name {
10+
__typename
11+
value
12+
}
13+
}
14+
}
15+
}
16+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""Attribute of type Text"""
2+
type TextAttribute implements AttributeInterface {
3+
is_default: Boolean
4+
is_inherited: Boolean
5+
is_protected: Boolean
6+
is_visible: Boolean
7+
updated_at: DateTime
8+
id: String
9+
value: String
10+
}
11+
12+
interface AttributeInterface {
13+
is_default: Boolean
14+
is_inherited: Boolean
15+
is_protected: Boolean
16+
is_visible: Boolean
17+
updated_at: DateTime
18+
}
19+
20+
scalar DateTime
21+
22+
type BuiltinTag implements CoreNode {
23+
"""Unique identifier"""
24+
id: String!
25+
display_label: String
26+
"""Description"""
27+
description: TextAttribute
28+
"""Name (required)"""
29+
name: TextAttribute
30+
}
31+
32+
interface CoreNode {
33+
id: String!
34+
}
35+
36+
type EdgedBuiltinTag {
37+
node: BuiltinTag
38+
}
39+
40+
type PaginatedBuiltinTag {
41+
count: Int!
42+
edges: [EdgedBuiltinTag!]!
43+
}
44+
45+
type Query {
46+
BuiltinTag(name__value: String, ids: [ID]): PaginatedBuiltinTag
47+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
query GetTags($name: String!) {
2+
BuiltinTag(name__value: $name) {
3+
edges {
4+
node {
5+
id
6+
name {
7+
value
8+
}
9+
description {
10+
value
11+
}
12+
}
13+
}
14+
}
15+
}

0 commit comments

Comments
 (0)