Skip to content

Commit f278420

Browse files
committed
feat: wip python generator
1 parent f4ed51c commit f278420

5 files changed

Lines changed: 362 additions & 9 deletions

File tree

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import {stripIndent} from 'common-tags'
2+
import camelCase from 'lodash.camelcase'
3+
4+
import {ZodToPythonParameterClassMapper} from '../language-mappers/zod-to-python-parameter-class-mapper.js'
5+
import {ZodToTypescriptReturnValueMapper} from '../language-mappers/zod-to-typescript-return-value-mapper.js'
6+
import {PythonTyping} from '../types.js'
7+
import {BaseGenerator} from './base-generator.js'
8+
9+
export class PythonGenerator extends BaseGenerator {
10+
private _typings = new Set<PythonTyping>()
11+
12+
private DATACLASS_IMPORT = 'from dataclasses import dataclass'
13+
private MUSTACHE_IMPORT = 'import pystache'
14+
private PYDANTIC_IMPORT = 'from pydantic import BaseModel'
15+
16+
get filename(): string {
17+
return 'prefab.py'
18+
}
19+
20+
generate(): string {
21+
// Need to genereate these before referencing _typings to ensure all required types are known
22+
const parameterClassTemplates = this.generateParameterClasses()
23+
const typings = this._typings.size > 0 ? [...this._typings].sort().join(', ') : null
24+
const accessorMethods = this.generateAccessorMethods()
25+
26+
const additionalDependencies = new Set<string>()
27+
28+
if (parameterClassTemplates.length > 0) {
29+
additionalDependencies.add(this.DATACLASS_IMPORT)
30+
}
31+
32+
if (accessorMethods.length > 0) {
33+
additionalDependencies.add(this.PYDANTIC_IMPORT)
34+
}
35+
36+
if (this.configurations().some((c) => c.hasFunction)) {
37+
additionalDependencies.add(this.MUSTACHE_IMPORT)
38+
}
39+
40+
return stripIndent`
41+
# AUTOGENERATED by prefab-cli's 'gen' command
42+
import prefab_cloud_python
43+
from prefab_cloud_python import ContextDictOrContext
44+
45+
${[...additionalDependencies].join('\n ') || '# No additional dependencies required'}
46+
47+
# Optional - need to make this dynamic
48+
from datetime import timedelta # for Durations
49+
50+
${typings ? `from typing import ${typings}` : '# No additional typings required'}
51+
52+
class PrefabTypedClient:
53+
"""Client for accessing Prefab configuration with type-safe methods"""
54+
def __init__(self, client=None, use_global_client=False):
55+
"""
56+
Initialize the typed client.
57+
58+
Args:
59+
client: A Prefab client instance. If not provided and use_global_client is False,
60+
uses the global client at initialization time.
61+
use_global_client: If True, dynamically calls prefab_cloud_python.get_client() for each request
62+
instead of storing a reference. Useful in long-running applications where
63+
the client might be reset or reconfigured.
64+
"""
65+
self._prefab = prefab_cloud_python
66+
self._use_global_client = use_global_client
67+
self._client = None if use_global_client else (client or prefab_cloud_python.get_client())
68+
69+
@property
70+
def client(self):
71+
"""
72+
Returns the client to use for the current request.
73+
74+
If use_global_client is True, dynamically retrieves the current global client.
75+
Otherwise, returns the stored client instance.
76+
"""
77+
if self._use_global_client:
78+
return self._prefab.get_client()
79+
return self._client
80+
81+
${parameterClassTemplates.join('\n\n ') || '# No parameter classes generated'}
82+
83+
${accessorMethods.join('\n\n ') || '# No methods generated'}
84+
`
85+
}
86+
87+
private generateAccessorMethods(): string[] {
88+
const uniqueMethods: Record<string, string> = {}
89+
const schemaTypes = this.configurations().map((config) => {
90+
let methodName = camelCase(config.key)
91+
92+
// If the method name starts with a digit, prefix it with an underscore to ensure method name is valid
93+
if (/^\d/.test(methodName)) {
94+
methodName = `_${methodName}`
95+
}
96+
97+
if (uniqueMethods[methodName]) {
98+
throw new Error(
99+
`Method '${methodName}' is already registered. Prefab key ${config.key} conflicts with '${uniqueMethods[methodName]}'!`,
100+
)
101+
}
102+
103+
uniqueMethods[methodName] = config.key
104+
105+
if (config.configType === 'FEATURE_FLAG') {
106+
return stripIndent`
107+
get ${methodName}(): boolean {
108+
return this.prefab.isEnabled('${config.key}')
109+
}
110+
`
111+
}
112+
113+
if (config.hasFunction) {
114+
const returnValue = new ZodToTypescriptReturnValueMapper().resolveType(config.schema)
115+
116+
return stripIndent`
117+
${methodName}(): PrefabTypeGeneration.ReactHookConfigurationAccessor['${config.key}'] {
118+
const raw = this.get('${config.key}')
119+
return ${returnValue}
120+
}
121+
`
122+
}
123+
124+
return stripIndent`
125+
get ${methodName}(): PrefabTypeGeneration.ReactHookConfigurationAccessor['${config.key}'] {
126+
return this.get('${config.key}')
127+
}
128+
`
129+
})
130+
131+
return schemaTypes
132+
}
133+
134+
private generateParameterClasses(): string[] {
135+
const uniqueClasses: Record<string, string> = {}
136+
const schemaTypes = this.configurations().map((config) => {
137+
const mapper = new ZodToPythonParameterClassMapper({fieldName: config.key})
138+
const result = mapper.renderClass(config.schema)
139+
140+
const className = ZodToPythonParameterClassMapper.parameterClassName(config.key)
141+
142+
if (uniqueClasses[className]) {
143+
throw new Error(
144+
`Class '${className}' is already registered. Prefab key ${config.key} conflicts with '${uniqueClasses[className]}'!`,
145+
)
146+
}
147+
148+
uniqueClasses[className] = config.key
149+
150+
if (mapper.hasAny) {
151+
this._typings.add(PythonTyping.Any)
152+
}
153+
if (mapper.hasDict) {
154+
this._typings.add(PythonTyping.Dict)
155+
}
156+
if (mapper.hasList) {
157+
this._typings.add(PythonTyping.List)
158+
}
159+
if (mapper.hasOptional) {
160+
this._typings.add(PythonTyping.Optional)
161+
}
162+
if (mapper.hasTuple) {
163+
this._typings.add(PythonTyping.Tuple)
164+
}
165+
if (mapper.hasUnion) {
166+
this._typings.add(PythonTyping.Union)
167+
}
168+
169+
return result
170+
})
171+
172+
return schemaTypes
173+
}
174+
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import {stripIndent} from 'common-tags'
2+
import {z} from 'zod'
3+
4+
import pascalCase from '../../util/pascal-case.js'
5+
import {PythonTyping, ZodTypeSupported} from '../types.js'
6+
import {ZodBaseMapper} from './zod-base-mapper.js'
7+
8+
export class ZodToPythonParameterClassMapper extends ZodBaseMapper {
9+
private _fieldName: string | undefined
10+
private _hasAny: boolean = false
11+
private _hasDict: boolean = false
12+
private _hasList: boolean = false
13+
private _hasOptional: boolean = false
14+
private _hasTuple: boolean = false
15+
private _hasUnion: boolean = false
16+
17+
constructor({fieldName}: {fieldName?: string} = {}) {
18+
super()
19+
this._fieldName = fieldName
20+
}
21+
22+
static parameterClassName(key: string) {
23+
let keyPrefix = pascalCase(key)
24+
25+
// If the method name starts with a digit, prefix it with an underscore to ensure method name is valid
26+
if (/^\d/.test(keyPrefix)) {
27+
keyPrefix = `_${keyPrefix}`
28+
}
29+
30+
return `${keyPrefix}Params`
31+
}
32+
33+
get hasAny() {
34+
return this._hasAny
35+
}
36+
37+
get hasDict() {
38+
return this._hasDict
39+
}
40+
41+
get hasList() {
42+
return this._hasList
43+
}
44+
45+
get hasOptional() {
46+
return this._hasOptional
47+
}
48+
49+
get hasTuple() {
50+
return this._hasTuple
51+
}
52+
53+
get hasUnion() {
54+
return this._hasUnion
55+
}
56+
57+
any() {
58+
this._hasAny = true
59+
60+
return PythonTyping.Any
61+
}
62+
63+
array(wrappedType: string) {
64+
this._hasList = true
65+
66+
return `${PythonTyping.List}[${wrappedType}]`
67+
}
68+
69+
boolean() {
70+
return 'bool'
71+
}
72+
73+
enum(values: string[]) {
74+
// TODO: figure out support for these
75+
return values.map((v) => `'${v}'`).join(' | ')
76+
}
77+
78+
function(args: string) {
79+
// All we care about are the arguments
80+
return args
81+
}
82+
83+
functionArguments(value?: z.ZodTuple): string {
84+
if (!value) {
85+
return ''
86+
}
87+
88+
const mapper = new ZodToPythonParameterClassMapper()
89+
return mapper.resolveType(value)
90+
}
91+
92+
functionReturns(): string {
93+
// We don't care about return values when mapping parameter classes
94+
return ''
95+
}
96+
97+
null() {
98+
return 'None'
99+
}
100+
101+
number(isInteger: boolean) {
102+
return isInteger ? 'int' : 'float'
103+
}
104+
105+
object(properties: [string, z.ZodTypeAny][]) {
106+
const nestedClasses: [string, string][] = properties.map(([fieldName, type]) => {
107+
const mapper = new ZodToPythonParameterClassMapper({fieldName})
108+
return [ZodToPythonParameterClassMapper.parameterClassName(fieldName), mapper.renderClass(type)]
109+
})
110+
111+
// TODO: Fix this
112+
return `{ ${nestedClasses.map(([className]) => className)} }`
113+
}
114+
115+
optional(wrappedType: string) {
116+
this._hasOptional = true
117+
118+
return `Optional[${wrappedType}]`
119+
}
120+
121+
renderClass(type: ZodTypeSupported): string {
122+
if (!this._fieldName) {
123+
throw new Error('Field name must be set to render a class.')
124+
}
125+
126+
const resolved = this.resolveType(type)
127+
128+
return stripIndent`
129+
@dataclass
130+
class ${ZodToPythonParameterClassMapper.parameterClassName(this._fieldName)}:
131+
${resolved}
132+
`
133+
}
134+
135+
string() {
136+
return 'str'
137+
}
138+
139+
tuple(wrappedTypes: string[]) {
140+
this._hasTuple = true
141+
142+
return `Tuple[${wrappedTypes.join(', ')}]`
143+
}
144+
145+
undefined() {
146+
// There is no equivalent to `undefined` in Python, so we use `None`
147+
return 'None'
148+
}
149+
150+
union(wrappedTypes: string[]) {
151+
this._hasUnion = true
152+
153+
return `Union[${wrappedTypes.join(', ')}]`
154+
}
155+
156+
unknown() {
157+
// There is no equivalent to `unknown` in Python, so we use `Any`
158+
return PythonTyping.Any
159+
}
160+
}

src/codegen/types.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,18 @@
11
import {z} from 'zod'
22

33
export enum SupportedLanguage {
4+
Node = 'node',
5+
Python = 'python',
46
React = 'react',
5-
TypeScript = 'typescript',
7+
}
8+
9+
export enum PythonTyping {
10+
Any = 'Any',
11+
Dict = 'Dict',
12+
List = 'List',
13+
Optional = 'Optional',
14+
Tuple = 'Tuple',
15+
Union = 'Union',
616
}
717

818
export interface ConfigValue {

0 commit comments

Comments
 (0)