diff --git a/.chronus/changes/add-pydantic-emitter-2026-02-13-02-26-11.md b/.chronus/changes/add-pydantic-emitter-2026-02-13-02-26-11.md new file mode 100644 index 00000000000..51d90024907 --- /dev/null +++ b/.chronus/changes/add-pydantic-emitter-2026-02-13-02-26-11.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/pydantic" +--- + +Add new `@typespec/pydantic` emitter that generates Python pydantic model classes from TypeSpec definitions. Models are categorized by HTTP operation usage into separate modules (`input_types.py`, `output_types.py`, `roundtrip_types.py`). Includes support for constraints, enums, unions, inheritance, and configurable module names. diff --git a/.chronus/config.yaml b/.chronus/config.yaml index 88efb045be2..dd97e35a028 100644 --- a/.chronus/config.yaml +++ b/.chronus/config.yaml @@ -67,6 +67,7 @@ versionPolicies: packages: - "@typespec/http-client-python" - "@typespec/http-client-java" + - "@typespec/pydantic" changelog: ["@chronus/github/changelog", { repo: "microsoft/typespec" }] diff --git a/cspell.yaml b/cspell.yaml index a726a71c8f9..0a8e744c1a4 100644 --- a/cspell.yaml +++ b/cspell.yaml @@ -211,6 +211,7 @@ words: - pyimport - pylint - pylintrc + - pydantic - pyodide - pyproject - pyright diff --git a/packages/pydantic/lib/main.tsp b/packages/pydantic/lib/main.tsp new file mode 100644 index 00000000000..1b59e7f5f31 --- /dev/null +++ b/packages/pydantic/lib/main.tsp @@ -0,0 +1 @@ +import "@typespec/http"; diff --git a/packages/pydantic/package.json b/packages/pydantic/package.json new file mode 100644 index 00000000000..8f96112c41c --- /dev/null +++ b/packages/pydantic/package.json @@ -0,0 +1,68 @@ +{ + "name": "@typespec/pydantic", + "version": "0.1.0", + "author": "Microsoft Corporation", + "description": "TypeSpec library for emitting Pydantic model classes", + "homepage": "https://github.com/microsoft/typespec", + "readme": "https://github.com/microsoft/typespec/blob/main/README.md", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/microsoft/typespec.git" + }, + "bugs": { + "url": "https://github.com/microsoft/typespec/issues" + }, + "keywords": [ + "TypeSpec", + "pydantic", + "python" + ], + "type": "module", + "main": "dist/src/index.js", + "exports": { + ".": { + "typespec": "./lib/main.tsp", + "types": "./dist/src/index.d.ts", + "default": "./dist/src/index.js" + }, + "./testing": { + "types": "./dist/src/testing/index.d.ts", + "default": "./dist/src/testing/index.js" + } + }, + "tspMain": "lib/main.tsp", + "engines": { + "node": ">=20.0.0" + }, + "scripts": { + "clean": "rimraf ./dist ./temp", + "build": "tsc -p tsconfig.build.json", + "watch": "tsc -p tsconfig.build.json --watch", + "test": "vitest run", + "test:ui": "vitest --ui", + "test:ci": "vitest run --coverage --reporter=junit --reporter=default", + "lint": "eslint . --max-warnings=0", + "lint:fix": "eslint . --fix" + }, + "files": [ + "lib/*.tsp", + "dist/**", + "!dist/test/**" + ], + "peerDependencies": { + "@typespec/compiler": "workspace:^", + "@typespec/http": "workspace:^" + }, + "devDependencies": { + "@types/node": "~25.0.2", + "@typespec/compiler": "workspace:^", + "@typespec/http": "workspace:^", + "@typespec/rest": "workspace:^", + "@vitest/coverage-v8": "^4.0.15", + "@vitest/ui": "^4.0.15", + "rimraf": "~6.1.2", + "typescript": "~5.9.2", + "vitest": "^4.0.15" + } +} diff --git a/packages/pydantic/src/emitter.ts b/packages/pydantic/src/emitter.ts new file mode 100644 index 00000000000..001bd322245 --- /dev/null +++ b/packages/pydantic/src/emitter.ts @@ -0,0 +1,745 @@ +import type { Namespace } from "@typespec/compiler"; +import { + emitFile, + getDoc, + getMaxItems, + getMaxLength, + getMaxValue, + getMaxValueExclusive, + getMinItems, + getMinLength, + getMinValue, + getMinValueExclusive, + getPattern, + isArrayModelType, + resolvePath, + type Enum, + type Model, + type ModelProperty, + type Program, + type Scalar, + type Type, + type Union, +} from "@typespec/compiler"; +import { getAllHttpServices, listHttpOperationsIn } from "@typespec/http"; +import type { PydanticEmitterOptions } from "./lib.js"; + +/** Usage category for a model */ +export type ModelUsage = "input" | "output" | "roundtrip"; + +interface EmitResult { + /** Map from output file path to content */ + outputs: Map; +} + +/** + * Categorize all models by their usage in HTTP operations. + * Returns maps of model → usage for the given program. + */ +export function categorizeModels( + program: Program, +): Map> { + const usage = new Map>(); + + function markUsage(type: Type, role: "input" | "output", visited: Set): void { + if (visited.has(type)) return; + visited.add(type); + + if (type.kind === "Model") { + if (isIntrinsicModel(type)) return; + if (!usage.has(type)) usage.set(type, new Set()); + usage.get(type)!.add(role); + // Recursively mark property types + for (const prop of type.properties.values()) { + markUsage(prop.type, role, visited); + } + if (type.baseModel) { + markUsage(type.baseModel, role, visited); + } + if (type.indexer) { + markUsage(type.indexer.value, role, visited); + } + } else if (type.kind === "Enum") { + if (isLibraryType(type)) return; + if (!usage.has(type)) usage.set(type, new Set()); + usage.get(type)!.add(role); + } else if (type.kind === "Union" && type.name) { + if (isLibraryType(type)) return; + if (!usage.has(type)) usage.set(type, new Set()); + usage.get(type)!.add(role); + for (const variant of type.variants.values()) { + markUsage(variant.type, role, visited); + } + } else if (type.kind === "Union") { + for (const variant of type.variants.values()) { + markUsage(variant.type, role, visited); + } + } + } + + // Try services first, then fallback to iterating all namespaces + const [services] = getAllHttpServices(program); + let foundOps = false; + for (const service of services) { + for (const op of service.operations) { + foundOps = true; + processOp(op); + } + } + + // Also scan all user namespaces for operations not in services + if (!foundOps) { + const visitedNs = new Set(); + function scanNamespace(ns: Namespace): void { + if (visitedNs.has(ns)) return; + visitedNs.add(ns); + if (isLibraryNamespace(ns)) return; + const [ops] = listHttpOperationsIn(program, ns); + for (const op of ops) { + processOp(op); + } + for (const child of ns.namespaces.values()) { + scanNamespace(child); + } + } + for (const ns of program.getGlobalNamespaceType().namespaces.values()) { + scanNamespace(ns); + } + } + + function processOp(op: import("@typespec/http").HttpOperation): void { + // Input: request body + if (op.parameters.body) { + markUsage(op.parameters.body.type, "input", new Set()); + } + // Input: parameter types that are models + for (const param of op.parameters.parameters) { + markUsage(param.param.type, "input", new Set()); + } + // Output: response bodies + for (const response of op.responses) { + for (const content of response.responses) { + if (content.body) { + markUsage(content.body.type, "output", new Set()); + } + } + } + } + + return usage; +} + +function isIntrinsicModel(model: Model): boolean { + // Skip built-in types like Array, Record, etc. that have no namespace or are from TypeSpec namespace + if (!model.name) return true; + return isLibraryType(model); +} + +/** + * Check if a type belongs to a standard library namespace (TypeSpec, TypeSpec.Http, etc.) + */ +function isLibraryType(type: Model | Enum | Union): boolean { + const ns = type.namespace; + if (!ns) return false; + const nsName = getNamespaceName(ns); + return nsName === "TypeSpec" || nsName.startsWith("TypeSpec."); +} + +function getNamespaceName(ns: { + name: string; + namespace?: { name: string; namespace?: any }; +}): string { + if (ns.namespace && ns.namespace.name) { + const parent = getNamespaceName(ns.namespace); + return parent ? `${parent}.${ns.name}` : ns.name; + } + return ns.name; +} + +function isLibraryNamespace(ns: Namespace): boolean { + const nsName = getNamespaceName(ns); + return nsName === "TypeSpec" || nsName.startsWith("TypeSpec."); +} + +/** + * Determine the usage category for a model. + */ +export function getModelUsage( + usageMap: Map>, + type: Model | Enum | Union, +): ModelUsage { + const roles = usageMap.get(type); + if (!roles) return "roundtrip"; // default for uncategorized models + if (roles.has("input") && roles.has("output")) return "roundtrip"; + if (roles.has("input")) return "input"; + return "output"; +} + +/** + * Map a TypeSpec scalar to Python type string. + */ +export function scalarToPythonType(scalar: Scalar): string { + let current: Scalar | undefined = scalar; + while (current) { + switch (current.name) { + case "string": + case "url": + case "plainDate": + case "plainTime": + return "str"; + case "boolean": + return "bool"; + case "int8": + case "int16": + case "int32": + case "int64": + case "uint8": + case "uint16": + case "uint32": + case "uint64": + case "integer": + case "safeint": + return "int"; + case "float32": + case "float64": + case "float": + case "numeric": + case "decimal": + case "decimal128": + return "float"; + case "bytes": + return "bytes"; + case "utcDateTime": + case "offsetDateTime": + return "datetime"; + case "duration": + return "timedelta"; + } + current = current.baseScalar; + } + return "Any"; +} + +/** + * Get the set of imports needed for the given Python types. + */ +function getImportsForTypes(types: Set): string[] { + const imports: string[] = []; + if (types.has("datetime") || types.has("timedelta")) { + const dtTypes: string[] = []; + if (types.has("datetime")) dtTypes.push("datetime"); + if (types.has("timedelta")) dtTypes.push("timedelta"); + imports.push(`from datetime import ${dtTypes.join(", ")}`); + } + return imports; +} + +/** + * Convert a TypeSpec type reference to a Python type annotation string. + * Returns [typeAnnotation, referencedModels] where referencedModels are model + * names that need to be imported. + */ +export function typeToAnnotation(program: Program, type: Type, refModels: Set): string { + switch (type.kind) { + case "Scalar": + return scalarToPythonType(type); + case "Model": { + if (isArrayModelType(type)) { + // Array → List[T] + const elementType = type.indexer!.value; + const inner = typeToAnnotation(program, elementType, refModels); + return `List[${inner}]`; + } + if (type.name === "Record") { + // Record → Dict[str, T] + const valueType = type.indexer!.value; + const inner = typeToAnnotation(program, valueType, refModels); + return `Dict[str, ${inner}]`; + } + if (!type.name || isIntrinsicModel(type)) { + return "Any"; + } + refModels.add(type.name); + return `"${type.name}"`; + } + case "Enum": + if (type.name) refModels.add(type.name); + return `"${type.name}"`; + case "Union": { + const variants: string[] = []; + for (const v of type.variants.values()) { + variants.push(typeToAnnotation(program, v.type, refModels)); + } + if (variants.length === 0) return "Any"; + if (variants.length === 1) return variants[0]; + return `Union[${variants.join(", ")}]`; + } + case "Intrinsic": + if (type.name === "null") return "None"; + return "Any"; + case "String": + return "str"; + case "Number": + return Number.isInteger(type.value) ? "int" : "float"; + case "Boolean": + return "bool"; + default: + return "Any"; + } +} + +/** + * Build the Field() arguments for constraints on a property. + */ +export function getFieldConstraints(program: Program, prop: ModelProperty): string[] { + const args: string[] = []; + const type = prop.type; + const targets: (ModelProperty | Scalar | Model)[] = [prop]; + if (type.kind === "Scalar") targets.push(type); + if (type.kind === "Model") targets.push(type); + + for (const target of targets) { + const minLength = getMinLength(program, target); + if (minLength !== undefined) args.push(`min_length=${minLength}`); + + const maxLength = getMaxLength(program, target); + if (maxLength !== undefined) args.push(`max_length=${maxLength}`); + + const pattern = getPattern(program, target); + if (pattern !== undefined) args.push(`pattern=${JSON.stringify(pattern)}`); + + const minVal = getMinValue(program, target); + if (minVal !== undefined) args.push(`ge=${minVal}`); + + const maxVal = getMaxValue(program, target); + if (maxVal !== undefined) args.push(`le=${maxVal}`); + + const minValEx = getMinValueExclusive(program, target); + if (minValEx !== undefined) args.push(`gt=${minValEx}`); + + const maxValEx = getMaxValueExclusive(program, target); + if (maxValEx !== undefined) args.push(`lt=${maxValEx}`); + + const minItems = getMinItems(program, target); + if (minItems !== undefined) args.push(`min_length=${minItems}`); + + const maxItems = getMaxItems(program, target); + if (maxItems !== undefined) args.push(`max_length=${maxItems}`); + } + + // Deduplicate + return [...new Set(args)]; +} + +/** + * Get the default value string for Python. + */ +function getDefaultValue(prop: ModelProperty): string | undefined { + if (prop.defaultValue === undefined) return undefined; + const val = prop.defaultValue; + switch (val.valueKind) { + case "StringValue": + return JSON.stringify(val.value); + case "NumericValue": + return val.value.toString(); + case "BooleanValue": + return val.value ? "True" : "False"; + case "NullValue": + return "None"; + case "EnumValue": + return val.value.value !== undefined + ? JSON.stringify(val.value.value) + : JSON.stringify(val.value.name); + default: + return undefined; + } +} + +/** + * Generate a Pydantic model class for a TypeSpec Model. + */ +export function emitModel(program: Program, model: Model): string { + const lines: string[] = []; + const doc = getDoc(program, model); + const baseClass = + model.baseModel && !isIntrinsicModel(model.baseModel) ? model.baseModel.name : "BaseModel"; + + lines.push(`class ${model.name}(${baseClass}):`); + if (doc) { + lines.push(` """${doc}"""`); + } + + const props = [...model.properties.values()]; + if (props.length === 0 && !doc) { + lines.push(" pass"); + } else if (props.length === 0) { + lines.push(" pass"); + } + + for (const prop of props) { + const refModels = new Set(); + let annotation = typeToAnnotation(program, prop.type, refModels); + const constraints = getFieldConstraints(program, prop); + const defaultVal = getDefaultValue(prop); + const propDoc = getDoc(program, prop); + + if (propDoc) { + constraints.push(`description=${JSON.stringify(propDoc)}`); + } + + if (prop.optional) { + annotation = `Optional[${annotation}]`; + } + + let line: string; + if (constraints.length > 0) { + if (defaultVal !== undefined) { + constraints.unshift(`default=${defaultVal}`); + } else if (prop.optional) { + constraints.unshift("default=None"); + } + line = ` ${prop.name}: ${annotation} = Field(${constraints.join(", ")})`; + } else if (defaultVal !== undefined) { + line = ` ${prop.name}: ${annotation} = ${defaultVal}`; + } else if (prop.optional) { + line = ` ${prop.name}: ${annotation} = None`; + } else { + line = ` ${prop.name}: ${annotation}`; + } + lines.push(line); + } + + return lines.join("\n"); +} + +/** + * Generate a Python Enum class for a TypeSpec Enum. + */ +export function emitEnum(program: Program, enumType: Enum): string { + const lines: string[] = []; + const doc = getDoc(program, enumType); + const members = [...enumType.members.values()]; + + // Determine if all values are strings or ints + const allString = members.every((m) => m.value === undefined || typeof m.value === "string"); + const allInt = members.every((m) => m.value !== undefined && typeof m.value === "number"); + const baseClass = allInt ? "IntEnum" : allString ? "StrEnum" : "Enum"; + + lines.push(`class ${enumType.name}(${baseClass}):`); + if (doc) { + lines.push(` """${doc}"""`); + } + + for (const member of members) { + const val = member.value !== undefined ? member.value : member.name; + const pyVal = typeof val === "string" ? JSON.stringify(val) : String(val); + lines.push(` ${member.name} = ${pyVal}`); + } + + if (members.length === 0) { + lines.push(" pass"); + } + + return lines.join("\n"); +} + +/** + * Generate a type alias or Union for a TypeSpec Union. + */ +export function emitUnion(program: Program, union: Union): string { + const lines: string[] = []; + const doc = getDoc(program, union); + const refModels = new Set(); + const variants: string[] = []; + for (const v of union.variants.values()) { + variants.push(typeToAnnotation(program, v.type, refModels)); + } + + const unionType = variants.length > 0 ? `Union[${variants.join(", ")}]` : "Any"; + + lines.push(`${union.name} = ${unionType}`); + if (doc) { + lines.push(`"""${doc}"""`); + } + + return lines.join("\n"); +} + +/** + * Collect all models, enums, and named unions from a program. + */ +export function collectAllTypes(program: Program): { + models: Model[]; + enums: Enum[]; + unions: Union[]; +} { + const models: Model[] = []; + const enums: Enum[] = []; + const unions: Union[] = []; + const visited = new Set(); + + function visit(ns: any): void { + if (!ns) return; + if (ns.models) { + for (const model of ns.models.values()) { + if (!visited.has(model) && !isIntrinsicModel(model) && model.name) { + visited.add(model); + models.push(model); + } + } + } + if (ns.enums) { + for (const e of ns.enums.values()) { + if (!visited.has(e) && !isLibraryType(e)) { + visited.add(e); + enums.push(e); + } + } + } + if (ns.unions) { + for (const u of ns.unions.values()) { + if (!visited.has(u) && u.name && !isLibraryType(u)) { + visited.add(u); + unions.push(u); + } + } + } + if (ns.namespaces) { + for (const child of ns.namespaces.values()) { + visit(child); + } + } + } + + // Visit all namespaces in the program + for (const ns of program.getGlobalNamespaceType().namespaces.values()) { + visit(ns); + } + + return { models, enums, unions }; +} + +/** + * Generate a Python module with the given types. + */ +export function generateModule(program: Program, types: (Model | Enum | Union)[]): string { + const sections: string[] = []; + const needsField = types.some( + (t) => + t.kind === "Model" && + [...t.properties.values()].some( + (p) => getFieldConstraints(program, p).length > 0 || getDoc(program, p) !== undefined, + ), + ); + const needsOptional = types.some( + (t) => t.kind === "Model" && [...t.properties.values()].some((p) => p.optional), + ); + const needsUnionImport = + types.some((t) => t.kind === "Union") || + types.some( + (t) => + t.kind === "Model" && + [...t.properties.values()].some((p) => { + return p.type.kind === "Union"; + }), + ); + const needsList = types.some( + (t) => + t.kind === "Model" && + [...t.properties.values()].some((p) => p.type.kind === "Model" && isArrayModelType(p.type)), + ); + const needsDict = types.some( + (t) => + t.kind === "Model" && + [...t.properties.values()].some((p) => p.type.kind === "Model" && p.type.name === "Record"), + ); + const needsAny = types.some( + (t) => + t.kind === "Model" && + [...t.properties.values()].some((p) => { + const refModels = new Set(); + return typeToAnnotation(program, p.type, refModels) === "Any"; + }), + ); + + const pythonTypes = new Set(); + for (const t of types) { + if (t.kind === "Model") { + for (const p of t.properties.values()) { + const refModels = new Set(); + const ann = typeToAnnotation(program, p.type, refModels); + if (ann === "datetime" || ann.includes("datetime")) pythonTypes.add("datetime"); + if (ann === "timedelta" || ann.includes("timedelta")) pythonTypes.add("timedelta"); + } + } + } + + // Standard library imports + const stdImports = getImportsForTypes(pythonTypes); + + // Typing imports + const typingImports: string[] = []; + if (needsOptional) typingImports.push("Optional"); + if (needsUnionImport) typingImports.push("Union"); + if (needsList) typingImports.push("List"); + if (needsDict) typingImports.push("Dict"); + if (needsAny) typingImports.push("Any"); + if (typingImports.length > 0) { + sections.push(`from typing import ${typingImports.join(", ")}`); + } + + // Enum imports + const hasEnum = types.some((t) => t.kind === "Enum"); + if (hasEnum) { + const enumBases = new Set(); + for (const t of types) { + if (t.kind === "Enum") { + const members = [...t.members.values()]; + const allInt = members.every((m) => m.value !== undefined && typeof m.value === "number"); + const allString = members.every( + (m) => m.value === undefined || typeof m.value === "string", + ); + if (allInt) enumBases.add("IntEnum"); + else if (allString) enumBases.add("StrEnum"); + else enumBases.add("Enum"); + } + } + sections.push(`from enum import ${[...enumBases].join(", ")}`); + } + + // Standard library imports + for (const imp of stdImports) { + sections.push(imp); + } + + // Pydantic imports + const pydanticImports: string[] = ["BaseModel"]; + if (needsField) pydanticImports.push("Field"); + const hasModelType = types.some((t) => t.kind === "Model"); + if (hasModelType) { + sections.push(`from pydantic import ${pydanticImports.join(", ")}`); + } else if (needsField) { + sections.push(`from pydantic import ${pydanticImports.join(", ")}`); + } + + sections.push(""); // blank line after imports + + // Emit enums first (they may be referenced by models) + for (const t of types) { + if (t.kind === "Enum") { + sections.push(""); + sections.push(emitEnum(program, t)); + } + } + + // Emit unions + for (const t of types) { + if (t.kind === "Union") { + sections.push(""); + sections.push(emitUnion(program, t)); + } + } + + // Emit models (order: base classes first) + const modelOrder = orderModels(types.filter((t) => t.kind === "Model") as Model[]); + for (const model of modelOrder) { + sections.push(""); + sections.push(emitModel(program, model)); + } + + return sections.join("\n") + "\n"; +} + +/** + * Sort models so base classes appear before derived classes. + */ +function orderModels(models: Model[]): Model[] { + const modelSet = new Set(models); + const ordered: Model[] = []; + const visited = new Set(); + + function visit(model: Model): void { + if (visited.has(model)) return; + visited.add(model); + if (model.baseModel && modelSet.has(model.baseModel)) { + visit(model.baseModel); + } + ordered.push(model); + } + + for (const m of models) { + visit(m); + } + + return ordered; +} + +/** + * Main emit function. + */ +export async function emitPydantic( + program: Program, + emitterOutputDir: string, + options: PydanticEmitterOptions, +): Promise { + const inputModuleName = options["input-module-name"] ?? "input_types"; + const outputModuleName = options["output-module-name"] ?? "output_types"; + const roundtripModuleName = options["roundtrip-module-name"] ?? "roundtrip_types"; + const constrainToUsed = options["constrain-to-used"] ?? false; + + // Categorize models by usage + const usageMap = categorizeModels(program); + + // Collect all types + const { models, enums, unions } = collectAllTypes(program); + const allTypes: (Model | Enum | Union)[] = [...models, ...enums, ...unions]; + + // Group types by usage + const inputTypes: (Model | Enum | Union)[] = []; + const outputTypes: (Model | Enum | Union)[] = []; + const roundtripTypes: (Model | Enum | Union)[] = []; + + for (const t of allTypes) { + const usage = getModelUsage(usageMap, t); + if (constrainToUsed && !usageMap.has(t)) { + continue; // Skip types not used in any operation + } + switch (usage) { + case "input": + inputTypes.push(t); + break; + case "output": + outputTypes.push(t); + break; + case "roundtrip": + roundtripTypes.push(t); + break; + } + } + + const outputs = new Map(); + + // Generate modules + if (inputTypes.length > 0) { + const content = generateModule(program, inputTypes); + const path = resolvePath(emitterOutputDir, `${inputModuleName}.py`); + await emitFile(program, { path, content }); + outputs.set(`${inputModuleName}.py`, content); + } + + if (outputTypes.length > 0) { + const content = generateModule(program, outputTypes); + const path = resolvePath(emitterOutputDir, `${outputModuleName}.py`); + await emitFile(program, { path, content }); + outputs.set(`${outputModuleName}.py`, content); + } + + if (roundtripTypes.length > 0) { + const content = generateModule(program, roundtripTypes); + const path = resolvePath(emitterOutputDir, `${roundtripModuleName}.py`); + await emitFile(program, { path, content }); + outputs.set(`${roundtripModuleName}.py`, content); + } + + return { outputs }; +} diff --git a/packages/pydantic/src/index.ts b/packages/pydantic/src/index.ts new file mode 100644 index 00000000000..60459df9f56 --- /dev/null +++ b/packages/pydantic/src/index.ts @@ -0,0 +1,12 @@ +import type { EmitContext } from "@typespec/compiler"; +import { emitPydantic } from "./emitter.js"; +import type { PydanticEmitterOptions } from "./lib.js"; + +export { $flags, $lib, EmitterOptionsSchema, type PydanticEmitterOptions } from "./lib.js"; + +/** + * Internal: TypeSpec emitter entry point + */ +export async function $onEmit(context: EmitContext) { + await emitPydantic(context.program, context.emitterOutputDir, context.options); +} diff --git a/packages/pydantic/src/lib.ts b/packages/pydantic/src/lib.ts new file mode 100644 index 00000000000..7b828adf007 --- /dev/null +++ b/packages/pydantic/src/lib.ts @@ -0,0 +1,77 @@ +import { createTypeSpecLibrary, definePackageFlags, type JSONSchemaType } from "@typespec/compiler"; + +/** + * Pydantic emitter options + */ +export interface PydanticEmitterOptions { + /** + * Name of the module for input (request) models. + * @defaultValue "input_types" + */ + "input-module-name"?: string; + + /** + * Name of the module for output (response) models. + * @defaultValue "output_types" + */ + "output-module-name"?: string; + + /** + * Name of the module for roundtrip (input+output) models. + * @defaultValue "roundtrip_types" + */ + "roundtrip-module-name"?: string; + + /** + * When true, only emit models that are used as input or output in HTTP operations. + * When false, emit all models defined in the program. + * @defaultValue false + */ + "constrain-to-used"?: boolean; +} + +/** + * Internal: Pydantic emitter options schema + */ +export const EmitterOptionsSchema: JSONSchemaType = { + type: "object", + additionalProperties: false, + properties: { + "input-module-name": { + type: "string", + nullable: true, + description: "Name of the module for input (request) models. Default: input_types", + }, + "output-module-name": { + type: "string", + nullable: true, + description: "Name of the module for output (response) models. Default: output_types", + }, + "roundtrip-module-name": { + type: "string", + nullable: true, + description: + "Name of the module for roundtrip (input+output) models. Default: roundtrip_types", + }, + "constrain-to-used": { + type: "boolean", + nullable: true, + default: false, + description: + "When true, only emit models that are used as input or output in HTTP operations. Default: false", + }, + }, + required: [], +}; + +/** Internal: TypeSpec library definition */ +export const $lib = createTypeSpecLibrary({ + name: "@typespec/pydantic", + diagnostics: {}, + emitter: { + options: EmitterOptionsSchema as JSONSchemaType, + }, +} as const); + +/** Internal: TypeSpec flags */ +export const $flags = definePackageFlags({}); diff --git a/packages/pydantic/src/testing/index.ts b/packages/pydantic/src/testing/index.ts new file mode 100644 index 00000000000..0d0e20cf9d2 --- /dev/null +++ b/packages/pydantic/src/testing/index.ts @@ -0,0 +1 @@ +export { PydanticTestLibrary } from "./test-host.js"; diff --git a/packages/pydantic/src/testing/test-host.ts b/packages/pydantic/src/testing/test-host.ts new file mode 100644 index 00000000000..894a2be3e04 --- /dev/null +++ b/packages/pydantic/src/testing/test-host.ts @@ -0,0 +1,6 @@ +import { resolvePath } from "@typespec/compiler"; +import { createTester } from "@typespec/compiler/testing"; + +export const PydanticTestLibrary = createTester(resolvePath(import.meta.dirname, "..", ".."), { + libraries: ["@typespec/http", "@typespec/rest"], +}); diff --git a/packages/pydantic/test/categorization.test.ts b/packages/pydantic/test/categorization.test.ts new file mode 100644 index 00000000000..94bc7ee313c --- /dev/null +++ b/packages/pydantic/test/categorization.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; +import { emitPydantic } from "./utils.js"; + +describe("pydantic emitter: input/output/roundtrip categorization", () => { + it("categorizes request body model as input", async () => { + const output = await emitPydantic(` + using TypeSpec.Http; + @route("/test") + namespace TestService; + model CreateRequest { + name: string; + } + model CreateResponse { + id: string; + } + @post op create(@body body: CreateRequest): CreateResponse; + `); + + expect(output["input_types.py"]).toBeDefined(); + expect(output["input_types.py"]).toContain("class CreateRequest(BaseModel):"); + expect(output["output_types.py"]).toBeDefined(); + expect(output["output_types.py"]).toContain("class CreateResponse(BaseModel):"); + }); + + it("categorizes model used in both request and response as roundtrip", async () => { + const output = await emitPydantic(` + using TypeSpec.Http; + @route("/test") + namespace TestService; + model User { + name: string; + age: int32; + } + @post op createUser(@body body: User): User; + `); + + expect(output["roundtrip_types.py"]).toBeDefined(); + expect(output["roundtrip_types.py"]).toContain("class User(BaseModel):"); + // Should NOT appear in input or output + expect(output["input_types.py"]).toBeUndefined(); + expect(output["output_types.py"]).toBeUndefined(); + }); + + it("categorizes response-only model as output", async () => { + const output = await emitPydantic(` + using TypeSpec.Http; + @route("/test") + namespace TestService; + model Result { + value: string; + } + @get op getResult(): Result; + `); + + expect(output["output_types.py"]).toBeDefined(); + expect(output["output_types.py"]).toContain("class Result(BaseModel):"); + }); + + it("puts uncategorized models in roundtrip when constrain-to-used is false", async () => { + const output = await emitPydantic(` + using TypeSpec.Http; + @route("/test") + namespace TestService; + model Unused { + data: string; + } + model Used { + value: string; + } + @get op getUsed(): Used; + `); + + // Unused model should go to roundtrip since it's not used in any operation + expect(output["roundtrip_types.py"]).toBeDefined(); + expect(output["roundtrip_types.py"]).toContain("class Unused(BaseModel):"); + expect(output["output_types.py"]).toBeDefined(); + expect(output["output_types.py"]).toContain("class Used(BaseModel):"); + }); + + it("excludes uncategorized models when constrain-to-used is true", async () => { + const output = await emitPydantic( + ` + using TypeSpec.Http; + @route("/test") + namespace TestService; + model Unused { + data: string; + } + model Used { + value: string; + } + @get op getUsed(): Used; + `, + { "constrain-to-used": true }, + ); + + // Unused model should be excluded + expect(output["output_types.py"]).toBeDefined(); + expect(output["output_types.py"]).toContain("class Used(BaseModel):"); + const roundtrip = output["roundtrip_types.py"]; + if (roundtrip) { + expect(roundtrip).not.toContain("class Unused(BaseModel):"); + } + }); + + it("includes referenced models transitively", async () => { + const output = await emitPydantic(` + using TypeSpec.Http; + @route("/test") + namespace TestService; + model Address { + street: string; + city: string; + } + model User { + name: string; + address: Address; + } + @get op getUser(): User; + `); + + // Both User and Address should be in output since Address is referenced by User + const content = output["output_types.py"]; + expect(content).toBeDefined(); + expect(content).toContain("class User(BaseModel):"); + expect(content).toContain("class Address(BaseModel):"); + }); +}); diff --git a/packages/pydantic/test/complex-types.test.ts b/packages/pydantic/test/complex-types.test.ts new file mode 100644 index 00000000000..246348c7b09 --- /dev/null +++ b/packages/pydantic/test/complex-types.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; +import { emitPydantic } from "./utils.js"; + +describe("pydantic emitter: complex types", () => { + it("emits array properties as List", async () => { + const output = await emitPydantic(` + using TypeSpec.Http; + @route("/test") + namespace TestService; + model Foo { + items: string[]; + } + @get op getFoo(): Foo; + `); + + const content = output["output_types.py"]; + expect(content).toBeDefined(); + expect(content).toContain("items: List[str]"); + expect(content).toContain("from typing import"); + expect(content).toContain("List"); + }); + + it("emits Record properties as Dict", async () => { + const output = await emitPydantic(` + using TypeSpec.Http; + @route("/test") + namespace TestService; + model Foo { + metadata: Record; + } + @get op getFoo(): Foo; + `); + + const content = output["output_types.py"]; + expect(content).toBeDefined(); + expect(content).toContain("Dict[str, str]"); + }); + + it("emits model references as forward references", async () => { + const output = await emitPydantic(` + using TypeSpec.Http; + @route("/test") + namespace TestService; + model Parent { + child: Child; + } + model Child { + name: string; + } + @get op getParent(): Parent; + `); + + const content = output["output_types.py"]; + expect(content).toBeDefined(); + expect(content).toContain('"Child"'); + expect(content).toContain("class Child(BaseModel):"); + expect(content).toContain("class Parent(BaseModel):"); + }); + + it("emits enum references in model properties", async () => { + const output = await emitPydantic(` + using TypeSpec.Http; + @route("/test") + namespace TestService; + enum Status { active, inactive } + model User { + name: string; + status: Status; + } + @get op getUser(): User; + `); + + const content = output["output_types.py"]; + expect(content).toBeDefined(); + expect(content).toContain("class Status(StrEnum):"); + expect(content).toContain('"Status"'); + }); + + it("handles multiple operations correctly", async () => { + const output = await emitPydantic(` + using TypeSpec.Http; + @route("/test") + namespace TestService; + model CreateUserRequest { + name: string; + email: string; + } + model UserResponse { + id: string; + name: string; + } + model UpdateUserRequest { + name?: string; + } + @post op createUser(@body body: CreateUserRequest): UserResponse; + @put op updateUser(@body body: UpdateUserRequest): UserResponse; + `); + + // CreateUserRequest and UpdateUserRequest are input only + expect(output["input_types.py"]).toBeDefined(); + expect(output["input_types.py"]).toContain("class CreateUserRequest(BaseModel):"); + expect(output["input_types.py"]).toContain("class UpdateUserRequest(BaseModel):"); + + // UserResponse is output only + expect(output["output_types.py"]).toBeDefined(); + expect(output["output_types.py"]).toContain("class UserResponse(BaseModel):"); + }); + + it("emits datetime types correctly", async () => { + const output = await emitPydantic(` + using TypeSpec.Http; + @route("/test") + namespace TestService; + model Event { + createdAt: utcDateTime; + } + @get op getEvent(): Event; + `); + + const content = output["output_types.py"]; + expect(content).toBeDefined(); + expect(content).toContain("createdAt: datetime"); + expect(content).toContain("from datetime import datetime"); + }); +}); diff --git a/packages/pydantic/test/configuration.test.ts b/packages/pydantic/test/configuration.test.ts new file mode 100644 index 00000000000..6de84e8c8d6 --- /dev/null +++ b/packages/pydantic/test/configuration.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import { emitPydantic } from "./utils.js"; + +describe("pydantic emitter: configuration", () => { + it("allows overriding input module name", async () => { + const output = await emitPydantic( + ` + using TypeSpec.Http; + @route("/test") + namespace TestService; + model Req { name: string; } + model Res { id: string; } + @post op create(@body body: Req): Res; + `, + { "input-module-name": "my_input" }, + ); + + expect(output["my_input.py"]).toBeDefined(); + expect(output["my_input.py"]).toContain("class Req(BaseModel):"); + expect(output["input_types.py"]).toBeUndefined(); + }); + + it("allows overriding output module name", async () => { + const output = await emitPydantic( + ` + using TypeSpec.Http; + @route("/test") + namespace TestService; + model Req { name: string; } + model Res { id: string; } + @post op create(@body body: Req): Res; + `, + { "output-module-name": "my_output" }, + ); + + expect(output["my_output.py"]).toBeDefined(); + expect(output["my_output.py"]).toContain("class Res(BaseModel):"); + expect(output["output_types.py"]).toBeUndefined(); + }); + + it("allows overriding roundtrip module name", async () => { + const output = await emitPydantic( + ` + using TypeSpec.Http; + @route("/test") + namespace TestService; + model Shared { value: string; } + @post op echo(@body body: Shared): Shared; + `, + { "roundtrip-module-name": "shared_models" }, + ); + + expect(output["shared_models.py"]).toBeDefined(); + expect(output["shared_models.py"]).toContain("class Shared(BaseModel):"); + expect(output["roundtrip_types.py"]).toBeUndefined(); + }); + + it("allows overriding all module names simultaneously", async () => { + const output = await emitPydantic( + ` + using TypeSpec.Http; + @route("/test") + namespace TestService; + model InOnly { data: string; } + model OutOnly { result: string; } + model Both { value: string; } + @post op doSomething(@body body: InOnly): OutOnly; + @post op doRoundtrip(@body body: Both): Both; + `, + { + "input-module-name": "req", + "output-module-name": "res", + "roundtrip-module-name": "shared", + }, + ); + + expect(output["req.py"]).toBeDefined(); + expect(output["req.py"]).toContain("class InOnly(BaseModel):"); + expect(output["res.py"]).toBeDefined(); + expect(output["res.py"]).toContain("class OutOnly(BaseModel):"); + expect(output["shared.py"]).toBeDefined(); + expect(output["shared.py"]).toContain("class Both(BaseModel):"); + }); + + it("constrains output to only used models", async () => { + const output = await emitPydantic( + ` + using TypeSpec.Http; + @route("/test") + namespace TestService; + model UsedModel { name: string; } + model UnusedModel { data: string; } + @get op get(): UsedModel; + `, + { "constrain-to-used": true }, + ); + + expect(output["output_types.py"]).toBeDefined(); + expect(output["output_types.py"]).toContain("class UsedModel(BaseModel):"); + + // UnusedModel should NOT appear anywhere + for (const content of Object.values(output)) { + expect(content).not.toContain("UnusedModel"); + } + }); +}); diff --git a/packages/pydantic/test/constraints.test.ts b/packages/pydantic/test/constraints.test.ts new file mode 100644 index 00000000000..986a93b8702 --- /dev/null +++ b/packages/pydantic/test/constraints.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; +import { emitPydantic } from "./utils.js"; + +describe("pydantic emitter: constraints", () => { + describe("string constraints", () => { + it("emits minLength and maxLength", async () => { + const output = await emitPydantic(` + using TypeSpec.Http; + @route("/test") + namespace TestService; + model User { + @minLength(1) + @maxLength(100) + name: string; + } + @get op getUser(): User; + `); + + const content = output["output_types.py"]; + expect(content).toBeDefined(); + expect(content).toContain("min_length=1"); + expect(content).toContain("max_length=100"); + expect(content).toContain("Field("); + }); + + it("emits pattern constraint", async () => { + const output = await emitPydantic(` + using TypeSpec.Http; + @route("/test") + namespace TestService; + model User { + @pattern("^[a-zA-Z]+$") + name: string; + } + @get op getUser(): User; + `); + + const content = output["output_types.py"]; + expect(content).toBeDefined(); + expect(content).toContain('pattern="^[a-zA-Z]+$"'); + }); + }); + + describe("numeric constraints", () => { + it("emits minValue and maxValue", async () => { + const output = await emitPydantic(` + using TypeSpec.Http; + @route("/test") + namespace TestService; + model Score { + @minValue(0) + @maxValue(100) + value: int32; + } + @get op getScore(): Score; + `); + + const content = output["output_types.py"]; + expect(content).toBeDefined(); + expect(content).toContain("ge=0"); + expect(content).toContain("le=100"); + }); + + it("emits exclusive min and max values", async () => { + const output = await emitPydantic(` + using TypeSpec.Http; + @route("/test") + namespace TestService; + model Range { + @minValueExclusive(0) + @maxValueExclusive(100) + value: float64; + } + @get op getRange(): Range; + `); + + const content = output["output_types.py"]; + expect(content).toBeDefined(); + expect(content).toContain("gt=0"); + expect(content).toContain("lt=100"); + }); + }); + + describe("array constraints", () => { + it("emits minItems and maxItems", async () => { + const output = await emitPydantic(` + using TypeSpec.Http; + @route("/test") + namespace TestService; + model Collection { + @minItems(1) + @maxItems(50) + items: string[]; + } + @get op getCollection(): Collection; + `); + + const content = output["output_types.py"]; + expect(content).toBeDefined(); + expect(content).toContain("min_length=1"); + expect(content).toContain("max_length=50"); + }); + }); + + it("emits property documentation as field description", async () => { + const output = await emitPydantic(` + using TypeSpec.Http; + @route("/test") + namespace TestService; + model User { + @doc("The user's full name") + name: string; + } + @get op getUser(): User; + `); + + const content = output["output_types.py"]; + expect(content).toBeDefined(); + expect(content).toContain('description="The user\'s full name"'); + }); + + it("combines multiple constraints with Field", async () => { + const output = await emitPydantic(` + using TypeSpec.Http; + @route("/test") + namespace TestService; + model User { + @minLength(1) + @maxLength(100) + @pattern("^[a-zA-Z ]+$") + @doc("User name") + name: string; + } + @get op getUser(): User; + `); + + const content = output["output_types.py"]; + expect(content).toBeDefined(); + expect(content).toContain("Field("); + expect(content).toContain("min_length=1"); + expect(content).toContain("max_length=100"); + expect(content).toContain('pattern="^[a-zA-Z ]+$"'); + expect(content).toContain('description="User name"'); + }); +}); diff --git a/packages/pydantic/test/enums.test.ts b/packages/pydantic/test/enums.test.ts new file mode 100644 index 00000000000..3b80b72820a --- /dev/null +++ b/packages/pydantic/test/enums.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { emitPydantic } from "./utils.js"; + +describe("pydantic emitter: enums", () => { + it("emits string enum as StrEnum", async () => { + const output = await emitPydantic(` + using TypeSpec.Http; + @route("/test") + namespace TestService; + enum Color { Red, Green, Blue } + model Item { + color: Color; + } + @get op getItem(): Item; + `); + + const content = output["output_types.py"]; + expect(content).toBeDefined(); + expect(content).toContain("class Color(StrEnum):"); + expect(content).toContain('Red = "Red"'); + expect(content).toContain('Green = "Green"'); + expect(content).toContain('Blue = "Blue"'); + }); + + it("emits enum with string values", async () => { + const output = await emitPydantic(` + using TypeSpec.Http; + @route("/test") + namespace TestService; + enum Status { + active: "ACTIVE", + inactive: "INACTIVE", + } + model Item { + status: Status; + } + @get op getItem(): Item; + `); + + const content = output["output_types.py"]; + expect(content).toBeDefined(); + expect(content).toContain("class Status(StrEnum):"); + expect(content).toContain('active = "ACTIVE"'); + expect(content).toContain('inactive = "INACTIVE"'); + }); + + it("emits integer enum as IntEnum", async () => { + const output = await emitPydantic(` + using TypeSpec.Http; + @route("/test") + namespace TestService; + enum Priority { + low: 1, + medium: 2, + high: 3, + } + model Task { + priority: Priority; + } + @get op getTask(): Task; + `); + + const content = output["output_types.py"]; + expect(content).toBeDefined(); + expect(content).toContain("class Priority(IntEnum):"); + expect(content).toContain("low = 1"); + expect(content).toContain("medium = 2"); + expect(content).toContain("high = 3"); + }); + + it("emits enum with doc", async () => { + const output = await emitPydantic(` + using TypeSpec.Http; + @route("/test") + namespace TestService; + @doc("Available colors") + enum Color { Red, Green, Blue } + model Item { + color: Color; + } + @get op getItem(): Item; + `); + + const content = output["output_types.py"]; + expect(content).toBeDefined(); + expect(content).toContain('"""Available colors"""'); + }); +}); diff --git a/packages/pydantic/test/inheritance.test.ts b/packages/pydantic/test/inheritance.test.ts new file mode 100644 index 00000000000..2e38c3fa7fa --- /dev/null +++ b/packages/pydantic/test/inheritance.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { emitPydantic } from "./utils.js"; + +describe("pydantic emitter: inheritance", () => { + it("emits model extending another model", async () => { + const output = await emitPydantic(` + using TypeSpec.Http; + @route("/test") + namespace TestService; + model Base { + id: string; + } + model Child extends Base { + name: string; + } + @get op getChild(): Child; + `); + + const content = output["output_types.py"]; + expect(content).toBeDefined(); + expect(content).toContain("class Base(BaseModel):"); + expect(content).toContain("class Child(Base):"); + expect(content).toContain("name: str"); + }); + + it("orders base class before derived class", async () => { + const output = await emitPydantic(` + using TypeSpec.Http; + @route("/test") + namespace TestService; + model Child extends Base { + name: string; + } + model Base { + id: string; + } + @get op getChild(): Child; + `); + + const content = output["output_types.py"]; + expect(content).toBeDefined(); + const baseIdx = content.indexOf("class Base(BaseModel):"); + const childIdx = content.indexOf("class Child(Base):"); + expect(baseIdx).toBeLessThan(childIdx); + }); +}); diff --git a/packages/pydantic/test/models.test.ts b/packages/pydantic/test/models.test.ts new file mode 100644 index 00000000000..85e37675e21 --- /dev/null +++ b/packages/pydantic/test/models.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from "vitest"; +import { emitPydantic } from "./utils.js"; + +describe("pydantic emitter: basic models", () => { + it("emits a simple model with string and int properties", async () => { + const output = await emitPydantic(` + using TypeSpec.Http; + @route("/test") + namespace TestService; + model Foo { + name: string; + age: int32; + } + @get op getFoo(): Foo; + `); + + const content = output["output_types.py"]; + expect(content).toBeDefined(); + expect(content).toContain("class Foo(BaseModel):"); + expect(content).toContain("name: str"); + expect(content).toContain("age: int"); + expect(content).toContain("from pydantic import BaseModel"); + }); + + it("emits optional properties with None default", async () => { + const output = await emitPydantic(` + using TypeSpec.Http; + @route("/test") + namespace TestService; + model Foo { + name: string; + nickname?: string; + } + @get op getFoo(): Foo; + `); + + const content = output["output_types.py"]; + expect(content).toBeDefined(); + expect(content).toContain("name: str"); + expect(content).toContain("nickname: Optional[str]"); + expect(content).toContain("Optional"); + }); + + it("emits model with default values", async () => { + const output = await emitPydantic(` + using TypeSpec.Http; + @route("/test") + namespace TestService; + model Config { + enabled?: boolean = true; + count?: int32 = 10; + label?: string = "default"; + } + @get op getConfig(): Config; + `); + + const content = output["output_types.py"]; + expect(content).toBeDefined(); + expect(content).toContain("enabled: Optional[bool] = True"); + expect(content).toContain("count: Optional[int] = 10"); + expect(content).toContain('label: Optional[str] = "default"'); + }); + + it("emits model with docstring", async () => { + const output = await emitPydantic(` + using TypeSpec.Http; + @route("/test") + namespace TestService; + @doc("A user model") + model User { + name: string; + } + @get op getUser(): User; + `); + + const content = output["output_types.py"]; + expect(content).toBeDefined(); + expect(content).toContain('"""A user model"""'); + }); + + it("emits empty model with pass", async () => { + const output = await emitPydantic(` + using TypeSpec.Http; + @route("/test") + namespace TestService; + model Container { + inner: Empty; + } + model Empty {} + @get op getContainer(): Container; + `); + + const content = output["output_types.py"]; + expect(content).toBeDefined(); + expect(content).toContain("class Empty(BaseModel):"); + expect(content).toContain(" pass"); + }); + + it("emits model with boolean property", async () => { + const output = await emitPydantic(` + using TypeSpec.Http; + @route("/test") + namespace TestService; + model Flags { + active: boolean; + } + @get op getFlags(): Flags; + `); + + const content = output["output_types.py"]; + expect(content).toContain("active: bool"); + }); + + it("emits model with float property", async () => { + const output = await emitPydantic(` + using TypeSpec.Http; + @route("/test") + namespace TestService; + model Measurement { + value: float64; + } + @get op getMeasurement(): Measurement; + `); + + const content = output["output_types.py"]; + expect(content).toContain("value: float"); + }); + + it("emits model with bytes property", async () => { + const output = await emitPydantic(` + using TypeSpec.Http; + @route("/test") + namespace TestService; + model Data { + payload: bytes; + } + @get op getData(): Data; + `); + + const content = output["output_types.py"]; + expect(content).toContain("payload: bytes"); + }); +}); diff --git a/packages/pydantic/test/unions.test.ts b/packages/pydantic/test/unions.test.ts new file mode 100644 index 00000000000..f0152d01901 --- /dev/null +++ b/packages/pydantic/test/unions.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { emitPydantic } from "./utils.js"; + +describe("pydantic emitter: unions", () => { + it("emits named union as type alias", async () => { + const output = await emitPydantic(` + using TypeSpec.Http; + @route("/test") + namespace TestService; + union StringOrInt { string, int32 } + model Foo { + value: StringOrInt; + } + @get op getFoo(): Foo; + `); + + const content = output["output_types.py"]; + expect(content).toBeDefined(); + expect(content).toContain("StringOrInt = Union[str, int]"); + }); + + it("emits inline union as Union type", async () => { + const output = await emitPydantic(` + using TypeSpec.Http; + @route("/test") + namespace TestService; + model Foo { + value: string | int32; + } + @get op getFoo(): Foo; + `); + + const content = output["output_types.py"]; + expect(content).toBeDefined(); + expect(content).toContain("Union[str, int]"); + }); + + it("emits nullable as Optional", async () => { + const output = await emitPydantic(` + using TypeSpec.Http; + @route("/test") + namespace TestService; + model Foo { + value: string | null; + } + @get op getFoo(): Foo; + `); + + const content = output["output_types.py"]; + expect(content).toBeDefined(); + expect(content).toContain("Union[str, None]"); + }); +}); diff --git a/packages/pydantic/test/utils.ts b/packages/pydantic/test/utils.ts new file mode 100644 index 00000000000..8ab5f54012c --- /dev/null +++ b/packages/pydantic/test/utils.ts @@ -0,0 +1,31 @@ +import { resolvePath, type Diagnostic } from "@typespec/compiler"; +import { createTester, expectDiagnosticEmpty } from "@typespec/compiler/testing"; +import type { PydanticEmitterOptions } from "../src/lib.js"; + +export const Tester = createTester(resolvePath(import.meta.dirname, ".."), { + libraries: ["@typespec/http", "@typespec/rest", "@typespec/pydantic"], +}) + .import("@typespec/http") + .import("@typespec/rest") + .emit("@typespec/pydantic"); + +export async function emitPydanticFor( + code: string, + options: PydanticEmitterOptions = {}, +): Promise<[Record, readonly Diagnostic[]]> { + const [{ outputs }, diagnostics] = await Tester.compileAndDiagnose(code, { + compilerOptions: { + options: { "@typespec/pydantic": options as any }, + }, + }); + return [outputs, diagnostics]; +} + +export async function emitPydantic( + code: string, + options: PydanticEmitterOptions = {}, +): Promise> { + const [outputs, diagnostics] = await emitPydanticFor(code, options); + expectDiagnosticEmpty(diagnostics); + return outputs; +} diff --git a/packages/pydantic/tsconfig.build.json b/packages/pydantic/tsconfig.build.json new file mode 100644 index 00000000000..d389807a318 --- /dev/null +++ b/packages/pydantic/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*.ts", "test/**/*.ts"], + "references": [ + { "path": "../compiler/tsconfig.build.json" }, + { "path": "../http/tsconfig.build.json" } + ] +} diff --git a/packages/pydantic/tsconfig.json b/packages/pydantic/tsconfig.json new file mode 100644 index 00000000000..04221d766d2 --- /dev/null +++ b/packages/pydantic/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "references": [{ "path": "../compiler/tsconfig.json" }, { "path": "../http/tsconfig.json" }], + "compilerOptions": { + "outDir": "dist", + "rootDir": ".", + "tsBuildInfoFile": "temp/tsconfig.tsbuildinfo", + "verbatimModuleSyntax": true + } +} diff --git a/packages/pydantic/vitest.config.ts b/packages/pydantic/vitest.config.ts new file mode 100644 index 00000000000..63cad767f57 --- /dev/null +++ b/packages/pydantic/vitest.config.ts @@ -0,0 +1,4 @@ +import { defineConfig, mergeConfig } from "vitest/config"; +import { defaultTypeSpecVitestConfig } from "../../vitest.config.js"; + +export default mergeConfig(defaultTypeSpecVitestConfig, defineConfig({})); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 42ac27e3349..a9358250269 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1709,6 +1709,36 @@ importers: specifier: ^4.0.15 version: 4.0.17(@types/node@25.0.9)(@vitest/ui@4.0.17)(happy-dom@20.3.4)(jsdom@25.0.1)(tsx@4.21.0)(yaml@2.8.2) + packages/pydantic: + devDependencies: + '@types/node': + specifier: ~25.0.2 + version: 25.0.9 + '@typespec/compiler': + specifier: workspace:^ + version: link:../compiler + '@typespec/http': + specifier: workspace:^ + version: link:../http + '@typespec/rest': + specifier: workspace:^ + version: link:../rest + '@vitest/coverage-v8': + specifier: ^4.0.15 + version: 4.0.17(vitest@4.0.17) + '@vitest/ui': + specifier: ^4.0.15 + version: 4.0.17(vitest@4.0.17) + rimraf: + specifier: ~6.1.2 + version: 6.1.2 + typescript: + specifier: ~5.9.2 + version: 5.9.3 + vitest: + specifier: ^4.0.15 + version: 4.0.17(@types/node@25.0.9)(@vitest/ui@4.0.17)(happy-dom@20.3.4)(jsdom@25.0.1)(tsx@4.21.0)(yaml@2.8.2) + packages/react-components: dependencies: '@fluentui/react-components': @@ -9099,12 +9129,12 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@8.1.0: resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} engines: {node: '>=12'} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me global-directory@4.0.1: resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==}