@graphql-codegen/python-operations 0.0.0-stage → 0.0.1

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.
@@ -0,0 +1,81 @@
1
+ import { GraphQLSchema, OperationDefinitionNode } from 'graphql';
2
+ import { BaseVisitor } from '@graphql-codegen/visitor-plugin-common';
3
+ import { PythonOperationsPluginConfig } from './config.js';
4
+ /**
5
+ * Walks GraphQL `OperationDefinition` nodes against the schema and recursively
6
+ * emits named, nested Python `TypedDict` classes for every object selection set,
7
+ * plus a `<Operation>Variables` `TypedDict` for the operation variables.
8
+ *
9
+ * Unlike the C-family `SelectionSetToObject`/`DeclarationBlock` approach (which
10
+ * emits inline anonymous objects), this mirrors the C#/Java strategy: every
11
+ * nested selection becomes its own named type referenced by its parent.
12
+ */
13
+ export declare class PythonOperationsVisitor extends BaseVisitor<PythonOperationsPluginConfig, any> {
14
+ private _schema;
15
+ private _typesImport?;
16
+ private _namingConvention;
17
+ private _baseModel;
18
+ private _runtimeImport;
19
+ private _usesOptional;
20
+ private _usesAny;
21
+ private _usesLiteral;
22
+ private _usesBaseModel;
23
+ private _usesConfigDict;
24
+ private _usesField;
25
+ private _anonymousCount;
26
+ constructor(schema: GraphQLSchema, rawConfig: PythonOperationsPluginConfig);
27
+ /** PascalCase a name segment without applying any prefix/suffix. */
28
+ private pascal;
29
+ private get operationRootTypes();
30
+ /**
31
+ * Build a Python annotation from a (resolved) GraphQL output type. The
32
+ * `objectBaseName` is the generated nested type name used when the base type
33
+ * is an object/interface/union selection.
34
+ */
35
+ private pyTypeFromGraphQL;
36
+ private innerFromGraphQL;
37
+ /**
38
+ * Build a Python annotation from a variable `TypeNode`. Named types resolve
39
+ * to mapped scalars, or the bare base type name (enums / input objects).
40
+ */
41
+ private pyTypeFromTypeNode;
42
+ private innerFromTypeNode;
43
+ /**
44
+ * Build a variables `TypedDict`. Variable keys are ALWAYS the wire (GraphQL)
45
+ * name — they are sent to the server as-is. If every key is a valid Python
46
+ * attribute the readable class syntax is used; otherwise (a key is a keyword /
47
+ * non-identifier) the functional `TypedDict('Name', { 'key': '...' })` syntax
48
+ * is emitted so the offending keys remain valid.
49
+ */
50
+ private buildVariablesClass;
51
+ /**
52
+ * Emit the per-class `__init__` that pre-maps wire keys to their escaped
53
+ * attribute names before delegating to the base model. Only emitted for result
54
+ * classes that contain keyword fields.
55
+ */
56
+ /**
57
+ * Render a single result field line: `key: 'annotation'`, with a pydantic
58
+ * `Field(alias=...)` when the Python attribute differs from the wire response
59
+ * key, and a `None` default for optional fields.
60
+ */
61
+ private renderResultField;
62
+ /**
63
+ * Build a result base-model subclass. A field whose Python attribute differs
64
+ * from the wire key is pinned with `Field(alias=...)`, and a
65
+ * `model_config = ConfigDict(populate_by_name=True)` line is prepended so such
66
+ * classes can also be built by attribute name.
67
+ */
68
+ private buildResultClass;
69
+ /**
70
+ * Emit a `TypedDict` for `selectionSet` (a selection against `parentType`),
71
+ * named `typeName`. Nested object selections are emitted FIRST (deepest
72
+ * first) so references resolve top-to-bottom. Returns nothing; classes are
73
+ * pushed onto `out`.
74
+ */
75
+ private processSelectionSet;
76
+ private buildVariables;
77
+ /** Generate all types for a single operation. Returns joined class blocks. */
78
+ generateOperation(operation: OperationDefinitionNode): string;
79
+ /** Build the `prepend` import lines, emitting only what is referenced. */
80
+ getImports(): string[];
81
+ }
package/package.json CHANGED
@@ -1,6 +1,54 @@
1
1
  {
2
2
  "name": "@graphql-codegen/python-operations",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "description": "Document-driven GraphQL Code Generator plugin that emits Python operation types (per-operation result + variables).",
6
+ "repository": "git@github.com:dotansimha/graphql-code-generator-community.git",
7
+ "license": "MIT",
8
+ "engines": {
9
+ "node": ">= 16.0.0"
10
+ },
11
+ "main": "dist/cjs/index.js",
12
+ "module": "dist/esm/index.js",
13
+ "exports": {
14
+ ".": {
15
+ "require": {
16
+ "types": "./dist/typings/index.d.cts",
17
+ "default": "./dist/cjs/index.js"
18
+ },
19
+ "import": {
20
+ "types": "./dist/typings/index.d.ts",
21
+ "default": "./dist/esm/index.js"
22
+ },
23
+ "default": {
24
+ "types": "./dist/typings/index.d.ts",
25
+ "default": "./dist/esm/index.js"
26
+ }
27
+ },
28
+ "./package.json": "./package.json"
29
+ },
30
+ "typings": "dist/typings/index.d.ts",
31
+ "scripts": {
32
+ "lint": "eslint **/*.ts"
33
+ },
34
+ "peerDependencies": {
35
+ "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0"
36
+ },
37
+ "dependencies": {
38
+ "@graphql-codegen/plugin-helpers": "^6.3.0",
39
+ "@graphql-codegen/python-common": "0.0.1",
40
+ "@graphql-codegen/visitor-plugin-common": "^6.3.0",
41
+ "tslib": "^2.8.1"
42
+ },
43
+ "devDependencies": {
44
+ "@graphql-codegen/testing": "4.0.5"
45
+ },
46
+ "publishConfig": {
47
+ "directory": "dist",
48
+ "access": "public"
49
+ },
50
+ "sideEffects": false,
51
+ "typescript": {
52
+ "definition": "dist/typings/index.d.ts"
53
+ }
54
+ }
package/src/config.ts ADDED
@@ -0,0 +1,72 @@
1
+ import { RawDocumentsConfig } from '@graphql-codegen/visitor-plugin-common';
2
+
3
+ /**
4
+ * @description This plugin generates Python operation types (per-operation
5
+ * result + variables `TypedDict`s) from your GraphQL documents.
6
+ *
7
+ * It is document-driven (like `@graphql-codegen/typescript-operations`): it
8
+ * walks each `OperationDefinition` against the schema and recursively emits
9
+ * named, nested classes for every object selection set. Operation RESULT types
10
+ * become [pydantic](https://docs.pydantic.dev) `BaseModel` subclasses with
11
+ * snake_case attributes (each renamed key pinned via `Field(alias=...)`), while
12
+ * the `<Operation>Variables` type is a `TypedDict` keyed by the WIRE (GraphQL)
13
+ * variable name (variables are sent to the server as-is).
14
+ * Every annotation is quoted so forward / recursive references resolve without
15
+ * ordering constraints.
16
+ */
17
+ export interface PythonOperationsPluginConfig extends RawDocumentsConfig {
18
+ /**
19
+ * @description Controls how GraphQL response keys (field names / aliases) are
20
+ * turned into Python `BaseModel` result-class attributes. Variable keys are
21
+ * unaffected — they always keep the wire (GraphQL) name.
22
+ *
23
+ * - `'snakeCase'` (default): keys are converted from camelCase to snake_case
24
+ * and Python reserved keywords are suffixed with `_`. Each renamed key is
25
+ * pinned with `Field(alias=...)` so pydantic hydrates the original wire key
26
+ * onto the attribute.
27
+ * - `'keep'`: response keys are used verbatim; a keyword / non-identifier key
28
+ * falls back to a trailing `_` for just that field.
29
+ *
30
+ * @default "snakeCase"
31
+ */
32
+ namingConvention?: 'keep' | 'snakeCase';
33
+ /**
34
+ * @description The base class that generated operation result types extend.
35
+ * @default "BaseModel"
36
+ */
37
+ baseModel?: string;
38
+ /**
39
+ * @description The module the base model is imported from. Emitted as
40
+ * `from {runtimeImport} import {baseModel}` (with `ConfigDict` / `Field`
41
+ * appended when the generated types use aliases). Defaults to `pydantic`.
42
+ * @default "pydantic"
43
+ */
44
+ runtimeImport?: string;
45
+ /**
46
+ * @description Optional module to star-import base schema type names from.
47
+ *
48
+ * Generated field annotations reference base type names (enums, input types)
49
+ * as quoted forward references, e.g. `role: 'Role'`. By default no import for
50
+ * those names is emitted (the plugin assumes the base types live in the same
51
+ * file or are provided elsewhere). If set, the plugin prepends
52
+ * `from {typesImport} import *` so those names resolve at runtime.
53
+ *
54
+ * @exampleMarkdown
55
+ * ```ts filename="codegen.ts"
56
+ * import type { CodegenConfig } from '@graphql-codegen/cli';
57
+ *
58
+ * const config: CodegenConfig = {
59
+ * generates: {
60
+ * 'operations.py': {
61
+ * plugins: ['@graphql-codegen/python-operations'],
62
+ * config: {
63
+ * typesImport: 'my_package.schema_types',
64
+ * },
65
+ * },
66
+ * },
67
+ * };
68
+ * export default config;
69
+ * ```
70
+ */
71
+ typesImport?: string;
72
+ }
package/src/index.ts ADDED
@@ -0,0 +1,34 @@
1
+ import { concatAST, GraphQLSchema, Kind, OperationDefinitionNode } from 'graphql';
2
+ import { PluginFunction, Types } from '@graphql-codegen/plugin-helpers';
3
+ import { PythonOperationsPluginConfig } from './config.js';
4
+ import { PythonOperationsVisitor } from './visitor.js';
5
+
6
+ export * from './config.js';
7
+ export * from './visitor.js';
8
+ export * from './python-helpers.js';
9
+
10
+ /**
11
+ * Document-driven plugin: for each GraphQL operation in the provided documents,
12
+ * emit a per-operation result `TypedDict` (with named nested `TypedDict`s for
13
+ * every object selection) plus a `<Operation>Variables` `TypedDict`.
14
+ */
15
+ export const plugin: PluginFunction<PythonOperationsPluginConfig, Types.ComplexPluginOutput> = (
16
+ schema: GraphQLSchema,
17
+ documents: Types.DocumentFile[],
18
+ config: PythonOperationsPluginConfig,
19
+ ) => {
20
+ const allAst = concatAST(documents.map(d => d.document).filter(Boolean) as any);
21
+
22
+ const operations = allAst.definitions.filter(
23
+ (def): def is OperationDefinitionNode => def.kind === Kind.OPERATION_DEFINITION,
24
+ );
25
+
26
+ const visitor = new PythonOperationsVisitor(schema, config);
27
+
28
+ const definitions = operations.map(op => visitor.generateOperation(op));
29
+
30
+ return {
31
+ prepend: visitor.getImports(),
32
+ content: definitions.join('\n\n\n'),
33
+ };
34
+ };
@@ -0,0 +1,34 @@
1
+ import {
2
+ isReservedField,
3
+ isValidClassAttr,
4
+ pythonFieldName as pyFieldName,
5
+ PYTHON_SCALARS,
6
+ } from '@graphql-codegen/python-common';
7
+
8
+ export { isReservedField, isValidClassAttr, PYTHON_SCALARS, pyFieldName };
9
+
10
+ export type PythonNamingConvention = 'keep' | 'snakeCase';
11
+
12
+ /**
13
+ * Compute the attribute name for a `BaseModel` result class. Under `'snakeCase'`
14
+ * the key is snake-cased (keywords suffixed with `_`); under `'keep'` the wire
15
+ * name is used verbatim, but — because `BaseModel` classes cannot use functional
16
+ * `TypedDict` syntax — a keyword / non-identifier name still falls back to a
17
+ * trailing `_`.
18
+ */
19
+ export function resultFieldKey(name: string, convention: PythonNamingConvention): string {
20
+ if (convention === 'snakeCase') {
21
+ return pyFieldName(name);
22
+ }
23
+ return isValidClassAttr(name) ? name : `${name}_`;
24
+ }
25
+
26
+ /**
27
+ * Compute the `TypedDict` key for a GraphQL response / variable name under the
28
+ * given naming convention. Under `'keep'` the raw name is returned verbatim
29
+ * (functional `TypedDict` syntax handles invalid keys); under `'snakeCase'` the
30
+ * legacy snake_case + `_`-suffix behavior is applied.
31
+ */
32
+ export function fieldKey(name: string, convention: PythonNamingConvention): string {
33
+ return convention === 'snakeCase' ? pyFieldName(name) : name;
34
+ }
package/src/visitor.ts ADDED
@@ -0,0 +1,395 @@
1
+ import {
2
+ getNamedType,
3
+ GraphQLInterfaceType,
4
+ GraphQLObjectType,
5
+ GraphQLOutputType,
6
+ GraphQLSchema,
7
+ isEnumType,
8
+ isInterfaceType,
9
+ isListType,
10
+ isNonNullType,
11
+ isObjectType,
12
+ isScalarType,
13
+ Kind,
14
+ OperationDefinitionNode,
15
+ SelectionSetNode,
16
+ TypeNode,
17
+ VariableDefinitionNode,
18
+ } from 'graphql';
19
+ import { BaseVisitor, getConfigValue } from '@graphql-codegen/visitor-plugin-common';
20
+ import { PythonOperationsPluginConfig } from './config.js';
21
+ import {
22
+ isValidClassAttr,
23
+ PYTHON_SCALARS,
24
+ PythonNamingConvention,
25
+ resultFieldKey,
26
+ } from './python-helpers.js';
27
+
28
+ const INDENT = ' ';
29
+
30
+ /** GraphQL parent object/interface types can carry field definitions. */
31
+ type SelectableType = GraphQLObjectType | GraphQLInterfaceType;
32
+
33
+ /**
34
+ * A single class / `TypedDict` entry. `wire` is the response key on the wire and
35
+ * is set for result fields — when it differs from `key` the field is pinned with
36
+ * a pydantic `Field(alias=...)`. Variables entries leave it undefined.
37
+ */
38
+ interface PyField {
39
+ key: string;
40
+ annotation: string;
41
+ wire?: string;
42
+ }
43
+
44
+ /**
45
+ * Walks GraphQL `OperationDefinition` nodes against the schema and recursively
46
+ * emits named, nested Python `TypedDict` classes for every object selection set,
47
+ * plus a `<Operation>Variables` `TypedDict` for the operation variables.
48
+ *
49
+ * Unlike the C-family `SelectionSetToObject`/`DeclarationBlock` approach (which
50
+ * emits inline anonymous objects), this mirrors the C#/Java strategy: every
51
+ * nested selection becomes its own named type referenced by its parent.
52
+ */
53
+ export class PythonOperationsVisitor extends BaseVisitor<PythonOperationsPluginConfig, any> {
54
+ private _schema: GraphQLSchema;
55
+ private _typesImport?: string;
56
+ private _namingConvention: PythonNamingConvention;
57
+ private _baseModel: string;
58
+ private _runtimeImport: string;
59
+ private _usesOptional = false;
60
+ private _usesAny = false;
61
+ private _usesLiteral = false;
62
+ private _usesBaseModel = false;
63
+ private _usesConfigDict = false;
64
+ private _usesField = false;
65
+ private _anonymousCount = 0;
66
+
67
+ constructor(schema: GraphQLSchema, rawConfig: PythonOperationsPluginConfig) {
68
+ // Our `namingConvention` (`'keep'` | `'snakeCase'`) governs field-KEY naming
69
+ // only. It must NOT leak into the base visitor's `namingConvention`, which
70
+ // drives TYPE-name conversion (`convertName` / `pascal`) — those names must
71
+ // stay PascalCase (e.g. `GetUsersQueryUsers`).
72
+ super({ ...rawConfig, namingConvention: undefined }, {});
73
+ this._schema = schema;
74
+ this._typesImport = rawConfig.typesImport;
75
+ this._namingConvention = getConfigValue(
76
+ rawConfig.namingConvention,
77
+ 'snakeCase',
78
+ ) as PythonNamingConvention;
79
+ this._baseModel = getConfigValue(rawConfig.baseModel, 'BaseModel') as string;
80
+ this._runtimeImport = getConfigValue(rawConfig.runtimeImport, 'pydantic') as string;
81
+ }
82
+
83
+ /** PascalCase a name segment without applying any prefix/suffix. */
84
+ private pascal(name: string): string {
85
+ return this.convertName(name, {
86
+ useTypesPrefix: false,
87
+ useTypesSuffix: false,
88
+ });
89
+ }
90
+
91
+ private get operationRootTypes(): Record<string, SelectableType | null | undefined> {
92
+ return {
93
+ query: this._schema.getQueryType(),
94
+ mutation: this._schema.getMutationType(),
95
+ subscription: this._schema.getSubscriptionType(),
96
+ };
97
+ }
98
+
99
+ // --- Python annotation builders ----------------------------------------
100
+
101
+ /**
102
+ * Build a Python annotation from a (resolved) GraphQL output type. The
103
+ * `objectBaseName` is the generated nested type name used when the base type
104
+ * is an object/interface/union selection.
105
+ */
106
+ private pyTypeFromGraphQL(type: GraphQLOutputType, objectBaseName: string): string {
107
+ if (isNonNullType(type)) {
108
+ return this.innerFromGraphQL(type.ofType, objectBaseName);
109
+ }
110
+ this._usesOptional = true;
111
+ return `Optional[${this.innerFromGraphQL(type, objectBaseName)}]`;
112
+ }
113
+
114
+ private innerFromGraphQL(type: GraphQLOutputType, objectBaseName: string): string {
115
+ if (isListType(type)) {
116
+ return `list[${this.pyTypeFromGraphQL(type.ofType, objectBaseName)}]`;
117
+ }
118
+ const named = getNamedType(type);
119
+ if (isScalarType(named)) {
120
+ const mapped = PYTHON_SCALARS[named.name];
121
+ if (mapped) {
122
+ return mapped.output;
123
+ }
124
+ this._usesAny = true;
125
+ return 'Any';
126
+ }
127
+ if (isEnumType(named)) {
128
+ return named.name;
129
+ }
130
+ // object / interface / union selection -> generated nested type name
131
+ return objectBaseName;
132
+ }
133
+
134
+ /**
135
+ * Build a Python annotation from a variable `TypeNode`. Named types resolve
136
+ * to mapped scalars, or the bare base type name (enums / input objects).
137
+ */
138
+ private pyTypeFromTypeNode(type: TypeNode): string {
139
+ if (type.kind === Kind.NON_NULL_TYPE) {
140
+ return this.innerFromTypeNode(type.type);
141
+ }
142
+ this._usesOptional = true;
143
+ return `Optional[${this.innerFromTypeNode(type)}]`;
144
+ }
145
+
146
+ private innerFromTypeNode(type: TypeNode): string {
147
+ if (type.kind === Kind.LIST_TYPE) {
148
+ return `list[${this.pyTypeFromTypeNode(type.type)}]`;
149
+ }
150
+ if (type.kind === Kind.NON_NULL_TYPE) {
151
+ // Non-null base (e.g. inside a list) — no Optional wrapper.
152
+ return this.innerFromTypeNode(type.type);
153
+ }
154
+ // NamedType
155
+ const name = type.name.value;
156
+ const mapped = PYTHON_SCALARS[name];
157
+ if (mapped) {
158
+ return mapped.input;
159
+ }
160
+ const schemaType = this._schema.getType(name);
161
+ if (schemaType && isScalarType(schemaType)) {
162
+ this._usesAny = true;
163
+ return 'Any';
164
+ }
165
+ // enum or input object -> bare name (quoted forward ref by caller)
166
+ return name;
167
+ }
168
+
169
+ // --- Class builders ----------------------------------------------------
170
+
171
+ /**
172
+ * Build a variables `TypedDict`. Variable keys are ALWAYS the wire (GraphQL)
173
+ * name — they are sent to the server as-is. If every key is a valid Python
174
+ * attribute the readable class syntax is used; otherwise (a key is a keyword /
175
+ * non-identifier) the functional `TypedDict('Name', { 'key': '...' })` syntax
176
+ * is emitted so the offending keys remain valid.
177
+ */
178
+ private buildVariablesClass(name: string, fields: PyField[]): string {
179
+ if (fields.length === 0) {
180
+ return `class ${name}(TypedDict):\n${INDENT}pass`;
181
+ }
182
+ const needsFunctional = fields.some(f => !isValidClassAttr(f.key));
183
+ if (needsFunctional) {
184
+ const entries = fields.map(f => `${INDENT}'${f.key}': '${f.annotation}',`);
185
+ return [`${name} = TypedDict('${name}', {`, ...entries, '})'].join('\n');
186
+ }
187
+ const lines = fields.map(f => `${INDENT}${f.key}: '${f.annotation}'`);
188
+ return [`class ${name}(TypedDict):`, ...lines].join('\n');
189
+ }
190
+
191
+ /**
192
+ * Emit the per-class `__init__` that pre-maps wire keys to their escaped
193
+ * attribute names before delegating to the base model. Only emitted for result
194
+ * classes that contain keyword fields.
195
+ */
196
+ /**
197
+ * Render a single result field line: `key: 'annotation'`, with a pydantic
198
+ * `Field(alias=...)` when the Python attribute differs from the wire response
199
+ * key, and a `None` default for optional fields.
200
+ */
201
+ private renderResultField(field: PyField): string {
202
+ const optional = field.annotation.startsWith('Optional[');
203
+ let assignment = '';
204
+ if (field.wire !== undefined && field.key !== field.wire) {
205
+ this._usesField = true;
206
+ assignment = optional
207
+ ? ` = Field(default=None, alias='${field.wire}')`
208
+ : ` = Field(alias='${field.wire}')`;
209
+ } else if (optional) {
210
+ assignment = ' = None';
211
+ }
212
+ return `${field.key}: '${field.annotation}'${assignment}`;
213
+ }
214
+
215
+ /**
216
+ * Build a result base-model subclass. A field whose Python attribute differs
217
+ * from the wire key is pinned with `Field(alias=...)`, and a
218
+ * `model_config = ConfigDict(populate_by_name=True)` line is prepended so such
219
+ * classes can also be built by attribute name.
220
+ */
221
+ private buildResultClass(name: string, fields: PyField[]): string {
222
+ this._usesBaseModel = true;
223
+ const header = `class ${name}(${this._baseModel}):`;
224
+ if (fields.length === 0) {
225
+ return `${header}\n${INDENT}pass`;
226
+ }
227
+ const lines = [header];
228
+ if (fields.some(f => f.wire !== undefined && f.key !== f.wire)) {
229
+ this._usesConfigDict = true;
230
+ lines.push(`${INDENT}model_config = ConfigDict(populate_by_name=True)`);
231
+ }
232
+ for (const field of fields) {
233
+ lines.push(`${INDENT}${this.renderResultField(field)}`);
234
+ }
235
+ return lines.join('\n');
236
+ }
237
+
238
+ // --- Selection set walking ---------------------------------------------
239
+
240
+ /**
241
+ * Emit a `TypedDict` for `selectionSet` (a selection against `parentType`),
242
+ * named `typeName`. Nested object selections are emitted FIRST (deepest
243
+ * first) so references resolve top-to-bottom. Returns nothing; classes are
244
+ * pushed onto `out`.
245
+ */
246
+ private processSelectionSet(
247
+ parentType: SelectableType,
248
+ selectionSet: SelectionSetNode,
249
+ typeName: string,
250
+ out: string[],
251
+ ): void {
252
+ const fields: PyField[] = [];
253
+
254
+ const addField = (responseKey: string, annotation: string): void => {
255
+ const key = resultFieldKey(responseKey, this._namingConvention);
256
+ // Record the wire (response) key so a `Field(alias=...)` is emitted when
257
+ // the Python attribute differs (snake_case conversion or keyword escape).
258
+ fields.push({ key, annotation, wire: responseKey });
259
+ };
260
+
261
+ for (const selection of selectionSet.selections) {
262
+ // Fragments (spread / inline) are not supported yet. Fail loudly instead
263
+ // of silently omitting their fields, which would otherwise produce a
264
+ // result type that looks valid but is missing data.
265
+ if (selection.kind === Kind.FRAGMENT_SPREAD) {
266
+ throw new Error(
267
+ `@graphql-codegen/python-operations: fragment spread "...${selection.name.value}" on ` +
268
+ `"${parentType.name}" is not supported yet. Inline the fragment's fields directly into ` +
269
+ 'the operation for now.',
270
+ );
271
+ }
272
+ if (selection.kind === Kind.INLINE_FRAGMENT) {
273
+ const onType = selection.typeCondition?.name.value ?? parentType.name;
274
+ throw new Error(
275
+ `@graphql-codegen/python-operations: inline fragment "... on ${onType}" on ` +
276
+ `"${parentType.name}" is not supported yet. Union/interface member selections cannot ` +
277
+ 'be generated; select only common fields for now.',
278
+ );
279
+ }
280
+ const fieldNode = selection;
281
+ const fieldName = fieldNode.name.value;
282
+ const responseKey = fieldNode.alias?.value ?? fieldName;
283
+
284
+ if (fieldName === '__typename') {
285
+ // `__typename` selections become a `Literal["ParentType"]` attribute.
286
+ // pydantic forbids a leading-underscore field name, so the attribute is
287
+ // `typename__` with `Field(alias='__typename')` pinning the wire key.
288
+ this._usesLiteral = true;
289
+ fields.push({
290
+ key: 'typename__',
291
+ annotation: `Literal["${parentType.name}"]`,
292
+ wire: '__typename',
293
+ });
294
+ continue;
295
+ }
296
+
297
+ const fieldDef = parentType.getFields()[fieldName];
298
+ if (!fieldDef) {
299
+ // Unknown field (e.g. introspection on a type without it) — skip.
300
+ continue;
301
+ }
302
+
303
+ const fieldType = fieldDef.type;
304
+ const namedType = getNamedType(fieldType);
305
+
306
+ if ((isObjectType(namedType) || isInterfaceType(namedType)) && fieldNode.selectionSet) {
307
+ const nestedName = `${typeName}${this.pascal(responseKey)}`;
308
+ // Recurse FIRST so deepest types are emitted before this one.
309
+ this.processSelectionSet(
310
+ namedType as SelectableType,
311
+ fieldNode.selectionSet,
312
+ nestedName,
313
+ out,
314
+ );
315
+ addField(responseKey, this.pyTypeFromGraphQL(fieldType, nestedName));
316
+ } else {
317
+ // Scalar / enum leaf (or union without object handling).
318
+ addField(responseKey, this.pyTypeFromGraphQL(fieldType, namedType.name));
319
+ }
320
+ }
321
+
322
+ out.push(this.buildResultClass(typeName, fields));
323
+ }
324
+
325
+ private buildVariables(
326
+ variableDefinitions: readonly VariableDefinitionNode[],
327
+ variablesTypeName: string,
328
+ ): string {
329
+ const fields: PyField[] = variableDefinitions.map(def => {
330
+ // Variable keys are ALWAYS the wire name (sent to the server as-is).
331
+ const name = def.variable.name.value;
332
+ const annotation = this.pyTypeFromTypeNode(def.type);
333
+ return { key: name, annotation };
334
+ });
335
+ return this.buildVariablesClass(variablesTypeName, fields);
336
+ }
337
+
338
+ /** Generate all types for a single operation. Returns joined class blocks. */
339
+ public generateOperation(operation: OperationDefinitionNode): string {
340
+ const operationType = operation.operation; // 'query' | 'mutation' | 'subscription'
341
+ const operationTypeName = operationType.charAt(0).toUpperCase() + operationType.slice(1);
342
+
343
+ const operationName = operation.name?.value ?? `Anonymous${this._anonymousCount++}`;
344
+ const baseName = `${this.pascal(operationName)}${operationTypeName}`;
345
+
346
+ const rootType = this.operationRootTypes[operationType];
347
+ const out: string[] = [];
348
+
349
+ if (rootType) {
350
+ this.processSelectionSet(rootType, operation.selectionSet, baseName, out);
351
+ }
352
+
353
+ out.push(this.buildVariables(operation.variableDefinitions ?? [], `${baseName}Variables`));
354
+
355
+ return out.join('\n\n\n');
356
+ }
357
+
358
+ /** Build the `prepend` import lines, emitting only what is referenced. */
359
+ public getImports(): string[] {
360
+ const imports: string[] = [];
361
+
362
+ const typingParts: string[] = [];
363
+ if (this._usesOptional) {
364
+ typingParts.push('Optional');
365
+ }
366
+ // Every operation emits at least a variables TypedDict.
367
+ typingParts.push('TypedDict');
368
+ if (this._usesLiteral) {
369
+ typingParts.push('Literal');
370
+ }
371
+ if (this._usesAny) {
372
+ typingParts.push('Any');
373
+ }
374
+ imports.push(`from typing import ${typingParts.join(', ')}`);
375
+
376
+ if (this._usesBaseModel) {
377
+ // `ConfigDict` / `Field` are pydantic names; emit them from the same module
378
+ // as the base model, and only when the generated content uses them.
379
+ const parts = [this._baseModel];
380
+ if (this._usesConfigDict) {
381
+ parts.push('ConfigDict');
382
+ }
383
+ if (this._usesField) {
384
+ parts.push('Field');
385
+ }
386
+ imports.push(`from ${this._runtimeImport} import ${parts.join(', ')}`);
387
+ }
388
+
389
+ if (this._typesImport) {
390
+ imports.push(`from ${this._typesImport} import *`);
391
+ }
392
+
393
+ return imports;
394
+ }
395
+ }