Skip to content

Commit b61c7bd

Browse files
authored
Merge pull request #546 from opsmill/dga-20250913-graphql-return-types
Add command to generate Pydantic model based on a GraphQL query
2 parents 423cfbf + d490fd8 commit b61c7bd

File tree

24 files changed

+20641
-364
lines changed

24 files changed

+20641
-364
lines changed

.vale/styles/Infrahub/branded-terms-case-swap.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ swap:
1313
(?:[Gg]itlab): GitLab
1414
(?:gitpod): GitPod
1515
(?:grafana): Grafana
16-
(?:[^/][Gg]raphql): GraphQL
1716
(?:[Ii]nflux[Dd]b): InfluxDB
1817
infrahub(?:\s|$): Infrahub
1918
(?:jinja2): Jinja2

changelog/+gql-command.added.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add `infrahubctl graphql` commands to export schema and generate Pydantic types from GraphQL queries
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# `infrahubctl graphql`
2+
3+
Various GraphQL related commands.
4+
5+
**Usage**:
6+
7+
```console
8+
$ infrahubctl graphql [OPTIONS] COMMAND [ARGS]...
9+
```
10+
11+
**Options**:
12+
13+
* `--install-completion`: Install completion for the current shell.
14+
* `--show-completion`: Show completion for the current shell, to copy it or customize the installation.
15+
* `--help`: Show this message and exit.
16+
17+
**Commands**:
18+
19+
* `export-schema`: Export the GraphQL schema to a file.
20+
* `generate-return-types`: Create Pydantic Models for GraphQL query...
21+
22+
## `infrahubctl graphql export-schema`
23+
24+
Export the GraphQL schema to a file.
25+
26+
**Usage**:
27+
28+
```console
29+
$ infrahubctl graphql export-schema [OPTIONS]
30+
```
31+
32+
**Options**:
33+
34+
* `--destination PATH`: Path to the GraphQL schema file. [default: schema.graphql]
35+
* `--config-file TEXT`: [env var: INFRAHUBCTL_CONFIG; default: infrahubctl.toml]
36+
* `--help`: Show this message and exit.
37+
38+
## `infrahubctl graphql generate-return-types`
39+
40+
Create Pydantic Models for GraphQL query return types
41+
42+
**Usage**:
43+
44+
```console
45+
$ infrahubctl graphql generate-return-types [OPTIONS] [QUERY]
46+
```
47+
48+
**Arguments**:
49+
50+
* `[QUERY]`: Location of the GraphQL query file(s). Defaults to current directory if not specified.
51+
52+
**Options**:
53+
54+
* `--schema PATH`: Path to the GraphQL schema file. [default: schema.graphql]
55+
* `--config-file TEXT`: [env var: INFRAHUBCTL_CONFIG; default: infrahubctl.toml]
56+
* `--help`: Show this message and exit.

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

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,4 +100,52 @@ from lib.protocols import MyOwnObject
100100
my_object = client.get(MyOwnObject, name__value="example")
101101
```
102102

103-
> if you don't have your own Python module, it's possible to use relative path by having the `procotols.py` in the same directory as your script/transform/generator
103+
> if you don't have your own Python module, it's possible to use relative path by having the `protocols.py` in the same directory as your script/transform/generator
104+
105+
## Generating Pydantic models from GraphQL queries
106+
107+
When working with GraphQL queries, you can generate type-safe Pydantic models that correspond to your query return types. This provides excellent type safety and IDE support for your GraphQL operations.
108+
109+
### Why use generated return types?
110+
111+
Generated Pydantic models from GraphQL queries offer several important benefits:
112+
113+
- **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
115+
- **Documentation**: Generated models serve as living documentation of your GraphQL API
116+
- **Validation**: Automatic validation of query responses against the expected schema
117+
118+
### Generating return types
119+
120+
Use the `infrahubctl graphql generate-return-types` command to create Pydantic models from your GraphQL queries:
121+
122+
```shell
123+
# Generate models for queries in current directory
124+
infrahubctl graphql generate-return-types
125+
126+
# Generate models for specific query files
127+
infrahubctl graphql generate-return-types queries/get_devices.gql
128+
```
129+
130+
> You can also export the GraphQL schema first using the `infrahubctl graphql export-schema` command:
131+
132+
### Example workflow
133+
134+
1. **Create your GraphQL queries** in `.gql` files:
135+
136+
2. **Generate the Pydantic models**:
137+
138+
```shell
139+
infrahubctl graphql generate-return-types queries/
140+
```
141+
142+
The command will generate the Python file per query based on the name of the query.
143+
144+
3. **Use the generated models** in your Python code
145+
146+
```python
147+
from .queries.get_devices import GetDevicesQuery
148+
149+
response = await client.execute_graphql(query=MY_QUERY)
150+
data = GetDevicesQuery(**response)
151+
```

infrahub_sdk/ctl/cli_commands.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from ..ctl.client import initialize_client, initialize_client_sync
2727
from ..ctl.exceptions import QueryNotFoundError
2828
from ..ctl.generator import run as run_generator
29+
from ..ctl.graphql import app as graphql_app
2930
from ..ctl.menu import app as menu_app
3031
from ..ctl.object import app as object_app
3132
from ..ctl.render import list_jinja2_transforms, print_template_errors
@@ -63,6 +64,7 @@
6364
app.add_typer(repository_app, name="repository")
6465
app.add_typer(menu_app, name="menu")
6566
app.add_typer(object_app, name="object")
67+
app.add_typer(graphql_app, name="graphql")
6668

6769
app.command(name="dump")(dump)
6870
app.command(name="load")(load)

infrahub_sdk/ctl/graphql.py

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
from __future__ import annotations
2+
3+
import ast
4+
from collections import defaultdict
5+
from pathlib import Path
6+
from typing import Optional
7+
8+
import typer
9+
from ariadne_codegen.client_generators.package import PackageGenerator, get_package_generator
10+
from ariadne_codegen.exceptions import ParsingError
11+
from ariadne_codegen.plugins.explorer import get_plugins_types
12+
from ariadne_codegen.plugins.manager import PluginManager
13+
from ariadne_codegen.schema import (
14+
filter_fragments_definitions,
15+
filter_operations_definitions,
16+
get_graphql_schema_from_path,
17+
)
18+
from ariadne_codegen.settings import ClientSettings, CommentsStrategy
19+
from ariadne_codegen.utils import ast_to_str
20+
from graphql import DefinitionNode, GraphQLSchema, NoUnusedFragmentsRule, parse, specified_rules, validate
21+
from rich.console import Console
22+
23+
from ..async_typer import AsyncTyper
24+
from ..ctl.client import initialize_client
25+
from ..ctl.utils import catch_exception
26+
from ..graphql.utils import insert_fragments_inline, remove_fragment_import
27+
from .parameters import CONFIG_PARAM
28+
29+
app = AsyncTyper()
30+
console = Console()
31+
32+
ARIADNE_PLUGINS = [
33+
"infrahub_sdk.graphql.plugin.PydanticBaseModelPlugin",
34+
"infrahub_sdk.graphql.plugin.FutureAnnotationPlugin",
35+
"infrahub_sdk.graphql.plugin.StandardTypeHintPlugin",
36+
]
37+
38+
39+
def find_gql_files(query_path: Path) -> list[Path]:
40+
"""
41+
Find all files with .gql extension in the specified directory.
42+
43+
Args:
44+
query_path: Path to the directory to search for .gql files
45+
46+
Returns:
47+
List of Path objects for all .gql files found
48+
"""
49+
if not query_path.exists():
50+
raise FileNotFoundError(f"File or directory not found: {query_path}")
51+
52+
if not query_path.is_dir() and query_path.is_file():
53+
return [query_path]
54+
55+
return list(query_path.glob("**/*.gql"))
56+
57+
58+
def get_graphql_query(queries_path: Path, schema: GraphQLSchema) -> tuple[DefinitionNode, ...]:
59+
"""Get GraphQL queries definitions from a single GraphQL file."""
60+
61+
if not queries_path.exists():
62+
raise FileNotFoundError(f"File not found: {queries_path}")
63+
if not queries_path.is_file():
64+
raise ValueError(f"{queries_path} is not a file")
65+
66+
queries_str = queries_path.read_text(encoding="utf-8")
67+
queries_ast = parse(queries_str)
68+
validation_errors = validate(
69+
schema=schema,
70+
document_ast=queries_ast,
71+
rules=[r for r in specified_rules if r is not NoUnusedFragmentsRule],
72+
)
73+
if validation_errors:
74+
raise ValueError("\n\n".join(error.message for error in validation_errors))
75+
return queries_ast.definitions
76+
77+
78+
def generate_result_types(directory: Path, package: PackageGenerator, fragment: ast.Module) -> None:
79+
for file_name, module in package._result_types_files.items():
80+
file_path = directory / file_name
81+
82+
insert_fragments_inline(module, fragment)
83+
remove_fragment_import(module)
84+
85+
code = package._add_comments_to_code(ast_to_str(module), package.queries_source)
86+
if package.plugin_manager:
87+
code = package.plugin_manager.generate_result_types_code(code)
88+
file_path.write_text(code)
89+
package._generated_files.append(file_path.name)
90+
91+
92+
@app.callback()
93+
def callback() -> None:
94+
"""
95+
Various GraphQL related commands.
96+
"""
97+
98+
99+
@app.command()
100+
@catch_exception(console=console)
101+
async def export_schema(
102+
destination: Path = typer.Option("schema.graphql", help="Path to the GraphQL schema file."),
103+
_: str = CONFIG_PARAM,
104+
) -> None:
105+
"""Export the GraphQL schema to a file."""
106+
107+
client = initialize_client()
108+
schema_text = await client.schema.get_graphql_schema()
109+
110+
destination.parent.mkdir(parents=True, exist_ok=True)
111+
destination.write_text(schema_text)
112+
console.print(f"[green]Schema exported to {destination}")
113+
114+
115+
@app.command()
116+
@catch_exception(console=console)
117+
async def generate_return_types(
118+
query: Optional[Path] = typer.Argument(
119+
None, help="Location of the GraphQL query file(s). Defaults to current directory if not specified."
120+
),
121+
schema: Path = typer.Option("schema.graphql", help="Path to the GraphQL schema file."),
122+
_: str = CONFIG_PARAM,
123+
) -> None:
124+
"""Create Pydantic Models for GraphQL query return types"""
125+
126+
query = Path.cwd() if query is None else query
127+
128+
# Load the GraphQL schema
129+
if not schema.exists():
130+
raise FileNotFoundError(f"GraphQL Schema file not found: {schema}")
131+
graphql_schema = get_graphql_schema_from_path(schema_path=str(schema))
132+
133+
# Initialize the plugin manager
134+
plugin_manager = PluginManager(
135+
schema=graphql_schema,
136+
plugins_types=get_plugins_types(plugins_strs=ARIADNE_PLUGINS),
137+
)
138+
139+
# Find the GraphQL files and organize them by directory
140+
gql_files = find_gql_files(query)
141+
gql_per_directory: dict[Path, list[Path]] = defaultdict(list)
142+
for gql_file in gql_files:
143+
gql_per_directory[gql_file.parent].append(gql_file)
144+
145+
# Generate the Pydantic Models for the GraphQL queries
146+
for directory, gql_files in gql_per_directory.items():
147+
for gql_file in gql_files:
148+
try:
149+
definitions = get_graphql_query(queries_path=gql_file, schema=graphql_schema)
150+
except ValueError as exc:
151+
console.print(f"[red]Error generating result types for {gql_file}: {exc}")
152+
continue
153+
queries = filter_operations_definitions(definitions)
154+
fragments = filter_fragments_definitions(definitions)
155+
156+
package_generator = get_package_generator(
157+
schema=graphql_schema,
158+
fragments=fragments,
159+
settings=ClientSettings(
160+
schema_path=str(schema),
161+
target_package_name=directory.name,
162+
queries_path=str(directory),
163+
include_comments=CommentsStrategy.NONE,
164+
),
165+
plugin_manager=plugin_manager,
166+
)
167+
168+
parsing_failed = False
169+
try:
170+
for query_operation in queries:
171+
package_generator.add_operation(query_operation)
172+
except ParsingError as exc:
173+
console.print(f"[red]Unable to process {gql_file.name}: {exc}")
174+
parsing_failed = True
175+
176+
if parsing_failed:
177+
continue
178+
179+
module_fragment = package_generator.fragments_generator.generate()
180+
181+
generate_result_types(directory=directory, package=package_generator, fragment=module_fragment)
182+
183+
for file_name in package_generator._result_types_files.keys():
184+
console.print(f"[green]Generated {file_name} in {directory}")

infrahub_sdk/graphql/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from .constants import VARIABLE_TYPE_MAPPING
2+
from .query import Mutation, Query
3+
from .renderers import render_input_block, render_query_block, render_variables_to_string
4+
5+
__all__ = [
6+
"VARIABLE_TYPE_MAPPING",
7+
"Mutation",
8+
"Query",
9+
"render_input_block",
10+
"render_query_block",
11+
"render_variables_to_string",
12+
]

infrahub_sdk/graphql/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
VARIABLE_TYPE_MAPPING = ((str, "String!"), (int, "Int!"), (float, "Float!"), (bool, "Boolean!"))

0 commit comments

Comments
 (0)