diff --git a/src/server/routes/generators/python.ts b/src/server/routes/generators/python.ts new file mode 100644 index 00000000..51385373 --- /dev/null +++ b/src/server/routes/generators/python.ts @@ -0,0 +1,35 @@ +import type { FastifyInstance } from 'fastify' +import { PostgresMeta } from '../../../lib/index.js' +import { DEFAULT_POOL_CONFIG } from '../../constants.js' +import { extractRequestForLogging } from '../../utils.js' +import { apply as applyPyTemplate } from '../../templates/python.js' +import { getGeneratorMetadata } from '../../../lib/generators.js' + +export default async (fastify: FastifyInstance) => { + fastify.get<{ + Headers: { pg: string } + Querystring: { + excluded_schemas?: string + included_schemas?: string + } + }>('/', async (request, reply) => { + const connectionString = request.headers.pg + const excludedSchemas = + request.query.excluded_schemas?.split(',').map((schema) => schema.trim()) ?? [] + const includedSchemas = + request.query.included_schemas?.split(',').map((schema) => schema.trim()) ?? [] + + const pgMeta: PostgresMeta = new PostgresMeta({ ...DEFAULT_POOL_CONFIG, connectionString }) + const { data: generatorMeta, error: generatorMetaError } = await getGeneratorMetadata(pgMeta, { + includedSchemas, + excludedSchemas, + }) + if (generatorMetaError) { + request.log.error({ error: generatorMetaError, request: extractRequestForLogging(request) }) + reply.code(500) + return { error: generatorMetaError.message } + } + + return applyPyTemplate(generatorMeta) + }) +} diff --git a/src/server/routes/index.ts b/src/server/routes/index.ts index 1532c4ea..46ffba0f 100644 --- a/src/server/routes/index.ts +++ b/src/server/routes/index.ts @@ -21,6 +21,7 @@ import ViewsRoute from './views.js' import TypeScriptTypeGenRoute from './generators/typescript.js' import GoTypeGenRoute from './generators/go.js' import SwiftTypeGenRoute from './generators/swift.js' +import PythonTypeGenRoute from './generators/python.js' import { PG_CONNECTION, CRYPTO_KEY } from '../constants.js' export default async (fastify: FastifyInstance) => { @@ -82,4 +83,5 @@ export default async (fastify: FastifyInstance) => { fastify.register(TypeScriptTypeGenRoute, { prefix: '/generators/typescript' }) fastify.register(GoTypeGenRoute, { prefix: '/generators/go' }) fastify.register(SwiftTypeGenRoute, { prefix: '/generators/swift' }) + fastify.register(PythonTypeGenRoute, { prefix: '/generators/python' }) } diff --git a/src/server/templates/python.ts b/src/server/templates/python.ts new file mode 100644 index 00000000..2ac80996 --- /dev/null +++ b/src/server/templates/python.ts @@ -0,0 +1,405 @@ +import type { + PostgresColumn, + PostgresMaterializedView, + PostgresSchema, + PostgresTable, + PostgresType, + PostgresView, +} from '../../lib/index.js' +import type { GeneratorMetadata } from '../../lib/generators.js' + +export const apply = ({ + schemas, + tables, + views, + materializedViews, + columns, + types, +}: GeneratorMetadata): string => { + const ctx = new PythonContext(types, columns, schemas) + const py_tables = tables + .filter((table) => schemas.some((schema) => schema.name === table.schema)) + .flatMap((table) => { + const py_class_and_methods = ctx.tableToClass(table) + return py_class_and_methods + }) + const composite_types = types + .filter((type) => type.attributes.length > 0) + .map((type) => ctx.typeToClass(type)) + const py_views = views.map((view) => ctx.viewToClass(view)) + const py_matviews = materializedViews.map((matview) => ctx.matViewToClass(matview)) + + let output = ` +from __future__ import annotations + +import datetime +from typing import ( + Annotated, + Any, + List, + Literal, + NotRequired, + Optional, + TypeAlias, + TypedDict, +) + +from pydantic import BaseModel, Field, Json + +${concatLines(Object.values(ctx.user_enums))} + +${concatLines(py_tables)} + +${concatLines(py_views)} + +${concatLines(py_matviews)} + +${concatLines(composite_types)} + +`.trim() + + return output +} + +interface Serializable { + serialize(): string +} + +class PythonContext { + types: { [k: string]: PostgresType } + user_enums: { [k: string]: PythonEnum } + columns: Record + schemas: { [k: string]: PostgresSchema } + + constructor(types: PostgresType[], columns: PostgresColumn[], schemas: PostgresSchema[]) { + this.schemas = Object.fromEntries(schemas.map((schema) => [schema.name, schema])) + this.types = Object.fromEntries(types.map((type) => [type.name, type])) + this.columns = columns + .sort(({ name: a }, { name: b }) => a.localeCompare(b)) + .reduce( + (acc, curr) => { + acc[curr.table_id] ??= [] + acc[curr.table_id].push(curr) + return acc + }, + {} as Record + ) + this.user_enums = Object.fromEntries( + types.filter((type) => type.enums.length > 0).map((type) => [type.name, new PythonEnum(type)]) + ) + } + + resolveTypeName(name: string): string { + if (name in this.user_enums) { + return this.user_enums[name].name + } + if (name in PY_TYPE_MAP) { + return PY_TYPE_MAP[name] + } + if (name in this.types) { + const type = this.types[name] + const schema = type!.schema + return `${formatForPyClassName(schema)}${formatForPyClassName(type.name)}` + } + console.log(`Unknown recognized row type ${name}`) + return 'Any' + } + + parsePgType(pg_type: string): PythonType { + if (pg_type.startsWith('_')) { + const inner_str = pg_type.slice(1) + const inner = this.parsePgType(inner_str) + return new PythonListType(inner) + } else { + const type_name = this.resolveTypeName(pg_type) + return new PythonSimpleType(type_name) + } + } + + typeToClass(type: PostgresType): PythonBaseModel { + const types = Object.values(this.types) + const attributes = type.attributes.map((attribute) => { + const type = types.find((type) => type.id === attribute.type_id) + return { + ...attribute, + type, + } + }) + const attributeEntries: PythonBaseModelAttr[] = attributes.map((attribute) => { + const type = this.parsePgType(attribute.type!.name) + return new PythonBaseModelAttr(attribute.name, type, false) + }) + + const schema = this.schemas[type.schema] + return new PythonBaseModel(type.name, schema, attributeEntries) + } + + columnsToClassAttrs(table_id: number): PythonBaseModelAttr[] { + const attrs = this.columns[table_id] ?? [] + return attrs.map((col) => { + const type = this.parsePgType(col.format) + return new PythonBaseModelAttr(col.name, type, col.is_nullable) + }) + } + + columnsToDictAttrs(table_id: number, not_required: boolean): PythonTypedDictAttr[] { + const attrs = this.columns[table_id] ?? [] + return attrs.map((col) => { + const type = this.parsePgType(col.format) + return new PythonTypedDictAttr( + col.name, + type, + col.is_nullable, + not_required || col.is_nullable || col.is_identity || col.default_value !== null + ) + }) + } + + tableToClass(table: PostgresTable): [PythonBaseModel, PythonTypedDict, PythonTypedDict] { + const schema = this.schemas[table.schema] + const select = new PythonBaseModel(table.name, schema, this.columnsToClassAttrs(table.id)) + const insert = new PythonTypedDict( + table.name, + 'Insert', + schema, + this.columnsToDictAttrs(table.id, false) + ) + const update = new PythonTypedDict( + table.name, + 'Update', + schema, + this.columnsToDictAttrs(table.id, true) + ) + return [select, insert, update] + } + + viewToClass(view: PostgresView): PythonBaseModel { + const attributes = this.columnsToClassAttrs(view.id) + return new PythonBaseModel(view.name, this.schemas[view.schema], attributes) + } + + matViewToClass(matview: PostgresMaterializedView): PythonBaseModel { + const attributes = this.columnsToClassAttrs(matview.id) + return new PythonBaseModel(matview.name, this.schemas[matview.schema], attributes) + } +} + +class PythonEnum implements Serializable { + name: string + variants: string[] + constructor(type: PostgresType) { + this.name = `${formatForPyClassName(type.schema)}${formatForPyClassName(type.name)}` + this.variants = type.enums + } + serialize(): string { + const variants = this.variants.map((item) => `"${item}"`).join(', ') + return `${this.name}: TypeAlias = Literal[${variants}]` + } +} + +type PythonType = PythonListType | PythonSimpleType + +class PythonSimpleType implements Serializable { + name: string + constructor(name: string) { + this.name = name + } + serialize(): string { + return this.name + } +} + +class PythonListType implements Serializable { + inner: PythonType + constructor(inner: PythonType) { + this.inner = inner + } + serialize(): string { + return `List[${this.inner.serialize()}]` + } +} + +class PythonBaseModelAttr implements Serializable { + name: string + pg_name: string + py_type: PythonType + nullable: boolean + + constructor(name: string, py_type: PythonType, nullable: boolean) { + this.name = formatForPyAttributeName(name) + this.pg_name = name + this.py_type = py_type + this.nullable = nullable + } + + serialize(): string { + const py_type = this.nullable + ? `Optional[${this.py_type.serialize()}]` + : this.py_type.serialize() + return ` ${this.name}: ${py_type} = Field(alias="${this.pg_name}")` + } +} + +class PythonBaseModel implements Serializable { + name: string + table_name: string + schema: PostgresSchema + class_attributes: PythonBaseModelAttr[] + + constructor(name: string, schema: PostgresSchema, class_attributes: PythonBaseModelAttr[]) { + this.schema = schema + this.class_attributes = class_attributes + this.table_name = name + this.name = `${formatForPyClassName(schema.name)}${formatForPyClassName(name)}` + } + serialize(): string { + const attributes = + this.class_attributes.length > 0 + ? this.class_attributes.map((attr) => attr.serialize()).join('\n') + : ' pass' + return `class ${this.name}(BaseModel):\n${attributes}` + } +} + +class PythonTypedDictAttr implements Serializable { + name: string + pg_name: string + py_type: PythonType + nullable: boolean + not_required: boolean + + constructor(name: string, py_type: PythonType, nullable: boolean, required: boolean) { + this.name = formatForPyAttributeName(name) + this.pg_name = name + this.py_type = py_type + this.nullable = nullable + this.not_required = required + } + + serialize(): string { + const annotation = `Annotated[${this.py_type.serialize()}, Field(alias="${this.pg_name}")]` + const rhs = this.not_required ? `NotRequired[${annotation}]` : annotation + return ` ${this.name}: ${rhs}` + } +} + +class PythonTypedDict implements Serializable { + name: string + table_name: string + parent_class: string + schema: PostgresSchema + dict_attributes: PythonTypedDictAttr[] + operation: 'Insert' | 'Update' + + constructor( + name: string, + operation: 'Insert' | 'Update', + schema: PostgresSchema, + dict_attributes: PythonTypedDictAttr[], + parent_class: string = 'BaseModel' + ) { + this.schema = schema + this.dict_attributes = dict_attributes + this.table_name = name + this.name = `${formatForPyClassName(schema.name)}${formatForPyClassName(name)}` + this.parent_class = parent_class + this.operation = operation + } + serialize(): string { + const attributes = + this.dict_attributes.length > 0 + ? this.dict_attributes.map((attr) => attr.serialize()).join('\n') + : ' pass' + return `class ${this.name}${this.operation}(TypedDict):\n${attributes}` + } +} + +function concatLines(items: Serializable[]): string { + return items.map((item) => item.serialize()).join('\n\n') +} + +const PY_TYPE_MAP: Record = { + // Bool + bool: 'bool', + + // Numbers + int2: 'int', + int4: 'int', + int8: 'int', + float4: 'float', + float8: 'float', + numeric: 'float', + + // Strings + bytea: 'bytes', + bpchar: 'str', + varchar: 'str', + string: 'str', + date: 'datetime.date', + text: 'str', + citext: 'str', + time: 'datetime.time', + timetz: 'datetime.time', + timestamp: 'datetime.datetime', + timestamptz: 'datetime.datetime', + uuid: 'uuid.UUID', + vector: 'list[Any]', + + // JSON + json: 'Json[Any]', + jsonb: 'Json[Any]', + + // Range types (can be adjusted to more complex types if needed) + int4range: 'str', + int4multirange: 'str', + int8range: 'str', + int8multirange: 'str', + numrange: 'str', + nummultirange: 'str', + tsrange: 'str', + tsmultirange: 'str', + tstzrange: 'str', + tstzmultirange: 'str', + daterange: 'str', + datemultirange: 'str', + + // Miscellaneous types + void: 'None', + record: 'dict[str, Any]', +} as const + +/** + * Converts a Postgres name to PascalCase. + * + * @example + * ```ts + * formatForPyTypeName('pokedex') // Pokedex + * formatForPyTypeName('pokemon_center') // PokemonCenter + * formatForPyTypeName('victory-road') // VictoryRoad + * formatForPyTypeName('pokemon league') // PokemonLeague + * ``` + */ +function formatForPyClassName(name: string): string { + return name + .split(/[^a-zA-Z0-9]/) + .map((word) => `${word[0].toUpperCase()}${word.slice(1)}`) + .join('') +} + +/** + * Converts a Postgres name to snake_case. + * + * @example + * ```ts + * formatForPyTypeName('Pokedex') // pokedex + * formatForPyTypeName('PokemonCenter') // pokemon_enter + * formatForPyTypeName('victory-road') // victory_road + * formatForPyTypeName('pokemon league') // pokemon_league + * ``` + */ +function formatForPyAttributeName(name: string): string { + return name + .split(/[^a-zA-Z0-9]+/) // Split on non-alphanumeric characters (like spaces, dashes, etc.) + .map((word) => word.toLowerCase()) // Convert each word to lowercase + .join('_') // Join with underscores +} diff --git a/test/server/typegen.ts b/test/server/typegen.ts index 4bb83d94..46079de0 100644 --- a/test/server/typegen.ts +++ b/test/server/typegen.ts @@ -6248,3 +6248,256 @@ test('typegen: swift w/ public access control', async () => { }" `) }) + +test('typegen: python', async () => { + const { body } = await app.inject({ + method: 'GET', + path: '/generators/python', + query: { access_control: 'public' }, + }) + expect(body).toMatchInlineSnapshot(` +"from __future__ import annotations + +import datetime +from typing import ( + Annotated, + Any, + List, + Literal, + NotRequired, + Optional, + TypeAlias, + TypedDict, +) + +from pydantic import BaseModel, Field, Json + +PublicUserStatus: TypeAlias = Literal["ACTIVE", "INACTIVE"] + +PublicMemeStatus: TypeAlias = Literal["new", "old", "retired"] + +class PublicUsers(BaseModel): + decimal: Optional[float] = Field(alias="decimal") + id: int = Field(alias="id") + name: Optional[str] = Field(alias="name") + status: Optional[PublicUserStatus] = Field(alias="status") + +class PublicUsersInsert(TypedDict): + decimal: NotRequired[Annotated[float, Field(alias="decimal")]] + id: NotRequired[Annotated[int, Field(alias="id")]] + name: NotRequired[Annotated[str, Field(alias="name")]] + status: NotRequired[Annotated[PublicUserStatus, Field(alias="status")]] + +class PublicUsersUpdate(TypedDict): + decimal: NotRequired[Annotated[float, Field(alias="decimal")]] + id: NotRequired[Annotated[int, Field(alias="id")]] + name: NotRequired[Annotated[str, Field(alias="name")]] + status: NotRequired[Annotated[PublicUserStatus, Field(alias="status")]] + +class PublicTodos(BaseModel): + details: Optional[str] = Field(alias="details") + id: int = Field(alias="id") + user_id: int = Field(alias="user-id") + +class PublicTodosInsert(TypedDict): + details: NotRequired[Annotated[str, Field(alias="details")]] + id: NotRequired[Annotated[int, Field(alias="id")]] + user_id: Annotated[int, Field(alias="user-id")] + +class PublicTodosUpdate(TypedDict): + details: NotRequired[Annotated[str, Field(alias="details")]] + id: NotRequired[Annotated[int, Field(alias="id")]] + user_id: NotRequired[Annotated[int, Field(alias="user-id")]] + +class PublicUsersAudit(BaseModel): + created_at: Optional[datetime.datetime] = Field(alias="created_at") + id: int = Field(alias="id") + previous_value: Optional[Json[Any]] = Field(alias="previous_value") + user_id: Optional[int] = Field(alias="user_id") + +class PublicUsersAuditInsert(TypedDict): + created_at: NotRequired[Annotated[datetime.datetime, Field(alias="created_at")]] + id: NotRequired[Annotated[int, Field(alias="id")]] + previous_value: NotRequired[Annotated[Json[Any], Field(alias="previous_value")]] + user_id: NotRequired[Annotated[int, Field(alias="user_id")]] + +class PublicUsersAuditUpdate(TypedDict): + created_at: NotRequired[Annotated[datetime.datetime, Field(alias="created_at")]] + id: NotRequired[Annotated[int, Field(alias="id")]] + previous_value: NotRequired[Annotated[Json[Any], Field(alias="previous_value")]] + user_id: NotRequired[Annotated[int, Field(alias="user_id")]] + +class PublicUserDetails(BaseModel): + details: Optional[str] = Field(alias="details") + user_id: int = Field(alias="user_id") + +class PublicUserDetailsInsert(TypedDict): + details: NotRequired[Annotated[str, Field(alias="details")]] + user_id: Annotated[int, Field(alias="user_id")] + +class PublicUserDetailsUpdate(TypedDict): + details: NotRequired[Annotated[str, Field(alias="details")]] + user_id: NotRequired[Annotated[int, Field(alias="user_id")]] + +class PublicEmpty(BaseModel): + pass + +class PublicEmptyInsert(TypedDict): + pass + +class PublicEmptyUpdate(TypedDict): + pass + +class PublicTableWithOtherTablesRowType(BaseModel): + col1: Optional[PublicUserDetails] = Field(alias="col1") + col2: Optional[PublicAView] = Field(alias="col2") + +class PublicTableWithOtherTablesRowTypeInsert(TypedDict): + col1: NotRequired[Annotated[PublicUserDetails, Field(alias="col1")]] + col2: NotRequired[Annotated[PublicAView, Field(alias="col2")]] + +class PublicTableWithOtherTablesRowTypeUpdate(TypedDict): + col1: NotRequired[Annotated[PublicUserDetails, Field(alias="col1")]] + col2: NotRequired[Annotated[PublicAView, Field(alias="col2")]] + +class PublicTableWithPrimaryKeyOtherThanId(BaseModel): + name: Optional[str] = Field(alias="name") + other_id: int = Field(alias="other_id") + +class PublicTableWithPrimaryKeyOtherThanIdInsert(TypedDict): + name: NotRequired[Annotated[str, Field(alias="name")]] + other_id: NotRequired[Annotated[int, Field(alias="other_id")]] + +class PublicTableWithPrimaryKeyOtherThanIdUpdate(TypedDict): + name: NotRequired[Annotated[str, Field(alias="name")]] + other_id: NotRequired[Annotated[int, Field(alias="other_id")]] + +class PublicEvents(BaseModel): + created_at: datetime.datetime = Field(alias="created_at") + data: Optional[Json[Any]] = Field(alias="data") + event_type: Optional[str] = Field(alias="event_type") + id: int = Field(alias="id") + +class PublicEventsInsert(TypedDict): + created_at: NotRequired[Annotated[datetime.datetime, Field(alias="created_at")]] + data: NotRequired[Annotated[Json[Any], Field(alias="data")]] + event_type: NotRequired[Annotated[str, Field(alias="event_type")]] + id: NotRequired[Annotated[int, Field(alias="id")]] + +class PublicEventsUpdate(TypedDict): + created_at: NotRequired[Annotated[datetime.datetime, Field(alias="created_at")]] + data: NotRequired[Annotated[Json[Any], Field(alias="data")]] + event_type: NotRequired[Annotated[str, Field(alias="event_type")]] + id: NotRequired[Annotated[int, Field(alias="id")]] + +class PublicEvents2024(BaseModel): + created_at: datetime.datetime = Field(alias="created_at") + data: Optional[Json[Any]] = Field(alias="data") + event_type: Optional[str] = Field(alias="event_type") + id: int = Field(alias="id") + +class PublicEvents2024Insert(TypedDict): + created_at: NotRequired[Annotated[datetime.datetime, Field(alias="created_at")]] + data: NotRequired[Annotated[Json[Any], Field(alias="data")]] + event_type: NotRequired[Annotated[str, Field(alias="event_type")]] + id: Annotated[int, Field(alias="id")] + +class PublicEvents2024Update(TypedDict): + created_at: NotRequired[Annotated[datetime.datetime, Field(alias="created_at")]] + data: NotRequired[Annotated[Json[Any], Field(alias="data")]] + event_type: NotRequired[Annotated[str, Field(alias="event_type")]] + id: NotRequired[Annotated[int, Field(alias="id")]] + +class PublicEvents2025(BaseModel): + created_at: datetime.datetime = Field(alias="created_at") + data: Optional[Json[Any]] = Field(alias="data") + event_type: Optional[str] = Field(alias="event_type") + id: int = Field(alias="id") + +class PublicEvents2025Insert(TypedDict): + created_at: NotRequired[Annotated[datetime.datetime, Field(alias="created_at")]] + data: NotRequired[Annotated[Json[Any], Field(alias="data")]] + event_type: NotRequired[Annotated[str, Field(alias="event_type")]] + id: Annotated[int, Field(alias="id")] + +class PublicEvents2025Update(TypedDict): + created_at: NotRequired[Annotated[datetime.datetime, Field(alias="created_at")]] + data: NotRequired[Annotated[Json[Any], Field(alias="data")]] + event_type: NotRequired[Annotated[str, Field(alias="event_type")]] + id: NotRequired[Annotated[int, Field(alias="id")]] + +class PublicCategory(BaseModel): + id: int = Field(alias="id") + name: str = Field(alias="name") + +class PublicCategoryInsert(TypedDict): + id: NotRequired[Annotated[int, Field(alias="id")]] + name: Annotated[str, Field(alias="name")] + +class PublicCategoryUpdate(TypedDict): + id: NotRequired[Annotated[int, Field(alias="id")]] + name: NotRequired[Annotated[str, Field(alias="name")]] + +class PublicMemes(BaseModel): + category: Optional[int] = Field(alias="category") + created_at: datetime.datetime = Field(alias="created_at") + id: int = Field(alias="id") + metadata: Optional[Json[Any]] = Field(alias="metadata") + name: str = Field(alias="name") + status: Optional[PublicMemeStatus] = Field(alias="status") + +class PublicMemesInsert(TypedDict): + category: NotRequired[Annotated[int, Field(alias="category")]] + created_at: Annotated[datetime.datetime, Field(alias="created_at")] + id: NotRequired[Annotated[int, Field(alias="id")]] + metadata: NotRequired[Annotated[Json[Any], Field(alias="metadata")]] + name: Annotated[str, Field(alias="name")] + status: NotRequired[Annotated[PublicMemeStatus, Field(alias="status")]] + +class PublicMemesUpdate(TypedDict): + category: NotRequired[Annotated[int, Field(alias="category")]] + created_at: NotRequired[Annotated[datetime.datetime, Field(alias="created_at")]] + id: NotRequired[Annotated[int, Field(alias="id")]] + metadata: NotRequired[Annotated[Json[Any], Field(alias="metadata")]] + name: NotRequired[Annotated[str, Field(alias="name")]] + status: NotRequired[Annotated[PublicMemeStatus, Field(alias="status")]] + +class PublicAView(BaseModel): + id: Optional[int] = Field(alias="id") + +class PublicTodosView(BaseModel): + details: Optional[str] = Field(alias="details") + id: Optional[int] = Field(alias="id") + user_id: Optional[int] = Field(alias="user-id") + +class PublicUsersView(BaseModel): + decimal: Optional[float] = Field(alias="decimal") + id: Optional[int] = Field(alias="id") + name: Optional[str] = Field(alias="name") + status: Optional[PublicUserStatus] = Field(alias="status") + +class PublicUserTodosSummaryView(BaseModel): + todo_count: Optional[int] = Field(alias="todo_count") + todo_details: Optional[List[str]] = Field(alias="todo_details") + user_id: Optional[int] = Field(alias="user_id") + user_name: Optional[str] = Field(alias="user_name") + user_status: Optional[PublicUserStatus] = Field(alias="user_status") + +class PublicUsersViewWithMultipleRefsToUsers(BaseModel): + initial_id: Optional[int] = Field(alias="initial_id") + initial_name: Optional[str] = Field(alias="initial_name") + second_id: Optional[int] = Field(alias="second_id") + second_name: Optional[str] = Field(alias="second_name") + +class PublicTodosMatview(BaseModel): + details: Optional[str] = Field(alias="details") + id: Optional[int] = Field(alias="id") + user_id: Optional[int] = Field(alias="user-id") + +class PublicCompositeTypeWithArrayAttribute(BaseModel): + my_text_array: List[str] = Field(alias="my_text_array") + +class PublicCompositeTypeWithRecordAttribute(BaseModel): + todo: PublicTodos = Field(alias="todo")" +`) +})