graphql-codegen 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. graphql_codegen/__init__.py +7 -0
  2. graphql_codegen/__main__.py +5 -0
  3. graphql_codegen/_cli/__init__.py +41 -0
  4. graphql_codegen/_cli/_graphql_config.py +291 -0
  5. graphql_codegen/_cli/_introspection.py +87 -0
  6. graphql_codegen/_cli/_introspection_graphql.py +53 -0
  7. graphql_codegen/_cli/_parsing.py +67 -0
  8. graphql_codegen/_cli/_schema_pointer.py +108 -0
  9. graphql_codegen/_cli/_source.py +68 -0
  10. graphql_codegen/_cli/_write.py +22 -0
  11. graphql_codegen/_generator/__init__.py +0 -0
  12. graphql_codegen/_generator/_annotation.py +149 -0
  13. graphql_codegen/_generator/_ast_nodes.py +235 -0
  14. graphql_codegen/_generator/_data_type.py +532 -0
  15. graphql_codegen/_generator/_document.py +108 -0
  16. graphql_codegen/_generator/_document_module.py +54 -0
  17. graphql_codegen/_generator/_imports.py +166 -0
  18. graphql_codegen/_generator/_injector.py +267 -0
  19. graphql_codegen/_generator/_merge.py +149 -0
  20. graphql_codegen/_generator/_naming.py +80 -0
  21. graphql_codegen/_generator/_operation.py +223 -0
  22. graphql_codegen/_generator/_scalar.py +76 -0
  23. graphql_codegen/_generator/_schema.py +46 -0
  24. graphql_codegen/_generator/_schema_type.py +334 -0
  25. graphql_codegen/_generator/_selection.py +246 -0
  26. graphql_codegen/_generator/_structs.py +87 -0
  27. graphql_codegen/_generator/_typed_dict.py +154 -0
  28. graphql_codegen/_generator/dotted_name.py +48 -0
  29. graphql_codegen/_generator/package.py +695 -0
  30. graphql_codegen/_generator/spelling.py +168 -0
  31. graphql_codegen/_metadata.py +11 -0
  32. graphql_codegen/_note.py +12 -0
  33. graphql_codegen/config.py +42 -0
  34. graphql_codegen/document_sibling_module.py +58 -0
  35. graphql_codegen/generate.py +19 -0
  36. graphql_codegen/package_location.py +41 -0
  37. graphql_codegen/py.typed +0 -0
  38. graphql_codegen/runtime/__init__.py +20 -0
  39. graphql_codegen/runtime/_compat.py +27 -0
  40. graphql_codegen/runtime/_literal.py +18 -0
  41. graphql_codegen/runtime/_merge.py +153 -0
  42. graphql_codegen/runtime/_prepare.py +250 -0
  43. graphql_codegen/runtime/_reflection.py +376 -0
  44. graphql_codegen/runtime/_sigil.py +11 -0
  45. graphql_codegen/runtime/_transport.py +21 -0
  46. graphql_codegen/runtime/client.py +384 -0
  47. graphql_codegen/runtime/error.py +135 -0
  48. graphql_codegen/runtime/injection.py +107 -0
  49. graphql_codegen/runtime/operation.py +135 -0
  50. graphql_codegen/scalar.py +59 -0
  51. graphql_codegen-0.1.0.dist-info/METADATA +871 -0
  52. graphql_codegen-0.1.0.dist-info/RECORD +55 -0
  53. graphql_codegen-0.1.0.dist-info/WHEEL +4 -0
  54. graphql_codegen-0.1.0.dist-info/entry_points.txt +3 -0
  55. graphql_codegen-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,871 @@
1
+ Metadata-Version: 2.4
2
+ Name: graphql-codegen
3
+ Version: 0.1.0
4
+ Summary: Turns a GraphQL schema and your operations into a typed Python client with no imposed transport, no validation overhead, and no runtime dependencies.
5
+ Keywords: client,code generation,codegen,graphql,sans-io,typeddict
6
+ Author: Thibault Derousseaux
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Programming Language :: Python :: 3.15
16
+ Classifier: Topic :: Software Development :: Code Generators
17
+ Classifier: Typing :: Typed
18
+ Requires-Dist: graphql-core>=3.3.0
19
+ Requires-Dist: typing-extensions>=4.16.0 ; python_full_version < '3.15'
20
+ Requires-Dist: pyyaml>=6.0.3 ; extra == 'yaml'
21
+ Requires-Python: >=3.12
22
+ Provides-Extra: yaml
23
+ Description-Content-Type: text/markdown
24
+
25
+ # graphql-codegen
26
+
27
+ `graphql-codegen` turns a GraphQL schema and your operations into a typed Python client with:
28
+
29
+ - no imposed transport;
30
+ - no validation overhead;
31
+ - no runtime dependencies[^typing-extensions].
32
+
33
+ ## Installation
34
+
35
+ ```shell
36
+ uv add --dev graphql-codegen
37
+ ```
38
+
39
+ The generated client depends on nothing but the standard library[^typing-extensions].
40
+
41
+ ## Quick start
42
+
43
+ Every example of this README is a file of [`bookshop`](https://github.com/tibdex/graphql-codegen/tree/main/bookshop), whose schema is [`schema.graphqls`](https://github.com/tibdex/graphql-codegen/blob/main/bookshop/schema.graphqls):
44
+
45
+ <!-- excerpt: bookshop/schema.graphqls -->
46
+
47
+ ```graphql
48
+ type Query {
49
+ # …
50
+ "The order with this identifier."
51
+ order(id: ID!): Order
52
+ # …
53
+ }
54
+
55
+ enum OrderStatus {
56
+ PENDING
57
+ SHIPPED
58
+ DELIVERED
59
+ CANCELED
60
+ }
61
+
62
+ type Order {
63
+ id: ID!
64
+ status: OrderStatus!
65
+ # …
66
+ }
67
+ ```
68
+
69
+ Write your operations in `.graphql` files:
70
+
71
+ <!-- file: bookshop/get_order.graphql -->
72
+
73
+ ```graphql
74
+ query GetOrder($id: ID!) {
75
+ order(id: $id) {
76
+ id
77
+ status
78
+ }
79
+ }
80
+ ```
81
+
82
+ The generator reads [graphql-config](https://the-guild.dev/graphql/config), the file GraphQL editor extensions and linters already use[^bootstrapping]:
83
+
84
+ <!-- excerpt: bookshop/graphql.config.yml -->
85
+
86
+ ```yaml
87
+ # An SDL file here, but globs, introspection results, and server URLs work too.
88
+ schema: schema.graphqls
89
+ documents: "*.graphql"
90
+ # This library's config, under its extension name.
91
+ extensions:
92
+ pythonCodegen:
93
+ # Where imports start, relative to this file's directory.
94
+ moduleRoot: ..
95
+ # Dotted name from the module root, so written to `bookshop/client`.
96
+ package: bookshop.client
97
+ ```
98
+
99
+ Generate the client:
100
+
101
+ ```shell
102
+ graphql-codegen bookshop/graphql.config.yml
103
+ ```
104
+
105
+ The generated package holds only the `enum` and `input` types the operations reach, so that it grows with your documents rather than with the schema.
106
+ Each GraphQL document also gets its own Python module holding its operations (`get_order_graphql.py` here).
107
+ Run them over any transport; your type checker verifies every variable and every field of the response:
108
+
109
+ <!-- file: bookshop/quickstart.py -->
110
+
111
+ ```python
112
+ from typing import Literal, assert_never, assert_type
113
+
114
+ from bookshop.client.runtime import Client
115
+ from bookshop.client.schema import OrderStatus
116
+ from bookshop.get_order_graphql import GetOrder
117
+ from bookshop.transport import transport
118
+
119
+ client = Client(transport)
120
+ data = client(GetOrder({"id": "o1"}))
121
+ order = data["order"]
122
+
123
+ # `Query.order`'s type is nullable so the type checker requires this test.
124
+ if order is None:
125
+ print("No such order.")
126
+ else:
127
+ # `Order.id: ID!` is a `str` on the wire.
128
+ assert_type(order["id"], str)
129
+
130
+ # An `enum` gets a generated alias of the `Literal` of its values.
131
+ assert_type(order["status"], OrderStatus)
132
+ assert_type(order["status"], Literal["PENDING", "SHIPPED", "DELIVERED", "CANCELED"])
133
+
134
+ if "total" in order:
135
+ # A field not selected in the GraphQL operation can never be there.
136
+ assert_never(order)
137
+
138
+ print(f"Order {order['id']} is {order['status']}.")
139
+ ```
140
+
141
+ ## Typing
142
+
143
+ ### Type checking, not runtime validation
144
+
145
+ GraphQL is strongly typed, and the server:
146
+
147
+ - [validates](https://spec.graphql.org/September2025/#sec-Validation) each operation against its schema before running it;
148
+ - [responds](https://spec.graphql.org/September2025/#sec-Response) with exactly the operation's shape.
149
+
150
+ Client-side validation of responses thus only adds overhead[^breaking-changes].
151
+ What a Python client lacks is the other half: knowing, while you write `order["status"]`, that the key exists and holds an `OrderStatus`.
152
+ That is a type checker's job, done once, before the code runs.
153
+
154
+ This library therefore generates exact types for your type checker and leaves each response as decoded from JSON.
155
+ Only [custom scalars](#custom-scalars) with a codec are converted, and only fields asserted [non-null](#non-null-fields) are checked.
156
+
157
+ > [!NOTE]
158
+ > Most of Python's other GraphQL client generators rely on [Pydantic](https://docs.pydantic.dev) models for both type checking and runtime validation.
159
+ > The [TypeScript GraphQL Code Generator](https://the-guild.dev/graphql/codegen), which sets the standard for generating code from GraphQL, does otherwise: it could validate each response with [Zod](https://zod.dev) (Pydantic's TypeScript counterpart) but relies on type checking alone.
160
+ > This library makes the same call.
161
+
162
+ ### Operation types
163
+
164
+ Each operation gets a type for its variables and one for its data, both keyed by the names the GraphQL document uses.
165
+ Wherever GraphQL lets a value be one of several things, its Python type is a union that a `match` checks for exhaustiveness:
166
+
167
+ - a selection on a [`union`](https://spec.graphql.org/September2025/#sec-Unions) or an [`interface`](https://spec.graphql.org/September2025/#sec-Interfaces) is one of several types, and becomes one type per concrete type, told apart by [`__typename`](https://spec.graphql.org/September2025/#sec-Type-Name-Introspection) (which the generator selects for you);
168
+ - an [`enum`](https://spec.graphql.org/September2025/#sec-Enums) is one of several values, and becomes a closed `Literal`;
169
+ - a [`@oneOf` `input`](https://spec.graphql.org/September2025/#sec-OneOf-Input-Objects) is one of several fields, and becomes a union of single-key types, so that a value with two keys fails type checking.
170
+
171
+ For instance, [`app.graphql`](https://github.com/tibdex/graphql-codegen/blob/main/bookshop/app.graphql) selects a publication's length:
172
+
173
+ - in pages when it is printed;
174
+ - in minutes when it is an audiobook:
175
+
176
+ <!-- excerpt: bookshop/app.graphql -->
177
+
178
+ ```graphql
179
+ query GetPublication($id: ID!) {
180
+ publication(id: $id) {
181
+ title
182
+ ... on Printed {
183
+ pages: pageCount
184
+ ... on Book {
185
+ isbn
186
+ }
187
+ }
188
+ ... on Audiobook {
189
+ duration
190
+ }
191
+ }
192
+ }
193
+ ```
194
+
195
+ Testing for a key narrows it to every `type` selecting that key, even one the server adds later.
196
+ A `match` on `__typename` narrows it to one type:
197
+
198
+ <!-- excerpt: bookshop/app.py -->
199
+
200
+ ```python
201
+ def length(publication_id: str, /, *, client: Client) -> str:
202
+ data = client(GetPublication({"id": publication_id}))
203
+ publication = data["publication"]
204
+
205
+ if publication is None:
206
+ return "No such publication."
207
+
208
+ if "pages" in publication:
209
+ return f"{publication['title']} has {publication['pages']} pages."
210
+
211
+ match publication["__typename"]:
212
+ case "Audiobook":
213
+ return f"{publication['title']} lasts {publication['duration']} minutes."
214
+ case _ as never:
215
+ assert_never(never)
216
+ ```
217
+
218
+ > [!NOTE]
219
+ > Not validating responses pays off when a server moves ahead of its clients.
220
+ > A server may add a `type` to a `union`, an implementation to an `interface`, a member to an `enum`, or a field to a [struct](#structs)'s `input` type without it being considered a breaking change.
221
+ > A validating client would raise in production on the new value.
222
+ > Here, the value reaches the code as the server sent it, and the client keeps working.
223
+
224
+ A `match` handles a value it does not know as you see fit, with a `case _:` arm:
225
+
226
+ <!-- excerpt: bookshop/app.py -->
227
+
228
+ ```python
229
+ def status_label(status: OrderStatus, /) -> str:
230
+ match status:
231
+ case "PENDING":
232
+ return "Being prepared"
233
+ case "SHIPPED":
234
+ return "On its way"
235
+ case "DELIVERED":
236
+ return "Delivered"
237
+ case "CANCELED":
238
+ return "Canceled"
239
+ case _:
240
+ # A member added after this client was generated.
241
+ return "Unknown"
242
+ ```
243
+
244
+ Or with `case _ as never: assert_never(never)`, as the `match` on `__typename` above does, so that the type checker points at every `match` the addition misses once the client is regenerated.
245
+
246
+ ### No name clashes
247
+
248
+ Nothing prevents a schema or a document from using names that clash with Python keywords (`class`), standard library names (`list`, `Literal`), or the generator's own helpers.
249
+
250
+ The names the generator adds itself, such as `_builtins` or `_GetBookData_book`, are spelled around every name a module holds, so no name can shadow another, whatever names the schema and the documents use.
251
+
252
+ ## Client
253
+
254
+ ### Sans-IO
255
+
256
+ The small [sans-IO](https://sans-io.readthedocs.io) runtime is copied into the generated package, and its [public API](https://github.com/tibdex/graphql-codegen/blob/main/bookshop/client/runtime/__init__.py) is limited to:
257
+
258
+ <!-- excerpt: bookshop/client/runtime/__init__.py -->
259
+
260
+ ```python
261
+ from .client import (
262
+ AsyncClient as AsyncClient,
263
+ AsyncSubscriptionClient as AsyncSubscriptionClient,
264
+ Client as Client,
265
+ SubscriptionClient as SubscriptionClient,
266
+ )
267
+ from .error import (
268
+ ClientError as ClientError,
269
+ Error as Error,
270
+ ExecutionError as ExecutionError,
271
+ Location as Location,
272
+ ProtocolError as ProtocolError,
273
+ RequestError as RequestError,
274
+ ResponseError as ResponseError,
275
+ UnexpectedNullError as UnexpectedNullError,
276
+ )
277
+ from .injection import OMITTED as OMITTED
278
+ from .operation import Operation as Operation, Request as Request
279
+ ```
280
+
281
+ A transport is a function from a request body to a response body, so any HTTP client, synchronous or asynchronous, works, and so does anything else that carries `bytes`.
282
+ `Client`, `AsyncClient`, `SubscriptionClient`, and `AsyncSubscriptionClient` take the same generated operations, so one generation serves both synchronous and asynchronous code.
283
+ Each client forwards every argument but the first (the request) to its transport, type checked against the transport's signature.
284
+
285
+ As an example, the bookshop's asynchronous transport uses [httpx2](https://pydantic.dev/docs/httpx2) and accepts a `timeout` (and nothing else):
286
+
287
+ <!-- excerpt: bookshop/async_transport.py -->
288
+
289
+ ```python
290
+ http = httpx2.AsyncClient(base_url="https://bookshop.example")
291
+ HEADERS = {"Accept": mime_type.GRAPHQL_RESPONSE, "Content-Type": mime_type.JSON}
292
+
293
+
294
+ async def transport(body: bytes, /, *, timeout: float | None = None) -> bytes:
295
+ response = await http.post(
296
+ "/graphql", content=body, headers=HEADERS, timeout=timeout
297
+ )
298
+
299
+ # GraphQL over HTTP sends a request error as a response with a 4xx status.
300
+ if not response.headers.get("Content-Type", "").startswith(
301
+ mime_type.GRAPHQL_RESPONSE
302
+ ):
303
+ response.raise_for_status()
304
+
305
+ return response.content
306
+ ```
307
+
308
+ A call through a client over this transport can thus pass a `timeout`:
309
+
310
+ <!-- file: bookshop/async_app.py -->
311
+
312
+ ```python
313
+ from bookshop.app_graphql import GetBook
314
+ from bookshop.async_transport import AsyncClient
315
+ from bookshop.scalar import ISBN
316
+
317
+
318
+ async def title(isbn: ISBN, /, *, client: AsyncClient) -> str:
319
+ data = await client(GetBook({"lookup": {"isbn": isbn}}), timeout=5.0)
320
+ return data["book"]["title"]
321
+ ```
322
+
323
+ [`async_transport.py`](https://github.com/tibdex/graphql-codegen/blob/main/bookshop/async_transport.py) also streams a subscription's Server-Sent Events, and [`transport.py`](https://github.com/tibdex/graphql-codegen/blob/main/bookshop/transport.py) does both with the standard library alone.
324
+
325
+ ### Subscriptions
326
+
327
+ A subscription client works over any transport yielding one body per event, [Server-Sent Events](https://github.com/graphql/graphql-over-http/blob/main/rfcs/GraphQLOverSSE.md), [`graphql-transport-ws`](https://github.com/graphql/graphql-over-http/blob/main/rfcs/GraphQLOverWebSocket.md), or [multipart HTTP](https://github.com/graphql/graphql-over-http/blob/main/rfcs/IncrementalDelivery.md) alike:
328
+
329
+ <!-- excerpt: bookshop/app.py -->
330
+
331
+ ```python
332
+ def watch(order_id: str, /, *, client: SubscriptionClient) -> list[OrderStatus]:
333
+ """Follow the order until it is delivered, and return its statuses."""
334
+ statuses: list[OrderStatus] = []
335
+ events = client(OnOrderStatusChanged({"orderId": order_id}))
336
+
337
+ # Closing the stream, however the loop ends, unsubscribes.
338
+ with closing(events):
339
+ for event in events:
340
+ statuses.append(event["orderStatusChanged"]["status"])
341
+
342
+ if statuses[-1] == "DELIVERED":
343
+ break
344
+
345
+ return statuses
346
+ ```
347
+
348
+ ### Merging
349
+
350
+ Merging composes requests at runtime from operations written ahead of time, so every result keeps its exact type.
351
+ A document built at runtime could only be typed loosely.
352
+
353
+ A tuple of queries, or of mutations, runs in one call to the transport, each result typed by its own operation:
354
+
355
+ <!-- excerpt: bookshop/app.py -->
356
+
357
+ ```python
358
+ def book_and_similar(
359
+ isbn: ISBN, text: str, /, *, client: Client
360
+ ) -> tuple[str, list[str]]:
361
+ # Two queries in one call to the transport.
362
+ book_data, search_data = client(
363
+ (
364
+ GetBook({"lookup": {"isbn": isbn}}),
365
+ Search({"text": text}),
366
+ )
367
+ )
368
+ assert_type(book_data, GetBookData)
369
+ assert_type(search_data, SearchData)
370
+ ```
371
+
372
+ A list built at runtime also runs in one call to the transport, whatever its length.
373
+ Its results then share one type, the union of its operations' data types:
374
+
375
+ <!-- excerpt: bookshop/app.py -->
376
+
377
+ ```python
378
+ def look_up(
379
+ isbns: Sequence[ISBN], publication_ids: Sequence[str], /, *, client: Client
380
+ ) -> tuple[GetBookData | GetPublicationData, ...]:
381
+ """Fetch the books, then the publications, in one call to the transport."""
382
+ return client( # ty: ignore[unsound-return-statement]
383
+ [
384
+ *(GetBook({"lookup": {"isbn": isbn}}) for isbn in isbns),
385
+ *(GetPublication({"id": id_}) for id_ in publication_ids),
386
+ ]
387
+ )
388
+ ```
389
+
390
+ ### Errors
391
+
392
+ A response with errors raises an `ExecutionError`:
393
+
394
+ <!-- excerpt: bookshop/client/runtime/error.py -->
395
+
396
+ ```python
397
+ class ExecutionError(ResponseError, Generic[_Data_co]):
398
+ """The server raised errors executing the request, but sent the rest of the data.
399
+
400
+ A field that raised is `null`, as is its nearest nullable parent if it is non-null.
401
+ """
402
+
403
+ data: Final[Mapping[str, object] | None]
404
+
405
+ def parse_data(self) -> _Data_co | None:
406
+ """Return the data converted as in a response without errors, in a fresh copy.
407
+ ```
408
+
409
+ When several operations were merged, an `ExceptionGroup` holds one for each operation that failed.
410
+
411
+ You can also have the client return the error instead of raising it, by calling `returning_error()` on the request:
412
+
413
+ - the result is then typed as either the data or the error, which you tell apart before using it;
414
+ - in a [merge](#merging), you choose for each request whether its error is returned or raised;
415
+ - a subscription carries on past an event with errors.
416
+
417
+ <!-- excerpt: bookshop/app.py -->
418
+
419
+ ```python
420
+ def cancel(order_ids: list[str], /, *, client: Client) -> list[str]:
421
+ """Cancel the orders in one call to the transport, and explain each failure."""
422
+ results = client(
423
+ [
424
+ CancelOrder({"input": {"order": order_id}}).returning_error()
425
+ for order_id in order_ids
426
+ ]
427
+ )
428
+ # Each error has a note naming its operation and the variables sent.
429
+ return [
430
+ f"{error.__notes__[0]} {error!s}"
431
+ for error in results
432
+ if isinstance(error, ExecutionError)
433
+ ]
434
+
435
+
436
+ def track(order_id: str, /, *, client: Client) -> str:
437
+ result = client(GetOrder({"id": order_id}).returning_error())
438
+
439
+ if isinstance(result, ExecutionError):
440
+ data = result.parse_data()
441
+ assert_type(data, GetOrderData | None)
442
+ return f"Partially loaded: {data} ({result!s})."
443
+ ```
444
+
445
+ ## Config
446
+
447
+ ### Custom scalars
448
+
449
+ [Custom scalars](https://spec.graphql.org/September2025/#sec-Scalars.Custom-Scalars) travel as JSON values of the server's choosing, such as a date as a string:
450
+
451
+ <!-- excerpt: bookshop/graphql.config.yml -->
452
+
453
+ ```yaml
454
+ extensions:
455
+ pythonCodegen:
456
+ # …
457
+ # Each custom scalar's Python type, with its codec if any.
458
+ scalars:
459
+ DateTime:
460
+ type: datetime.datetime
461
+ codec:
462
+ decode: ..scalar.decode_datetime
463
+ encode: ..scalar.encode_datetime
464
+ ISBN:
465
+ type: ..scalar.ISBN
466
+ Money:
467
+ type: decimal.Decimal
468
+ codec:
469
+ decode: decimal.Decimal
470
+ encode: str
471
+ UUID:
472
+ type: uuid.UUID
473
+ codec:
474
+ decode: uuid.UUID
475
+ encode: str
476
+ ```
477
+
478
+ - a path starting with `..` is relative to the directory holding the package;
479
+ - a bare name, such as `str`, is a builtin;
480
+ - an unconfigured custom scalar is typed `object`.
481
+
482
+ Where no existing callable fits, write the codec yourself:
483
+
484
+ <!-- file: bookshop/scalar.py -->
485
+
486
+ ```python
487
+ from datetime import datetime
488
+ from typing import NewType
489
+
490
+ ISBN = NewType("ISBN", str)
491
+ """A string on the wire and in Python that a type checker tells apart from others."""
492
+
493
+
494
+ def decode_datetime(value: str, /) -> datetime:
495
+ return datetime.fromisoformat(value)
496
+
497
+
498
+ def encode_datetime(value: datetime, /) -> str:
499
+ return value.isoformat()
500
+ ```
501
+
502
+ > [!TIP]
503
+ > If your project already relies on a validation library, it can supply the codec.
504
+ > With Pydantic, for instance, build `adapter = TypeAdapter(Point)` once, then use `decode = adapter.validate_python` and `encode = partial(adapter.dump_python, mode="json")`.
505
+
506
+ Each scalar becomes a type alias, and one with a codec carries it for the client, which converts its values on the way in and out:
507
+
508
+ <!-- excerpt: bookshop/client/_scalar.py -->
509
+
510
+ ```python
511
+ import builtins as _builtins
512
+ import typing as _typing
513
+ from .runtime import _reflection
514
+ from datetime import datetime as _datetime
515
+ from ..scalar import decode_datetime as _decode_datetime
516
+ from ..scalar import encode_datetime as _encode_datetime
517
+ from ..scalar import ISBN as _ISBN
518
+ from decimal import Decimal as _Decimal
519
+ from uuid import UUID as _UUID
520
+
521
+ type DateTime = _typing.Annotated[_datetime, _reflection.Codec(decode=_decode_datetime, encode=_encode_datetime)]
522
+
523
+ type ISBN = _ISBN
524
+
525
+ type Money = _typing.Annotated[_Decimal, _reflection.Codec(decode=_Decimal, encode=_builtins.str)]
526
+
527
+ type UUID = _typing.Annotated[_UUID, _reflection.Codec(decode=_UUID, encode=_builtins.str)]
528
+ ```
529
+
530
+ <!-- excerpt: bookshop/app.py -->
531
+
532
+ ```python
533
+ def order(book_id: str, address: Address, /, *, client: Client) -> str:
534
+ data = client(
535
+ PlaceOrder(
536
+ {"input": {"lines": [{"book": book_id}], "shippingAddress": address}}
537
+ )
538
+ )
539
+ order = data["placeOrder"]
540
+ assert_type(order["total"], Decimal)
541
+ assert_type(order["placedAt"], datetime)
542
+ return f"Order {order['id']}: {order['total']:.2f} at {order['placedAt']:%H:%M}."
543
+ ```
544
+
545
+ ### Non-null fields
546
+
547
+ A directive brings the idea of [Client Controlled Nullability](https://github.com/graphql/graphql-wg/blob/main/rfcs/ClientControlledNullability.md) to any server, since only the generated code is aware of it:
548
+
549
+ <!-- excerpt: bookshop/graphql.config.yml -->
550
+
551
+ ```yaml
552
+ extensions:
553
+ pythonCodegen:
554
+ # …
555
+ # Client directive asserting a schema-nullable field is not null.
556
+ nonNullDirectiveName: nonNull
557
+ ```
558
+
559
+ Asserted on `book`, the field's type is then not optional:
560
+
561
+ <!-- excerpt: bookshop/app.graphql -->
562
+
563
+ ```graphql
564
+ query GetBook(
565
+ "An identifier or an ISBN."
566
+ $lookup: BookLookup!
567
+ $withReviews: Boolean! = false
568
+ ) {
569
+ book(lookup: $lookup) @nonNull {
570
+ ...BookCard
571
+ isbn
572
+ pageCount
573
+ reviews @include(if: $withReviews) {
574
+ rating
575
+ text
576
+ postedAt
577
+ }
578
+ }
579
+ }
580
+ ```
581
+
582
+ <!-- excerpt: bookshop/app_graphql.py -->
583
+
584
+ ```python
585
+ class GetBookData(_compat.TypedDict, closed=True):
586
+ book: _typing.Annotated[_GetBookData_book, _reflection.NON_NULL]
587
+ """The book with this identifier or ISBN."""
588
+ ```
589
+
590
+ <!-- excerpt: bookshop/app.py -->
591
+
592
+ ```python
593
+ def describe(isbn: ISBN, /, *, client: Client) -> str:
594
+ variables: GetBookVariables = {"lookup": {"isbn": isbn}}
595
+
596
+ try:
597
+ data = client(GetBook(variables))
598
+ except UnexpectedNullError as error:
599
+ assert error.path == ["book"]
600
+ assert error.__notes__ == [f"Raised by `GetBook` with variables {variables!r}."]
601
+ return "No such book."
602
+
603
+ book = data["book"]
604
+ author = book["author"]
605
+ by = "an anthology" if author is None else f"by {author['name']}"
606
+ assert_type(book["price"], Decimal)
607
+ return f"{book['title']}, {by}, costs {book['price']:.2f}."
608
+ ```
609
+
610
+ > [!NOTE]
611
+ > On an [`ExecutionError`](#errors), `data` keeps the partial data as the server sent it, while `parse_data()` moves an asserted field's null up to its nearest nullable parent rather than raising, as the server does for a non-null field.
612
+
613
+ ### Structs
614
+
615
+ A selection set has a fixed depth, so data of unbounded depth, such as a tree, can only come back as a JSON scalar.
616
+ Following the [Struct RFC](https://github.com/graphql/graphql-wg/blob/main/rfcs/Struct.md), a `Struct` types that scalar with an `input` type, which may be recursive:
617
+
618
+ <!-- excerpt: bookshop/graphql.config.yml -->
619
+
620
+ ```yaml
621
+ extensions:
622
+ pythonCodegen:
623
+ # …
624
+ # Interface of object types carrying a JSON payload shaped like an input.
625
+ structInterfaceName: Struct
626
+ ```
627
+
628
+ <!-- excerpt: bookshop/schema.graphqls -->
629
+
630
+ ```graphql
631
+ type Query {
632
+ # …
633
+ "The filter of the saved search with this name, exactly as it was saved."
634
+ savedSearch(name: String!): BookFilterStruct
635
+ }
636
+
637
+ "A JSON payload with the shape of the input type the implementation is named after."
638
+ interface Struct {
639
+ value: JSON
640
+ }
641
+
642
+ type BookFilterStruct implements Struct {
643
+ value: JSON
644
+ }
645
+
646
+ "The books meeting a condition, or a combination of conditions."
647
+ input BookFilter @oneOf {
648
+ genre: Genre
649
+ author: ID
650
+ priceBelow: Money
651
+ and: [BookFilter!]
652
+ or: [BookFilter!]
653
+ not: BookFilter
654
+ }
655
+ ```
656
+
657
+ `BookFilterStruct`'s payload is then typed by `BookFilter` (the `input` type named after the struct minus the interface's name) regardless of its depth:
658
+
659
+ <!-- excerpt: bookshop/app_graphql.py -->
660
+
661
+ ```python
662
+ class _GetSavedSearchData_savedSearch(_compat.TypedDict, closed=True):
663
+ value: _input.BookFilter | None
664
+ ```
665
+
666
+ The same definition can also type what is sent, as `ListBooks` takes a `BookFilter` too:
667
+
668
+ <!-- excerpt: bookshop/app.graphql -->
669
+
670
+ ```graphql
671
+ query ListBooks($filter: BookFilter, $first: Int) {
672
+ books(filter: $filter, first: $first) {
673
+ ...BookCard
674
+ genre
675
+ }
676
+ }
677
+ ```
678
+
679
+ <!-- excerpt: bookshop/app.py -->
680
+
681
+ ```python
682
+ def run_saved_search(name: str, /, *, client: Client) -> list[str]:
683
+ data = client(GetSavedSearch({"name": name}))
684
+ search = data["savedSearch"]
685
+
686
+ if search is None:
687
+ return []
688
+
689
+ # Sent back as is.
690
+ books = client(ListBooks({"filter": search["value"]}))
691
+ return [book["title"] for book in books["books"]]
692
+ ```
693
+
694
+ ### Injectors
695
+
696
+ Some input values are `client`'s business rather than each `client()` call's.
697
+
698
+ Take idempotency keys.
699
+ They make retries safe: if the connection drops after the server placed an order, the transport sends it again, and the key, unique to the order, tells the server it already placed it rather than charging the customer twice.
700
+ GraphQL has no built-in idempotency, so implementing it usually means adding the key as an argument or an input field of each mutation that needs it.
701
+ When many different mutation operations require idempotency, it becomes the concern of all their `client()` calls, each having to get hold of a key.
702
+ This applies to other concepts too, such as database transaction IDs.
703
+
704
+ An injector handles such a value in one place instead: when `client` is constructed.
705
+ The client then passes it to every variable or input field with the name given in the config.
706
+ No variables' type accepts it, so that no `client()` call can pass one by mistake:
707
+
708
+ <!-- excerpt: bookshop/graphql.config.yml -->
709
+
710
+ ```yaml
711
+ extensions:
712
+ pythonCodegen:
713
+ # …
714
+ # Values the client injects, which no call can pass.
715
+ injectorNames: [idempotencyKey]
716
+ ```
717
+
718
+ `PlaceOrderInput` holds one, for instance:
719
+
720
+ <!-- excerpt: bookshop/schema.graphqls -->
721
+
722
+ ```graphql
723
+ input PlaceOrderInput {
724
+ "Makes placing the same order twice harmless: the client sends a new one per order."
725
+ idempotencyKey: UUID
726
+ ```
727
+
728
+ The generated package's `injection` module types the injectors you must supply:
729
+
730
+ <!-- excerpt: bookshop/client/injection.py -->
731
+
732
+ ```python
733
+ import collections.abc as _abc
734
+ import typing as _typing
735
+ from .runtime import _compat
736
+ from .runtime import injection as _injection
737
+ from .runtime import OMITTED as _OMITTED
738
+ from . import _scalar
739
+
740
+ class InjectorFunctions(_compat.TypedDict, closed=True):
741
+ """The functions supplying each injected value, by name.
742
+
743
+ Where the value may be null, one returning `OMITTED` leaves it out, and one returning `None` sends `null`."""
744
+ idempotencyKey: _typing.NotRequired[_abc.Callable[[], _scalar.UUID | None | _OMITTED]]
745
+
746
+ def injectors(functions: InjectorFunctions, /) -> _injection._Injectors:
747
+ """Return what a client calls to supply the injected values, each serialized as its type says."""
748
+ return _injection._Injectors(functions, injector_functions_type=InjectorFunctions)
749
+ ```
750
+
751
+ The client gets its injector once, when built:
752
+
753
+ <!-- excerpt: bookshop/app.py -->
754
+
755
+ ```python
756
+ if __name__ == "__main__":
757
+ client = Client(
758
+ transport,
759
+ injectors=injectors({"idempotencyKey": uuid4}),
760
+ )
761
+ ```
762
+
763
+ And no call passes a key:
764
+
765
+ <!-- excerpt: bookshop/app.py -->
766
+
767
+ ```python
768
+ def order(book_id: str, address: Address, /, *, client: Client) -> str:
769
+ data = client(
770
+ PlaceOrder(
771
+ {"input": {"lines": [{"book": book_id}], "shippingAddress": address}}
772
+ )
773
+ )
774
+ ```
775
+
776
+ The client injects new values on each call, so a retry belongs in the transport, which sends the same body, key included, again:
777
+
778
+ <!-- excerpt: bookshop/transport.py -->
779
+
780
+ ```python
781
+ def transport(body: bytes, /, *, timeout: float | None = None) -> bytes:
782
+ retries = 2
783
+
784
+ while True:
785
+ try:
786
+ response = _post(
787
+ "/graphql", body, accept=mime_type.GRAPHQL_RESPONSE, timeout=timeout
788
+ )
789
+ except HTTPError as error:
790
+ # GraphQL over HTTP sends a request error as a response with a 4xx status.
791
+ if error.headers.get_content_type() != mime_type.GRAPHQL_RESPONSE:
792
+ raise
793
+
794
+ response = error
795
+ except ConnectionError:
796
+ # The response was lost.
797
+ if not retries:
798
+ raise
799
+
800
+ retries -= 1
801
+ continue
802
+
803
+ with response:
804
+ return response.read()
805
+ ```
806
+
807
+ ### Colocation
808
+
809
+ By default, each document's operations and fragments go into a module of the generated package's `document` subpackage, such as `document/get_order.py` for `get_order.graphql`, which the subpackage re-exports (lazily from Python 3.15).
810
+ However, each generated operation is a module-level constant rather than a method of one client class, so it can live anywhere.
811
+ In particular, it can live next to the GraphQL document it comes from, so that a feature's `.graphql` files, their generated modules, and the code calling their operations sit side by side and evolve together:
812
+
813
+ <!-- excerpt: bookshop/graphql.config.yml -->
814
+
815
+ ```yaml
816
+ extensions:
817
+ pythonCodegen:
818
+ # …
819
+ # Name pattern of each document's module, written next to the document.
820
+ documentSiblingModule: "{document}_graphql"
821
+ ```
822
+
823
+ This pattern puts `get_order.graphql`'s operations and fragments in `get_order_graphql.py`.
824
+ Code calling these operations imports them from there:
825
+
826
+ <!-- excerpt: bookshop/quickstart.py -->
827
+
828
+ ```python
829
+ from bookshop.client.schema import OrderStatus
830
+ from bookshop.get_order_graphql import GetOrder
831
+ ```
832
+
833
+ ## Python API
834
+
835
+ [`generate()`](https://github.com/tibdex/graphql-codegen/blob/main/src/graphql_codegen/generate.py) does the same as the command:
836
+
837
+ <!-- excerpt: src/graphql_codegen/generate.py -->
838
+
839
+ ```python
840
+ class _Params(TypedDict, closed=True):
841
+ document: DocumentNode
842
+ schema: GraphQLSchema
843
+ config: Config
844
+
845
+
846
+ def generate(**args: Unpack[_Params]) -> dict[PurePosixPath, bytes]:
847
+ """Pure function returning the content of each file of the generated package by its path."""
848
+ ```
849
+
850
+ The [public API](https://github.com/tibdex/graphql-codegen/blob/main/src/graphql_codegen/__init__.py) is limited to:
851
+
852
+ <!-- file: src/graphql_codegen/__init__.py -->
853
+
854
+ ```python
855
+ from graphql_codegen.config import Config as Config
856
+ from graphql_codegen.document_sibling_module import (
857
+ DocumentSiblingModule as DocumentSiblingModule,
858
+ )
859
+ from graphql_codegen.generate import generate as generate
860
+ from graphql_codegen.package_location import PackageLocation as PackageLocation
861
+ from graphql_codegen.scalar import Codec as Codec, Scalar as Scalar
862
+ ```
863
+
864
+ [^typing-extensions]: Before Python 3.15, the client also needs [`typing_extensions`](https://typing-extensions.readthedocs.io), for typing features the standard library does not have yet.
865
+
866
+ [^bootstrapping]: This library is partly [bootstrapped](https://en.wikipedia.org/wiki/Bootstrapping_(compilers)): to fetch a schema from a URL, [it uses a client](https://github.com/tibdex/graphql-codegen/blob/main/src/graphql_codegen/_cli/_introspection.py) it generated itself from [`_introspection.graphql`](https://github.com/tibdex/graphql-codegen/blob/main/src/graphql_codegen/_cli/_introspection.graphql).
867
+
868
+ [^breaking-changes]: Only a breaking change made to the schema after generation can contradict the generated types, and most never reach the code: removing or renaming a field, changing its arguments, or turning its type from an object into a leaf or the reverse invalidates the operation, so the server never runs it.
869
+ That leaves a small subset where validation would help, by failing as soon as the response arrives: a field becoming nullable, its leaf type changing, or it switching between a list and a single value.
870
+ Without validation, such a change fails only deeper in your code, if at all.
871
+ GraphQL APIs [avoid such changes](https://graphql.org/learn/schema-design/#versioning) by evolving their schema instead of breaking it, and regenerating against the deployed schema in CI catches the few that slip through.