@graphql-codegen/python 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.
package/src/visitor.ts ADDED
@@ -0,0 +1,346 @@
1
+ import autoBind from 'auto-bind';
2
+ import {
3
+ EnumTypeDefinitionNode,
4
+ FieldDefinitionNode,
5
+ GraphQLSchema,
6
+ InputObjectTypeDefinitionNode,
7
+ InputValueDefinitionNode,
8
+ InterfaceTypeDefinitionNode,
9
+ ListTypeNode,
10
+ NamedTypeNode,
11
+ NonNullTypeNode,
12
+ ObjectTypeDefinitionNode,
13
+ UnionTypeDefinitionNode,
14
+ } from 'graphql';
15
+ import { isValidClassAttr, PYTHON_SCALARS, pythonFieldName } from '@graphql-codegen/python-common';
16
+ import {
17
+ BaseTypesVisitor,
18
+ getConfigValue,
19
+ ParsedTypesConfig,
20
+ } from '@graphql-codegen/visitor-plugin-common';
21
+ import { PythonPluginConfig } from './config.js';
22
+
23
+ export { PYTHON_SCALARS };
24
+
25
+ const INDENT = ' ';
26
+
27
+ export type PythonNamingConvention = 'keep' | 'snakeCase';
28
+
29
+ export interface PythonPluginParsedConfig extends ParsedTypesConfig {
30
+ baseModel: string;
31
+ runtimeImport: string;
32
+ pyNamingConvention: PythonNamingConvention;
33
+ }
34
+
35
+ export class PythonVisitor<
36
+ TRawConfig extends PythonPluginConfig = PythonPluginConfig,
37
+ TParsedConfig extends PythonPluginParsedConfig = PythonPluginParsedConfig,
38
+ > extends BaseTypesVisitor<TRawConfig, TParsedConfig> {
39
+ private _usesEnum = false;
40
+ private _usesTypedDict = false;
41
+ private _usesBaseModel = false;
42
+ private _usesUnion = false;
43
+
44
+ constructor(schema: GraphQLSchema, pluginConfig: TRawConfig) {
45
+ // `namingConvention` here is our own field-naming option (`'keep'` |
46
+ // `'snakeCase'`) and must NOT leak into the base visitor's `namingConvention`
47
+ // (which controls TYPE-name conversion via `convertFactory`). Strip it before
48
+ // calling `super` so type names keep their default (PascalCase) conversion.
49
+ super(
50
+ schema,
51
+ // Route custom scalars (no explicit mapping) to `Any` instead of `unknown`.
52
+ {
53
+ ...pluginConfig,
54
+ namingConvention: undefined,
55
+ defaultScalarType: getConfigValue(pluginConfig.defaultScalarType, 'Any'),
56
+ },
57
+ {
58
+ baseModel: getConfigValue(pluginConfig.baseModel, 'BaseModel'),
59
+ runtimeImport: getConfigValue(pluginConfig.runtimeImport, 'pydantic'),
60
+ pyNamingConvention: getConfigValue(pluginConfig.namingConvention, 'snakeCase'),
61
+ } as TParsedConfig,
62
+ PYTHON_SCALARS,
63
+ );
64
+
65
+ autoBind(this);
66
+ }
67
+
68
+ /** Python has no `export` keyword; disable the C-family export prefix. */
69
+ protected getExportPrefix(): string {
70
+ return '';
71
+ }
72
+
73
+ protected _getScalar(name: string, type: 'input' | 'output'): string {
74
+ const mapped = this.scalars[name];
75
+ if (mapped) {
76
+ return mapped[type];
77
+ }
78
+ return 'Any';
79
+ }
80
+
81
+ // --- Nullability / list composition (emit Python instead of TS) ---
82
+
83
+ NamedType(node: NamedTypeNode, key: any, parent: any, path: any, ancestors: any): string {
84
+ return `Optional[${super.NamedType(node, key, parent, path, ancestors)}]`;
85
+ }
86
+
87
+ ListType(node: ListTypeNode, key: any, parent: any, path: any, ancestors: any): string {
88
+ return `Optional[${super.ListType(node, key, parent, path, ancestors)}]`;
89
+ }
90
+
91
+ protected wrapWithListType(str: string): string {
92
+ return `list[${str}]`;
93
+ }
94
+
95
+ NonNullType(node: NonNullTypeNode): string {
96
+ const baseValue = super.NonNullType(node);
97
+ if (baseValue.startsWith('Optional[') && baseValue.endsWith(']')) {
98
+ return baseValue.slice('Optional['.length, -1);
99
+ }
100
+ return baseValue;
101
+ }
102
+
103
+ // --- Field rendering (name: 'annotation') ---
104
+
105
+ private snakeFieldName(name: string): string {
106
+ return pythonFieldName(name);
107
+ }
108
+
109
+ /**
110
+ * Field key for a `TypedDict` (input type). Input types are what the caller
111
+ * SENDS — the runtime forwards the dict to the server as-is — so they ALWAYS
112
+ * keep the wire (GraphQL) name regardless of `namingConvention`. If the name
113
+ * is a keyword / non-identifier the containing `TypedDict` is later emitted
114
+ * with functional syntax (see `buildTypedDict`).
115
+ */
116
+ private dictFieldName(name: string): string {
117
+ return name;
118
+ }
119
+
120
+ /**
121
+ * Field key for an object / interface type (a class attribute). Under `'keep'`
122
+ * a keyword / non-identifier name cannot be a class attribute and BaseModel
123
+ * cannot use functional `TypedDict` syntax, so we fall back to appending `_`
124
+ * for just that field.
125
+ */
126
+ private objectFieldName(name: string): string {
127
+ if (this.config.pyNamingConvention === 'snakeCase') {
128
+ return this.snakeFieldName(name);
129
+ }
130
+ return isValidClassAttr(name) ? name : `${name}_`;
131
+ }
132
+
133
+ FieldDefinition(node: FieldDefinitionNode): string {
134
+ if (this.config.onlyEnums) return '';
135
+ const wire = node.name.value;
136
+ const attr = this.objectFieldName(wire);
137
+ // The rendered annotation (e.g. `Optional[str]`, `list[User]`). Quoted below
138
+ // so forward / recursive references resolve without ordering constraints.
139
+ const annotation = node.type as unknown as string;
140
+ const optional = annotation.startsWith('Optional[');
141
+ // When the Python attribute differs from the GraphQL wire key (snake_case
142
+ // conversion or a keyword escaped with `_`), pin the wire key with a pydantic
143
+ // `Field(alias=...)` so hydration maps it onto the attribute. Optional fields
144
+ // also default to `None` so a missing key is allowed.
145
+ let assignment = '';
146
+ if (attr !== wire) {
147
+ assignment = optional
148
+ ? ` = Field(default=None, alias='${wire}')`
149
+ : ` = Field(alias='${wire}')`;
150
+ } else if (optional) {
151
+ assignment = ' = None';
152
+ }
153
+ return `${attr}: '${annotation}'${assignment}`;
154
+ }
155
+
156
+ InputValueDefinition(node: InputValueDefinitionNode): string {
157
+ if (this.config.onlyEnums) return '';
158
+ return `${this.dictFieldName(node.name.value)}: '${node.type as unknown as string}'`;
159
+ }
160
+
161
+ // --- Class / block builder ---
162
+
163
+ private buildClass(name: string, base: string, fields: readonly string[]): string {
164
+ const header = `class ${name}(${base}):`;
165
+ const body = fields.filter(Boolean);
166
+ if (body.length === 0) {
167
+ return `${header}\n${INDENT}pass`;
168
+ }
169
+ return [header, ...body.map(f => INDENT + f)].join('\n');
170
+ }
171
+
172
+ /**
173
+ * Build a base-model subclass. Fields are class-level annotations; if any field
174
+ * pins a wire key with `Field(alias=...)`, a
175
+ * `model_config = ConfigDict(populate_by_name=True)` line is prepended so the
176
+ * class can be constructed by attribute name as well as by alias.
177
+ */
178
+ private buildBaseModelClass(name: string, fields: readonly string[]): string {
179
+ const header = `class ${name}(${this.config.baseModel}):`;
180
+ const body = fields.filter(Boolean);
181
+ if (body.length === 0) {
182
+ return `${header}\n${INDENT}pass`;
183
+ }
184
+ const lines = [header];
185
+ if (body.some(f => f.includes('alias='))) {
186
+ lines.push(`${INDENT}model_config = ConfigDict(populate_by_name=True)`);
187
+ }
188
+ lines.push(...body.map(f => INDENT + f));
189
+ return lines.join('\n');
190
+ }
191
+
192
+ /**
193
+ * Split a rendered `key: 'annotation'` field back into its parts. The key is
194
+ * always a GraphQL Name (no `: '`) and the annotation is always single-quoted,
195
+ * so splitting on the first `: '` is unambiguous.
196
+ */
197
+ private splitField(rendered: string): { key: string; annotation: string } {
198
+ const sep = rendered.indexOf(": '");
199
+ const key = rendered.slice(0, sep);
200
+ const annotation = rendered.slice(sep + 3, -1);
201
+ return { key, annotation };
202
+ }
203
+
204
+ /**
205
+ * Build a `TypedDict`. If every key is a valid Python attribute, the readable
206
+ * class syntax is used; otherwise (a key is a keyword / non-identifier under
207
+ * `'keep'`) the functional `TypedDict('Name', { 'key': 'annotation' })` syntax
208
+ * is emitted so the offending keys remain valid.
209
+ */
210
+ private buildTypedDict(name: string, fields: readonly string[]): string {
211
+ const body = fields.filter(Boolean);
212
+ const parsed = body.map(f => this.splitField(f));
213
+ const needsFunctional = parsed.some(p => !isValidClassAttr(p.key));
214
+ if (!needsFunctional) {
215
+ return this.buildClass(name, 'TypedDict', body);
216
+ }
217
+ const entries = parsed.map(p => `${INDENT}'${p.key}': '${p.annotation}',`);
218
+ return [`${name} = TypedDict('${name}', {`, ...entries, '})'].join('\n');
219
+ }
220
+
221
+ ObjectTypeDefinition(node: ObjectTypeDefinitionNode): string {
222
+ if (this.config.onlyOperationTypes || this.config.onlyEnums) return '';
223
+ if (node.name.value.startsWith('__')) return '';
224
+ this._usesBaseModel = true;
225
+ return this.buildBaseModelClass(this.convertName(node), node.fields as unknown as string[]);
226
+ }
227
+
228
+ InterfaceTypeDefinition(node: InterfaceTypeDefinitionNode): string {
229
+ if (this.config.onlyOperationTypes || this.config.onlyEnums) return '';
230
+ if (node.name.value.startsWith('__')) return '';
231
+ this._usesBaseModel = true;
232
+ return this.buildBaseModelClass(this.convertName(node), node.fields as unknown as string[]);
233
+ }
234
+
235
+ InputObjectTypeDefinition(node: InputObjectTypeDefinitionNode): string {
236
+ if (this.config.onlyEnums) return '';
237
+ if (node.name.value.startsWith('__')) return '';
238
+ this._usesTypedDict = true;
239
+ // Under `'keep'`, a keyword / non-identifier field key forces functional
240
+ // `TypedDict` syntax; otherwise readable class syntax is used.
241
+ return this.buildTypedDict(this.convertName(node), node.fields as unknown as string[]);
242
+ }
243
+
244
+ EnumTypeDefinition(node: EnumTypeDefinitionNode): string {
245
+ if (node.name.value.startsWith('__')) return '';
246
+ this._usesEnum = true;
247
+ const name = this.convertName(node, {
248
+ useTypesPrefix: this.config.enumPrefix,
249
+ useTypesSuffix: this.config.enumSuffix,
250
+ });
251
+ const members = (node.values ?? []).map(
252
+ value => `${INDENT}${value.name.value} = '${value.name.value}'`,
253
+ );
254
+ const body = members.length > 0 ? members : [`${INDENT}pass`];
255
+ return [`class ${name}(str, Enum):`, ...body].join('\n');
256
+ }
257
+
258
+ /** Scalars are handled inline via the scalar map; no standalone class. */
259
+ ScalarTypeDefinition(): string {
260
+ return '';
261
+ }
262
+
263
+ /**
264
+ * `union Foo = A | B` -> `Foo = Union['A', 'B']`.
265
+ *
266
+ * Must be overridden: `BaseTypesVisitor`'s implementation emits TypeScript
267
+ * (`export type Foo = A | B;`), and it dereferences the node description
268
+ * unguarded, so an undocumented union crashes the generator outright. A schema
269
+ * merely *containing* a union is enough to hit this, even when no operation
270
+ * selects one.
271
+ *
272
+ * Member names come from `parent[key]` — the original, un-visited node — the
273
+ * same way `typescript`'s visitor reads them. By `leave` time `node.types`
274
+ * has already been rendered into annotations (`Optional[Foo]`) and would
275
+ * otherwise be mangled. Members are quoted so a union declared before its
276
+ * members still resolves, matching how every other annotation in this plugin
277
+ * is emitted.
278
+ *
279
+ * Note this only makes a union *declaration* valid Python. Actually selecting
280
+ * union members in an operation needs inline-fragment support, which
281
+ * `python-operations` does not implement yet (it throws).
282
+ */
283
+ UnionTypeDefinition(
284
+ node: UnionTypeDefinitionNode,
285
+ key: string | number | undefined,
286
+ parent: any,
287
+ ): string {
288
+ if (this.config.onlyOperationTypes || this.config.onlyEnums) return '';
289
+ if (node.name.value.startsWith('__')) return '';
290
+
291
+ const originalNode = parent[key] as UnionTypeDefinitionNode;
292
+ const members = (originalNode.types ?? []).map(t => `'${this.convertName(t)}'`);
293
+
294
+ // A member-less union is legal to declare but has no inhabitant.
295
+ if (members.length === 0) {
296
+ return `${this.convertName(node)} = Any`;
297
+ }
298
+
299
+ this._usesUnion = true;
300
+ return `${this.convertName(node)} = Union[${members.join(', ')}]`;
301
+ }
302
+
303
+ /**
304
+ * Build the `prepend` import lines, emitting only what the generated
305
+ * `content` actually references.
306
+ */
307
+ getImports(content: string): string[] {
308
+ const imports: string[] = [];
309
+
310
+ if (this._usesEnum) {
311
+ imports.push('from enum import Enum');
312
+ }
313
+
314
+ const typingParts: string[] = [];
315
+ if (content.includes('Optional[')) {
316
+ typingParts.push('Optional');
317
+ }
318
+ if (this._usesTypedDict) {
319
+ typingParts.push('TypedDict');
320
+ }
321
+ if (this._usesUnion) {
322
+ typingParts.push('Union');
323
+ }
324
+ if (/\bAny\b/.test(content)) {
325
+ typingParts.push('Any');
326
+ }
327
+ if (typingParts.length > 0) {
328
+ imports.push(`from typing import ${typingParts.join(', ')}`);
329
+ }
330
+
331
+ if (this._usesBaseModel) {
332
+ // `ConfigDict` / `Field` are pydantic names; emit them from the same module
333
+ // as the base model, and only when the generated content uses them.
334
+ const parts = [this.config.baseModel];
335
+ if (content.includes('ConfigDict(')) {
336
+ parts.push('ConfigDict');
337
+ }
338
+ if (content.includes('Field(')) {
339
+ parts.push('Field');
340
+ }
341
+ imports.push(`from ${this.config.runtimeImport} import ${parts.join(', ')}`);
342
+ }
343
+
344
+ return imports;
345
+ }
346
+ }
@@ -0,0 +1,37 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`@graphql-codegen/python > matches the full schema snapshot 1`] = `
4
+ "from enum import Enum
5
+ from typing import Optional, TypedDict, Any
6
+ from pydantic import BaseModel, ConfigDict, Field
7
+
8
+
9
+ class Role(str, Enum):
10
+ ADMIN = 'ADMIN'
11
+ USER = 'USER'
12
+
13
+
14
+ class Node(BaseModel):
15
+ id: 'str'
16
+
17
+
18
+ class User(BaseModel):
19
+ model_config = ConfigDict(populate_by_name=True)
20
+ id: 'str'
21
+ name: 'str'
22
+ email: 'Optional[str]' = None
23
+ role: 'Role'
24
+ friends: 'list[User]'
25
+ created_at: 'Optional[Any]' = Field(default=None, alias='createdAt')
26
+
27
+
28
+ UserFilter = TypedDict('UserFilter', {
29
+ 'role': 'Optional[Role]',
30
+ 'from': 'Optional[str]',
31
+ })
32
+
33
+
34
+ class Query(BaseModel):
35
+ users: 'list[User]'
36
+ "
37
+ `;
@@ -0,0 +1,286 @@
1
+ import { buildSchema } from 'graphql';
2
+ import { describe, expect, it } from 'vitest';
3
+ import type { PythonPluginConfig } from '../src/config.js';
4
+ import { plugin } from '../src/index.js';
5
+
6
+ const FULL_SCHEMA = /* GraphQL */ `
7
+ scalar DateTime
8
+ enum Role {
9
+ ADMIN
10
+ USER
11
+ }
12
+ interface Node {
13
+ id: ID!
14
+ }
15
+ type User implements Node {
16
+ id: ID!
17
+ name: String!
18
+ email: String
19
+ role: Role!
20
+ friends: [User!]!
21
+ createdAt: DateTime
22
+ }
23
+ input UserFilter {
24
+ role: Role
25
+ from: String
26
+ }
27
+ type Query {
28
+ users(filter: UserFilter): [User!]!
29
+ }
30
+ `;
31
+
32
+ function run(schemaSDL: string, config: PythonPluginConfig = {}) {
33
+ const schema = buildSchema(schemaSDL);
34
+ const result = plugin(schema, [], config) as { prepend: string[]; content: string };
35
+ return result;
36
+ }
37
+
38
+ function full(schemaSDL: string, config: PythonPluginConfig = {}): string {
39
+ const { prepend, content } = run(schemaSDL, config);
40
+ return `${prepend.join('\n')}\n\n\n${content}\n`;
41
+ }
42
+
43
+ describe('@graphql-codegen/python', () => {
44
+ it('maps built-in scalars to native Python types and strips Optional on non-null', () => {
45
+ const { content } = run(`
46
+ type Q { a: ID! b: String! c: Int! d: Float! e: Boolean! }
47
+ `);
48
+ expect(content).toContain(`a: 'str'`);
49
+ expect(content).toContain(`b: 'str'`);
50
+ expect(content).toContain(`c: 'int'`);
51
+ expect(content).toContain(`d: 'float'`);
52
+ expect(content).toContain(`e: 'bool'`);
53
+ });
54
+
55
+ it('renders enums as (str, Enum) classes', () => {
56
+ const { content, prepend } = run(`enum Role { ADMIN USER } type Q { r: Role! }`);
57
+ expect(content).toContain('class Role(str, Enum):');
58
+ expect(content).toContain(` ADMIN = 'ADMIN'`);
59
+ expect(content).toContain(` USER = 'USER'`);
60
+ expect(prepend).toContain('from enum import Enum');
61
+ });
62
+
63
+ it('renders object types as BaseModel classes', () => {
64
+ const { content, prepend } = run(`type User { id: ID! name: String! }`);
65
+ expect(content).toContain('class User(BaseModel):');
66
+ expect(content).toContain(` id: 'str'`);
67
+ expect(content).toContain(` name: 'str'`);
68
+ expect(prepend).toContain('from pydantic import BaseModel');
69
+ });
70
+
71
+ it('renders interfaces as BaseModel classes', () => {
72
+ const { content } = run(`interface Node { id: ID! }`);
73
+ expect(content).toContain('class Node(BaseModel):');
74
+ expect(content).toContain(` id: 'str'`);
75
+ });
76
+
77
+ it('renders input types as TypedDict', () => {
78
+ const { content, prepend } = run(`input UserFilter { role: String }`);
79
+ expect(content).toContain('class UserFilter(TypedDict):');
80
+ expect(prepend).toContain('from typing import Optional, TypedDict');
81
+ });
82
+
83
+ it('maps custom scalars to Any', () => {
84
+ const { content, prepend } = run(`scalar DateTime type Q { at: DateTime }`);
85
+ expect(content).toContain(`at: 'Optional[Any]'`);
86
+ // no standalone class for the scalar
87
+ expect(content).not.toContain('class DateTime');
88
+ expect(prepend.some(l => l.includes('Any'))).toBe(true);
89
+ });
90
+
91
+ it('honors config.scalars overrides for custom scalars', () => {
92
+ const { content } = run(`scalar DateTime type Q { at: DateTime! }`, {
93
+ scalars: { DateTime: 'datetime' },
94
+ });
95
+ expect(content).toContain(`at: 'datetime'`);
96
+ });
97
+
98
+ it('converts object field names to snake_case by default (namingConvention: snakeCase)', () => {
99
+ const { content } = run(`type User { createdAt: String firstName: String! }`);
100
+ expect(content).toContain(`created_at: 'Optional[str]'`);
101
+ expect(content).toContain(`first_name: 'str'`);
102
+ });
103
+
104
+ it('emits a functional TypedDict when an input key is a reserved keyword (inputs keep wire names)', () => {
105
+ const { content, prepend } = run(`input F { from: String role: String }`);
106
+ // `from` is a Python keyword -> whole input type uses functional syntax.
107
+ expect(content).toContain(`F = TypedDict('F', {`);
108
+ expect(content).toContain(` 'from': 'Optional[str]',`);
109
+ expect(content).toContain(` 'role': 'Optional[str]',`);
110
+ expect(content).not.toContain(`class F(TypedDict):`);
111
+ expect(prepend).toContain('from typing import Optional, TypedDict');
112
+ });
113
+
114
+ it('keeps input keys as wire names (never snake-cased), class syntax when valid', () => {
115
+ const { content } = run(`input UserFilter { role: String createdAt: String }`);
116
+ expect(content).toContain(`class UserFilter(TypedDict):`);
117
+ expect(content).toContain(` role: 'Optional[str]'`);
118
+ // Input keys keep the WIRE name (the runtime sends the dict as-is).
119
+ expect(content).toContain(` createdAt: 'Optional[str]'`);
120
+ expect(content).not.toContain(`TypedDict('UserFilter'`);
121
+ });
122
+
123
+ it('input keys keep wire names even under namingConvention: snakeCase', () => {
124
+ const { content } = run(`input F { from: String createdAt: String }`, {
125
+ namingConvention: 'snakeCase',
126
+ });
127
+ expect(content).toContain(`F = TypedDict('F', {`);
128
+ expect(content).toContain(` 'from': 'Optional[str]',`);
129
+ expect(content).toContain(` 'createdAt': 'Optional[str]',`);
130
+ });
131
+
132
+ it('escapes keyword fields on object types and pins the wire key with Field(alias=...)', () => {
133
+ // Object types extend a pydantic BaseModel; a keyword field is escaped with
134
+ // `_` and `Field(alias=...)` pins the original wire key so pydantic hydrates
135
+ // it onto the escaped attribute. `model_config` enables population by name.
136
+ const { content, prepend } = run(
137
+ `type Message { from: String! createdAt: String! class: Int }`,
138
+ );
139
+ expect(content).toContain(`class Message(BaseModel):`);
140
+ expect(content).toContain(` model_config = ConfigDict(populate_by_name=True)`);
141
+ expect(content).toContain(` from_: 'str' = Field(alias='from')`);
142
+ expect(content).toContain(` created_at: 'str' = Field(alias='createdAt')`);
143
+ expect(content).toContain(` class_: 'Optional[int]' = Field(default=None, alias='class')`);
144
+ expect(content).not.toContain(`def __init__`);
145
+ expect(prepend).toContain('from pydantic import BaseModel, ConfigDict, Field');
146
+ });
147
+
148
+ it('namingConvention: keep leaves object field names verbatim (keyword -> _ + alias)', () => {
149
+ const { content } = run(`type User { createdAt: String firstName: String! from: String }`, {
150
+ namingConvention: 'keep',
151
+ });
152
+ expect(content).toContain(`createdAt: 'Optional[str]' = None`);
153
+ expect(content).toContain(`firstName: 'str'`);
154
+ expect(content).toContain(`from_: 'Optional[str]' = Field(default=None, alias='from')`);
155
+ expect(content).not.toContain(`def __init__`);
156
+ });
157
+
158
+ it('composes nullability and lists correctly', () => {
159
+ const { content } = run(`
160
+ type User { id: ID! }
161
+ type Q {
162
+ nn: String!
163
+ opt: String
164
+ reqList: [User!]!
165
+ nullListNullItem: [User]
166
+ }
167
+ `);
168
+ expect(content).toContain(`nn: 'str'`);
169
+ expect(content).toContain(`opt: 'Optional[str]'`);
170
+ expect(content).toContain(`req_list: 'list[User]'`);
171
+ expect(content).toContain(`null_list_null_item: 'Optional[list[Optional[User]]]'`);
172
+ });
173
+
174
+ it('verifies onlyOperationTypes emits enums + inputs + scalars and skips object types', () => {
175
+ const { content } = run(
176
+ `scalar DateTime
177
+ enum Role { ADMIN USER }
178
+ input UserFilter { from: String role: Role }
179
+ type Message { from: String! createdAt: DateTime! }
180
+ type Query { m: Message }`,
181
+ { onlyOperationTypes: true },
182
+ );
183
+ // enums + input types present...
184
+ expect(content).toContain(`class Role(str, Enum):`);
185
+ expect(content).toContain(`UserFilter = TypedDict('UserFilter', {`);
186
+ // ...object types skipped.
187
+ expect(content).not.toContain(`class Message`);
188
+ expect(content).not.toContain(`class Query`);
189
+ expect(content).not.toContain('BaseModel');
190
+ });
191
+
192
+ it('supports recursive types via quoted forward references', () => {
193
+ const { content } = run(`type User { id: ID! friends: [User!]! }`);
194
+ expect(content).toContain('class User(BaseModel):');
195
+ expect(content).toContain(`friends: 'list[User]'`);
196
+ });
197
+
198
+ it('honors baseModel and runtimeImport config', () => {
199
+ const { content, prepend } = run(`type User { id: ID! }`, {
200
+ baseModel: 'PydModel',
201
+ runtimeImport: 'myapp.models',
202
+ });
203
+ expect(content).toContain('class User(PydModel):');
204
+ expect(prepend).toContain('from myapp.models import PydModel');
205
+ });
206
+
207
+ it('only emits imports that are actually used', () => {
208
+ // No enum, no input, no Any -> only Optional + BaseModel
209
+ const { prepend } = run(`type User { name: String }`);
210
+ expect(prepend).not.toContain('from enum import Enum');
211
+ expect(prepend).toContain('from typing import Optional');
212
+ expect(prepend).toContain('from pydantic import BaseModel');
213
+ });
214
+
215
+ it('does not emit introspection types', () => {
216
+ const { content } = run(FULL_SCHEMA);
217
+ expect(content).not.toContain('__Schema');
218
+ expect(content).not.toContain('__Type');
219
+ });
220
+
221
+ describe('unions', () => {
222
+ const UNION_SCHEMA = /* GraphQL */ `
223
+ type A {
224
+ a: String
225
+ }
226
+ type B {
227
+ b: Int
228
+ }
229
+ union AB = A | B
230
+ type Query {
231
+ thing: AB
232
+ }
233
+ `;
234
+
235
+ it('emits a typing.Union alias with quoted members', () => {
236
+ const { content, prepend } = run(UNION_SCHEMA);
237
+ expect(content).toContain(`Ab = Union['A', 'B']`);
238
+ expect(prepend.some(l => /from typing import .*\bUnion\b/.test(l))).toBe(true);
239
+ });
240
+
241
+ it('never emits TypeScript for a union', () => {
242
+ // The inherited BaseTypesVisitor implementation emits `export type Ab = A | B;`
243
+ // plus a `/** ... */` comment — neither is valid Python.
244
+ const { content } = run(UNION_SCHEMA);
245
+ expect(content).not.toContain('export type');
246
+ expect(content).not.toContain('/**');
247
+ });
248
+
249
+ it('does not crash on a union without a description', () => {
250
+ // The inherited implementation dereferences the description unguarded.
251
+ expect(() => run(UNION_SCHEMA)).not.toThrow();
252
+ });
253
+
254
+ it('handles a documented union the same way', () => {
255
+ const { content } = run(/* GraphQL */ `
256
+ type A {
257
+ a: String
258
+ }
259
+ type B {
260
+ b: Int
261
+ }
262
+ "Documented."
263
+ union AB = A | B
264
+ type Query {
265
+ thing: AB
266
+ }
267
+ `);
268
+ expect(content).toContain(`Ab = Union['A', 'B']`);
269
+ expect(content).not.toContain('export type');
270
+ });
271
+
272
+ it('is skipped under onlyOperationTypes / onlyEnums', () => {
273
+ expect(run(UNION_SCHEMA, { onlyOperationTypes: true }).content).not.toContain('Ab = Union');
274
+ expect(run(UNION_SCHEMA, { onlyEnums: true }).content).not.toContain('Ab = Union');
275
+ });
276
+
277
+ it('references the union from a field that selects it', () => {
278
+ const { content } = run(UNION_SCHEMA);
279
+ expect(content).toContain(`thing: 'Optional[Ab]'`);
280
+ });
281
+ });
282
+
283
+ it('matches the full schema snapshot', () => {
284
+ expect(full(FULL_SCHEMA)).toMatchSnapshot();
285
+ });
286
+ });
@@ -0,0 +1,12 @@
1
+ import { defineProject, mergeConfig } from 'vitest/config';
2
+ import { sharedConfig } from '../../../../vitest.config.js';
3
+
4
+ export default mergeConfig(
5
+ sharedConfig,
6
+ defineProject({
7
+ test: {
8
+ name: 'python',
9
+ include: ['**/*.spec.ts'],
10
+ },
11
+ }),
12
+ );