iron_gql
iron_gql is a GraphQL code generator and runtime. It reads a schema SDL and your query documents, and it generates a typed Python client with Pydantic models. You can connect GraphQL APIs to services, CLIs, background jobs, and tests without hand-written boilerplate.
Installation
pip install iron-gql # runtime only (httpx2 + pydantic)
pip install iron-gql[codegen] # + graphql-core for code generation
pip install iron-gql[testing] # + uvicorn for the loopback test server
Key Features
- Query discovery.
generate_gql_packagescans your codebase for calls of the form<package>_gql("""..."""). It validates each statement and writes a module with typed helpers. - Typed inputs and results. The generated Pydantic models match every selection set, enum, and input object that the discovered queries reference.
- Sync or async runtime. Pick the mode per package with
mode="sync"ormode="async". The generated module targetsruntime.GQLClientorruntime.AsyncGQLClient, both of which send requests throughhttpx2. One project can hold packages of both kinds. - ASGI in-process calls.
AsyncGQLClientaccepts an ASGItarget_appand then calls the app in-process without using the network. The sync client has no such transport: the ASGI transport ofhttpx2is async-only, and WSGI cannot carry websockets. Test synchronous packages against a real server on a loopback port, whichiron_gql.testingstarts for you. - Deterministic validation.
graphql-core(a codegen dependency) validates every statement against the schema. It rejects operations that share a name but have different bodies.
Package Layout
runtime.pycontainsGQLClientandAsyncGQLClient, the reusableGQLOperationbase class, and the value serialization helpers.codegen/generate.pyruns query discovery, validation, and module rendering.codegen/parser.pyconverts the GraphQL AST into typed helper structures for the renderer.testing/holds the test helpers: the client swap, thegraphql-transport-wsserver primitives, and the loopback server.
Getting Started
-
Describe your schema. Write the schema in an SDL file (
schema.graphql). Include the root types that you use (query, mutation, subscription). -
Write queries where you use them. Import the helper that the generator will create. Wrap each GraphQL statement in a call to this helper:
from myapp.gql.client import client_gql get_user = client_gql( """ query GetUser($id: ID!) { user(id: $id) { id name } } """ )
The generator derives the helper name (
client_gql) from the package path that you ask it to build. -
Generate the client module.
from pathlib import Path from iron_gql.codegen import generate_gql_package generate_gql_package( mode="async", schema_path=Path("schema.graphql"), src_path=Path("."), package_full_name="myapp.gql.client", base_url_import="myapp.config:GRAPHQL_URL", scalars={"ID": "builtins:str"}, to_camel_fn_full_name="myapp.inflection:to_camel", to_snake_fn=my_project_to_snake, debug_path=Path("iron_gql/debug/myapp.gql.client"), )
modeis required: pass"async"or"sync"for every package. Each package is self-contained, so one project can generate both.The call writes
myapp/gql/client.py. The module contains:- a client singleton for the chosen mode,
- Pydantic result and input models,
- a query class per operation with typed
executemethods, - overloads for the helper function, so editors can infer return types.
-
Call your API. An async package awaits
execute:async def fetch_user(user_id: str): query = get_user.with_headers({"Authorization": "Bearer token"}) result = await query.execute(id=user_id) return result.user
A sync package calls it directly:
def fetch_user(user_id: str): query = get_user.with_headers({"Authorization": "Bearer token"}) result = query.execute(id=user_id) return result.user
Custom Scalars
The generator maps GraphQL scalars to Python types in two layers.
It maps built-in scalars automatically:
| GraphQL | Python |
|---|---|
String, Int, Float, Boolean |
str, int, float, bool |
Date |
datetime.date |
DateTime |
datetime.datetime |
JSON |
object |
Upload |
iron_gql.FileVar |
You configure custom scalars with the scalars parameter, in "module:type" format:
generate_gql_package(
...,
scalars={
"ID": "builtins:str",
"Money": "decimal:Decimal",
"ULID": "ulid:ULID",
},
)
Custom scalar types must be Pydantic-compatible. That is, Pydantic must know how to parse the type from JSON (deserialization) and how to serialize it to JSON. Standard library types (datetime, Decimal, UUID, Enum) are compatible by default. Any type that implements __get_pydantic_core_schema__ is also compatible. The generator maps unknown scalars to object and writes a warning to the log.
Fragment Slots
Shared infrastructure code often owns a GraphQL operation, but it does not know which fields each caller needs on some field of that operation. A fragment slot lets each caller supply its own fragment for that field at call time. The operation does not have to name the fragments of its consumers in advance.
Mark a field with @slot in a query, mutation, or subscription. Do not use @slot inside a fragment definition. Give the field a static selection that selects __typename at the top level of its own selection set. This __typename must be unaliased and must have no directives. It must not come through an inline fragment or a fragment spread. The slot field itself cannot have @skip or @include: the operation always requests the slot field. If a caller wants no fragment data, it passes an empty list.
get_post_attachment = api_gql("""
query GetPostAttachment($id: ID!) {
post(id: $id) {
id
attachment @slot { __typename }
}
}
""")
A statement that holds exactly one fragment definition becomes a typed handle when some slot in the package can accept the fragment. A slot can accept a fragment when the fragment is spread-compatible with the type of the slot field. You can still spread the same fragment by name into other operations. For two kinds of statement, the helper returns a plain runtime.GQLOperation:
- a statement with one fragment definition that no slot accepts,
- a statement with several fragment definitions and no operation.
The fragments of these statements continue to work as building blocks for name spreads. They carry none of the obligations of a handle: self-containedness, a __typename of its own on polymorphic selections, and a non-empty selection. For a statement that contains an operation, the helper returns the class of that operation, as always.
IMAGE_URL = api_gql("""
fragment ImageUrl on ImageAttachment {
url
}
""")
Pass a handle, or a sequence of handles, into execute. The keyword argument is the snake_case form of the name of the slot field, or of its alias. For example, mainAttachment @slot becomes main_attachment=. Then read the typed model of each fragment from the slot node with handle.read(node):
result = await get_post_attachment.execute(id="p-1", attachment=IMAGE_URL)
if result.post is not None:
image = IMAGE_URL.read(result.post.attachment)
if image is not None:
print(image.url)
read returns None in exactly two situations:
- The node is
Nonebecause the server sentnull. - The runtime type of the node is outside the fragment's own selection.
If you read with a handle that you never passed to that slot, read raises an error. It does not return None, because a wiring bug must not look like a legitimate mismatch.
Fragments are isolated from each other. Each fragment reads exactly its own selection. It never receives the fields that the fragment of another caller selected.
You can reach slot data only through read. The data is not part of the fields of the result model, so model_dump() does not include it. A dumped result does not round-trip. To validate the dump again, you must supply a fragments context. Without one, validation fails with an error. With or without one, the data of the fragments is gone.
For each slot field type, the generator writes one compatibility base class, named {FieldType}Fragment. With this class, shared code can be generic over any fragment that is compatible with that field. The shared code does not have to know the concrete shape of the fragment:
async def read_attachment[T: pydantic.BaseModel](
post_id: str, fragment: AttachmentFragment[T]
) -> T | None:
result = await get_post_attachment.execute(id=post_id, attachment=fragment)
if result.post is None:
return None
return fragment.read(result.post.attachment)
A fragment that is not spread-compatible with the slot causes a type error. The type checker finds the mismatch before you ship your code.
The runtime validates every fragment that you pass into a slot at the response boundary. For queries and mutations, validation occurs inside execute. For subscriptions, validation occurs on each received message. Malformed data causes an immediate error. The malformed data never surfaces later from read.
Three rules apply:
- A handle must be self-contained. It cannot spread other fragments, and it cannot reference variables (
$name). The handle travels to the server as its own text, next to an operation that declares nothing for it. Fragments that no slot accepts are free of both rules. They can spread other fragments by name, and they take their variables from the operations that spread them. - An operation that declares a compatible slot cannot define or spread any fragment name that a handle ships. This covers the handle's own name and its transitive dependencies. The generator rejects the combination as soon as the handle exists, whether or not anyone passes the handle. To remove the conflict, rename one of the two. Operations without a compatible slot are outside this rule. The same fragment can work in both roles across different operations.
- The slot keyword argument in
executeis mandatory, and there is no default. To send no fragments, pass an empty list explicitly.
Customization Hooks
- Naming conventions. Supply
to_camel_fn_full_name(a module:path string) and ato_snake_fncallable. These functions align the casing with your ownalias_generator. - Endpoint configuration. The generator writes
base_url_importverbatim into the generated module. Set it to a global string, a configuration object, or a helper that returns the GraphQL endpoint.
Runtime Highlights
AsyncGQLClientaccepts an ASGItarget_app. You can use the same runtime for production HTTP calls and for in-process ASGI execution.GQLClient.subscribereturns a plain context manager over a blocking generator, so a synchronous consumer reads subscription messages withfor.GQLOperation.with_headersclones the operation object. The original does not change, so each call can have its own headers.Uploadscalars map toiron_gql.FileVar. When variables containFileVarinstances, the client automatically sends a multipart upload (see the GraphQL multipart request spec).serialize_varconverts variables to JSON-compatible structures with the PydanticTypeAdapter. It supports custom scalar types, nested models, dicts, and lists.
Example
The example/ directory contains a complete working setup. It has a GraphQL schema with queries, mutations, enums, interfaces, unions, and fragments. It also has the generation script and sample query definitions. See example/generate.py for the codegen calls of both modes, example/main.py for async query usage, and example/main_sync.py for the synchronous form.
Testing
iron_gql.testing holds the helpers that a service needs to test its own use of a generated package.
Which transport a test needs follows from the client:
| client | queries and mutations | subscriptions |
|---|---|---|
AsyncGQLClient |
target_app, no socket |
target_app, no socket |
GQLClient |
server on a loopback port | server on a loopback port |
AsyncGQLClient takes an ASGI target_app and calls it in process, websockets included, so an async package never needs a socket. GQLClient has no such transport: the ASGI transport of httpx2 is async-only, and WSGI cannot carry websockets. A synchronous package is therefore tested against a real server, which live_asgi_server starts for you.
Only live_asgi_server has a dependency of its own — uvicorn, from pip install iron-gql[testing]. Everything else here needs nothing beyond the runtime, so a project that only replaces clients installs plain iron-gql.
You supply the fake yourself. The library takes no position on how you answer a query: run your own schema library, return canned JSON, or serve the app you are testing.
Replace the client
use_async_client and use_sync_client bind your own client into a generated package. On exit each one restores the previous client and closes the client that you passed in:
from iron_gql.runtime import AsyncGQLClient
from iron_gql.testing import use_async_client
from myapp.gql import api
async def test_get_user():
client = AsyncGQLClient(base_url="http://testserver", target_app=my_asgi_app)
async with use_async_client(api, client):
result = await get_user.execute(id="1")
assert result.user.name == "Alice"
The generated query classes resolve the client by module attribute name at call time, so this replacement is sufficient. The helpers derive that name from the name of the module, exactly as the generator derives it. For the package myapp.gql.api, the attribute is API_CLIENT.
Serve an app on a loopback port
live_asgi_server serves an ASGI app with uvicorn on a port that the operating system picks, and it yields the URL of that server:
from iron_gql.runtime import GQLClient
from iron_gql.testing import use_sync_client
from iron_gql.testing.server import live_asgi_server
def test_get_user_sync():
with (
live_asgi_server(my_asgi_app) as base_url,
use_sync_client(api, GQLClient(base_url=base_url)),
):
result = get_user.execute(id="1")
assert result.user.name == "Alice"
The URL ends with /graphql. Pass path="/other" for a different one. The path is only a part of the URL: a fake app usually answers on every path, so the helper does not route.
Script a subscription fake
Subscriptions speak graphql-transport-ws, and a fake has to hold up the server end of it. accept_graphql_ws performs the handshake and hands you the connection; each step then asserts that the client kept to the protocol, and reports what arrived instead when it did not.
from iron_gql.runtime import ASGIReceive
from iron_gql.runtime import ASGIScope
from iron_gql.runtime import ASGISend
from iron_gql.testing import accept_graphql_ws
async def events_app(scope: ASGIScope, receive: ASGIReceive, send: ASGISend) -> None:
connection = await accept_graphql_ws(scope, receive, send)
subscription = await connection.ack()
assert subscription.payload["variables"] == {"channel": "test"}
await subscription.next({"events": {"id": "1", "message": "hello"}})
await subscription.complete()
await connection.drain()
accept_graphql_ws accepts the socket, echoes the subprotocol when the client offered it, and consumes connection_init. On the connection you then call:
ack()— sendsconnection_ackand waits for the client'ssubscribe. The subscription it returns exposes the received message aspayloadand stamps itsidon everything it sends.send_message(message)andexpect_pong()— forpingand other connection-level traffic.close(code, reason)— closes the socket, for testing how the client reacts.drain()— waits for the client to hang up. Return before that and you tear the connection down under it.
On the subscription: next(data), error(errors), complete(), and send_message(message) for anything else.
Control flow stays in your fake, so state lives in ordinary Python around these calls — a connection counter, a drop on the N-th connect, a failure of the first few messages.
Keep the generated code current
generate_gql_package returns True when it wrote a change. Committing generated modules and asserting the generator has nothing left to write catches a schema or a query that moved ahead of the committed code:
def test_generated_package_is_current():
assert generate_gql_package(...) is False
Validation and Troubleshooting
- Error messages show the file and the line of the statement that caused the error.
- Operations that share a name must have identical bodies. To remove the conflict, rename the operations or merge them.
- The generated helper raises
LookupErrorfor a statement that it does not know. After you add or edit a statement, regenerate the package.
Release files for iron-gql 0.9.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| iron_gql-0.9.0.tar.gz | 61.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| iron_gql-0.9.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 132.6 kB
Release files / iron_gql-0.9.0.tar.gz
| Download URL | iron_gql-0.9.0.tar.gz |
|---|---|
| Size | 61.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
06f25bd06c686c8f1f81d863b2d1bb7e54078abe6c68a9e8d045c6e3a24fbffe
|
|
BLAKE2b-256 checksum How to use checksums |
5c36b6ed25a659a6ceb645c022e402f5348025d86cb8dbe57d22c27cf8826300
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.12.2 {"installer":{"name":"uv","version":"0.12.2","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / iron_gql-0.9.0-py3-none-any.whl
| Download URL | iron_gql-0.9.0-py3-none-any.whl |
|---|---|
| Size | 71.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
b4ab06c3730d72ae323a02e880006fd2b05c56eeab153d156f1c4c9449dec2db
|
|
BLAKE2b-256 checksum How to use checksums |
13bd286f3f2627bd16d778015c77538c694fe27ec110e6d00716b5e528e815c9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.12.2 {"installer":{"name":"uv","version":"0.12.2","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|