@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/README.md +22 -2
- package/dist/README.md +23 -0
- package/dist/cjs/config.js +0 -0
- package/dist/cjs/index.js +21 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/visitor.js +289 -0
- package/dist/esm/config.js +0 -0
- package/dist/esm/index.js +16 -0
- package/dist/esm/visitor.js +284 -0
- package/dist/package.json +45 -0
- package/dist/typings/config.d.cts +110 -0
- package/dist/typings/config.d.ts +110 -0
- package/dist/typings/index.d.cts +5 -0
- package/dist/typings/index.d.ts +5 -0
- package/dist/typings/visitor.d.cts +96 -0
- package/dist/typings/visitor.d.ts +96 -0
- package/package.json +53 -4
- package/src/config.ts +111 -0
- package/src/index.ts +32 -0
- package/src/visitor.ts +346 -0
- package/tests/__snapshots__/python.spec.ts.snap +37 -0
- package/tests/python.spec.ts +286 -0
- package/vitest.config.ts +12 -0
package/README.md
CHANGED
|
@@ -1,3 +1,23 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @graphql-codegen/python
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A **schema-driven** [GraphQL Code Generator](https://the-guild.dev/graphql/codegen) plugin that
|
|
4
|
+
emits **Python base types** from a `GraphQLSchema`:
|
|
5
|
+
|
|
6
|
+
- object & interface types → [pydantic](https://docs.pydantic.dev) `BaseModel` classes (snake_case
|
|
7
|
+
attributes; keys that differ from the GraphQL name are pinned with `Field(alias=...)` +
|
|
8
|
+
`model_config = ConfigDict(populate_by_name=True)`)
|
|
9
|
+
- input types → `TypedDict`
|
|
10
|
+
- enums → `class X(str, Enum)`
|
|
11
|
+
- scalars → native Python (`ID`/`String` → `str`, `Int` → `int`, `Float` → `float`, `Boolean` →
|
|
12
|
+
`bool`, custom → `Any`)
|
|
13
|
+
|
|
14
|
+
It is the base-types counterpart to `@graphql-codegen/python-operations`, analogous to how
|
|
15
|
+
`typescript` underpins `typescript-operations`.
|
|
16
|
+
|
|
17
|
+
## Config
|
|
18
|
+
|
|
19
|
+
| Option | Type | Default | Description |
|
|
20
|
+
| ------------------ | ----------------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
21
|
+
| `baseModel` | `string` | `'BaseModel'` | Base class the generated object/interface types extend. |
|
|
22
|
+
| `runtimeImport` | `string` | `'pydantic'` | Module the base model is imported from (`from {runtimeImport} import {baseModel}`, with `ConfigDict`/`Field` appended when aliases are used). |
|
|
23
|
+
| `namingConvention` | `'keep' \| 'snakeCase'` | `'snakeCase'` | How field names become attributes (`snakeCase` converts and pins renamed keys with `Field(alias=...)`). |
|
package/dist/README.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# @graphql-codegen/python
|
|
2
|
+
|
|
3
|
+
A **schema-driven** [GraphQL Code Generator](https://the-guild.dev/graphql/codegen) plugin that
|
|
4
|
+
emits **Python base types** from a `GraphQLSchema`:
|
|
5
|
+
|
|
6
|
+
- object & interface types → [pydantic](https://docs.pydantic.dev) `BaseModel` classes (snake_case
|
|
7
|
+
attributes; keys that differ from the GraphQL name are pinned with `Field(alias=...)` +
|
|
8
|
+
`model_config = ConfigDict(populate_by_name=True)`)
|
|
9
|
+
- input types → `TypedDict`
|
|
10
|
+
- enums → `class X(str, Enum)`
|
|
11
|
+
- scalars → native Python (`ID`/`String` → `str`, `Int` → `int`, `Float` → `float`, `Boolean` →
|
|
12
|
+
`bool`, custom → `Any`)
|
|
13
|
+
|
|
14
|
+
It is the base-types counterpart to `@graphql-codegen/python-operations`, analogous to how
|
|
15
|
+
`typescript` underpins `typescript-operations`.
|
|
16
|
+
|
|
17
|
+
## Config
|
|
18
|
+
|
|
19
|
+
| Option | Type | Default | Description |
|
|
20
|
+
| ------------------ | ----------------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
21
|
+
| `baseModel` | `string` | `'BaseModel'` | Base class the generated object/interface types extend. |
|
|
22
|
+
| `runtimeImport` | `string` | `'pydantic'` | Module the base model is imported from (`from {runtimeImport} import {baseModel}`, with `ConfigDict`/`Field` appended when aliases are used). |
|
|
23
|
+
| `namingConvention` | `'keep' \| 'snakeCase'` | `'snakeCase'` | How field names become attributes (`snakeCase` converts and pins renamed keys with `Field(alias=...)`). |
|
|
File without changes
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.plugin = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const plugin_helpers_1 = require("@graphql-codegen/plugin-helpers");
|
|
6
|
+
const visitor_js_1 = require("./visitor.js");
|
|
7
|
+
tslib_1.__exportStar(require("./config.js"), exports);
|
|
8
|
+
tslib_1.__exportStar(require("./visitor.js"), exports);
|
|
9
|
+
const plugin = (schema, _documents, config) => {
|
|
10
|
+
const astNode = (0, plugin_helpers_1.getCachedDocumentNodeFromSchema)(schema);
|
|
11
|
+
const visitor = new visitor_js_1.PythonVisitor(schema, config);
|
|
12
|
+
const visitorResult = (0, plugin_helpers_1.oldVisit)(astNode, { leave: visitor });
|
|
13
|
+
const content = visitorResult.definitions
|
|
14
|
+
.filter((def) => typeof def === 'string' && def.length > 0)
|
|
15
|
+
.join('\n\n\n');
|
|
16
|
+
return {
|
|
17
|
+
prepend: visitor.getImports(content),
|
|
18
|
+
content,
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
exports.plugin = plugin;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"type":"commonjs"}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.PythonVisitor = exports.PYTHON_SCALARS = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const auto_bind_1 = tslib_1.__importDefault(require("auto-bind"));
|
|
6
|
+
const python_common_1 = require("@graphql-codegen/python-common");
|
|
7
|
+
Object.defineProperty(exports, "PYTHON_SCALARS", { enumerable: true, get: function () { return python_common_1.PYTHON_SCALARS; } });
|
|
8
|
+
const visitor_plugin_common_1 = require("@graphql-codegen/visitor-plugin-common");
|
|
9
|
+
const INDENT = ' ';
|
|
10
|
+
class PythonVisitor extends visitor_plugin_common_1.BaseTypesVisitor {
|
|
11
|
+
constructor(schema, pluginConfig) {
|
|
12
|
+
// `namingConvention` here is our own field-naming option (`'keep'` |
|
|
13
|
+
// `'snakeCase'`) and must NOT leak into the base visitor's `namingConvention`
|
|
14
|
+
// (which controls TYPE-name conversion via `convertFactory`). Strip it before
|
|
15
|
+
// calling `super` so type names keep their default (PascalCase) conversion.
|
|
16
|
+
super(schema,
|
|
17
|
+
// Route custom scalars (no explicit mapping) to `Any` instead of `unknown`.
|
|
18
|
+
{
|
|
19
|
+
...pluginConfig,
|
|
20
|
+
namingConvention: undefined,
|
|
21
|
+
defaultScalarType: (0, visitor_plugin_common_1.getConfigValue)(pluginConfig.defaultScalarType, 'Any'),
|
|
22
|
+
}, {
|
|
23
|
+
baseModel: (0, visitor_plugin_common_1.getConfigValue)(pluginConfig.baseModel, 'BaseModel'),
|
|
24
|
+
runtimeImport: (0, visitor_plugin_common_1.getConfigValue)(pluginConfig.runtimeImport, 'pydantic'),
|
|
25
|
+
pyNamingConvention: (0, visitor_plugin_common_1.getConfigValue)(pluginConfig.namingConvention, 'snakeCase'),
|
|
26
|
+
}, python_common_1.PYTHON_SCALARS);
|
|
27
|
+
this._usesEnum = false;
|
|
28
|
+
this._usesTypedDict = false;
|
|
29
|
+
this._usesBaseModel = false;
|
|
30
|
+
this._usesUnion = false;
|
|
31
|
+
(0, auto_bind_1.default)(this);
|
|
32
|
+
}
|
|
33
|
+
/** Python has no `export` keyword; disable the C-family export prefix. */
|
|
34
|
+
getExportPrefix() {
|
|
35
|
+
return '';
|
|
36
|
+
}
|
|
37
|
+
_getScalar(name, type) {
|
|
38
|
+
const mapped = this.scalars[name];
|
|
39
|
+
if (mapped) {
|
|
40
|
+
return mapped[type];
|
|
41
|
+
}
|
|
42
|
+
return 'Any';
|
|
43
|
+
}
|
|
44
|
+
// --- Nullability / list composition (emit Python instead of TS) ---
|
|
45
|
+
NamedType(node, key, parent, path, ancestors) {
|
|
46
|
+
return `Optional[${super.NamedType(node, key, parent, path, ancestors)}]`;
|
|
47
|
+
}
|
|
48
|
+
ListType(node, key, parent, path, ancestors) {
|
|
49
|
+
return `Optional[${super.ListType(node, key, parent, path, ancestors)}]`;
|
|
50
|
+
}
|
|
51
|
+
wrapWithListType(str) {
|
|
52
|
+
return `list[${str}]`;
|
|
53
|
+
}
|
|
54
|
+
NonNullType(node) {
|
|
55
|
+
const baseValue = super.NonNullType(node);
|
|
56
|
+
if (baseValue.startsWith('Optional[') && baseValue.endsWith(']')) {
|
|
57
|
+
return baseValue.slice('Optional['.length, -1);
|
|
58
|
+
}
|
|
59
|
+
return baseValue;
|
|
60
|
+
}
|
|
61
|
+
// --- Field rendering (name: 'annotation') ---
|
|
62
|
+
snakeFieldName(name) {
|
|
63
|
+
return (0, python_common_1.pythonFieldName)(name);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Field key for a `TypedDict` (input type). Input types are what the caller
|
|
67
|
+
* SENDS — the runtime forwards the dict to the server as-is — so they ALWAYS
|
|
68
|
+
* keep the wire (GraphQL) name regardless of `namingConvention`. If the name
|
|
69
|
+
* is a keyword / non-identifier the containing `TypedDict` is later emitted
|
|
70
|
+
* with functional syntax (see `buildTypedDict`).
|
|
71
|
+
*/
|
|
72
|
+
dictFieldName(name) {
|
|
73
|
+
return name;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Field key for an object / interface type (a class attribute). Under `'keep'`
|
|
77
|
+
* a keyword / non-identifier name cannot be a class attribute and BaseModel
|
|
78
|
+
* cannot use functional `TypedDict` syntax, so we fall back to appending `_`
|
|
79
|
+
* for just that field.
|
|
80
|
+
*/
|
|
81
|
+
objectFieldName(name) {
|
|
82
|
+
if (this.config.pyNamingConvention === 'snakeCase') {
|
|
83
|
+
return this.snakeFieldName(name);
|
|
84
|
+
}
|
|
85
|
+
return (0, python_common_1.isValidClassAttr)(name) ? name : `${name}_`;
|
|
86
|
+
}
|
|
87
|
+
FieldDefinition(node) {
|
|
88
|
+
if (this.config.onlyEnums)
|
|
89
|
+
return '';
|
|
90
|
+
const wire = node.name.value;
|
|
91
|
+
const attr = this.objectFieldName(wire);
|
|
92
|
+
// The rendered annotation (e.g. `Optional[str]`, `list[User]`). Quoted below
|
|
93
|
+
// so forward / recursive references resolve without ordering constraints.
|
|
94
|
+
const annotation = node.type;
|
|
95
|
+
const optional = annotation.startsWith('Optional[');
|
|
96
|
+
// When the Python attribute differs from the GraphQL wire key (snake_case
|
|
97
|
+
// conversion or a keyword escaped with `_`), pin the wire key with a pydantic
|
|
98
|
+
// `Field(alias=...)` so hydration maps it onto the attribute. Optional fields
|
|
99
|
+
// also default to `None` so a missing key is allowed.
|
|
100
|
+
let assignment = '';
|
|
101
|
+
if (attr !== wire) {
|
|
102
|
+
assignment = optional
|
|
103
|
+
? ` = Field(default=None, alias='${wire}')`
|
|
104
|
+
: ` = Field(alias='${wire}')`;
|
|
105
|
+
}
|
|
106
|
+
else if (optional) {
|
|
107
|
+
assignment = ' = None';
|
|
108
|
+
}
|
|
109
|
+
return `${attr}: '${annotation}'${assignment}`;
|
|
110
|
+
}
|
|
111
|
+
InputValueDefinition(node) {
|
|
112
|
+
if (this.config.onlyEnums)
|
|
113
|
+
return '';
|
|
114
|
+
return `${this.dictFieldName(node.name.value)}: '${node.type}'`;
|
|
115
|
+
}
|
|
116
|
+
// --- Class / block builder ---
|
|
117
|
+
buildClass(name, base, fields) {
|
|
118
|
+
const header = `class ${name}(${base}):`;
|
|
119
|
+
const body = fields.filter(Boolean);
|
|
120
|
+
if (body.length === 0) {
|
|
121
|
+
return `${header}\n${INDENT}pass`;
|
|
122
|
+
}
|
|
123
|
+
return [header, ...body.map(f => INDENT + f)].join('\n');
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Build a base-model subclass. Fields are class-level annotations; if any field
|
|
127
|
+
* pins a wire key with `Field(alias=...)`, a
|
|
128
|
+
* `model_config = ConfigDict(populate_by_name=True)` line is prepended so the
|
|
129
|
+
* class can be constructed by attribute name as well as by alias.
|
|
130
|
+
*/
|
|
131
|
+
buildBaseModelClass(name, fields) {
|
|
132
|
+
const header = `class ${name}(${this.config.baseModel}):`;
|
|
133
|
+
const body = fields.filter(Boolean);
|
|
134
|
+
if (body.length === 0) {
|
|
135
|
+
return `${header}\n${INDENT}pass`;
|
|
136
|
+
}
|
|
137
|
+
const lines = [header];
|
|
138
|
+
if (body.some(f => f.includes('alias='))) {
|
|
139
|
+
lines.push(`${INDENT}model_config = ConfigDict(populate_by_name=True)`);
|
|
140
|
+
}
|
|
141
|
+
lines.push(...body.map(f => INDENT + f));
|
|
142
|
+
return lines.join('\n');
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Split a rendered `key: 'annotation'` field back into its parts. The key is
|
|
146
|
+
* always a GraphQL Name (no `: '`) and the annotation is always single-quoted,
|
|
147
|
+
* so splitting on the first `: '` is unambiguous.
|
|
148
|
+
*/
|
|
149
|
+
splitField(rendered) {
|
|
150
|
+
const sep = rendered.indexOf(": '");
|
|
151
|
+
const key = rendered.slice(0, sep);
|
|
152
|
+
const annotation = rendered.slice(sep + 3, -1);
|
|
153
|
+
return { key, annotation };
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Build a `TypedDict`. If every key is a valid Python attribute, the readable
|
|
157
|
+
* class syntax is used; otherwise (a key is a keyword / non-identifier under
|
|
158
|
+
* `'keep'`) the functional `TypedDict('Name', { 'key': 'annotation' })` syntax
|
|
159
|
+
* is emitted so the offending keys remain valid.
|
|
160
|
+
*/
|
|
161
|
+
buildTypedDict(name, fields) {
|
|
162
|
+
const body = fields.filter(Boolean);
|
|
163
|
+
const parsed = body.map(f => this.splitField(f));
|
|
164
|
+
const needsFunctional = parsed.some(p => !(0, python_common_1.isValidClassAttr)(p.key));
|
|
165
|
+
if (!needsFunctional) {
|
|
166
|
+
return this.buildClass(name, 'TypedDict', body);
|
|
167
|
+
}
|
|
168
|
+
const entries = parsed.map(p => `${INDENT}'${p.key}': '${p.annotation}',`);
|
|
169
|
+
return [`${name} = TypedDict('${name}', {`, ...entries, '})'].join('\n');
|
|
170
|
+
}
|
|
171
|
+
ObjectTypeDefinition(node) {
|
|
172
|
+
if (this.config.onlyOperationTypes || this.config.onlyEnums)
|
|
173
|
+
return '';
|
|
174
|
+
if (node.name.value.startsWith('__'))
|
|
175
|
+
return '';
|
|
176
|
+
this._usesBaseModel = true;
|
|
177
|
+
return this.buildBaseModelClass(this.convertName(node), node.fields);
|
|
178
|
+
}
|
|
179
|
+
InterfaceTypeDefinition(node) {
|
|
180
|
+
if (this.config.onlyOperationTypes || this.config.onlyEnums)
|
|
181
|
+
return '';
|
|
182
|
+
if (node.name.value.startsWith('__'))
|
|
183
|
+
return '';
|
|
184
|
+
this._usesBaseModel = true;
|
|
185
|
+
return this.buildBaseModelClass(this.convertName(node), node.fields);
|
|
186
|
+
}
|
|
187
|
+
InputObjectTypeDefinition(node) {
|
|
188
|
+
if (this.config.onlyEnums)
|
|
189
|
+
return '';
|
|
190
|
+
if (node.name.value.startsWith('__'))
|
|
191
|
+
return '';
|
|
192
|
+
this._usesTypedDict = true;
|
|
193
|
+
// Under `'keep'`, a keyword / non-identifier field key forces functional
|
|
194
|
+
// `TypedDict` syntax; otherwise readable class syntax is used.
|
|
195
|
+
return this.buildTypedDict(this.convertName(node), node.fields);
|
|
196
|
+
}
|
|
197
|
+
EnumTypeDefinition(node) {
|
|
198
|
+
var _a;
|
|
199
|
+
if (node.name.value.startsWith('__'))
|
|
200
|
+
return '';
|
|
201
|
+
this._usesEnum = true;
|
|
202
|
+
const name = this.convertName(node, {
|
|
203
|
+
useTypesPrefix: this.config.enumPrefix,
|
|
204
|
+
useTypesSuffix: this.config.enumSuffix,
|
|
205
|
+
});
|
|
206
|
+
const members = ((_a = node.values) !== null && _a !== void 0 ? _a : []).map(value => `${INDENT}${value.name.value} = '${value.name.value}'`);
|
|
207
|
+
const body = members.length > 0 ? members : [`${INDENT}pass`];
|
|
208
|
+
return [`class ${name}(str, Enum):`, ...body].join('\n');
|
|
209
|
+
}
|
|
210
|
+
/** Scalars are handled inline via the scalar map; no standalone class. */
|
|
211
|
+
ScalarTypeDefinition() {
|
|
212
|
+
return '';
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* `union Foo = A | B` -> `Foo = Union['A', 'B']`.
|
|
216
|
+
*
|
|
217
|
+
* Must be overridden: `BaseTypesVisitor`'s implementation emits TypeScript
|
|
218
|
+
* (`export type Foo = A | B;`), and it dereferences the node description
|
|
219
|
+
* unguarded, so an undocumented union crashes the generator outright. A schema
|
|
220
|
+
* merely *containing* a union is enough to hit this, even when no operation
|
|
221
|
+
* selects one.
|
|
222
|
+
*
|
|
223
|
+
* Member names come from `parent[key]` — the original, un-visited node — the
|
|
224
|
+
* same way `typescript`'s visitor reads them. By `leave` time `node.types`
|
|
225
|
+
* has already been rendered into annotations (`Optional[Foo]`) and would
|
|
226
|
+
* otherwise be mangled. Members are quoted so a union declared before its
|
|
227
|
+
* members still resolves, matching how every other annotation in this plugin
|
|
228
|
+
* is emitted.
|
|
229
|
+
*
|
|
230
|
+
* Note this only makes a union *declaration* valid Python. Actually selecting
|
|
231
|
+
* union members in an operation needs inline-fragment support, which
|
|
232
|
+
* `python-operations` does not implement yet (it throws).
|
|
233
|
+
*/
|
|
234
|
+
UnionTypeDefinition(node, key, parent) {
|
|
235
|
+
var _a;
|
|
236
|
+
if (this.config.onlyOperationTypes || this.config.onlyEnums)
|
|
237
|
+
return '';
|
|
238
|
+
if (node.name.value.startsWith('__'))
|
|
239
|
+
return '';
|
|
240
|
+
const originalNode = parent[key];
|
|
241
|
+
const members = ((_a = originalNode.types) !== null && _a !== void 0 ? _a : []).map(t => `'${this.convertName(t)}'`);
|
|
242
|
+
// A member-less union is legal to declare but has no inhabitant.
|
|
243
|
+
if (members.length === 0) {
|
|
244
|
+
return `${this.convertName(node)} = Any`;
|
|
245
|
+
}
|
|
246
|
+
this._usesUnion = true;
|
|
247
|
+
return `${this.convertName(node)} = Union[${members.join(', ')}]`;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Build the `prepend` import lines, emitting only what the generated
|
|
251
|
+
* `content` actually references.
|
|
252
|
+
*/
|
|
253
|
+
getImports(content) {
|
|
254
|
+
const imports = [];
|
|
255
|
+
if (this._usesEnum) {
|
|
256
|
+
imports.push('from enum import Enum');
|
|
257
|
+
}
|
|
258
|
+
const typingParts = [];
|
|
259
|
+
if (content.includes('Optional[')) {
|
|
260
|
+
typingParts.push('Optional');
|
|
261
|
+
}
|
|
262
|
+
if (this._usesTypedDict) {
|
|
263
|
+
typingParts.push('TypedDict');
|
|
264
|
+
}
|
|
265
|
+
if (this._usesUnion) {
|
|
266
|
+
typingParts.push('Union');
|
|
267
|
+
}
|
|
268
|
+
if (/\bAny\b/.test(content)) {
|
|
269
|
+
typingParts.push('Any');
|
|
270
|
+
}
|
|
271
|
+
if (typingParts.length > 0) {
|
|
272
|
+
imports.push(`from typing import ${typingParts.join(', ')}`);
|
|
273
|
+
}
|
|
274
|
+
if (this._usesBaseModel) {
|
|
275
|
+
// `ConfigDict` / `Field` are pydantic names; emit them from the same module
|
|
276
|
+
// as the base model, and only when the generated content uses them.
|
|
277
|
+
const parts = [this.config.baseModel];
|
|
278
|
+
if (content.includes('ConfigDict(')) {
|
|
279
|
+
parts.push('ConfigDict');
|
|
280
|
+
}
|
|
281
|
+
if (content.includes('Field(')) {
|
|
282
|
+
parts.push('Field');
|
|
283
|
+
}
|
|
284
|
+
imports.push(`from ${this.config.runtimeImport} import ${parts.join(', ')}`);
|
|
285
|
+
}
|
|
286
|
+
return imports;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
exports.PythonVisitor = PythonVisitor;
|
|
File without changes
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { getCachedDocumentNodeFromSchema, oldVisit, } from '@graphql-codegen/plugin-helpers';
|
|
2
|
+
import { PythonVisitor } from './visitor.js';
|
|
3
|
+
export * from './config.js';
|
|
4
|
+
export * from './visitor.js';
|
|
5
|
+
export const plugin = (schema, _documents, config) => {
|
|
6
|
+
const astNode = getCachedDocumentNodeFromSchema(schema);
|
|
7
|
+
const visitor = new PythonVisitor(schema, config);
|
|
8
|
+
const visitorResult = oldVisit(astNode, { leave: visitor });
|
|
9
|
+
const content = visitorResult.definitions
|
|
10
|
+
.filter((def) => typeof def === 'string' && def.length > 0)
|
|
11
|
+
.join('\n\n\n');
|
|
12
|
+
return {
|
|
13
|
+
prepend: visitor.getImports(content),
|
|
14
|
+
content,
|
|
15
|
+
};
|
|
16
|
+
};
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import autoBind from 'auto-bind';
|
|
2
|
+
import { isValidClassAttr, PYTHON_SCALARS, pythonFieldName } from '@graphql-codegen/python-common';
|
|
3
|
+
import { BaseTypesVisitor, getConfigValue, } from '@graphql-codegen/visitor-plugin-common';
|
|
4
|
+
export { PYTHON_SCALARS };
|
|
5
|
+
const INDENT = ' ';
|
|
6
|
+
export class PythonVisitor extends BaseTypesVisitor {
|
|
7
|
+
constructor(schema, pluginConfig) {
|
|
8
|
+
// `namingConvention` here is our own field-naming option (`'keep'` |
|
|
9
|
+
// `'snakeCase'`) and must NOT leak into the base visitor's `namingConvention`
|
|
10
|
+
// (which controls TYPE-name conversion via `convertFactory`). Strip it before
|
|
11
|
+
// calling `super` so type names keep their default (PascalCase) conversion.
|
|
12
|
+
super(schema,
|
|
13
|
+
// Route custom scalars (no explicit mapping) to `Any` instead of `unknown`.
|
|
14
|
+
{
|
|
15
|
+
...pluginConfig,
|
|
16
|
+
namingConvention: undefined,
|
|
17
|
+
defaultScalarType: getConfigValue(pluginConfig.defaultScalarType, 'Any'),
|
|
18
|
+
}, {
|
|
19
|
+
baseModel: getConfigValue(pluginConfig.baseModel, 'BaseModel'),
|
|
20
|
+
runtimeImport: getConfigValue(pluginConfig.runtimeImport, 'pydantic'),
|
|
21
|
+
pyNamingConvention: getConfigValue(pluginConfig.namingConvention, 'snakeCase'),
|
|
22
|
+
}, PYTHON_SCALARS);
|
|
23
|
+
this._usesEnum = false;
|
|
24
|
+
this._usesTypedDict = false;
|
|
25
|
+
this._usesBaseModel = false;
|
|
26
|
+
this._usesUnion = false;
|
|
27
|
+
autoBind(this);
|
|
28
|
+
}
|
|
29
|
+
/** Python has no `export` keyword; disable the C-family export prefix. */
|
|
30
|
+
getExportPrefix() {
|
|
31
|
+
return '';
|
|
32
|
+
}
|
|
33
|
+
_getScalar(name, type) {
|
|
34
|
+
const mapped = this.scalars[name];
|
|
35
|
+
if (mapped) {
|
|
36
|
+
return mapped[type];
|
|
37
|
+
}
|
|
38
|
+
return 'Any';
|
|
39
|
+
}
|
|
40
|
+
// --- Nullability / list composition (emit Python instead of TS) ---
|
|
41
|
+
NamedType(node, key, parent, path, ancestors) {
|
|
42
|
+
return `Optional[${super.NamedType(node, key, parent, path, ancestors)}]`;
|
|
43
|
+
}
|
|
44
|
+
ListType(node, key, parent, path, ancestors) {
|
|
45
|
+
return `Optional[${super.ListType(node, key, parent, path, ancestors)}]`;
|
|
46
|
+
}
|
|
47
|
+
wrapWithListType(str) {
|
|
48
|
+
return `list[${str}]`;
|
|
49
|
+
}
|
|
50
|
+
NonNullType(node) {
|
|
51
|
+
const baseValue = super.NonNullType(node);
|
|
52
|
+
if (baseValue.startsWith('Optional[') && baseValue.endsWith(']')) {
|
|
53
|
+
return baseValue.slice('Optional['.length, -1);
|
|
54
|
+
}
|
|
55
|
+
return baseValue;
|
|
56
|
+
}
|
|
57
|
+
// --- Field rendering (name: 'annotation') ---
|
|
58
|
+
snakeFieldName(name) {
|
|
59
|
+
return pythonFieldName(name);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Field key for a `TypedDict` (input type). Input types are what the caller
|
|
63
|
+
* SENDS — the runtime forwards the dict to the server as-is — so they ALWAYS
|
|
64
|
+
* keep the wire (GraphQL) name regardless of `namingConvention`. If the name
|
|
65
|
+
* is a keyword / non-identifier the containing `TypedDict` is later emitted
|
|
66
|
+
* with functional syntax (see `buildTypedDict`).
|
|
67
|
+
*/
|
|
68
|
+
dictFieldName(name) {
|
|
69
|
+
return name;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Field key for an object / interface type (a class attribute). Under `'keep'`
|
|
73
|
+
* a keyword / non-identifier name cannot be a class attribute and BaseModel
|
|
74
|
+
* cannot use functional `TypedDict` syntax, so we fall back to appending `_`
|
|
75
|
+
* for just that field.
|
|
76
|
+
*/
|
|
77
|
+
objectFieldName(name) {
|
|
78
|
+
if (this.config.pyNamingConvention === 'snakeCase') {
|
|
79
|
+
return this.snakeFieldName(name);
|
|
80
|
+
}
|
|
81
|
+
return isValidClassAttr(name) ? name : `${name}_`;
|
|
82
|
+
}
|
|
83
|
+
FieldDefinition(node) {
|
|
84
|
+
if (this.config.onlyEnums)
|
|
85
|
+
return '';
|
|
86
|
+
const wire = node.name.value;
|
|
87
|
+
const attr = this.objectFieldName(wire);
|
|
88
|
+
// The rendered annotation (e.g. `Optional[str]`, `list[User]`). Quoted below
|
|
89
|
+
// so forward / recursive references resolve without ordering constraints.
|
|
90
|
+
const annotation = node.type;
|
|
91
|
+
const optional = annotation.startsWith('Optional[');
|
|
92
|
+
// When the Python attribute differs from the GraphQL wire key (snake_case
|
|
93
|
+
// conversion or a keyword escaped with `_`), pin the wire key with a pydantic
|
|
94
|
+
// `Field(alias=...)` so hydration maps it onto the attribute. Optional fields
|
|
95
|
+
// also default to `None` so a missing key is allowed.
|
|
96
|
+
let assignment = '';
|
|
97
|
+
if (attr !== wire) {
|
|
98
|
+
assignment = optional
|
|
99
|
+
? ` = Field(default=None, alias='${wire}')`
|
|
100
|
+
: ` = Field(alias='${wire}')`;
|
|
101
|
+
}
|
|
102
|
+
else if (optional) {
|
|
103
|
+
assignment = ' = None';
|
|
104
|
+
}
|
|
105
|
+
return `${attr}: '${annotation}'${assignment}`;
|
|
106
|
+
}
|
|
107
|
+
InputValueDefinition(node) {
|
|
108
|
+
if (this.config.onlyEnums)
|
|
109
|
+
return '';
|
|
110
|
+
return `${this.dictFieldName(node.name.value)}: '${node.type}'`;
|
|
111
|
+
}
|
|
112
|
+
// --- Class / block builder ---
|
|
113
|
+
buildClass(name, base, fields) {
|
|
114
|
+
const header = `class ${name}(${base}):`;
|
|
115
|
+
const body = fields.filter(Boolean);
|
|
116
|
+
if (body.length === 0) {
|
|
117
|
+
return `${header}\n${INDENT}pass`;
|
|
118
|
+
}
|
|
119
|
+
return [header, ...body.map(f => INDENT + f)].join('\n');
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Build a base-model subclass. Fields are class-level annotations; if any field
|
|
123
|
+
* pins a wire key with `Field(alias=...)`, a
|
|
124
|
+
* `model_config = ConfigDict(populate_by_name=True)` line is prepended so the
|
|
125
|
+
* class can be constructed by attribute name as well as by alias.
|
|
126
|
+
*/
|
|
127
|
+
buildBaseModelClass(name, fields) {
|
|
128
|
+
const header = `class ${name}(${this.config.baseModel}):`;
|
|
129
|
+
const body = fields.filter(Boolean);
|
|
130
|
+
if (body.length === 0) {
|
|
131
|
+
return `${header}\n${INDENT}pass`;
|
|
132
|
+
}
|
|
133
|
+
const lines = [header];
|
|
134
|
+
if (body.some(f => f.includes('alias='))) {
|
|
135
|
+
lines.push(`${INDENT}model_config = ConfigDict(populate_by_name=True)`);
|
|
136
|
+
}
|
|
137
|
+
lines.push(...body.map(f => INDENT + f));
|
|
138
|
+
return lines.join('\n');
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Split a rendered `key: 'annotation'` field back into its parts. The key is
|
|
142
|
+
* always a GraphQL Name (no `: '`) and the annotation is always single-quoted,
|
|
143
|
+
* so splitting on the first `: '` is unambiguous.
|
|
144
|
+
*/
|
|
145
|
+
splitField(rendered) {
|
|
146
|
+
const sep = rendered.indexOf(": '");
|
|
147
|
+
const key = rendered.slice(0, sep);
|
|
148
|
+
const annotation = rendered.slice(sep + 3, -1);
|
|
149
|
+
return { key, annotation };
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Build a `TypedDict`. If every key is a valid Python attribute, the readable
|
|
153
|
+
* class syntax is used; otherwise (a key is a keyword / non-identifier under
|
|
154
|
+
* `'keep'`) the functional `TypedDict('Name', { 'key': 'annotation' })` syntax
|
|
155
|
+
* is emitted so the offending keys remain valid.
|
|
156
|
+
*/
|
|
157
|
+
buildTypedDict(name, fields) {
|
|
158
|
+
const body = fields.filter(Boolean);
|
|
159
|
+
const parsed = body.map(f => this.splitField(f));
|
|
160
|
+
const needsFunctional = parsed.some(p => !isValidClassAttr(p.key));
|
|
161
|
+
if (!needsFunctional) {
|
|
162
|
+
return this.buildClass(name, 'TypedDict', body);
|
|
163
|
+
}
|
|
164
|
+
const entries = parsed.map(p => `${INDENT}'${p.key}': '${p.annotation}',`);
|
|
165
|
+
return [`${name} = TypedDict('${name}', {`, ...entries, '})'].join('\n');
|
|
166
|
+
}
|
|
167
|
+
ObjectTypeDefinition(node) {
|
|
168
|
+
if (this.config.onlyOperationTypes || this.config.onlyEnums)
|
|
169
|
+
return '';
|
|
170
|
+
if (node.name.value.startsWith('__'))
|
|
171
|
+
return '';
|
|
172
|
+
this._usesBaseModel = true;
|
|
173
|
+
return this.buildBaseModelClass(this.convertName(node), node.fields);
|
|
174
|
+
}
|
|
175
|
+
InterfaceTypeDefinition(node) {
|
|
176
|
+
if (this.config.onlyOperationTypes || this.config.onlyEnums)
|
|
177
|
+
return '';
|
|
178
|
+
if (node.name.value.startsWith('__'))
|
|
179
|
+
return '';
|
|
180
|
+
this._usesBaseModel = true;
|
|
181
|
+
return this.buildBaseModelClass(this.convertName(node), node.fields);
|
|
182
|
+
}
|
|
183
|
+
InputObjectTypeDefinition(node) {
|
|
184
|
+
if (this.config.onlyEnums)
|
|
185
|
+
return '';
|
|
186
|
+
if (node.name.value.startsWith('__'))
|
|
187
|
+
return '';
|
|
188
|
+
this._usesTypedDict = true;
|
|
189
|
+
// Under `'keep'`, a keyword / non-identifier field key forces functional
|
|
190
|
+
// `TypedDict` syntax; otherwise readable class syntax is used.
|
|
191
|
+
return this.buildTypedDict(this.convertName(node), node.fields);
|
|
192
|
+
}
|
|
193
|
+
EnumTypeDefinition(node) {
|
|
194
|
+
var _a;
|
|
195
|
+
if (node.name.value.startsWith('__'))
|
|
196
|
+
return '';
|
|
197
|
+
this._usesEnum = true;
|
|
198
|
+
const name = this.convertName(node, {
|
|
199
|
+
useTypesPrefix: this.config.enumPrefix,
|
|
200
|
+
useTypesSuffix: this.config.enumSuffix,
|
|
201
|
+
});
|
|
202
|
+
const members = ((_a = node.values) !== null && _a !== void 0 ? _a : []).map(value => `${INDENT}${value.name.value} = '${value.name.value}'`);
|
|
203
|
+
const body = members.length > 0 ? members : [`${INDENT}pass`];
|
|
204
|
+
return [`class ${name}(str, Enum):`, ...body].join('\n');
|
|
205
|
+
}
|
|
206
|
+
/** Scalars are handled inline via the scalar map; no standalone class. */
|
|
207
|
+
ScalarTypeDefinition() {
|
|
208
|
+
return '';
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* `union Foo = A | B` -> `Foo = Union['A', 'B']`.
|
|
212
|
+
*
|
|
213
|
+
* Must be overridden: `BaseTypesVisitor`'s implementation emits TypeScript
|
|
214
|
+
* (`export type Foo = A | B;`), and it dereferences the node description
|
|
215
|
+
* unguarded, so an undocumented union crashes the generator outright. A schema
|
|
216
|
+
* merely *containing* a union is enough to hit this, even when no operation
|
|
217
|
+
* selects one.
|
|
218
|
+
*
|
|
219
|
+
* Member names come from `parent[key]` — the original, un-visited node — the
|
|
220
|
+
* same way `typescript`'s visitor reads them. By `leave` time `node.types`
|
|
221
|
+
* has already been rendered into annotations (`Optional[Foo]`) and would
|
|
222
|
+
* otherwise be mangled. Members are quoted so a union declared before its
|
|
223
|
+
* members still resolves, matching how every other annotation in this plugin
|
|
224
|
+
* is emitted.
|
|
225
|
+
*
|
|
226
|
+
* Note this only makes a union *declaration* valid Python. Actually selecting
|
|
227
|
+
* union members in an operation needs inline-fragment support, which
|
|
228
|
+
* `python-operations` does not implement yet (it throws).
|
|
229
|
+
*/
|
|
230
|
+
UnionTypeDefinition(node, key, parent) {
|
|
231
|
+
var _a;
|
|
232
|
+
if (this.config.onlyOperationTypes || this.config.onlyEnums)
|
|
233
|
+
return '';
|
|
234
|
+
if (node.name.value.startsWith('__'))
|
|
235
|
+
return '';
|
|
236
|
+
const originalNode = parent[key];
|
|
237
|
+
const members = ((_a = originalNode.types) !== null && _a !== void 0 ? _a : []).map(t => `'${this.convertName(t)}'`);
|
|
238
|
+
// A member-less union is legal to declare but has no inhabitant.
|
|
239
|
+
if (members.length === 0) {
|
|
240
|
+
return `${this.convertName(node)} = Any`;
|
|
241
|
+
}
|
|
242
|
+
this._usesUnion = true;
|
|
243
|
+
return `${this.convertName(node)} = Union[${members.join(', ')}]`;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Build the `prepend` import lines, emitting only what the generated
|
|
247
|
+
* `content` actually references.
|
|
248
|
+
*/
|
|
249
|
+
getImports(content) {
|
|
250
|
+
const imports = [];
|
|
251
|
+
if (this._usesEnum) {
|
|
252
|
+
imports.push('from enum import Enum');
|
|
253
|
+
}
|
|
254
|
+
const typingParts = [];
|
|
255
|
+
if (content.includes('Optional[')) {
|
|
256
|
+
typingParts.push('Optional');
|
|
257
|
+
}
|
|
258
|
+
if (this._usesTypedDict) {
|
|
259
|
+
typingParts.push('TypedDict');
|
|
260
|
+
}
|
|
261
|
+
if (this._usesUnion) {
|
|
262
|
+
typingParts.push('Union');
|
|
263
|
+
}
|
|
264
|
+
if (/\bAny\b/.test(content)) {
|
|
265
|
+
typingParts.push('Any');
|
|
266
|
+
}
|
|
267
|
+
if (typingParts.length > 0) {
|
|
268
|
+
imports.push(`from typing import ${typingParts.join(', ')}`);
|
|
269
|
+
}
|
|
270
|
+
if (this._usesBaseModel) {
|
|
271
|
+
// `ConfigDict` / `Field` are pydantic names; emit them from the same module
|
|
272
|
+
// as the base model, and only when the generated content uses them.
|
|
273
|
+
const parts = [this.config.baseModel];
|
|
274
|
+
if (content.includes('ConfigDict(')) {
|
|
275
|
+
parts.push('ConfigDict');
|
|
276
|
+
}
|
|
277
|
+
if (content.includes('Field(')) {
|
|
278
|
+
parts.push('Field');
|
|
279
|
+
}
|
|
280
|
+
imports.push(`from ${this.config.runtimeImport} import ${parts.join(', ')}`);
|
|
281
|
+
}
|
|
282
|
+
return imports;
|
|
283
|
+
}
|
|
284
|
+
}
|