diff --git a/.chronus/changes/feature-ef-python-2025-11-19-1-26-8.md b/.chronus/changes/feature-ef-python-2025-11-19-1-26-8.md new file mode 100644 index 00000000000..31ae4beca01 --- /dev/null +++ b/.chronus/changes/feature-ef-python-2025-11-19-1-26-8.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/emitter-framework" +--- + +Add Python Emitter Framework implementation \ No newline at end of file diff --git a/packages/emitter-framework/CHANGELOG.md b/packages/emitter-framework/CHANGELOG.md index 63d99036145..83f6111a2a8 100644 --- a/packages/emitter-framework/CHANGELOG.md +++ b/packages/emitter-framework/CHANGELOG.md @@ -14,7 +14,6 @@ No changes, version bump only. - [#8823](https://github.com/microsoft/typespec/pull/8823) Upgrade dependencies - ## 0.12.0 ### Features @@ -29,7 +28,6 @@ No changes, version bump only. - [#8474](https://github.com/microsoft/typespec/pull/8474) Remove development exports from published package - ## 0.11.0 ### Features @@ -53,7 +51,6 @@ No changes, version bump only. - [#8302](https://github.com/microsoft/typespec/pull/8302) [c#] Avoid generating double '?' after property name when the property is nullable union and it's prop.optional is true in the meantime - [#8362](https://github.com/microsoft/typespec/pull/8362) Upgrade alloy to 0.20 - ## 0.10.0 ### Bump dependencies @@ -61,7 +58,6 @@ No changes, version bump only. - [#8050](https://github.com/microsoft/typespec/pull/8050) Upgrade alloy 0.19 - [#7978](https://github.com/microsoft/typespec/pull/7978) Upgrade dependencies - ## 0.9.0 ### Features @@ -79,7 +75,6 @@ No changes, version bump only. - [#7650](https://github.com/microsoft/typespec/pull/7650) Adds subpath export for csharp emitter-framework components - ## 0.8.0 ### Features @@ -94,7 +89,6 @@ No changes, version bump only. - [#7369](https://github.com/microsoft/typespec/pull/7369) Render discriminated unions correctly - ## 0.7.1 ### Bump dependencies @@ -105,21 +99,19 @@ No changes, version bump only. - [#7321](https://github.com/microsoft/typespec/pull/7321) Use wasm version of tree sitter for snippet extractor - ## 0.7.0 ### Bump dependencies - [#7186](https://github.com/microsoft/typespec/pull/7186) Upgrade to alloy 15 - ## 0.6.0 ### Features - [#7017](https://github.com/microsoft/typespec/pull/7017) [TypeScript] Add various function-related components - FunctionType, FunctionExpression, ArrowFunction, and InterfaceMethod. - [#6972](https://github.com/microsoft/typespec/pull/6972) Add support for rendering a Value Expression -- [#7018](https://github.com/microsoft/typespec/pull/7018) Adds the `TspContextProvider` and `useTsp()` hook for providing and accessing TypeSpec context and the Typekit APIs (e.g. `# Changelog - @typespec/emitter-framework). Adds a new `Output` component that accepts a TypeSpec `Program` and automatically wraps children components with the `TspContextProvider`. +- [#7018](https://github.com/microsoft/typespec/pull/7018) Adds the `TspContextProvider` and `useTsp()` hook for providing and accessing TypeSpec context and the Typekit APIs (e.g. `# Changelog - @typespec/emitter-framework). Adds a new `Output`component that accepts a TypeSpec`Program`and automatically wraps children components with the`TspContextProvider`. ### Bump dependencies @@ -129,21 +121,18 @@ No changes, version bump only. - [#6951](https://github.com/microsoft/typespec/pull/6951) InterfaceMember should use Alloy - ## 0.5.0 ### Features - [#6875](https://github.com/microsoft/typespec/pull/6875) Upgrade to alloy 0.10.0 - ## 0.4.0 ### Bump dependencies - [#6595](https://github.com/microsoft/typespec/pull/6595) Upgrade dependencies - ## 0.3.0 ### Bump dependencies @@ -155,12 +144,8 @@ No changes, version bump only. - [#6178](https://github.com/microsoft/typespec/pull/6178) Improvements on the TestHarness - [#6460](https://github.com/microsoft/typespec/pull/6460) Update dependency structure for EmitterFramework, HttpClient and JS Emitter - - - ## 0.2.0 ### Features - [#5996](https://github.com/microsoft/typespec/pull/5996) Adding Emitter Framework and Http Client packages - diff --git a/packages/emitter-framework/package.json b/packages/emitter-framework/package.json index cae4ea31d97..03e16d69534 100644 --- a/packages/emitter-framework/package.json +++ b/packages/emitter-framework/package.json @@ -31,6 +31,9 @@ "./typescript": { "import": "./dist/src/typescript/index.js" }, + "./python": { + "import": "./dist/src/python/index.js" + }, "./testing": { "import": "./dist/src/testing/index.js" } @@ -48,6 +51,10 @@ "#typescript/*": { "development": "./src/typescript/*", "default": "./dist/src/typescript/*" + }, + "#python/*": { + "development": "./src/python/*", + "default": "./dist/src/python/*" } }, "keywords": [], @@ -57,12 +64,14 @@ "peerDependencies": { "@alloy-js/core": "^0.22.0", "@alloy-js/csharp": "^0.22.0", + "@alloy-js/python": "^0.3.0", "@alloy-js/typescript": "^0.22.0", "@typespec/compiler": "workspace:^" }, "devDependencies": { "@alloy-js/cli": "^0.22.0", "@alloy-js/core": "^0.22.0", + "@alloy-js/python": "^0.3.0", "@alloy-js/rollup-plugin": "^0.1.0", "@alloy-js/typescript": "^0.22.0", "@typespec/compiler": "workspace:^", diff --git a/packages/emitter-framework/src/python/builtins.ts b/packages/emitter-framework/src/python/builtins.ts new file mode 100644 index 00000000000..7fc20b4d539 --- /dev/null +++ b/packages/emitter-framework/src/python/builtins.ts @@ -0,0 +1,43 @@ +import type { SymbolCreator } from "@alloy-js/core"; +import { createModule } from "@alloy-js/python"; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +type dummy = SymbolCreator; + +export const abcModule = createModule({ + name: "abc", + descriptor: { + ".": ["ABC"], + }, +}); + +export const datetimeModule = createModule({ + name: "datetime", + descriptor: { + ".": ["datetime", "date", "time", "timedelta", "timezone"], + }, +}); + +export const decimalModule = createModule({ + name: "decimal", + descriptor: { + ".": ["Decimal"], + }, +}); + +export const typingModule = createModule({ + name: "typing", + descriptor: { + ".": [ + "Any", + "Callable", + "Generic", + "Literal", + "Never", + "Optional", + "Protocol", + "TypeAlias", + "TypeVar", + ], + }, +}); diff --git a/packages/emitter-framework/src/python/components/array-expression/array-expression.test.tsx b/packages/emitter-framework/src/python/components/array-expression/array-expression.test.tsx new file mode 100644 index 00000000000..d91493a8f0a --- /dev/null +++ b/packages/emitter-framework/src/python/components/array-expression/array-expression.test.tsx @@ -0,0 +1,14 @@ +import { Tester } from "#test/test-host.js"; +import { d } from "@alloy-js/core/testing"; +import { t } from "@typespec/compiler/testing"; +import { expect, it } from "vitest"; +import { getOutput } from "../../test-utils.js"; +import { TypeExpression } from "../type-expression/type-expression.js"; + +it("maps array expression to Python list", async () => { + const { program, TestArray } = await Tester.compile(t.code` + alias ${t.type("TestArray")} = string[]; + `); + + expect(getOutput(program, [])).toRenderTo(d`list[str]`); +}); diff --git a/packages/emitter-framework/src/python/components/array-expression/array-expression.tsx b/packages/emitter-framework/src/python/components/array-expression/array-expression.tsx new file mode 100644 index 00000000000..68173b0a10f --- /dev/null +++ b/packages/emitter-framework/src/python/components/array-expression/array-expression.tsx @@ -0,0 +1,11 @@ +import { code } from "@alloy-js/core"; +import type { Type } from "@typespec/compiler"; +import { TypeExpression } from "../type-expression/type-expression.js"; + +export interface ArrayExpressionProps { + elementType: Type; +} + +export function ArrayExpression({ elementType }: ArrayExpressionProps) { + return code`list[${()}]`; +} diff --git a/packages/emitter-framework/src/python/components/array-expression/index.ts b/packages/emitter-framework/src/python/components/array-expression/index.ts new file mode 100644 index 00000000000..6dc81620baf --- /dev/null +++ b/packages/emitter-framework/src/python/components/array-expression/index.ts @@ -0,0 +1 @@ +export * from "./array-expression.js"; diff --git a/packages/emitter-framework/src/python/components/atom/atom.test.tsx b/packages/emitter-framework/src/python/components/atom/atom.test.tsx new file mode 100644 index 00000000000..82ed8930a4c --- /dev/null +++ b/packages/emitter-framework/src/python/components/atom/atom.test.tsx @@ -0,0 +1,244 @@ +import { getOutput } from "#python/test-utils.js"; +import { Tester } from "#test/test-host.js"; +import { type Model, type Program, type Value } from "@typespec/compiler"; +import { t } from "@typespec/compiler/testing"; +import { $ } from "@typespec/compiler/typekit"; +import { assert, beforeAll, describe, expect, it } from "vitest"; +import { Atom } from "../../index.js"; + +let program: Program; + +beforeAll(async () => { + const result = await Tester.compile(""); + program = result.program; +}); + +describe("NullValue", () => { + it("null value", async () => { + const value = { entityKind: "Value", valueKind: "NullValue", value: null } as Value; + + await testValueExpression(value, `None`); + }); +}); + +describe("StringValue", () => { + it("normal string", async () => { + const value = $(program).value.createString("test"); + + await testValueExpression(value, `"test"`); + }); + + it("empty string", async () => { + const value = $(program).value.createString(""); + + await testValueExpression(value, `""`); + }); +}); + +describe("BooleanValue", () => { + it("True", async () => { + const value = $(program).value.createBoolean(true); + + await testValueExpression(value, `True`); + }); + + it("False", async () => { + const value = $(program).value.createBoolean(false); + + await testValueExpression(value, `False`); + }); +}); + +describe("NumericValue", () => { + it("integers", async () => { + const value = $(program).value.createNumeric(42); + + await testValueExpression(value, `42`); + }); + + it("decimals", async () => { + const fractional = $(program).value.createNumeric(42.5); + await testValueExpression(fractional, `42.5`); + }); + + it("decimals with .0", async () => { + // Generic Atom (no hint) normalizes 42.0 to 42 because it uses asNumber() + const value = $(program).value.createNumeric(42.0); + await testValueExpression(value, `42`); + }); + + it("decimals with .0 when float", async () => { + const value = $(program).value.createNumeric(42.0); + expect(getOutput(program, [])).toRenderTo(`42.0`); + }); + + it("negative integers", async () => { + const value = $(program).value.createNumeric(-42); + await testValueExpression(value, `-42`); + }); + + it("negative decimals", async () => { + const value = $(program).value.createNumeric(-42.5); + await testValueExpression(value, `-42.5`); + }); + + it("zero", async () => { + const value = $(program).value.createNumeric(0); + await testValueExpression(value, `0`); + }); + + it("zero with float", async () => { + const value = $(program).value.createNumeric(0); + expect(getOutput(program, [])).toRenderTo(`0.0`); + }); + + it("exponent that resolves to integer", async () => { + const value = $(program).value.createNumeric(1e3); + await testValueExpression(value, `1000`); + }); + + it("exponent that resolves to integer with float", async () => { + const value = $(program).value.createNumeric(1e3); + expect(getOutput(program, [])).toRenderTo(`1000.0`); + }); + + it("small decimal via exponent", async () => { + const value = $(program).value.createNumeric(1e-3); + await testValueExpression(value, `0.001`); + }); +}); + +describe("ArrayValue", () => { + it("empty", async () => { + // Can be replaced with TypeKit once #6976 is implemented + const value = { + entityKind: "Value", + valueKind: "ArrayValue", + values: [], + } as unknown as Value; + await testValueExpression(value, `[]`); + }); + + it("with mixed values", async () => { + // Can be replaced with TypeKit once #6976 is implemented + const value = { + entityKind: "Value", + valueKind: "ArrayValue", + values: [ + $(program).value.createString("some_text"), + $(program).value.createNumeric(42), + $(program).value.createBoolean(true), + { + entityKind: "Value", + valueKind: "ArrayValue", + values: [ + $(program).value.createNumeric(1), + $(program).value.createNumeric(2), + $(program).value.createNumeric(3), + ], + } as Value, + ], + } as Value; + await testValueExpression(value, `["some_text", 42, True, [1, 2, 3]]`); + }); +}); + +describe("ScalarValue", () => { + it("utcDateTime.fromISO correctly supplied", async () => { + const { minDate } = await Tester.compile(t.code` + model ${t.model("DateRange")} { + @encode("rfc7231") + ${t.modelProperty("minDate")}: ${t.type("utcDateTime")} = utcDateTime.fromISO("2024-02-15T18:36:03Z"); + } + `); + await testValueExpression( + minDate.defaultValue!, + `from datetime import datetime +from datetime import timezone + + +datetime(2024, 2, 15, 18, 36, 3, tzinfo=timezone.utc)`, + ); + }); + + it("Unsupported scalar constructor", async () => { + const { IpAddress } = await Tester.compile(t.code` + scalar ${t.scalar("ipv4")} extends ${t.scalar("string")} { + init fromInt(value: uint32); + } + + @example (#{ip: ipv4.fromInt(2130706433)}) + model ${t.model("IpAddress")} { + ${t.modelProperty("ip")}: ${t.type("ipv4")}; + } + `); + + const value = getExampleValue(IpAddress); + await expect(testValueExpression(value, ``)).rejects.toThrow( + /Unsupported scalar constructor fromInt/, + ); + }); +}); + +describe("ObjectValue", () => { + it("empty object", async () => { + // Can be replaced with TypeKit once #6976 is implemented + const { ObjectValue } = await Tester.compile(t.code` + @example(#{}) + model ${t.model("ObjectValue")} {}; + `); + + const value = getExampleValue(ObjectValue); + await testValueExpression(value, `{}`); + }); + + it("object with properties", async () => { + // Can be replaced with TypeKit once #6976 is implemented + const { ObjectValue } = await Tester.compile(t.code` + @example(#{aNumber: 5, aString: "foo", aBoolean: true}) + model ${t.model("ObjectValue")} { + ${t.modelProperty("aNumber")}: int32; + ${t.modelProperty("aString")}: string; + ${t.modelProperty("aBoolean")}: boolean; + }; + `); + const value = getExampleValue(ObjectValue); + await testValueExpression(value, `{"aNumber": 5, "aString": "foo", "aBoolean": True}`); + }); +}); + +describe("EnumValue", () => { + it("different EnumValue types", async () => { + // Can be replaced with TypeKit once #6976 is implemented + const { Red, Green, Blue } = await Tester.compile(t.code` + enum ${t.enum("Color")} { + Red, + Green: 3, + Blue: "cyan", + } + const ${t.value("Red")} = ${t.enumValue("Color.Red")}; + const ${t.value("Green")} = ${t.enumValue("Color.Green")}; + const ${t.value("Blue")} = ${t.enumValue("Color.Blue")}; + `); + + await testValueExpression(Red, `"Red"`); + await testValueExpression(Green, `3`); + await testValueExpression(Blue, `"cyan"`); + }); +}); + +/** + * Helper that renders a value expression and checks the output against the expected value. + */ +async function testValueExpression(value: Value, expected: string) { + expect(getOutput(program, [])).toRenderTo(`${expected}`); +} + +/** + * Extracts the value marked with the @example decorator from a model. + */ +function getExampleValue(model: Model): Value { + const decorator = model?.decorators.find((d) => d.definition?.name === "@example"); + assert.exists(decorator?.args[0]?.value, "unable to find example decorator"); + return decorator.args[0].value as Value; +} diff --git a/packages/emitter-framework/src/python/components/atom/atom.tsx b/packages/emitter-framework/src/python/components/atom/atom.tsx new file mode 100644 index 00000000000..139fac2e217 --- /dev/null +++ b/packages/emitter-framework/src/python/components/atom/atom.tsx @@ -0,0 +1,95 @@ +import { type Children } from "@alloy-js/core"; +import * as py from "@alloy-js/python"; +import { compilerAssert, type Value } from "@typespec/compiler"; +import { datetimeModule } from "../../builtins.js"; + +/** + * Base properties for the {@link Atom} component. + */ +interface BaseAtomProps { + /** + * The TypeSpec value to be converted to a Python expression. + */ + value: Value; +} + +/** + * Properties for the {@link Atom} component when dealing with numeric values. + */ +interface NumericAtomProps extends BaseAtomProps { + /** + * Hint that this numeric value should be emitted as a float (e.g., 42 -> 42.0). + * Only affects NumericValue. + */ + float?: boolean; +} + +/** + * All possible properties for the {@link Atom} component. + */ +type AtomProps = BaseAtomProps | NumericAtomProps; + +/** + * Generates a Python atom from a TypeSpec value. + * @param props properties for the atom + * @returns {@link Children} representing the Python value expression + */ +export function Atom(props: Readonly): Children { + switch (props.value.valueKind) { + case "StringValue": + case "BooleanValue": + case "NullValue": + return ; + case "NumericValue": { + const num = props.value.value.asNumber(); + const isNonInteger = num != null && !Number.isInteger(num); + const numericProps = props as NumericAtomProps; + const asFloat = isNonInteger || numericProps.float; + return ; + } + case "ArrayValue": + return ( + ( + + ))} + /> + ); + case "ScalarValue": + compilerAssert( + props.value.value.name === "fromISO", + `Unsupported scalar constructor ${props.value.value.name}`, + props.value, + ); + return handleISOStringValue(props.value); + case "ObjectValue": + const jsProperties: Record = {}; + for (const [key, value] of props.value.properties) { + jsProperties[key] = Atom({ value: value.value }); + } + return ; + case "EnumValue": + return ; + } +} + +/** + * Handles the conversion of ISO date strings to Python datetime objects. + * @param value the TypeSpec value containing the ISO string + * @returns {@link Children} representing the Python datetime expression + */ +function handleISOStringValue(value: Value & { valueKind: "ScalarValue" }): Children { + const arg0 = value.value.args[0]; + if (arg0.valueKind !== "StringValue") { + throw new Error("Expected arg0 to be a StringValue"); + } + const isoString = arg0.value; + const date = new Date(isoString); + return ( + <> + {datetimeModule["."]["datetime"]}({date.getUTCFullYear()}, {date.getUTCMonth() + 1},{" "} + {date.getUTCDate()}, {date.getUTCHours()}, {date.getUTCMinutes()}, {date.getUTCSeconds()}, + tzinfo={datetimeModule["."]["timezone"]}.utc) + + ); +} diff --git a/packages/emitter-framework/src/python/components/atom/index.ts b/packages/emitter-framework/src/python/components/atom/index.ts new file mode 100644 index 00000000000..b253f0db873 --- /dev/null +++ b/packages/emitter-framework/src/python/components/atom/index.ts @@ -0,0 +1 @@ +export * from "./atom.js"; diff --git a/packages/emitter-framework/src/python/components/class-declaration/class-bases.tsx b/packages/emitter-framework/src/python/components/class-declaration/class-bases.tsx new file mode 100644 index 00000000000..8cfab143cb5 --- /dev/null +++ b/packages/emitter-framework/src/python/components/class-declaration/class-bases.tsx @@ -0,0 +1,92 @@ +import { abcModule } from "#python/builtins.js"; +import { type Children } from "@alloy-js/core"; +import * as py from "@alloy-js/python"; +import { isTemplateDeclarationOrInstance, type Interface, type Model } from "@typespec/compiler"; +import { useTsp } from "../../../core/context/tsp-context.js"; +import { efRefkey } from "../../utils/refkey.js"; +import { TypeExpression } from "../type-expression/type-expression.js"; + +export interface ClassBasesProps { + /** + * The TypeSpec type to derive bases from. If not provided, only explicit bases are used. + */ + type?: Model | Interface; + + /** + * Explicit base classes to include. + */ + bases?: Children[]; + + /** + * Whether the class is abstract. If true, ABC is added to the bases. + */ + abstract?: boolean; + + /** + * Additional bases to include (e.g., for future Generic[T] support). + */ + extraBases?: Children[]; +} + +/** + * Computes the base classes for a Python class declaration. + * + * Combines: + * - Explicit bases from props + * - Type-derived bases (from TypeSpec model inheritance): + * - Template instances (e.g., `Response` → `Response[str]`) + * - Regular models (e.g., `BaseWidget`) via py.Reference + * - Arrays via TypeExpression for `typing.Sequence[T]` rendering + * - Records are not supported and ignored + * - Extra bases (for future generics support) + * - ABC if abstract (always last for proper Python MRO) + * + * For interfaces, type-derived bases are empty because TypeSpec flattens interface inheritance. + * + * @returns Array of base class Children, or empty array if none. + * + * @example + * ```tsx + * const bases = ClassBases({ type: model, abstract: true }); + * + * ``` + */ +export function ClassBases(props: ClassBasesProps): Children[] { + const { $ } = useTsp(); + const extraBases = [...(props.extraBases ?? [])]; + + // Add extends/inheritance from the TypeSpec type if present + if (props.type && $.model.is(props.type)) { + const type = props.type; + + if (type.baseModel) { + if ($.array.is(type.baseModel)) { + extraBases.push(); + } else if ($.record.is(type.baseModel)) { + // Record-based scenarios are not supported, do nothing here + } else if (isTemplateDeclarationOrInstance(type.baseModel)) { + // Template type (declaration or instance) - needs TypeExpression for type parameter handling + extraBases.push(); + } else { + // Regular model - use py.Reference for proper symbol resolution + extraBases.push(); + } + } + + // Handle index types: Arrays (int indexes) are supported, Records are not + const indexType = $.model.getIndexType(type); + if (indexType && !$.record.is(indexType)) { + extraBases.push(); + } + } + + // Combine explicit bases from props with extraBases (Generic, extends, etc.) + const allBases = (props.bases ?? []).concat(extraBases); + + // For abstract classes, always include ABC (last for proper MRO) + if (props.abstract) { + return [...allBases, abcModule["."]["ABC"]]; + } + + return allBases; +} diff --git a/packages/emitter-framework/src/python/components/class-declaration/class-body.tsx b/packages/emitter-framework/src/python/components/class-declaration/class-body.tsx new file mode 100644 index 00000000000..a6f57ae4455 --- /dev/null +++ b/packages/emitter-framework/src/python/components/class-declaration/class-body.tsx @@ -0,0 +1,56 @@ +import { For, type Children } from "@alloy-js/core"; +import { type Interface, type Model, type ModelProperty, type Operation } from "@typespec/compiler"; +import type { Typekit } from "@typespec/compiler/typekit"; +import { createRekeyableMap } from "@typespec/compiler/utils"; +import { useTsp } from "../../../core/context/tsp-context.js"; +import { ClassMember } from "./class-member.js"; + +export interface ClassBodyProps { + type: Model | Interface; + abstract?: boolean; + methodType?: "method" | "class" | "static"; + children?: Children; +} + +/** + * Gets type members (properties or operations) from a Model or Interface. + */ +function getTypeMembers($: Typekit, type: Model | Interface): (ModelProperty | Operation)[] { + if ($.model.is(type)) { + return Array.from($.model.getProperties(type).values()); + } else if (type.kind === "Interface") { + return Array.from(createRekeyableMap(type.operations).values()); + } else { + throw new Error("Expected Model or Interface type"); + } +} + +/** + * Renders the body of a class declaration. + * For models, renders properties as dataclass fields. + * For interfaces, renders operations as abstract methods. + * Includes any additional children provided. + */ +export function ClassBody(props: ClassBodyProps): Children { + const { $ } = useTsp(); + const typeMembers = getTypeMembers($, props.type); + + // Throw error for models with additional properties (Record-based scenarios) + if ($.model.is(props.type)) { + const additionalPropsRecord = $.model.getAdditionalPropertiesRecord(props.type); + if (additionalPropsRecord) { + throw new Error("Models with additional properties (Record[…]) are not supported"); + } + } + + return ( + <> + + {(typeMember) => ( + + )} + + {props.children} + + ); +} diff --git a/packages/emitter-framework/src/python/components/class-declaration/class-declaration.test.tsx b/packages/emitter-framework/src/python/components/class-declaration/class-declaration.test.tsx new file mode 100644 index 00000000000..e9c574273c8 --- /dev/null +++ b/packages/emitter-framework/src/python/components/class-declaration/class-declaration.test.tsx @@ -0,0 +1,1414 @@ +import { Tester } from "#test/test-host.js"; +import { List } from "@alloy-js/core"; +import * as py from "@alloy-js/python"; +import { t } from "@typespec/compiler/testing"; +import { describe, expect, it } from "vitest"; +import { ClassDeclaration } from "../../../../src/python/components/class-declaration/class-declaration.js"; +import { Method } from "../../../../src/python/components/class-declaration/class-method.js"; +import { EnumDeclaration } from "../../../../src/python/components/enum-declaration/enum-declaration.js"; +import { getOutput } from "../../test-utils.js"; +import { TypeAliasDeclaration } from "../type-alias-declaration/type-alias-declaration.js"; + +describe("Python Class from model", () => { + it("creates a class", async () => { + const { program, Widget } = await Tester.compile(t.code` + + model ${t.model("Widget")} { + id: string; + weight: int32; + aliases: string[]; + isActive: boolean; + color: "blue" | "red"; + promotionalPrice: float64; + description?: string = "This is a widget"; + createdAt: int64 = 1717334400; + tags: string[] = #["tag1", "tag2"]; + isDeleted: boolean = false; + alternativeColor: "green" | "yellow" = "green"; + price: float64 = 100.0; + } + `); + + expect(getOutput(program, [])).toRenderTo( + ` + from dataclasses import dataclass + from typing import Literal + from typing import Optional + + + @dataclass(kw_only=True) + class Widget: + id: str + weight: int + aliases: list[str] + is_active: bool + color: Literal["blue", "red"] + promotional_price: float + description: Optional[str] = "This is a widget" + created_at: int = 1717334400 + tags: list[str] = ["tag1", "tag2"] + is_deleted: bool = False + alternative_color: Literal["green", "yellow"] = "green" + price: float = 100.0 + + `, + ); + }); + + it("creates a class with non-default values followed by default values", async () => { + const { program, Widget } = await Tester.compile(t.code` + + model ${t.model("Widget")} { + id: string; + description?: string = "This is a widget"; + } + `); + + expect(getOutput(program, [])).toRenderTo( + ` + from dataclasses import dataclass + from typing import Optional + + + @dataclass(kw_only=True) + class Widget: + id: str + description: Optional[str] = "This is a widget" + + `, + ); + }); + + it("creates a class with non-default values followed by default values", async () => { + const { program, Widget } = await Tester.compile(t.code` + + model ${t.model("Widget")} { + description?: string = "This is a widget"; + id: string; + } + `); + + expect(getOutput(program, [])).toRenderTo( + ` + from dataclasses import dataclass + from typing import Optional + + + @dataclass(kw_only=True) + class Widget: + description: Optional[str] = "This is a widget" + id: str + + `, + ); + }); + + it("declares a class with multi line docs", async () => { + const { program, Foo } = await Tester.compile(t.code` + /** + * This is a test + * with multiple lines + */ + model ${t.model("Foo")} { + knownProp: string; + } + `); + + expect(getOutput(program, [])).toRenderTo( + ` + from dataclasses import dataclass + + + @dataclass(kw_only=True) + class Foo: + """ + This is a test + with multiple lines + """ + + known_prop: str + + `, + ); + }); + + it("declares a class overriding docs", async () => { + const { program, Foo } = await Tester.compile(t.code` + /** + * This is a test + * with multiple lines + */ + model ${t.model("Foo")} { + knownProp: string; + } + `); + + expect( + getOutput(program, [ + , + ]), + ).toRenderTo( + ` + from dataclasses import dataclass + + + @dataclass(kw_only=True) + class Foo: + """ + This is an overridden doc comment + with multiple lines + """ + + known_prop: str + + `, + ); + }); + + it("declares a class overriding docs with paragraphs array", async () => { + const { program, Foo } = await Tester.compile(t.code` + /** + * Base doc will be overridden + */ + model ${t.model("Foo")} { + knownProp: string; + } + `); + + expect( + getOutput(program, [ + , + ]), + ).toRenderTo( + ` + from dataclasses import dataclass + + + @dataclass(kw_only=True) + class Foo: + """ + First paragraph + + Second paragraph + """ + + known_prop: str + + `, + ); + }); + + it("declares a class overriding docs with prebuilt ClassDoc", async () => { + const { program, Foo } = await Tester.compile(t.code` + /** + * Base doc will be overridden + */ + model ${t.model("Foo")} { + knownProp: string; + } + `); + + expect( + getOutput(program, [ + Alpha, <>Beta]} />} />, + ]), + ).toRenderTo( + ` + from dataclasses import dataclass + + + @dataclass(kw_only=True) + class Foo: + """ + Alpha + + Beta + """ + + known_prop: str + + `, + ); + }); + + it("declares a class from a model with @doc", async () => { + const { program, Foo } = await Tester.compile(t.code` + @doc("This is a test") + model ${t.model("Foo")} { + knownProp: string; + } + `); + + expect(getOutput(program, [])).toRenderTo( + ` + from dataclasses import dataclass + + + @dataclass(kw_only=True) + class Foo: + """ + This is a test + """ + + known_prop: str + + `, + ); + }); + + it("declares a model property with @doc", async () => { + const { program, Foo } = await Tester.compile(t.code` + /** + * This is a test + */ + model ${t.model("Foo")} { + @doc("This is a known property") + knownProp: string; + } + `); + + expect(getOutput(program, [])).toRenderTo( + ` + from dataclasses import dataclass + + + @dataclass(kw_only=True) + class Foo: + """ + This is a test + """ + + # This is a known property + known_prop: str + + `, + ); + }); + + it("throws error for model is Record", async () => { + const { program, Person } = await Tester.compile(t.code` + model ${t.model("Person")} is Record; + `); + + expect(() => { + expect(getOutput(program, [])).toRenderTo(""); + }).toThrow(/Models with additional properties \(Record\[…\]\) are not supported/); + }); + + it("throws error for model is Record with properties", async () => { + const { program, Person } = await Tester.compile(t.code` + model ${t.model("Person")} is Record { + name: string; + } + `); + + expect(() => { + expect(getOutput(program, [])).toRenderTo(""); + }).toThrow(/Models with additional properties \(Record\[…\]\) are not supported/); + }); + + it("throws error for model extends Record", async () => { + const { program, Person } = await Tester.compile(t.code` + model ${t.model("Person")} extends Record { + name: string; + } + `); + + expect(() => { + expect(getOutput(program, [])).toRenderTo(""); + }).toThrow(/Models with additional properties \(Record\[…\]\) are not supported/); + }); + + it("throws error for model with ...Record", async () => { + const { program, Person } = await Tester.compile(t.code` + model ${t.model("Person")} { + age: int32; + ...Record; + } + `); + + expect(() => { + expect(getOutput(program, [])).toRenderTo(""); + }).toThrow(/Models with additional properties \(Record\[…\]\) are not supported/); + }); + + it("creates a class from a model that 'is' an array ", async () => { + const { program, Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} is Array; + `); + + expect(getOutput(program, [])).toRenderTo( + ` + class Foo(list[str]): + pass + + `, + ); + }); + + it("handles a type reference to a union variant in a class property", async () => { + const { program, Color, Widget } = await Tester.compile(t.code` + union ${t.union("Color")} { + red: "RED", + blue: "BLUE", + } + + model ${t.model("Widget")} { + id: string = "123"; + weight: int32 = 100; + color: Color.blue; + } + `); + + expect( + getOutput(program, [, ]), + ).toRenderTo( + ` + from dataclasses import dataclass + from enum import StrEnum + from typing import Literal + + + class Color(StrEnum): + RED = "RED" + BLUE = "BLUE" + + + @dataclass(kw_only=True) + class Widget: + id: str = "123" + weight: int = 100 + color: Literal[Color.BLUE] + + `, + ); + }); + + it("handles a union of variant references in a class property", async () => { + const { program, Color, Widget } = await Tester.compile(t.code` + union ${t.union("Color")} { + red: "RED", + blue: "BLUE", + green: "GREEN", + } + + model ${t.model("Widget")} { + id: string; + primaryColor: Color.red | Color.blue; + } + `); + + expect( + getOutput(program, [, ]), + ).toRenderTo( + ` + from dataclasses import dataclass + from enum import StrEnum + from typing import Literal + + + class Color(StrEnum): + RED = "RED" + BLUE = "BLUE" + GREEN = "GREEN" + + + @dataclass(kw_only=True) + class Widget: + id: str + primary_color: Literal[Color.RED, Color.BLUE] + + `, + ); + }); + + it("handles a union of integer literals in a class property", async () => { + const { program, Widget } = await Tester.compile(t.code` + model ${t.model("Widget")} { + id: string; + priority: 1 | 2 | 3; + } + `); + + expect(getOutput(program, [])).toRenderTo( + ` + from dataclasses import dataclass + from typing import Literal + + + @dataclass(kw_only=True) + class Widget: + id: str + priority: Literal[1, 2, 3] + + `, + ); + }); + + it("handles a union of boolean literals in a class property", async () => { + const { program, Widget } = await Tester.compile(t.code` + model ${t.model("Widget")} { + id: string; + isActiveOrEnabled: true | false; + } + `); + + expect(getOutput(program, [])).toRenderTo( + ` + from dataclasses import dataclass + from typing import Literal + + + @dataclass(kw_only=True) + class Widget: + id: str + is_active_or_enabled: Literal[True, False] + + `, + ); + }); + + it("handles a mixed union of literals and variant references", async () => { + const { program, Color, Widget } = await Tester.compile(t.code` + union ${t.union("Color")} { + red: "RED", + blue: "BLUE", + } + + model ${t.model("Widget")} { + id: string; + mixedValue: "custom" | 42 | true | Color.red; + } + `); + + expect( + getOutput(program, [, ]), + ).toRenderTo( + ` + from dataclasses import dataclass + from enum import StrEnum + from typing import Literal + + + class Color(StrEnum): + RED = "RED" + BLUE = "BLUE" + + + @dataclass(kw_only=True) + class Widget: + id: str + mixed_value: Literal["custom", 42, True, Color.RED] + + `, + ); + }); + + it("renders a never-typed member as typing.Never", async () => { + const { program, Widget } = await Tester.compile(t.code` + model ${t.model("Widget")} { + property: never; + } + `); + + expect(getOutput(program, [])).toRenderTo(` + from dataclasses import dataclass + from typing import Never + + + @dataclass(kw_only=True) + class Widget: + property: Never + + `); + }); + + it("can override class name", async () => { + const { program, Widget } = await Tester.compile(t.code` + model ${t.model("Widget")} { + id: string; + weight: int32; + color: "blue" | "red"; + } + `); + + expect(getOutput(program, [])) + .toRenderTo(` + from dataclasses import dataclass + from typing import Literal + + + @dataclass(kw_only=True) + class MyOperations: + id: str + weight: int + color: Literal["blue", "red"] + + `); + }); + + it("can add a members to the class", async () => { + const { program, Widget } = await Tester.compile(t.code` + model ${t.model("Widget")} { + id: string; + weight: int32; + color: "blue" | "red"; + } + `); + + expect( + getOutput(program, [ + + + + <>custom_property: str + + , + ]), + ).toRenderTo(` + from dataclasses import dataclass + from typing import Literal + + + @dataclass(kw_only=True) + class MyOperations: + id: str + weight: int + color: Literal["blue", "red"] + custom_property: str + + `); + }); + it("creates a class from a model with extends", async () => { + const { program, Widget, ErrorWidget } = await Tester.compile(t.code` + model ${t.model("Widget")} { + id: string; + weight: int32; + color: "blue" | "red"; + } + + model ${t.model("ErrorWidget")} extends Widget { + code: int32; + message: string; + } + `); + + expect( + getOutput(program, [ + , + , + ]), + ).toRenderTo(` + from dataclasses import dataclass + from typing import Literal + + + @dataclass(kw_only=True) + class Widget: + id: str + weight: int + color: Literal["blue", "red"] + + + @dataclass(kw_only=True) + class ErrorWidget(Widget): + code: int + message: str + + `); + }); +}); + +describe("Python Class from interface", () => { + it("creates a class from an interface declaration", async () => { + const { program, WidgetOperations } = await Tester.compile(t.code` + interface ${t.interface("WidgetOperations")} { + op getName(id: string): string; + } + `); + + expect(getOutput(program, [])).toRenderTo(` + from abc import ABC + from abc import abstractmethod + + + class WidgetOperations(ABC): + @abstractmethod + def get_name(self, id: str) -> str: + pass + + + `); + }); + + it("should handle spread and non spread interface parameters", async () => { + const { program, Foo, WidgetOperations } = await Tester.compile(t.code` + model ${t.model("Foo")} { + name: string + } + + interface ${t.interface("WidgetOperations")} { + op getName(foo: Foo): string; + op getOtherName(...Foo): string + } + `); + + expect( + getOutput(program, [ + , + , + ]), + ).toRenderTo(` + from abc import ABC + from abc import abstractmethod + from dataclasses import dataclass + + + @dataclass(kw_only=True) + class Foo: + name: str + + + class WidgetOperations(ABC): + @abstractmethod + def get_name(self, foo: Foo) -> str: + pass + + @abstractmethod + def get_other_name(self, name: str) -> str: + pass + + + `); + }); + + it("creates a class from an interface with Model references", async () => { + const { program, WidgetOperations, Widget } = await Tester.compile(t.code` + /** + * Operations for Widget + */ + interface ${t.interface("WidgetOperations")} { + /** + * Get the name of the widget + */ + op getName( + /** + * The id of the widget + */ + id: string + ): Widget; + } + + model ${t.model("Widget")} { + id: string; + weight: int32; + color: "blue" | "red"; + } + `); + + expect( + getOutput(program, [ + , + , + ]), + ).toRenderTo(` + from abc import ABC + from abc import abstractmethod + from dataclasses import dataclass + from typing import Literal + + + class WidgetOperations(ABC): + """ + Operations for Widget + """ + + @abstractmethod + def get_name(self, id: str) -> Widget: + """ + Get the name of the widget + """ + pass + + + + @dataclass(kw_only=True) + class Widget: + id: str + weight: int + color: Literal["blue", "red"] + + `); + }); + + it("creates a class from an interface that extends another", async () => { + const { program, WidgetOperations, WidgetOperationsExtended, Widget } = + await Tester.compile(t.code` + interface ${t.interface("WidgetOperations")} { + op getName(id: string): Widget; + } + + interface ${t.interface("WidgetOperationsExtended")} extends WidgetOperations{ + op delete(id: string): void; + } + + model ${t.model("Widget")} { + id: string; + weight: int32; + color: "blue" | "red"; + } + `); + + expect( + getOutput(program, [ + , + , + , + ]), + ).toRenderTo(` + from abc import ABC + from abc import abstractmethod + from dataclasses import dataclass + from typing import Literal + + + class WidgetOperations(ABC): + @abstractmethod + def get_name(self, id: str) -> Widget: + pass + + + + class WidgetOperationsExtended(ABC): + @abstractmethod + def get_name(self, id: str) -> Widget: + pass + + @abstractmethod + def delete(self, id: str) -> None: + pass + + + + @dataclass(kw_only=True) + class Widget: + id: str + weight: int + color: Literal["blue", "red"] + + `); + }); +}); + +describe("Python Class overrides", () => { + it("creates a class with a method if a model is provided and a class method is provided", async () => { + const { program, WidgetOperations } = await Tester.compile(t.code` + model ${t.model("WidgetOperations")} { + id: string; + weight: int32; + } + `); + + expect( + getOutput(program, [ + + + + + + + , + ]), + ).toRenderTo(` + from dataclasses import dataclass + + + @dataclass(kw_only=True) + class WidgetOperations: + id: str + weight: int + + def do_work(self) -> None: + """ + This is a test + """ + pass + + + `); + }); + + it("creates a class with a method if a model is provided and a class method is provided and methodType is set to method", async () => { + const { program, WidgetOperations } = await Tester.compile(t.code` + model ${t.model("WidgetOperations")} { + id: string; + weight: int32; + } + `); + + expect( + getOutput(program, [ + + + + + + + , + ]), + ).toRenderTo(` + from dataclasses import dataclass + + + @dataclass(kw_only=True) + class WidgetOperations: + id: str + weight: int + + def do_work(self) -> None: + """ + This is a test + """ + pass + + + `); + }); + + it("creates a class with a classmethod if a model is provided, a class method is provided and methodType is set to class", async () => { + const { program, WidgetOperations } = await Tester.compile(t.code` + model ${t.model("WidgetOperations")} { + id: string; + weight: int32; + } + `); + + expect( + getOutput(program, [ + + + + + + + , + ]), + ).toRenderTo(` + from dataclasses import dataclass + + + @dataclass(kw_only=True) + class WidgetOperations: + id: str + weight: int + + @classmethod + def do_work(cls) -> None: + """ + This is a test + """ + pass + + + `); + }); + + it("creates a class with a staticmethod if a model is provided, a class method is provided and methodType is set to static", async () => { + const { program, WidgetOperations } = await Tester.compile(t.code` + model ${t.model("WidgetOperations")} { + id: string; + weight: int32; + } + `); + + expect( + getOutput(program, [ + + + + + + + , + ]), + ).toRenderTo(` + from dataclasses import dataclass + + + @dataclass(kw_only=True) + class WidgetOperations: + id: str + weight: int + + @staticmethod + def do_work() -> None: + """ + This is a test + """ + pass + + + `); + }); + + it("creates a class with abstract method if an interface is provided", async () => { + const { program, WidgetOperations } = await Tester.compile(t.code` + interface ${t.interface("WidgetOperations")} { + op getName(id: string): string; + } + `); + + expect(getOutput(program, [])).toRenderTo(` + from abc import ABC + from abc import abstractmethod + + + class WidgetOperations(ABC): + @abstractmethod + def get_name(self, id: str) -> str: + pass + + + `); + }); + + it("creates a class with abstract method if an interface is provided and methodType is set to method", async () => { + const { program, WidgetOperations } = await Tester.compile(t.code` + interface ${t.interface("WidgetOperations")} { + op getName(id: string): string; + } + `); + + expect(getOutput(program, [])) + .toRenderTo(` + from abc import ABC + from abc import abstractmethod + + + class WidgetOperations(ABC): + @abstractmethod + def get_name(self, id: str) -> str: + pass + + + `); + }); + + it("creates a class with abstract classmethod if an interface is provided and methodType is set to class", async () => { + const { program, WidgetOperations } = await Tester.compile(t.code` + interface ${t.interface("WidgetOperations")} { + op getName(id: string): string; + } + `); + + expect(getOutput(program, [])) + .toRenderTo(` + from abc import ABC + from abc import abstractmethod + + + class WidgetOperations(ABC): + @classmethod + @abstractmethod + def get_name(cls, id: str) -> str: + pass + + + `); + }); + + it("creates a class with abstract staticmethod if an interface is provided and methodType is set to static", async () => { + const { program, WidgetOperations } = await Tester.compile(t.code` + interface ${t.interface("WidgetOperations")} { + op getName(id: string): string; + } + `); + + expect(getOutput(program, [])) + .toRenderTo(` + from abc import ABC + from abc import abstractmethod + + + class WidgetOperations(ABC): + @staticmethod + @abstractmethod + def get_name(id: str) -> str: + pass + + + `); + }); + + it("Emits type alias to template instance as dataclass", async () => { + const { program, StringResponse } = await Tester.compile(t.code` + model Response { + data: T; + status: string; + } + + alias ${t.type("StringResponse")} = Response; + `); + + // Type alias to a template instance is emitted as a dataclass, + // since Python doesn't support parameterized type aliases like TypeScript. + // This is equivalent to: model StringResponse is Response; + expect(getOutput(program, [])).toRenderTo(` + from dataclasses import dataclass + + + @dataclass(kw_only=True) + class StringResponse: + data: str + status: str + + `); + }); + + it("Emits multiple concrete models from template instances using 'is'", async () => { + const { program, StringResult, IntResult } = await Tester.compile(t.code` + model Result { + value: T; + error: E; + } + + model ${t.model("StringResult")} is Result; + model ${t.model("IntResult")} is Result; + `); + + // TypeSpec 'is' copies all properties from the template instance. + // Each concrete model gets fully expanded properties with substituted types. + expect( + getOutput(program, [ + , + , + ]), + ).toRenderTo(` + from dataclasses import dataclass + + + @dataclass(kw_only=True) + class StringResult: + value: str + error: str + + + @dataclass(kw_only=True) + class IntResult: + value: int + error: str + + `); + }); + + it("Emits concrete model using 'is' from template instance", async () => { + const { program, StringResponse } = await Tester.compile(t.code` + model Response { + data: T; + status: string; + } + + model ${t.model("StringResponse")} is Response; + `); + + // Using 'is' copies all properties from the template instance. + // StringResponse becomes a concrete model with all properties from Response. + expect(getOutput(program, [])).toRenderTo(` + from dataclasses import dataclass + + + @dataclass(kw_only=True) + class StringResponse: + data: str + status: str + + `); + }); + + it("Emits concrete interface extending template (operations use concrete types)", async () => { + const { program, StringRepository } = await Tester.compile(t.code` + interface Repository { + get(id: string): T; + list(): T[]; + } + + interface ${t.interface("StringRepository")} extends Repository { + findByPrefix(prefix: string): string[]; + } + `); + + // TypeSpec flattens interface inheritance - StringRepository gets all operations + // from Repository with T replaced by string. + expect(getOutput(program, [])).toRenderTo(` + from abc import ABC + from abc import abstractmethod + + + class StringRepository(ABC): + @abstractmethod + def get(self, id: str) -> str: + pass + + @abstractmethod + def list(self) -> list[str]: + pass + + @abstractmethod + def find_by_prefix(self, prefix: str) -> list[str]: + pass + + + `); + }); + + it("Emits multiple concrete interfaces (templates are macros, no generics)", async () => { + const { program, UserRepository, ProductRepository } = await Tester.compile(t.code` + interface Repository { + get(id: string): T; + } + + interface ${t.interface("UserRepository")} extends Repository { + findByEmail(email: string): string; + } + + interface ${t.interface("ProductRepository")} extends Repository { + findByCategory(category: string): int32[]; + } + `); + + // Each concrete interface extends the template with different type arguments. + // TypeSpec flattens the inheritance with concrete types substituted. + expect( + getOutput(program, [ + , + , + ]), + ).toRenderTo(` + from abc import ABC + from abc import abstractmethod + + + class UserRepository(ABC): + @abstractmethod + def get(self, id: str) -> str: + pass + + @abstractmethod + def find_by_email(self, email: str) -> str: + pass + + + + class ProductRepository(ABC): + @abstractmethod + def get(self, id: str) -> int: + pass + + @abstractmethod + def find_by_category(self, category: str) -> list[int]: + pass + + + `); + }); + + it("Handles template instance with 'is' (copies properties)", async () => { + const { program, CanadaAddress } = await Tester.compile(t.code` + model Address { + state: TState; + city: string; + street: string; + } + + model ${t.model("CanadaAddress")} is Address; + `); + + // TypeSpec 'is' copies all properties from the template instance. + // CanadaAddress is a concrete type with all properties from Address. + expect(getOutput(program, [])).toRenderTo(` + from dataclasses import dataclass + from typing import Never + + + @dataclass(kw_only=True) + class CanadaAddress: + state: Never + city: str + street: str + + `); + }); + + it("Handles template with bounded type parameter using 'is'", async () => { + const { program, StringContainer } = await Tester.compile(t.code` + model Container { + value: T; + label: string; + } + + model ${t.model("StringContainer")} is Container; + `); + + // Bounded type parameters (T extends string) work the same as unbounded - + // the constraint is enforced by TypeSpec at compile time, and the concrete + // type gets the substituted value. + expect(getOutput(program, [])).toRenderTo(` + from dataclasses import dataclass + + + @dataclass(kw_only=True) + class StringContainer: + value: str + label: str + + `); + }); + + it("Handles template with multiple bounded and unbounded parameters using 'is'", async () => { + const { program, MyResult } = await Tester.compile(t.code` + model Result { + value: T; + error: E; + } + + model ${t.model("MyResult")} is Result; + `); + + // Mixed bounded/unbounded parameters are handled the same way - + // TypeSpec expands the template with concrete types. + expect(getOutput(program, [])).toRenderTo(` + from dataclasses import dataclass + + + @dataclass(kw_only=True) + class MyResult: + value: str + error: int + + `); + }); + + it("Handles 'extends' with concrete model (Python inheritance)", async () => { + const { program, Address, CanadaAddress } = await Tester.compile(t.code` + model ${t.model("Address")} { + city: string; + } + + model ${t.model("CanadaAddress")} extends Address { + street: string; + } + `); + + // TypeSpec 'extends' creates Python class inheritance when the base is a concrete model. + expect( + getOutput(program, [ + , + , + ]), + ).toRenderTo(` + from dataclasses import dataclass + + + @dataclass(kw_only=True) + class Address: + city: str + + + @dataclass(kw_only=True) + class CanadaAddress(Address): + street: str + + `); + }); + + it("Handles 'extends' with template instance base (references concrete base)", async () => { + const { program, Response, ConcreteResponse } = await Tester.compile(t.code` + model ${t.model("Response")} { + data: string; + status: string; + } + + model ${t.model("ConcreteResponse")} extends Response { + timestamp: string; + } + `); + + // When extending a concrete model, Python inheritance is used. + // The base class must be emitted first for the reference to resolve. + expect( + getOutput(program, [ + , + , + ]), + ).toRenderTo(` + from dataclasses import dataclass + + + @dataclass(kw_only=True) + class Response: + data: str + status: str + + + @dataclass(kw_only=True) + class ConcreteResponse(Response): + timestamp: str + + `); + }); + + it("Handles template instance with 'extends' and never type using 'is' pattern", async () => { + const { program, CanadaAddress } = await Tester.compile(t.code` + model Address { + state: TState; + city: string; + } + + // Using 'is' instead of 'extends' to copy all properties from the template instance + model ${t.model("CanadaAddress")} is Address { + street: string; + } + `); + + // When extending template instances, prefer 'is' pattern which copies all properties. + // The 'extends' keyword with template instances would require the template declaration + // to be emitted, which is not supported since templates are macros. + expect(getOutput(program, [])).toRenderTo(` + from dataclasses import dataclass + from typing import Never + + + @dataclass(kw_only=True) + class CanadaAddress: + state: Never + city: str + street: str + + `); + }); + + it("creates an abstract dataclass when abstract prop is true with a model", async () => { + const { program, BaseEntity } = await Tester.compile(t.code` + model ${t.model("BaseEntity")} { + id: string; + createdAt: string; + } + `); + + expect(getOutput(program, [])) + .toRenderTo(` + from abc import ABC + from dataclasses import dataclass + + + @dataclass(kw_only=True) + class BaseEntity(ABC): + id: str + created_at: str + + `); + }); +}); diff --git a/packages/emitter-framework/src/python/components/class-declaration/class-declaration.tsx b/packages/emitter-framework/src/python/components/class-declaration/class-declaration.tsx new file mode 100644 index 00000000000..3b6fd3fcbc3 --- /dev/null +++ b/packages/emitter-framework/src/python/components/class-declaration/class-declaration.tsx @@ -0,0 +1,116 @@ +import * as py from "@alloy-js/python"; +import { type Interface, type Model } from "@typespec/compiler"; +import { useTsp } from "../../../core/context/tsp-context.js"; +import { reportDiagnostic } from "../../../lib.js"; +import { declarationRefkeys } from "../../utils/refkey.js"; +import { DocElement } from "../doc-element/doc-element.js"; +import { ClassBases } from "./class-bases.js"; +import { ClassBody } from "./class-body.js"; +import { MethodProvider } from "./class-method.js"; + +export interface ClassDeclarationPropsWithType extends Omit { + type: Model | Interface; + name?: string; + abstract?: boolean; // Global override for the abstract flag + methodType?: "method" | "class" | "static"; // Global override for the method type +} + +export type ClassDeclarationProps = ClassDeclarationPropsWithType | py.ClassDeclarationProps; + +function isTypedClassDeclarationProps( + props: ClassDeclarationProps, +): props is ClassDeclarationPropsWithType { + return "type" in props; +} + +/** + * Converts TypeSpec Models and Interfaces to Python classes. + * + * - **Models** are converted into Dataclasses with `@dataclass(kw_only=True)` + fields + * - **Interfaces** are converted into Abstract classes (ABC) with abstract methods + * - For models that extends another model, we convert that into Python class inheritance + * - For interfaces that extends another interface, there's no inheritance, since + * TypeSpec flattens the inheritance + * + * @param props - The props for the class declaration. + * @returns The class declaration. + */ +export function ClassDeclaration(props: ClassDeclarationProps) { + const { $ } = useTsp(); + + // Interfaces are rendered as abstract classes (ABC) with abstract methods + // Models are rendered as concrete dataclasses with fields + // If we are explicitly overriding the class as abstract or the type is not a model, we need to create an abstract class + const abstract = + ("abstract" in props && props.abstract) || ("type" in props && !$.model.is(props.type)); + + const type = "type" in props ? props.type : undefined; + const docSource = props.doc ?? (type ? $.type.getDoc(type) : undefined); + const docElement = docSource ? : undefined; + + // TODO: When TypeSpec adds true generics support, pass extraBases with Generic[T, ...] here. + // Currently, TypeSpec templates are macros that expand to concrete types, so we don't + // generate Python generics (TypeVar/Generic) for template declarations. + const basesType = ClassBases({ + type: "type" in props ? props.type : undefined, + bases: props.bases, + abstract, + }); + + if (!isTypedClassDeclarationProps(props)) { + return ( + + ); + } + + const namePolicy = py.usePythonNamePolicy(); + + let name = props.name ?? props.type.name; + if (!name) { + reportDiagnostic($.program, { code: "type-declaration-missing-name", target: props.type }); + } + name = namePolicy.getName(name, "class"); + + const refkeys = declarationRefkeys(props.refkey, props.type); + + // Check for models with additional properties (Record-based scenarios) + // This check must happen here (in addition to ClassBody) because models with no properties + // (e.g., `model Foo is Record`) won't render a ClassBody, so the error would never be thrown + if ($.model.is(props.type)) { + const additionalPropsRecord = $.model.getAdditionalPropertiesRecord(props.type); + if (additionalPropsRecord) { + throw new Error("Models with additional properties (Record[…]) are not supported"); + } + } + + // Array-based models (e.g., model Foo is Array) use regular classes, not dataclasses, + // since Array models in TypeSpec can't have properties, so they behave more like a class + // that inherits from a list. + // Similarly, interfaces should use regular classes (ABC) not dataclasses, since interfaces + // only define abstract methods, not fields. + const isArrayModel = $.model.is(props.type) && $.array.is(props.type); + const isInterface = props.type.kind === "Interface"; + const useDataclass = !isArrayModel && !isInterface; + + const ClassComponent = useDataclass ? py.DataclassDeclaration : py.ClassDeclaration; + + return ( + + + + {props.children} + + + + ); +} diff --git a/packages/emitter-framework/src/python/components/class-declaration/class-member.test.tsx b/packages/emitter-framework/src/python/components/class-declaration/class-member.test.tsx new file mode 100644 index 00000000000..47b71875bd5 --- /dev/null +++ b/packages/emitter-framework/src/python/components/class-declaration/class-member.test.tsx @@ -0,0 +1,194 @@ +import { Tester } from "#test/test-host.js"; +import { t } from "@typespec/compiler/testing"; +import { describe, expect, it } from "vitest"; +import { ClassDeclaration } from "../../../../src/python/components/class-declaration/class-declaration.js"; +import { getOutput } from "../../test-utils.js"; + +describe("Python Class Members", () => { + describe("default values", () => { + it("renders string default values", async () => { + const { program, MyModel } = await Tester.compile(t.code` + model ${t.model("MyModel")} { + name: string = "default"; + description?: string = "optional with default"; + emptyString: string = ""; + } + `); + + expect(getOutput(program, [])).toRenderTo( + ` + from dataclasses import dataclass + from typing import Optional + + + @dataclass(kw_only=True) + class MyModel: + name: str = "default" + description: Optional[str] = "optional with default" + empty_string: str = "" + + `, + ); + }); + + it("renders boolean default values", async () => { + const { program, BooleanModel } = await Tester.compile(t.code` + model ${t.model("BooleanModel")} { + isActive: boolean = true; + isDeleted: boolean = false; + optional?: boolean = true; + } + `); + + expect(getOutput(program, [])).toRenderTo( + ` + from dataclasses import dataclass + from typing import Optional + + + @dataclass(kw_only=True) + class BooleanModel: + is_active: bool = True + is_deleted: bool = False + optional: Optional[bool] = True + + `, + ); + }); + + it("renders array default values", async () => { + const { program, ArrayModel } = await Tester.compile(t.code` + model ${t.model("ArrayModel")} { + tags: string[] = #["tag1", "tag2"]; + emptyArray: int32[] = #[]; + numbers: int32[] = #[1, 2, 3]; + } + `); + + expect(getOutput(program, [])).toRenderTo( + ` + from dataclasses import dataclass + + + @dataclass(kw_only=True) + class ArrayModel: + tags: list[str] = ["tag1", "tag2"] + empty_array: list[int] = [] + numbers: list[int] = [1, 2, 3] + + `, + ); + }); + + it("renders integer default values without .0 suffix", async () => { + const { program, IntegerModel } = await Tester.compile(t.code` + model ${t.model("IntegerModel")} { + count: int32 = 42; + bigNumber: int64 = 1000000; + smallNumber: int8 = 127; + unsignedValue: uint32 = 100; + safeIntValue: safeint = 999; + } + `); + + expect(getOutput(program, [])).toRenderTo( + ` + from dataclasses import dataclass + + + @dataclass(kw_only=True) + class IntegerModel: + count: int = 42 + big_number: int = 1000000 + small_number: int = 127 + unsigned_value: int = 100 + safe_int_value: int = 999 + + `, + ); + }); + + it("renders float and decimal default values correctly", async () => { + const { program, NumericDefaults } = await Tester.compile(t.code` + + scalar customFloat extends float; + scalar customDecimal extends decimal; + + model ${t.model("NumericDefaults")} { + // Float variants with decimal values + floatBase: float = 1.5; + float32Value: float32 = 2.5; + float64Value: float64 = 3.5; + customFloatValue: customFloat = 4.5; + + // Float variants with integer values (should render with .0) + floatInt: float = 10; + float32Int: float32 = 20; + float64Int: float64 = 30; + + // Decimal variants + decimalBase: decimal = 100.25; + decimal128Value: decimal128 = 200.75; + customDecimalValue: customDecimal = 300.125; + + // Decimal with integer values (should render with .0) + decimalInt: decimal = 400; + decimal128Int: decimal128 = 500; + } + `); + + expect(getOutput(program, [])).toRenderTo( + ` + from dataclasses import dataclass + from decimal import Decimal + + + @dataclass(kw_only=True) + class NumericDefaults: + float_base: float = 1.5 + float32_value: float = 2.5 + float64_value: float = 3.5 + custom_float_value: float = 4.5 + float_int: float = 10.0 + float32_int: float = 20.0 + float64_int: float = 30.0 + decimal_base: Decimal = 100.25 + decimal128_value: Decimal = 200.75 + custom_decimal_value: Decimal = 300.125 + decimal_int: Decimal = 400.0 + decimal128_int: Decimal = 500.0 + + `, + ); + }); + + it("distinguishes between integer and float types with same numeric value", async () => { + const { program, MixedNumeric } = await Tester.compile(t.code` + model ${t.model("MixedNumeric")} { + intValue: int32 = 100; + int64Value: int64 = 100; + floatValue: float = 100; + float64Value: float64 = 100; + decimalValue: decimal = 100; + } + `); + + expect(getOutput(program, [])).toRenderTo( + ` + from dataclasses import dataclass + from decimal import Decimal + + + @dataclass(kw_only=True) + class MixedNumeric: + int_value: int = 100 + int64_value: int = 100 + float_value: float = 100.0 + float64_value: float = 100.0 + decimal_value: Decimal = 100.0 + + `, + ); + }); + }); +}); diff --git a/packages/emitter-framework/src/python/components/class-declaration/class-member.tsx b/packages/emitter-framework/src/python/components/class-declaration/class-member.tsx new file mode 100644 index 00000000000..f493082745c --- /dev/null +++ b/packages/emitter-framework/src/python/components/class-declaration/class-member.tsx @@ -0,0 +1,67 @@ +import { typingModule } from "#python/builtins.js"; +import { type Children } from "@alloy-js/core"; +import * as py from "@alloy-js/python"; +import { type ModelProperty, type Operation } from "@typespec/compiler"; +import { useTsp } from "../../../core/context/tsp-context.js"; +import { TypeExpression } from "../type-expression/type-expression.js"; +import { Method } from "./class-method.js"; +import { PrimitiveInitializer } from "./primitive-initializer.js"; + +export interface ClassMemberProps { + type: ModelProperty | Operation; + doc?: Children; + optional?: boolean; + methodType?: "method" | "class" | "static"; + abstract?: boolean; +} + +/** + * Creates the class member for the property. + * @param props - The props for the class member. + * @returns The class member. + */ +export function ClassMember(props: ClassMemberProps) { + const { $ } = useTsp(); + const namer = py.usePythonNamePolicy(); + const name = namer.getName(props.type.name, "class-member"); + const doc = props.doc ?? $.type.getDoc(props.type); + + if ($.modelProperty.is(props.type)) { + // Map never-typed properties to typing.Never + + const unpackedType = props.type.type; + const isOptional = props.optional ?? props.type.optional ?? false; + const defaultValue = props.type.defaultValue; + const initializer = defaultValue ? ( + + ) : undefined; + const unpackedTypeNode: Children = ; + const typeNode = isOptional ? ( + + ) : ( + unpackedTypeNode + ); + + const classMemberProps = { + doc, + name, + optional: isOptional, + ...(typeNode ? { type: typeNode } : {}), + ...(initializer ? { initializer } : {}), + omitNone: !isOptional, + }; + return ; + } + + if ($.operation.is(props.type)) { + return ( + + ); + } + + // If type is neither ModelProperty nor Operation, return empty fragment + return <>; +} diff --git a/packages/emitter-framework/src/python/components/class-declaration/class-method.test.tsx b/packages/emitter-framework/src/python/components/class-declaration/class-method.test.tsx new file mode 100644 index 00000000000..2e8217c5383 --- /dev/null +++ b/packages/emitter-framework/src/python/components/class-declaration/class-method.test.tsx @@ -0,0 +1,250 @@ +import { Tester } from "#test/test-host.js"; +import { getProgram } from "#test/utils.js"; +import * as py from "@alloy-js/python"; +import { t } from "@typespec/compiler/testing"; +import { describe, expect, it } from "vitest"; +import { ClassDeclaration } from "../../../../src/python/components/class-declaration/class-declaration.js"; +import { Method } from "../../../../src/python/components/class-declaration/class-method.js"; +import { getOutput } from "../../test-utils.js"; + +describe("interface methods with a `type` prop", () => { + it("creates a class method from an interface method", async () => { + const { program, getName } = await Tester.compile(t.code` + @test op ${t.op("getName")}(id: string): string; + `); + + expect( + getOutput(program, [ + + + , + ]), + ).toRenderTo(` + class BasicInterface: + async def get_name(self, id: str) -> str: + pass + + + `); + }); + + it("creates a class method that is a classmethod", async () => { + const { program, getName } = await Tester.compile(t.code` + @test op ${t.op("getName")}(id: string): string; + `); + + expect( + getOutput(program, [ + + + , + ]), + ).toRenderTo(` + class BasicInterface: + @classmethod + async def get_name(cls, id: str) -> str: + pass + + + `); + }); + + it("creates a class method that is a staticmethod", async () => { + const { program, getName } = await Tester.compile(t.code` + @test op ${t.op("getName")}(id: string): string; + `); + + expect( + getOutput(program, [ + + + , + ]), + ).toRenderTo(` + class BasicInterface: + @staticmethod + async def get_name(id: str) -> str: + pass + + + `); + }); + + it("creates an async class method from an asyncinterface method", async () => { + const { program, getName } = await Tester.compile(t.code` + @test op ${t.op("getName")}(id: string): string; + `); + + expect( + getOutput(program, [ + + + , + ]), + ).toRenderTo(` + class BasicInterface: + async def get_name(self, id: str) -> str: + pass + + + `); + }); + + it("can append extra keyword-only parameters", async () => { + const { program, getName } = await Tester.compile(t.code` + @test op ${t.op("getName")}(id: string): string; + `); + + expect( + getOutput(program, [ + + + , + ]), + ).toRenderTo(` + class BasicInterface: + def get_name(self, id: str, *, foo: str) -> str: + pass + + + `); + }); + + it("can add extra keyword-only parameters", async () => { + const { program, getName } = await Tester.compile(t.code` + @test op ${t.op("getName")}(id: string): string; + `); + + expect( + getOutput(program, [ + + + , + ]), + ).toRenderTo(` + class BasicInterface: + def get_name(self, id: str, *, foo: str) -> str: + pass + + + `); + }); + + it("can replace parameters with raw params provided", async () => { + const { program, getName } = await Tester.compile(t.code` + @test op ${t.op("getName")}(id: string): string; + `); + + expect( + getOutput(program, [ + + + , + ]), + ).toRenderTo(` + class BasicInterface: + def get_name(self, *, foo: str, bar: float) -> str: + pass + + + `); + }); + + it("can replace parameters with params having defaults (requires * marker)", async () => { + const { program, getName } = await Tester.compile(t.code` + @test op ${t.op("getName")}(id: string): string; + `); + + expect( + getOutput(program, [ + + }, + { name: "bar", type: "number", default: }, + ]} + /> + , + ]), + ).toRenderTo(` + class BasicInterface: + def get_name(self, *, foo: str = "default", bar: float = 42) -> str: + pass + + + `); + }); + + it("can override return type in a class method", async () => { + const { program, getName } = await Tester.compile(t.code` + @test op ${t.op("getName")}(id: string): string; + `); + + expect( + getOutput(program, [ + + + , + ]), + ).toRenderTo(` + class BasicInterface: + def get_name(self, id: str) -> ASpecialString: + pass + + + `); + }); + + it("can override method name in a class method", async () => { + const { program, getName } = await Tester.compile(t.code` + @test op ${t.op("getName")}(id: string): string; + `); + + expect( + getOutput(program, [ + + + , + ]), + ).toRenderTo(` + class BasicInterface: + def get_name_custom(self, id: str) -> str: + pass + + + `); + }); +}); + +describe("interface methods without a `type` prop", () => { + it("renders a plain interface method from a class method without a `type` prop", async () => { + const program = await getProgram(""); + + expect( + getOutput(program, [ + + + , + ]), + ).toRenderTo(` + class BasicInterface: + def plain_method(self, param1: string) -> number: + pass + + + `); + }); +}); diff --git a/packages/emitter-framework/src/python/components/class-declaration/class-method.tsx b/packages/emitter-framework/src/python/components/class-declaration/class-method.tsx new file mode 100644 index 00000000000..da717d79694 --- /dev/null +++ b/packages/emitter-framework/src/python/components/class-declaration/class-method.tsx @@ -0,0 +1,97 @@ +import { type Children, createContext, splitProps, useContext } from "@alloy-js/core"; +import * as py from "@alloy-js/python"; +import type { Operation } from "@typespec/compiler"; +import { useTsp } from "../../../core/index.js"; +import { buildParameterDescriptors, getReturnType } from "../../utils/operation.js"; +import { DocElement } from "../doc-element/doc-element.js"; +import { TypeExpression } from "../type-expression/type-expression.js"; + +export const MethodContext = createContext<"method" | "static" | "class" | undefined>(undefined); +export const MethodProvider = MethodContext.Provider; + +export interface MethodPropsWithType extends Omit { + type: Operation; + name?: string; + doc?: Children; + methodType?: "method" | "class" | "static"; + abstract?: boolean; + /** If true, parameters replaces operation parameters instead of adding to them as keyword-only */ + replaceParameters?: boolean; +} + +export type MethodProps = MethodPropsWithType | py.MethodDeclarationBaseProps; + +/** + * Get the method component based on the resolved method type. + * We prioritize the methodType prop provided in the Method component, + * and then the one provided in the context, and then we default to "method". + */ +function getResolvedMethodType(props: MethodProps): "method" | "class" | "static" { + const ctxMethodType = useContext(MethodContext); + const propMethodType = "methodType" in props ? (props as any).methodType : undefined; + return (propMethodType ?? ctxMethodType ?? "method") as "method" | "class" | "static"; +} + +/** + * A Python class method. Pass the `type` prop to create the + * method by converting from a TypeSpec Operation. Any other props + * provided will take precedence. + */ +export function Method(props: Readonly) { + const { $ } = useTsp(); + const isTypeSpecTyped = "type" in props; + const type = isTypeSpecTyped ? props.type : undefined; + const docSource = props.doc ?? (type ? $.type.getDoc(type) : undefined); + const docElement = docSource ? ( + + ) : undefined; + const resolvedMethodType = getResolvedMethodType(props); + const MethodComponent = + resolvedMethodType === "static" + ? py.StaticMethodDeclaration + : resolvedMethodType === "class" + ? py.ClassMethodDeclaration + : py.MethodDeclaration; + + // Default to abstract when deriving from a TypeSpec operation (`type` prop present), + // unless explicitly overridden by props.abstract === false + const abstractFlag = (() => { + const explicit = (props as any).abstract as boolean | undefined; + return explicit ?? (!isTypeSpecTyped ? false : undefined); + })(); + + /** + * If the method does not come from the Typespec class declaration, return a standard Python method declaration. + * Have in mind that, with that, we lose some of the TypeSpec class declaration overrides. + */ + if (!isTypeSpecTyped) { + return ; + } + + const [efProps, updateProps, forwardProps] = splitProps( + props, + ["type"], + ["returnType", "parameters"], + ); + + const name = props.name ?? py.usePythonNamePolicy().getName(efProps.type.name, "function"); + const returnType = props.returnType ?? ; + const allParameters = buildParameterDescriptors(efProps.type.parameters, { + params: props.parameters, + replaceParameters: props.replaceParameters, + }); + + return ( + <> + + + ); +} diff --git a/packages/emitter-framework/src/python/components/class-declaration/index.ts b/packages/emitter-framework/src/python/components/class-declaration/index.ts new file mode 100644 index 00000000000..19f53a8ea60 --- /dev/null +++ b/packages/emitter-framework/src/python/components/class-declaration/index.ts @@ -0,0 +1,6 @@ +export * from "./class-bases.js"; +export * from "./class-body.js"; +export * from "./class-declaration.js"; +export * from "./class-member.js"; +export * from "./class-method.js"; +export * from "./primitive-initializer.js"; diff --git a/packages/emitter-framework/src/python/components/class-declaration/primitive-initializer.tsx b/packages/emitter-framework/src/python/components/class-declaration/primitive-initializer.tsx new file mode 100644 index 00000000000..65aeca9b2d5 --- /dev/null +++ b/packages/emitter-framework/src/python/components/class-declaration/primitive-initializer.tsx @@ -0,0 +1,62 @@ +import { type Children } from "@alloy-js/core"; +import * as py from "@alloy-js/python"; +import type { Type, Value } from "@typespec/compiler"; +import { useTsp } from "../../../core/context/tsp-context.js"; +import { Atom } from "../atom/atom.js"; + +export interface PrimitiveInitializerProps { + /** + * The default value to convert to a Python initializer expression. + */ + defaultValue: Value; + /** + * The property type, used to determine float vs int formatting. + */ + propertyType: Type; +} + +/** + * Renders a Python primitive initializer from a TypeSpec default value. + * + * Handles StringValue, BooleanValue, NullValue, NumericValue, and ArrayValue. + * For numeric values, uses the propertyType to determine whether to render as float. + * + * @returns The Python initializer expression, or undefined if not supported. + */ +export function PrimitiveInitializer(props: PrimitiveInitializerProps): Children | undefined { + const { $ } = useTsp(); + const { defaultValue, propertyType } = props; + + if (!defaultValue) return undefined; + + const valueKind = (defaultValue as any).valueKind ?? (defaultValue as any).kind; + switch (valueKind) { + case "StringValue": + case "BooleanValue": + case "NullValue": + return ; + case "NumericValue": { + // The Atom component converts NumericValue via asNumber(), which normalizes 100.0 to 100. + // Atom also has no access to the field type (float vs int), so it can't decide when to keep a trailing .0. + // Here we do have the propertyType so, for float/decimal fields, we render a raw value and append ".0" + // when needed. For non-float fields, default to a plain numeric Atom. + + // Unwrap potential numeric wrapper shape and preserve float formatting + let raw: any = (defaultValue as any).value; + // Example: value is { value: "100", isInteger: true } + if (raw && typeof raw === "object" && "value" in raw) raw = raw.value; + + // Float-like property types (including custom subtypes) should render with float hint + if ($.scalar.extendsFloat(propertyType) || $.scalar.extendsDecimal(propertyType)) { + return ; + } + + // Otherwise output as a number atom + return ; + } + case "ArrayValue": + return ; + default: + return undefined; + } +} diff --git a/packages/emitter-framework/src/python/components/doc-element/doc-element.tsx b/packages/emitter-framework/src/python/components/doc-element/doc-element.tsx new file mode 100644 index 00000000000..75ccdd35b6f --- /dev/null +++ b/packages/emitter-framework/src/python/components/doc-element/doc-element.tsx @@ -0,0 +1,83 @@ +import { type Children, type Component, List } from "@alloy-js/core"; +import type { Type } from "@typespec/compiler"; +import { useTsp } from "../../../core/context/tsp-context.js"; + +export interface DocElementProps { + /** + * The TypeSpec type to get documentation from. + * If provided and no `doc` override is given, documentation will be + * fetched via `$.type.getDoc(type)`. + */ + type?: Type; + + /** + * Optional documentation override. If provided, this takes precedence + * over documentation from the `type` prop. + * + * Accepts: + * - string - split into lines and render as a multi-line docstring + * - string[] | Children[] - rendered as separate paragraphs + * - Children (e.g., an explicit Doc component) - returned as-is + */ + doc?: string | string[] | Children | Children[]; + + /** + * The Python doc component to use for rendering (ClassDoc, FunctionDoc, MethodDoc, etc.) + */ + component: Component<{ description: Children[] }>; +} + +/** + * Renders documentation for a Python declaration. + * + * This component handles fetching documentation from TypeSpec types and + * normalizing various doc formats into the appropriate Python doc component. + * + * @example + * ```tsx + * // With a TypeSpec type (fetches doc automatically) + * + * + * // With an explicit doc override + * + * + * // With both (doc takes precedence) + * + * ``` + */ +export function DocElement(props: DocElementProps): Children { + const { $ } = useTsp(); + + // Resolve the documentation source: explicit doc takes precedence over type-derived doc + const source = props.doc ?? (props.type ? $.type.getDoc(props.type) : undefined); + + if (!source) { + return undefined; + } + + const DocComponent = props.component; + + // Doc provided as an array (paragraphs/nodes) - preserve structure + if (Array.isArray(source)) { + return ; + } + + // Doc provided as a string - preserve line breaks + if (typeof source === "string") { + const lines = source.split(/\r?\n/); + return ( + + {lines.map((line) => ( + <>{line} + ))} + , + ]} + /> + ); + } + + // Doc provided as JSX - pass through unchanged + return source as Children; +} diff --git a/packages/emitter-framework/src/python/components/doc-element/index.ts b/packages/emitter-framework/src/python/components/doc-element/index.ts new file mode 100644 index 00000000000..918f7f95db9 --- /dev/null +++ b/packages/emitter-framework/src/python/components/doc-element/index.ts @@ -0,0 +1 @@ +export * from "./doc-element.js"; diff --git a/packages/emitter-framework/src/python/components/enum-declaration/enum-declaration.test.tsx b/packages/emitter-framework/src/python/components/enum-declaration/enum-declaration.test.tsx new file mode 100644 index 00000000000..a74c7abcdf7 --- /dev/null +++ b/packages/emitter-framework/src/python/components/enum-declaration/enum-declaration.test.tsx @@ -0,0 +1,319 @@ +import { efRefkey } from "#python/utils/refkey.js"; +import { Tester } from "#test/test-host.js"; +import { d } from "@alloy-js/core/testing"; +import * as py from "@alloy-js/python"; +import { t } from "@typespec/compiler/testing"; +import { describe, expect, it } from "vitest"; +import { getOutput } from "../../test-utils.js"; +import { EnumDeclaration } from "./enum-declaration.js"; + +describe("Python Enum Declaration", () => { + it("takes an enum type parameter", async () => { + const { program, Foo } = await Tester.compile(t.code` + enum ${t.enum("Foo")} { + one: 1, + two: 2, + three: 3 + } + `); + + expect(getOutput(program, [])).toRenderTo(d` + from enum import IntEnum + + + class Foo(IntEnum): + ONE = 1 + TWO = 2 + THREE = 3 + + + `); + }); + + it("adds Python doc from TypeSpec", async () => { + const { program, Foo } = await Tester.compile(t.code` + /** + * This is a test enum + */ + enum ${t.enum("Foo")} { + @doc("This is one") + one: 1, + two: 2, + three: 3 + } + `); + const output = getOutput(program, []); + + expect(output).toRenderTo(d` + from enum import IntEnum + + + class Foo(IntEnum): + """ + This is a test enum + """ + + ONE = 1 + """ + This is one + """ + TWO = 2 + THREE = 3 + + + `); + }); + + it("explicit doc take precedence", async () => { + const { program, Foo } = await Tester.compile(t.code` + /** + * This is a test enum + */ + enum ${t.enum("Foo")} { + @doc("This is one") + one: 1, + two: 2, + three: 3 + } + `); + const output = getOutput(program, [ + , + ]); + + expect(output).toRenderTo(d` + from enum import IntEnum + + + class Foo(IntEnum): + """ + This is an explicit doc + """ + + ONE = 1 + """ + This is one + """ + TWO = 2 + THREE = 3 + + + `); + }); + + it("takes a union type parameter", async () => { + const { program, Foo } = await Tester.compile(t.code` + union ${t.union("Foo")} { + one: 1, + two: 2, + three: 3 + } + `); + const output = getOutput(program, []); + + expect(output).toRenderTo(d` + from enum import IntEnum + + + class Foo(IntEnum): + ONE = 1 + TWO = 2 + THREE = 3 + + + `); + }); + + it("can be referenced", async () => { + const { program, Foo } = await Tester.compile(t.code` + enum ${t.enum("Foo")} { + one: 1, + two: 2, + three: 3 + } + `); + + const output = getOutput(program, [ + , + + {efRefkey(Foo)} + {efRefkey(Foo.members.get("one"))} + , + ]); + + expect(output).toRenderTo(d` + from enum import IntEnum + + + class Foo(IntEnum): + ONE = 1 + TWO = 2 + THREE = 3 + + + Foo + Foo.ONE + `); + }); + + it("can be referenced using union", async () => { + const { program, Foo } = await Tester.compile(t.code` + union ${t.union("Foo")} { + one: 1, + two: 2, + three: 3 + } + `); + + const output = getOutput(program, [ + , + + {efRefkey(Foo)} + {efRefkey(Foo.variants.get("one"))} + , + ]); + + expect(output).toRenderTo(d` + from enum import IntEnum + + + class Foo(IntEnum): + ONE = 1 + TWO = 2 + THREE = 3 + + + Foo + Foo.ONE + `); + }); + + describe("Enum Type Detection", () => { + it("generates IntEnum if all values are integer values", async () => { + const { program, StatusCode } = await Tester.compile(t.code` + enum ${t.enum("StatusCode")} { + success: 200, + notFound: 404, + serverError: 500 + } + `); + const output = getOutput(program, []); + + expect(output).toRenderTo(d` + from enum import IntEnum + + + class StatusCode(IntEnum): + SUCCESS = 200 + NOT_FOUND = 404 + SERVER_ERROR = 500 + + + `); + }); + + it("generates StrEnum if all values are string values", async () => { + const { program, Color } = await Tester.compile(t.code` + enum ${t.enum("Color")} { + red: "red", + green: "green", + blue: "blue" + } + `); + const output = getOutput(program, []); + + expect(output).toRenderTo(d` + from enum import StrEnum + + + class Color(StrEnum): + RED = "red" + GREEN = "green" + BLUE = "blue" + + + `); + }); + + it("generates StrEnum if all values are string values with custom values", async () => { + const { program, Color } = await Tester.compile(t.code` + enum ${t.enum("Color")} { + red: "This is red", + green: "This is green", + blue: "This is blue" + } + `); + const output = getOutput(program, [ + , + + {efRefkey(Color.members.get("red"))} + {efRefkey(Color.members.get("green"))} + {efRefkey(Color.members.get("blue"))} + , + ]); + + expect(output).toRenderTo(d` + from enum import StrEnum + + + class Color(StrEnum): + RED = "This is red" + GREEN = "This is green" + BLUE = "This is blue" + + + Color.RED + Color.GREEN + Color.BLUE + + `); + }); + + it("generates Enum for mixed value types", async () => { + const { program, Mixed } = await Tester.compile(t.code` + enum ${t.enum("Mixed")} { + stringValue: "hello", + numericValue: 42, + autoValue + } + `); + const output = getOutput(program, []); + + expect(output).toRenderTo(d` + from enum import auto + from enum import Enum + + + class Mixed(Enum): + STRING_VALUE = "hello" + NUMERIC_VALUE = 42 + AUTO_VALUE = auto() + + + `); + }); + + it("handles correctly enums without values", async () => { + const { program, EnumWithoutValues } = await Tester.compile(t.code` + enum ${t.enum("EnumWithoutValues")} { + someValue, + anotherValue, + yetAnotherValue + } + `); + const output = getOutput(program, []); + + expect(output).toRenderTo(d` + from enum import auto + from enum import Enum + + + class EnumWithoutValues(Enum): + SOME_VALUE = auto() + ANOTHER_VALUE = auto() + YET_ANOTHER_VALUE = auto() + + + `); + }); + }); +}); diff --git a/packages/emitter-framework/src/python/components/enum-declaration/enum-declaration.tsx b/packages/emitter-framework/src/python/components/enum-declaration/enum-declaration.tsx new file mode 100644 index 00000000000..7ce228ceb2b --- /dev/null +++ b/packages/emitter-framework/src/python/components/enum-declaration/enum-declaration.tsx @@ -0,0 +1,77 @@ +import { useTsp } from "#core/context/index.js"; +import { For, Prose } from "@alloy-js/core"; +import * as py from "@alloy-js/python"; +import type { Enum, EnumMember as TspEnumMember, Union } from "@typespec/compiler"; +import { reportDiagnostic } from "../../../lib.js"; +import { declarationRefkeys, efRefkey } from "../../utils/refkey.js"; +import { EnumMember } from "./enum-member.js"; + +export interface EnumDeclarationProps extends Omit { + name?: string; + type: Union | Enum; +} + +// Determine the appropriate enum type based on the member values +function determineEnumType( + members: Array<[string, TspEnumMember]>, +): "IntEnum" | "StrEnum" | "Enum" { + const allInteger = members.every(([, member]) => { + const value = member.value; + return typeof value === "number" && Number.isInteger(value); + }); + + const allString = members.every(([, member]) => { + const value = member.value; + return typeof value === "string"; + }); + + if (allInteger) { + return "IntEnum"; + } else if (allString) { + return "StrEnum"; + } else { + return "Enum"; + } +} + +export function EnumDeclaration(props: EnumDeclarationProps) { + const { $ } = useTsp(); + let type: Enum; + if ($.union.is(props.type)) { + if (!$.union.isValidEnum(props.type)) { + throw new Error("The provided union type cannot be represented as an enum"); + } + type = $.enum.createFromUnion(props.type); + } else { + type = props.type; + } + + if (!props.type.name) { + reportDiagnostic($.program, { code: "type-declaration-missing-name", target: props.type }); + } + const refkeys = declarationRefkeys(props.refkey, props.type); + const name = props.name ?? py.usePythonNamePolicy().getName(props.type.name!, "enum"); + const members = Array.from(type.members.entries()); + const doc = props.doc ?? $.type.getDoc(type); + const docElement = doc ? {doc}]} /> : undefined; + const enumType = determineEnumType(members); + + return ( + + + {([key, value]) => { + const memberDoc = $.type.getDoc(value); + return ( + + ); + }} + + + ); +} diff --git a/packages/emitter-framework/src/python/components/enum-declaration/enum-member.tsx b/packages/emitter-framework/src/python/components/enum-declaration/enum-member.tsx new file mode 100644 index 00000000000..57a7f7e60ce --- /dev/null +++ b/packages/emitter-framework/src/python/components/enum-declaration/enum-member.tsx @@ -0,0 +1,21 @@ +import { type Children, type Refkey } from "@alloy-js/core"; +import * as py from "@alloy-js/python"; +import type { EnumMember as TspEnumMember } from "@typespec/compiler"; + +export interface EnumMemberProps { + type: TspEnumMember; + doc?: Children; + refkey?: Refkey; +} + +export function EnumMember(props: EnumMemberProps) { + return ( + + ); +} diff --git a/packages/emitter-framework/src/python/components/enum-declaration/index.ts b/packages/emitter-framework/src/python/components/enum-declaration/index.ts new file mode 100644 index 00000000000..c6a84680c17 --- /dev/null +++ b/packages/emitter-framework/src/python/components/enum-declaration/index.ts @@ -0,0 +1,2 @@ +export * from "./enum-declaration.js"; +export * from "./enum-member.js"; diff --git a/packages/emitter-framework/src/python/components/function-declaration/function-declaration.test.tsx b/packages/emitter-framework/src/python/components/function-declaration/function-declaration.test.tsx new file mode 100644 index 00000000000..fac6d007c1c --- /dev/null +++ b/packages/emitter-framework/src/python/components/function-declaration/function-declaration.test.tsx @@ -0,0 +1,582 @@ +import { getOutput } from "#python/test-utils.js"; +import { Tester } from "#test/test-host.js"; +import * as py from "@alloy-js/python"; +import { t } from "@typespec/compiler/testing"; +import { describe, expect, it } from "vitest"; +import { FunctionDeclaration } from "./function-declaration.js"; + +describe("Python Function Declaration", () => { + it("creates a function with single positional param", async () => { + const { program, getName } = await Tester.compile(t.code` + op ${t.op("getName")}(id: string): string; + `); + + expect(getOutput(program, [])).toRenderTo(` + def get_name(id: str) -> str: + pass + + `); + }); + + it("creates an async function", async () => { + const { program, getName } = await Tester.compile(t.code` + op ${t.op("getName")}(id: string): string; + `); + + expect(getOutput(program, [])).toRenderTo(` + async def get_name(id: str) -> str: + pass + + `); + }); + + it("creates a function with a custom name", async () => { + const { program, getName } = await Tester.compile(t.code` + op ${t.op("getName")}(id: string): string; + `); + + expect(getOutput(program, [])) + .toRenderTo(` + def new_name(id: str) -> str: + pass + + `); + }); + + it("creates a function with additional keyword-only parameters", async () => { + const { program, createPerson } = await Tester.compile(t.code` + op ${t.op("createPerson")}(id: string): string; + `); + + expect( + getOutput(program, [ + , + ]), + ).toRenderTo(` + def create_person(id: str, *, name: str, age: float) -> str: + pass + + `); + }); + + it("creates a function with additional keyword-only parameters (string shorthand)", async () => { + const { program, createPerson } = await Tester.compile(t.code` + op ${t.op("createPerson")}(id: string): string; + `); + + expect( + getOutput(program, [ + , + ]), + ).toRenderTo(` + def create_person(id: str, *, name, age) -> str: + pass + + `); + }); + + it("creates a function replacing parameters with raw params provided", async () => { + const { program, createPerson } = await Tester.compile(t.code` + op ${t.op("createPerson")}(id: string): string; + `); + + expect( + getOutput(program, [ + , + ]), + ).toRenderTo(` + def create_person(*, name, age) -> str: + pass + + `); + }); + + it("creates a function replacing parameters with params having defaults (requires * marker)", async () => { + const { program, createPerson } = await Tester.compile(t.code` + op ${t.op("createPerson")}(id: string): string; + `); + + expect( + getOutput(program, [ + }, + { name: "age", type: "int", default: }, + ]} + replaceParameters={true} + />, + ]), + ).toRenderTo(` + def create_person(*, name: str = "alice", age: int = 30) -> str: + pass + + `); + }); + + it("creates a function with defaults in raw parameter descriptors", async () => { + const { program, createPerson } = await Tester.compile(t.code` + op ${t.op("createPerson")}(id: string, locale: string = "en-US"): string; + `); + + expect( + getOutput(program, [ + }, + { name: "verbose", type: "bool", default: }, + ]} + />, + ]), + ).toRenderTo(` + def create_person(id: str, *, locale: str = "en-US", limit: int = 10, verbose: bool = True) -> str: + pass + + `); + }); + + it("creates a function building parameters from a model via parametersModel (will replace parameters)", async () => { + const { program, createPerson, Foo } = await Tester.compile(t.code` + op ${t.op("createPerson")}(id: string): string; + + model ${t.model("Foo")} { + name: string; + age: int32; + } + `); + + expect(getOutput(program, [])) + .toRenderTo(` + def create_person(name: str, age: int) -> str: + pass + + `); + }); + + it("creates a function with parametersModel applying defaults and optionals", async () => { + const { program, createPerson, Foo } = await Tester.compile(t.code` + op ${t.op("createPerson")}(id: string): string; + + model ${t.model("Foo")} { + requiredName?: string = "alice"; + optionalAge?: int32; + } + `); + + expect(getOutput(program, [])) + .toRenderTo(` + def create_person(*, required_name: str = "alice", optional_age: int = None) -> str: + pass + + `); + }); + + it("creates a function that replaces parameters with parametersModel even when extras are provided", async () => { + const { program, createPerson, Foo } = await Tester.compile(t.code` + op ${t.op("createPerson")}(id: string): string; + + model ${t.model("Foo")} { + name: string; + age: int32; + } + `); + + expect( + getOutput(program, [ + , + ]), + ).toRenderTo(` + def create_person(name: str, age: int) -> str: + pass + + `); + }); + + it("creates a function overriding the return type", async () => { + const { program, getName } = await Tester.compile(t.code` + op ${t.op("getName")}(id: string): string; + `); + + expect(getOutput(program, [])) + .toRenderTo(` + def get_name(id: str) -> ASpecialString: + pass + + `); + }); + it("creates a function with body", async () => { + const { program, createPerson } = await Tester.compile(t.code` + op ${t.op("createPerson")}(id: string): string; + `); + + expect( + getOutput(program, [ + + <>print("Hello World!") + , + ]), + ).toRenderTo(` + def create_person(id: str) -> str: + print("Hello World!") + + `); + }); + + it("creates a function with a doc", async () => { + const { program, getName } = await Tester.compile(t.code` + op ${t.op("getName")}(id: string): string; + `); + + expect(getOutput(program, [])) + .toRenderTo(` + def get_name(id: str) -> str: + """ + This is a test doc + """ + + pass + + `); + }); + + it("creates a function with a multi-paragraph FunctionDoc", async () => { + const { program, getName } = await Tester.compile(t.code` + op ${t.op("getName")}(id: string): string; + `); + + expect( + getOutput(program, [ + First paragraph, <>Second paragraph]} />} + />, + ]), + ).toRenderTo(` + def get_name(id: str) -> str: + """ + First paragraph + + Second paragraph + """ + + pass + + `); + }); + + it("creates a function with doc as string[]", async () => { + const { program, getName } = await Tester.compile(t.code` + op ${t.op("getName")}(id: string): string; + `); + + expect( + getOutput(program, [ + , + ]), + ).toRenderTo(` + def get_name(id: str) -> str: + """ + First paragraph + + Second paragraph + """ + + pass + + `); + }); + + it("creates a function with doc as Children[]", async () => { + const { program, getName } = await Tester.compile(t.code` + op ${t.op("getName")}(id: string): string; + `); + + expect( + getOutput(program, [ + First paragraph, <>Second paragraph]} />, + ]), + ).toRenderTo(` + def get_name(id: str) -> str: + """ + First paragraph + + Second paragraph + """ + + pass + + `); + }); + + it("creates a function with doc lines (string with newlines)", async () => { + const { program, getName } = await Tester.compile(t.code` + op ${t.op("getName")}(id: string): string; + `); + + expect(getOutput(program, [])) + .toRenderTo(` + def get_name(id: str) -> str: + """ + Line 1 + Line 2 + """ + + pass + + `); + }); + + it("creates a function with no parameters", async () => { + const { program, ping } = await Tester.compile(t.code` + op ${t.op("ping")}(): string; + `); + + expect(getOutput(program, [])).toRenderTo(` + def ping() -> str: + pass + + `); + }); + + it("creates a function that returns None for void", async () => { + const { program, ping } = await Tester.compile(t.code` + op ${t.op("ping")}(): void; + `); + + expect(getOutput(program, [])).toRenderTo(` + def ping() -> None: + pass + + `); + }); + + it("creates a function that returns Never for never", async () => { + const { program, abort } = await Tester.compile(t.code` + op ${t.op("abort")}(): never; + `); + + expect(getOutput(program, [])).toRenderTo(` + from typing import Never + + + def abort() -> Never: + pass + + `); + }); + + it("creates a function that correctly handles simple unions", async () => { + const { program, get } = await Tester.compile(t.code` + op ${t.op("get")}(): int32 | string; + `); + + expect(getOutput(program, [])).toRenderTo(` + def get() -> int | str: + pass + + `); + }); + + it("creates a function with only keyword-only parameters (requires * marker for params with defaults)", async () => { + const { program, createPerson } = await Tester.compile(t.code` + op ${t.op("createPerson")}(name: string = "alice", age: int32 = 30): string; + `); + + expect(getOutput(program, [])).toRenderTo(` + def create_person(*, name: str = "alice", age: int = 30) -> str: + pass + + `); + }); + + it("creates a function with operation params positional and additional params keyword-only", async () => { + const { program, createPerson } = await Tester.compile(t.code` + op ${t.op("createPerson")}(id: string, locale: string = "en-US"): string; + `); + + expect( + getOutput(program, [ + }, + ]} + />, + ]), + ).toRenderTo(` + def create_person(id: str, *, version: int, locale: str = "en-US", debug: bool = False) -> str: + pass + + `); + }); + + it("creates a function with TSP params in wrong order (default before required) - reorders correctly", async () => { + const { program, createPerson } = await Tester.compile(t.code` + op ${t.op("createPerson")}(locale: string = "en-US", id: string): string; + `); + + expect(getOutput(program, [])).toRenderTo(` + def create_person(id: str, *, locale: str = "en-US") -> str: + pass + + `); + }); + + it("creates a function with only positional params when adding params to empty operation", async () => { + const { program, ping } = await Tester.compile(t.code` + op ${t.op("ping")}(): string; + `); + + expect(getOutput(program, [])) + .toRenderTo(` + def ping(name, age) -> str: + pass + + `); + }); + + it("creates a function with multiple positional params", async () => { + const { program, createPerson } = await Tester.compile(t.code` + op ${t.op("createPerson")}(id: string, name: string, age: int32): string; + `); + + expect(getOutput(program, [])).toRenderTo(` + def create_person(id: str, name: str, age: int) -> str: + pass + + `); + }); + + it("creates a function adding keyword-only params to operation with only positional params", async () => { + const { program, createPerson } = await Tester.compile(t.code` + op ${t.op("createPerson")}(id: string, name: string): string; + `); + + expect( + getOutput(program, [ + }, + ]} + />, + ]), + ).toRenderTo(` + def create_person(id: str, name: str, *, email, notify: bool = False) -> str: + pass + + `); + }); + + it("creates a function with multiple params with defaults (all keyword-only)", async () => { + const { program, search } = await Tester.compile(t.code` + op ${t.op("search")}( + limit: int32 = 10, + offset: int32 = 0, + sortBy: string = "name" + ): string; + `); + + expect(getOutput(program, [])).toRenderTo(` + def search(*, limit: int = 10, offset: int = 0, sort_by: str = "name") -> str: + pass + + `); + }); + + it("creates a function adding params without defaults to operation with only defaults", async () => { + const { program, search } = await Tester.compile(t.code` + op ${t.op("search")}(limit: int32 = 10, offset: int32 = 0): string; + `); + + expect(getOutput(program, [])) + .toRenderTo(` + def search(*, query, limit: int = 10, offset: int = 0) -> str: + pass + + `); + }); + + it("creates a function with complex parameter mix", async () => { + const { program, complexOp } = await Tester.compile(t.code` + op ${t.op("complexOp")}( + required1: string, + required2: int32, + optional1: string = "default", + optional2: int32 = 42 + ): string; + `); + + expect( + getOutput(program, [ + }, + ]} + />, + ]), + ).toRenderTo(` + def complex_op(required1: str, required2: int, *, additional_required, optional1: str = "default", optional2: int = 42, additional_optional: bool = True) -> str: + pass + + `); + }); + + it("creates a function with single positional param and additional keyword-only", async () => { + const { program, getUser } = await Tester.compile(t.code` + op ${t.op("getUser")}(id: string): string; + `); + + expect( + getOutput(program, [ + }, + ]} + />, + ]), + ).toRenderTo(` + def get_user(id: str, *, include_deleted: bool = False) -> str: + pass + + `); + }); + + it("creates a function adding only params with defaults to empty operation", async () => { + const { program, ping } = await Tester.compile(t.code` + op ${t.op("ping")}(): string; + `); + + expect( + getOutput(program, [ + }, + { name: "retries", type: "int", default: }, + ]} + />, + ]), + ).toRenderTo(` + def ping(*, timeout: int = 30, retries: int = 3) -> str: + pass + + `); + }); +}); diff --git a/packages/emitter-framework/src/python/components/function-declaration/function-declaration.tsx b/packages/emitter-framework/src/python/components/function-declaration/function-declaration.tsx new file mode 100644 index 00000000000..c77b2b9b5bd --- /dev/null +++ b/packages/emitter-framework/src/python/components/function-declaration/function-declaration.tsx @@ -0,0 +1,90 @@ +import { useTsp } from "#core/index.js"; +import { buildParameterDescriptors } from "#python/utils/operation.js"; +import { declarationRefkeys } from "#python/utils/refkey.js"; +import * as py from "@alloy-js/python"; +import type { Model, Operation } from "@typespec/compiler"; +import { DocElement } from "../doc-element/doc-element.js"; +import { TypeExpression } from "../type-expression/type-expression.js"; + +export interface FunctionDeclarationPropsWithType extends Omit< + py.FunctionDeclarationProps, + "name" +> { + type: Operation; + name?: string; + parametersModel?: Model; + /** If true, parameters replaces operation parameters instead of adding to them as keyword-only */ + replaceParameters?: boolean; +} + +export type FunctionDeclarationProps = + | FunctionDeclarationPropsWithType + | py.FunctionDeclarationProps; + +/** + * A Python function declaration. Pass the `type` prop to create the + * function declaration by converting from a TypeSpec Operation. Any other props + * provided will take precedence. + * + * @remarks + * - `parametersModel`: When provided, it replaces the function parameters, ignoring + * the `parameters` option. + * - Model-derived parameters include default values when present on the model, and + * emit `= None` for optional parameters without an explicit default. + * - Additional parameters (from `parameters` prop) are always keyword-only. + */ +export function FunctionDeclaration(props: FunctionDeclarationProps) { + const { $ } = useTsp(); + + if (!isTypedFunctionDeclarationProps(props)) { + return ; + } + + const refkeys = declarationRefkeys(props.refkey, props.type); + + const name = props.name + ? props.name + : py.usePythonNamePolicy().getName(props.type.name, "function"); + + const returnType = props.returnType ?? ; + let allParameters: (py.ParameterDescriptor | string)[] | undefined; + if (props.parametersModel) { + // When a parametersModel is provided, it always replaces parameters, + // ignoring extra parameters. + allParameters = buildParameterDescriptors(props.parametersModel) ?? []; + } else { + allParameters = buildParameterDescriptors(props.type.parameters, { + params: props.parameters, + replaceParameters: props.replaceParameters, + }); + } + const docSource = props.doc ?? $.type.getDoc(props.type); + const docElement = docSource ? ( + + ) : undefined; + const doc = docElement ? ( + <> + {docElement} + + + ) : undefined; + + return ( + + {props.children} + + ); +} + +function isTypedFunctionDeclarationProps( + props: FunctionDeclarationProps, +): props is FunctionDeclarationPropsWithType { + return "type" in props; +} diff --git a/packages/emitter-framework/src/python/components/function-declaration/index.ts b/packages/emitter-framework/src/python/components/function-declaration/index.ts new file mode 100644 index 00000000000..9b6812f3153 --- /dev/null +++ b/packages/emitter-framework/src/python/components/function-declaration/index.ts @@ -0,0 +1 @@ +export * from "./function-declaration.js"; diff --git a/packages/emitter-framework/src/python/components/index.ts b/packages/emitter-framework/src/python/components/index.ts new file mode 100644 index 00000000000..ca252a9df8b --- /dev/null +++ b/packages/emitter-framework/src/python/components/index.ts @@ -0,0 +1,11 @@ +export * from "./array-expression/index.js"; +export * from "./atom/index.js"; +export * from "./class-declaration/index.js"; +export * from "./doc-element/index.js"; +export * from "./enum-declaration/index.js"; +export * from "./function-declaration/index.js"; +export * from "./protocol-declaration/index.js"; +export * from "./record-expression/index.js"; +export * from "./type-alias-declaration/index.js"; +export * from "./type-declaration/index.js"; +export * from "./type-expression/index.js"; diff --git a/packages/emitter-framework/src/python/components/protocol-declaration/callable-parameters.tsx b/packages/emitter-framework/src/python/components/protocol-declaration/callable-parameters.tsx new file mode 100644 index 00000000000..5381f17da40 --- /dev/null +++ b/packages/emitter-framework/src/python/components/protocol-declaration/callable-parameters.tsx @@ -0,0 +1,44 @@ +import { refkey } from "@alloy-js/core"; +import * as py from "@alloy-js/python"; +import type { Operation } from "@typespec/compiler"; +import { useTsp } from "../../../core/context/tsp-context.js"; +import { buildParameterDescriptor } from "../../utils/operation.js"; + +export interface CallableParametersProps { + /** + * The operation to extract parameters from. + */ + type: Operation; +} + +/** + * Builds parameter descriptors for a callable (method/function) from an Operation's parameters. + * + * Iterates over the operation's parameters model and creates ParameterDescriptor objects + * with name, type expression, and default value (handles both explicit defaults and + * optional parameters without defaults which get `= None`). + * + * @returns Array of ParameterDescriptor objects for use with py.MethodDeclaration or similar. + * + * @example + * ```tsx + * const params = CallableParameters({ type: operation }); + * + * ``` + */ +export function CallableParameters(props: CallableParametersProps): py.ParameterDescriptor[] { + const { $ } = useTsp(); + const paramsModel = props.type.parameters; + + if (!paramsModel) { + return []; + } + + const parameters: py.ParameterDescriptor[] = []; + + for (const prop of $.model.getProperties(paramsModel).values()) { + parameters.push(buildParameterDescriptor(prop, refkey())); + } + + return parameters; +} diff --git a/packages/emitter-framework/src/python/components/protocol-declaration/index.ts b/packages/emitter-framework/src/python/components/protocol-declaration/index.ts new file mode 100644 index 00000000000..228289cca02 --- /dev/null +++ b/packages/emitter-framework/src/python/components/protocol-declaration/index.ts @@ -0,0 +1,2 @@ +export * from "./callable-parameters.js"; +export * from "./protocol-declaration.js"; diff --git a/packages/emitter-framework/src/python/components/protocol-declaration/protocol-declaration.test.tsx b/packages/emitter-framework/src/python/components/protocol-declaration/protocol-declaration.test.tsx new file mode 100644 index 00000000000..9af2d093eaf --- /dev/null +++ b/packages/emitter-framework/src/python/components/protocol-declaration/protocol-declaration.test.tsx @@ -0,0 +1,106 @@ +import { TypeExpression } from "#python/components/type-expression/type-expression.js"; +import { Tester } from "#test/test-host.js"; +import { t } from "@typespec/compiler/testing"; +import { describe, expect, it } from "vitest"; +import { getOutput } from "../../test-utils.js"; +import { ProtocolDeclaration } from "./protocol-declaration.js"; + +describe("Python ProtocolDeclaration", () => { + it("emits a callback Protocol for an operation", async () => { + const { program, getName, getOtherName } = await Tester.compile(t.code` + op ${t.op("getName")}(id: string): string; + op ${t.op("getOtherName")}(id: string): string; + `); + + expect( + getOutput(program, [ + , + , + ]), + ).toRenderTo(` + from typing import Protocol + + + class GetName(Protocol): + def __call__(self, id: str) -> str: + ... + + + + class GetOtherName(Protocol): + def __call__(self, id: str) -> str: + ... + + + `); + }); + + it("emits no return annotation when return type omitted", async () => { + const { program, getName } = await Tester.compile(t.code` + op ${t.op("getName")}(id: string): string; + `); + + // Create a shallow clone without returnType to simulate missing return info + const getNameNoReturn: any = { ...(getName as any) }; + delete getNameNoReturn.returnType; + + expect(getOutput(program, [])).toRenderTo(` + from typing import Protocol + + + class GetName(Protocol): + def __call__(self, id: str): + ... + + + `); + }); + + it("emits a Protocol for a TypeSpec interface (methods only)", async () => { + const { program, Greeter } = await Tester.compile(t.code` + interface ${t.interface("Greeter")} { + op getName(id: string): string; + op getOtherName(id: string): string; + } + `); + + expect(getOutput(program, [])).toRenderTo(` + from typing import Protocol + + + class Greeter(Protocol): + def get_name(self, id: str) -> str: + ... + + def get_other_name(self, id: str) -> str: + ... + + + `); + }); + + it("emits both a Protocol and a Callable for the same operation", async () => { + const { program, getName } = await Tester.compile(t.code` + op ${t.op("getName")}(id: string): string; + `); + + expect( + getOutput(program, [ + , + , + ]), + ).toRenderTo(` + from typing import Callable + from typing import Protocol + + + class GetName(Protocol): + def __call__(self, id: str) -> str: + ... + + + + Callable[[str], str] + `); + }); +}); diff --git a/packages/emitter-framework/src/python/components/protocol-declaration/protocol-declaration.tsx b/packages/emitter-framework/src/python/components/protocol-declaration/protocol-declaration.tsx new file mode 100644 index 00000000000..55d77b7f2e2 --- /dev/null +++ b/packages/emitter-framework/src/python/components/protocol-declaration/protocol-declaration.tsx @@ -0,0 +1,73 @@ +import { useTsp } from "#core/context/tsp-context.js"; +import { typingModule } from "#python/builtins.js"; +import { TypeExpression } from "#python/components/type-expression/type-expression.js"; +import { reportPythonDiagnostic } from "#python/lib.js"; +import { declarationRefkeys } from "#python/utils/refkey.js"; +import { mapJoin } from "@alloy-js/core"; +import * as py from "@alloy-js/python"; +import type { Interface, Operation, Type } from "@typespec/compiler"; +import { CallableParameters } from "./callable-parameters.js"; + +export interface ProtocolDeclarationProps extends Omit { + type: Interface | Operation; + name?: string; +} + +export function ProtocolDeclaration(props: ProtocolDeclarationProps) { + const { $ } = useTsp(); + + const refkeys = declarationRefkeys(props.refkey, props.type); + const protocolBase = typingModule["."]["Protocol"]; + + const namePolicy = py.usePythonNamePolicy(); + const originalName = props.name ?? (props.type as any)?.name ?? ""; + const name = namePolicy.getName(originalName, "class"); + + // Interfaces will be converted to Protocols with method stubs for operations + if ((props.type as any)?.kind === "Interface") { + const iface = props.type as Interface; + const operations = ((iface as any).operations ?? new Map()) as Map; + const methods = mapJoin( + () => Array.from(operations.values()) as any[], + (op: any) => { + const methodName = namePolicy.getName(op.name, "function"); + const prm = CallableParameters({ type: op as Operation }); // self injected by MethodDeclaration + const ret = (op as any)?.returnType ? ( + + ) : undefined; + return ( + + ... + + ); + }, + ); + return ( + + {methods} + + ); + } + + if ((props.type as any)?.kind !== "Operation") { + reportPythonDiagnostic($.program, { + code: "python-unsupported-type", + target: props.type as any, + }); + return <>; + } + + // Operations will be converted to Callback protocol using a dunder __call__ method + const op = props.type as Operation; + const cbParams = CallableParameters({ type: op }); + const cbReturn = (op as any)?.returnType ? ( + + ) : undefined; + return ( + + + ... + + + ); +} diff --git a/packages/emitter-framework/src/python/components/record-expression/index.ts b/packages/emitter-framework/src/python/components/record-expression/index.ts new file mode 100644 index 00000000000..a50b3eb7a99 --- /dev/null +++ b/packages/emitter-framework/src/python/components/record-expression/index.ts @@ -0,0 +1 @@ +export * from "./record-expression.js"; diff --git a/packages/emitter-framework/src/python/components/record-expression/record-expression.test.tsx b/packages/emitter-framework/src/python/components/record-expression/record-expression.test.tsx new file mode 100644 index 00000000000..bf71036d97d --- /dev/null +++ b/packages/emitter-framework/src/python/components/record-expression/record-expression.test.tsx @@ -0,0 +1,14 @@ +import { Tester } from "#test/test-host.js"; +import { d } from "@alloy-js/core/testing"; +import { t } from "@typespec/compiler/testing"; +import { expect, it } from "vitest"; +import { getOutput } from "../../test-utils.js"; +import { TypeExpression } from "../type-expression/type-expression.js"; + +it("maps Record to Python dict", async () => { + const { program, TestRecord } = await Tester.compile(t.code` + alias ${t.type("TestRecord")} = Record; + `); + + expect(getOutput(program, [])).toRenderTo(d`dict[str, bool]`); +}); diff --git a/packages/emitter-framework/src/python/components/record-expression/record-expression.tsx b/packages/emitter-framework/src/python/components/record-expression/record-expression.tsx new file mode 100644 index 00000000000..bd6ba683210 --- /dev/null +++ b/packages/emitter-framework/src/python/components/record-expression/record-expression.tsx @@ -0,0 +1,13 @@ +import { code } from "@alloy-js/core"; +import type { Type } from "@typespec/compiler"; +import { TypeExpression } from "../type-expression/type-expression.js"; + +export interface RecordExpressionProps { + elementType: Type; +} + +export function RecordExpression({ elementType }: RecordExpressionProps) { + return code` + dict[str, ${()}] + `; +} diff --git a/packages/emitter-framework/src/python/components/type-alias-declaration/index.ts b/packages/emitter-framework/src/python/components/type-alias-declaration/index.ts new file mode 100644 index 00000000000..57df7c72b96 --- /dev/null +++ b/packages/emitter-framework/src/python/components/type-alias-declaration/index.ts @@ -0,0 +1 @@ +export * from "./type-alias-declaration.js"; diff --git a/packages/emitter-framework/src/python/components/type-alias-declaration/type-alias-declaration.test.tsx b/packages/emitter-framework/src/python/components/type-alias-declaration/type-alias-declaration.test.tsx new file mode 100644 index 00000000000..8233243e4a5 --- /dev/null +++ b/packages/emitter-framework/src/python/components/type-alias-declaration/type-alias-declaration.test.tsx @@ -0,0 +1,117 @@ +import { Tester } from "#test/test-host.js"; +import { t } from "@typespec/compiler/testing"; +import { describe, expect, it } from "vitest"; +import { getOutput } from "../../test-utils.js"; +import { TypeAliasDeclaration } from "./type-alias-declaration.js"; + +describe("Python Declaration equivalency to Type Alias", () => { + describe("Type Alias Declaration bound to Typespec Scalar", () => { + describe("Scalar extends utcDateTime", () => { + it("creates a type alias declaration for a utcDateTime without encoding", async () => { + const { program, MyDate } = await Tester.compile(t.code` + scalar ${t.scalar("MyDate")} extends utcDateTime; + `); + + expect(getOutput(program, [])).toRenderTo(` + from datetime import datetime + from typing import TypeAlias + + + my_date: TypeAlias = datetime`); + }); + + it("creates a type alias declaration with doc", async () => { + const { program, MyDate } = await Tester.compile(t.code` + /** + * Type to represent a date + */ + scalar ${t.scalar("MyDate")} extends utcDateTime; + `); + + expect(getOutput(program, [])).toRenderTo(` + from datetime import datetime + from typing import TypeAlias + + + # Type to represent a date + my_date: TypeAlias = datetime`); + }); + + it("can override JSDoc", async () => { + const { program, MyDate } = await Tester.compile(t.code` + /** + * Type to represent a date + */ + scalar ${t.scalar("MyDate")} extends utcDateTime; + `); + + expect(getOutput(program, [])) + .toRenderTo(` + from datetime import datetime + from typing import TypeAlias + + + # Overridden Doc + my_date: TypeAlias = datetime`); + }); + + it("creates a type alias declaration for a utcDateTime with unixTimeStamp encoding", async () => { + const { program, MyDate } = await Tester.compile(t.code` + @encode("unixTimestamp", int32) + scalar ${t.scalar("MyDate")} extends utcDateTime; + `); + + expect(getOutput(program, [])).toRenderTo(` + from datetime import datetime + from typing import TypeAlias + + + my_date: TypeAlias = datetime`); + }); + + it("creates a type alias declaration for a utcDateTime with rfc7231 encoding", async () => { + const { program, MyDate } = await Tester.compile(t.code` + @encode("rfc7231") + scalar ${t.scalar("MyDate")} extends utcDateTime; + `); + + expect(getOutput(program, [])).toRenderTo(` + from datetime import datetime + from typing import TypeAlias + + + my_date: TypeAlias = datetime`); + }); + + it("creates a type alias declaration for a utcDateTime with rfc3339 encoding", async () => { + const { program, MyDate } = await Tester.compile(t.code` + @encode("rfc3339") + scalar ${t.scalar("MyDate")} extends utcDateTime; + `); + + expect(getOutput(program, [])).toRenderTo(` + from datetime import datetime + from typing import TypeAlias + + + my_date: TypeAlias = datetime`); + }); + }); + }); + + describe("Type Alias Declaration for Operation (Callable)", () => { + it("creates a type alias for an operation type reference", async () => { + const { program, Handler } = await Tester.compile(t.code` + op handleRequest(id: string): string; + alias ${t.type("Handler")} = handleRequest; + `); + + expect(getOutput(program, [])).toRenderTo(` + from typing import Callable + from typing import TypeAlias + + + handle_request: TypeAlias = Callable[[str], str]`); + }); + }); +}); diff --git a/packages/emitter-framework/src/python/components/type-alias-declaration/type-alias-declaration.tsx b/packages/emitter-framework/src/python/components/type-alias-declaration/type-alias-declaration.tsx new file mode 100644 index 00000000000..8f35f858534 --- /dev/null +++ b/packages/emitter-framework/src/python/components/type-alias-declaration/type-alias-declaration.tsx @@ -0,0 +1,67 @@ +import { useTsp } from "#core/context/index.js"; +import * as py from "@alloy-js/python"; +import { isTemplateInstance, type Model, type Type } from "@typespec/compiler"; +import { reportDiagnostic } from "../../../lib.js"; +import { typingModule } from "../../builtins.js"; +import { declarationRefkeys } from "../../utils/refkey.js"; +import { ClassDeclaration } from "../class-declaration/class-declaration.js"; +import { TypeExpression } from "../type-expression/type-expression.js"; + +export interface TypedAliasDeclarationProps extends Omit { + type: Type; + name?: string; +} + +/** + * Create a Python type alias declaration. Pass the `type` prop to emit the + * type alias as the provided TypeSpec type. + * + * For template instances (e.g., `alias StringResponse = Response`), + * this emits a dataclass instead of a type alias, since Python doesn't support + * parameterized type aliases the way TypeScript does. + */ +export function TypeAliasDeclaration(props: TypedAliasDeclarationProps) { + const { $ } = useTsp(); + + const originalName = + props.name ?? + ("name" in props.type && typeof props.type.name === "string" ? props.type.name : ""); + + if (!originalName || originalName === "") { + reportDiagnostic($.program, { code: "type-declaration-missing-name", target: props.type }); + } + + const doc = props.doc ?? $.type.getDoc(props.type); + const refkeys = declarationRefkeys(props.refkey, props.type); + + // For Model template instances (e.g., alias StringResponse = Response), + // emit as a dataclass since Python doesn't support parameterized type aliases. + // TypeSpec templates are macros that expand to concrete types. + if ($.model.is(props.type) && isTemplateInstance(props.type)) { + const namePolicy = py.usePythonNamePolicy(); + const plausibleName = $.type.getPlausibleName(props.type as Model); + const name = props.name ?? namePolicy.getName(plausibleName, "class"); + + return ( + + {props.children} + + ); + } + + // For other types (scalars, unions, operations), emit as a type alias + const name = py.usePythonNamePolicy().getName(originalName, "variable"); + + return ( + } + > + {props.children} + + ); +} diff --git a/packages/emitter-framework/src/python/components/type-declaration/index.ts b/packages/emitter-framework/src/python/components/type-declaration/index.ts new file mode 100644 index 00000000000..42e615a8166 --- /dev/null +++ b/packages/emitter-framework/src/python/components/type-declaration/index.ts @@ -0,0 +1 @@ +export * from "./type-declaration.js"; diff --git a/packages/emitter-framework/src/python/components/type-declaration/type-declaration.test.tsx b/packages/emitter-framework/src/python/components/type-declaration/type-declaration.test.tsx new file mode 100644 index 00000000000..e01adb0b7e1 --- /dev/null +++ b/packages/emitter-framework/src/python/components/type-declaration/type-declaration.test.tsx @@ -0,0 +1,58 @@ +import { Tester } from "#test/test-host.js"; +import { d } from "@alloy-js/core/testing"; +import { t } from "@typespec/compiler/testing"; +import { describe, expect, it } from "vitest"; +import { getOutput } from "../../test-utils.js"; +import { TypeDeclaration } from "./type-declaration.js"; + +describe("Python TypeDeclaration dispatcher", () => { + it("dispatches to EnumDeclaration for enums", async () => { + const { program, Foo } = await Tester.compile(t.code` + enum ${t.enum("Foo")} { + one: 1, + two: 2, + three: 3 + } + `); + + const output = getOutput(program, []); + expect(output).toRenderTo(d` + from enum import IntEnum + + + class Foo(IntEnum): + ONE = 1 + TWO = 2 + THREE = 3 + + + `); + }); + + it("dispatches scalars to TypeAliasDeclaration", async () => { + const { program, MyDate } = await Tester.compile(t.code` + scalar ${t.scalar("MyDate")} extends utcDateTime; + `); + + const output = getOutput(program, []); + expect(output).toRenderTo(d` + from datetime import datetime + from typing import TypeAlias + + + my_date: TypeAlias = datetime`); + }); + + it("dispatches arrays (model is Array) to type alias with list[T]", async () => { + const { program, Items } = await Tester.compile(t.code` + model ${t.model("Items")} is Array; + `); + + const output = getOutput(program, []); + expect(output).toRenderTo(d` + from typing import TypeAlias + + + items: TypeAlias = list[int]`); + }); +}); diff --git a/packages/emitter-framework/src/python/components/type-declaration/type-declaration.tsx b/packages/emitter-framework/src/python/components/type-declaration/type-declaration.tsx new file mode 100644 index 00000000000..d1617bd8f8a --- /dev/null +++ b/packages/emitter-framework/src/python/components/type-declaration/type-declaration.tsx @@ -0,0 +1,26 @@ +import { useTsp } from "#core/context/index.js"; +import * as py from "@alloy-js/python"; +import type { Type } from "@typespec/compiler"; +import { EnumDeclaration } from "../enum-declaration/enum-declaration.js"; +import { TypeAliasDeclaration } from "../type-alias-declaration/type-alias-declaration.js"; + +export interface TypeDeclarationProps extends Omit { + name?: string; + type: Type; +} + +/** + * Single entry point to declare a Python symbol for any TypeSpec `Type`. + */ +export function TypeDeclaration(props: TypeDeclarationProps) { + const { $ } = useTsp(); + const { type, ...restProps } = props; + const doc = props.doc ?? $.type.getDoc(type); + switch (type.kind) { + case "Enum": + return ; + default: + // All other kinds map to a Python type alias using TypeExpression + return ; + } +} diff --git a/packages/emitter-framework/src/python/components/type-expression/index.ts b/packages/emitter-framework/src/python/components/type-expression/index.ts new file mode 100644 index 00000000000..9ffe8521a6e --- /dev/null +++ b/packages/emitter-framework/src/python/components/type-expression/index.ts @@ -0,0 +1 @@ +export * from "./type-expression.js"; diff --git a/packages/emitter-framework/src/python/components/type-expression/type-expression.test.tsx b/packages/emitter-framework/src/python/components/type-expression/type-expression.test.tsx new file mode 100644 index 00000000000..67fe322e6bb --- /dev/null +++ b/packages/emitter-framework/src/python/components/type-expression/type-expression.test.tsx @@ -0,0 +1,463 @@ +import { Tester } from "#test/test-host.js"; +import { d } from "@alloy-js/core/testing"; +import { t } from "@typespec/compiler/testing"; +import { describe, expect, it } from "vitest"; +import { getOutput } from "../../test-utils.js"; +import { EnumDeclaration } from "../enum-declaration/enum-declaration.js"; +import { TypeExpression } from "./type-expression.js"; + +describe("map Typespec types to Python built-in types", () => { + it.each([ + ["unknown", "Any", "from typing import Any"], + ["string", "str"], + ["boolean", "bool"], + ["null", "None"], + ["void", "None"], + ["never", "Never", "from typing import Never"], + ["bytes", "bytes"], + ["numeric", "number"], + ["integer", "int"], + ["float", "float"], + ["decimal", "Decimal", "from decimal import Decimal"], + ["decimal128", "Decimal", "from decimal import Decimal"], + ["int64", "int"], + ["int32", "int"], + ["int16", "int"], + ["int8", "int"], + ["safeint", "int"], + ["uint64", "int"], + ["uint32", "int"], + ["uint16", "int"], + ["uint8", "int"], + ["float32", "float"], + ["float64", "float"], + ["plainDate", "str"], + ["plainTime", "str"], + ["utcDateTime", "datetime", "from datetime import datetime"], + ["offsetDateTime", "str"], + ["duration", "str"], + ["url", "str"], + ])("%s => %s", async (tspType, pythonType, extraImport = "") => { + const { program, Type } = await Tester.compile(t.code` + alias ${t.type("Type")} = ${t.type(tspType)}; + `); + + expect(getOutput(program, [])).toRenderTo(d` + ${extraImport ? `${extraImport}\n\n\n` : ""}${pythonType} + `); + }); +}); + +describe("map scalar to Python types", () => { + it("Email => str (scalars are inlined to base type)", async () => { + const { program, Email } = await Tester.compile(t.code` + scalar ${t.scalar("Email")} extends string; + `); + + expect(getOutput(program, [])).toRenderTo(d` + str + `); + }); + + it("PhoneNumber => str (scalar in model property is inlined)", async () => { + const { program, _ } = await Tester.compile(t.code` + scalar PhoneNumber extends string; + + model ${t.model("Widget")} { + phone: PhoneNumber; + } + `); + + const phoneProperty = program.resolveTypeReference("Widget")[0]!; + const phoneProp = (phoneProperty as any).properties.get("phone"); + + expect(getOutput(program, [])).toRenderTo(d` + str + `); + }); +}); + +describe("map tuple to Python types", () => { + it("[int32, int32] => tuple[int, int]", async () => { + const { program, Tuple } = await Tester.compile(t.code` + alias ${t.type("Tuple")} = [int32, int32]; + `); + + expect(getOutput(program, [])).toRenderTo(d` + tuple[int, int] + `); + }); +}); + +describe("map operation (function type) to typing.Callable", () => { + it("op f(a: int32, b: string): void => Callable[[int, str], None]", async () => { + const { program, f } = await Tester.compile(t.code` + op ${t.op("f")}(a: int32, b: string): void; + `); + + expect(getOutput(program, [])).toRenderTo(d` + from typing import Callable + + + Callable[[int, str], None] + `); + }); + + it("op g(): int32 => Callable[[], int]", async () => { + const { program, g } = await Tester.compile(t.code` + op ${t.op("g")}(): int32; + `); + + expect(getOutput(program, [])).toRenderTo(d` + from typing import Callable + + + Callable[[], int] + `); + }); +}); + +describe("correctly solves a ModelProperty to Python types", () => { + it("[int32, int32] => tuple[int, int]", async () => { + const { program, tupleProperty } = await Tester.compile(t.code` + model Test { + ${t.modelProperty("tupleProperty")}: [int32, int32]; + } + `); + + expect(getOutput(program, [])).toRenderTo(d` + tuple[int, int] + `); + }); +}); + +describe("handles UnionVariant types", () => { + it("renders named union variant with literal value as Literal[Union.MEMBER]", async () => { + const { program, Color } = await Tester.compile(t.code` + union ${t.union("Color")} { + red: "red", + blue: "blue", + } + + model Widget { + color: Color.red; + } + `); + + const Widget = program.resolveTypeReference("Widget")[0]! as any; + const colorProperty = Widget.properties.get("color"); + + expect( + getOutput(program, [ + , + , + ]), + ).toRenderTo(d` + from enum import StrEnum + from typing import Literal + + + class Color(StrEnum): + RED = "red" + BLUE = "blue" + + + Literal[Color.RED] + `); + }); + + it("renders named union variant with integer literal as Literal[Union.MEMBER]", async () => { + const { program, Status } = await Tester.compile(t.code` + union ${t.union("Status")} { + active: 1, + inactive: 0, + } + + model Widget { + status: Status.active; + } + `); + + const Widget = program.resolveTypeReference("Widget")[0]! as any; + const statusProperty = Widget.properties.get("status"); + + expect( + getOutput(program, [ + , + , + ]), + ).toRenderTo(d` + from enum import IntEnum + from typing import Literal + + + class Status(IntEnum): + ACTIVE = 1 + INACTIVE = 0 + + + Literal[Status.ACTIVE] + `); + }); + + it("unwraps union variant with non-literal type to reveal the inner type", async () => { + const { program } = await Tester.compile(t.code` + union Status { + active: string, + inactive: int32, + } + + model Widget { + status: Status.inactive; + } + `); + + const Widget = program.resolveTypeReference("Widget")[0]! as any; + const statusProperty = Widget.properties.get("status"); + + // The UnionVariant should unwrap to reveal int32 (non-literal type) + expect(statusProperty.type.kind).toBe("UnionVariant"); + expect(getOutput(program, [])).toRenderTo(d` + int + `); + }); + + it("handles named union variant in function return type", async () => { + const { program, Result } = await Tester.compile(t.code` + union ${t.union("Result")} { + success: "success", + failure: "failure", + } + + op getResult(): Result.success; + `); + + const getResult = program.resolveTypeReference("getResult")[0]! as any; + const returnType = getResult.returnType; + + expect( + getOutput(program, [, ]), + ).toRenderTo(d` + from enum import StrEnum + from typing import Literal + + + class Result(StrEnum): + SUCCESS = "success" + FAILURE = "failure" + + + Literal[Result.SUCCESS] + `); + }); + + it("unwraps nested union variant with non-literal inner types", async () => { + const { program } = await Tester.compile(t.code` + union Inner { + a: string, + b: int32, + } + + union Outer { + x: Inner.a, + y: Inner.b, + } + + model Widget { + value: Outer.x; + } + `); + + const Widget = program.resolveTypeReference("Widget")[0]! as any; + const valueProperty = Widget.properties.get("value"); + + // Inner.a has type `string` (not a literal), so it unwraps + expect(getOutput(program, [])).toRenderTo(d` + str + `); + }); +}); + +describe("handles literal types", () => { + it("renders union of string literals as Literal[...]", async () => { + const { program } = await Tester.compile(t.code` + model Widget { + status: "active" | "inactive" | "pending"; + } + `); + + const Widget = program.resolveTypeReference("Widget")[0]! as any; + const statusProperty = Widget.properties.get("status"); + + expect(getOutput(program, [])).toRenderTo(d` + from typing import Literal + + + Literal["active", "inactive", "pending"] + `); + }); + + it("renders union of integer literals as Literal[...]", async () => { + const { program } = await Tester.compile(t.code` + model Widget { + priority: 1 | 2 | 3; + } + `); + + const Widget = program.resolveTypeReference("Widget")[0]! as any; + const priorityProperty = Widget.properties.get("priority"); + + expect(getOutput(program, [])).toRenderTo(d` + from typing import Literal + + + Literal[1, 2, 3] + `); + }); + + it("renders union of boolean literals as Literal[...]", async () => { + const { program } = await Tester.compile(t.code` + model Widget { + flag: true | false; + } + `); + + const Widget = program.resolveTypeReference("Widget")[0]! as any; + const flagProperty = Widget.properties.get("flag"); + + expect(getOutput(program, [])).toRenderTo(d` + from typing import Literal + + + Literal[True, False] + `); + }); + + it("renders mixed literal union as Literal[...]", async () => { + const { program } = await Tester.compile(t.code` + model Widget { + value: "auto" | 0 | 100; + } + `); + + const Widget = program.resolveTypeReference("Widget")[0]! as any; + const valueProperty = Widget.properties.get("value"); + + expect(getOutput(program, [])).toRenderTo(d` + from typing import Literal + + + Literal["auto", 0, 100] + `); + }); + + it("renders function returning literal union as Literal[...]", async () => { + const { program } = await Tester.compile(t.code` + op getStatus(): "success" | "failure"; + `); + + const getStatus = program.resolveTypeReference("getStatus")[0]! as any; + + expect(getOutput(program, [])).toRenderTo(d` + from typing import Literal + + + Literal["success", "failure"] + `); + }); + + it("renders single string literal as Literal[...]", async () => { + const { program } = await Tester.compile(t.code` + model Widget { + constant: "fixed-value"; + } + `); + + const Widget = program.resolveTypeReference("Widget")[0]! as any; + const constantProperty = Widget.properties.get("constant"); + + expect(getOutput(program, [])).toRenderTo(d` + from typing import Literal + + + Literal["fixed-value"] + `); + }); + + it("renders function returning single string literal as Literal[...]", async () => { + const { program } = await Tester.compile(t.code` + op getSteve(): "steve"; + `); + + const getSteve = program.resolveTypeReference("getSteve")[0]! as any; + + expect(getOutput(program, [])).toRenderTo(d` + from typing import Literal + + + Literal["steve"] + `); + }); + + it("renders single numeric literal as Literal[...]", async () => { + const { program } = await Tester.compile(t.code` + op getAnswer(): 42; + `); + + const getAnswer = program.resolveTypeReference("getAnswer")[0]! as any; + + expect(getOutput(program, [])).toRenderTo(d` + from typing import Literal + + + Literal[42] + `); + }); + + it("renders single boolean literal as Literal[...]", async () => { + const { program } = await Tester.compile(t.code` + op isEnabled(): true; + `); + + const isEnabled = program.resolveTypeReference("isEnabled")[0]! as any; + + expect(getOutput(program, [])).toRenderTo(d` + from typing import Literal + + + Literal[True] + `); + }); + + it("renders union with non-literal types as regular union", async () => { + const { program } = await Tester.compile(t.code` + model Widget { + value: string | int32; + } + `); + + const Widget = program.resolveTypeReference("Widget")[0]! as any; + const valueProperty = Widget.properties.get("value"); + + // Mixed literal and non-literal types render as regular union + expect(getOutput(program, [])).toRenderTo(d` + str | int + `); + }); + + it("renders function with literal parameter type", async () => { + const { program } = await Tester.compile(t.code` + op setMode(mode: "auto" | "manual"): void; + `); + + const setMode = program.resolveTypeReference("setMode")[0]! as any; + const modeParam = setMode.parameters.properties.get("mode"); + + expect(getOutput(program, [])).toRenderTo(d` + from typing import Literal + + + Literal["auto", "manual"] + `); + }); +}); diff --git a/packages/emitter-framework/src/python/components/type-expression/type-expression.tsx b/packages/emitter-framework/src/python/components/type-expression/type-expression.tsx new file mode 100644 index 00000000000..6bf20e9e2cf --- /dev/null +++ b/packages/emitter-framework/src/python/components/type-expression/type-expression.tsx @@ -0,0 +1,333 @@ +import { Experimental_OverridableComponent } from "#core/components/index.js"; +import { useTsp } from "#core/context/index.js"; +import { reportPythonDiagnostic } from "#python/lib.js"; +import { code, For, List, mapJoin } from "@alloy-js/core"; +import * as py from "@alloy-js/python"; +import { + isNeverType, + type IntrinsicType, + type Model, + type Scalar, + type Type, +} from "@typespec/compiler"; +import type { TemplateParameterDeclarationNode } from "@typespec/compiler/ast"; +import type { Typekit } from "@typespec/compiler/typekit"; +import { datetimeModule, decimalModule, typingModule } from "../../builtins.js"; +import { efRefkey } from "../../utils/refkey.js"; +import { ArrayExpression } from "../array-expression/array-expression.js"; +import { RecordExpression } from "../record-expression/record-expression.js"; + +export interface TypeExpressionProps { + type: Type; + + /** + * Whether to disallow references. Setting this will force the type to be + * emitted inline, even if it is a declaration that would otherwise be + * referenced. + */ + noReference?: boolean; +} + +export function TypeExpression(props: TypeExpressionProps) { + const { $ } = useTsp(); + const type = props.type; + if (!props.noReference && isDeclaration($, type)) { + return ( + + + + ); + } + + switch (type.kind) { + case "Scalar": // Custom types based on primitives (Intrinsics) + case "Intrinsic": // Language primitives like `string`, `number`, etc. + if (isNeverType(type)) { + return typingModule["."]["Never"]; + } + return <>{getScalarIntrinsicExpression($, type)}; + case "Boolean": + case "Number": + case "String": + // Single literal values are wrapped in Literal[...] + return ( + <> + {typingModule["."]["Literal"]}[{formatLiteralValue(type)}] + + ); + case "Tuple": + return ( + <> + tuple[ + + {(element) => } + + ] + + ); + case "Union": { + const variants = Array.from((type as any).variants?.values?.() ?? []); + + // Check if all variants are literals or named union variant refs with literal values + const isLiteralOrVariantRef = (t: Type): boolean => { + if (!t) return false; + if (isLiteral($, t)) return true; + // Named union variant with a literal inner value + if (t.kind === "UnionVariant" && (t as any).union?.name) { + return isLiteral($, (t as any).type); + } + return false; + }; + + const innerTypes = variants.map((v: any) => v.type); + if (innerTypes.every(isLiteralOrVariantRef)) { + // All literals - render as Literal[...] + const literalValues = variants + .map((v: any) => { + const innerType = v.type; + // Named union variant ref with literal value + if (innerType.kind === "UnionVariant" && innerType.union?.name) { + const variantName = String(innerType.name).toUpperCase(); + return code`${efRefkey(innerType.union)}.${variantName}`; + } + if (isLiteral($, innerType)) { + return formatLiteralValue(innerType); + } + return undefined; + }) + .filter(Boolean); + + return ( + <> + {typingModule["."]["Literal"]}[] + + ); + } + + // Not all literals - render as union type + return mapJoin( + () => variants, + (v: any) => , + { joiner: " | " }, + ); + } + case "UnionVariant": { + // Union variant from a named union with a literal value + if (type.union && (type.union as any).name && isLiteral($, type.type)) { + // Use the variant's name (e.g., "red", "active"), converted to UPPER_CASE by the enum + const variantName = String(type.name).toUpperCase(); + return ( + <> + {typingModule["."]["Literal"]}[{efRefkey(type.union)}.{variantName}] + + ); + } + // Unnamed union variant or non-literal value, unwrap to its inner type + return ; + } + case "ModelProperty": + return ; + case "Model": + if ($.array.is(type)) { + const elementType = type.indexer!.value; + return ; + } + + if ($.record.is(type)) { + const elementType = (type as Model).indexer!.value; + return ; + } + + // TODO: When TypeSpec adds true generics support, handle generic type references here. + // Currently, TypeSpec templates are macros that expand to concrete types, so template + // instances (e.g., Response) are treated as regular concrete types, not as + // parameterized generic types (e.g., Response[str]). + // When generics are implemented, this is where we would render: ClassName[TypeArg, ...] + + // Regular named models should be handled as references + if (type.name) { + return ( + + + + ); + } + + reportPythonDiagnostic($.program, { code: "python-unsupported-type", target: type }); + return <>; + case "TemplateParameter": + return code`${String((type.node as TemplateParameterDeclarationNode).id.sv)}`; + + case "Operation": { + // Render function types as typing.Callable[[ArgTypes...], ReturnType] + // If parameters cannot be enumerated, fall back to Callable[..., ReturnType] + let paramTypes: Type[] | null = null; + const op: any = type as any; + if (op.parameters) { + try { + const { $ } = useTsp(); + const modelProps = $.model.getProperties(op.parameters); + paramTypes = Array.from(modelProps.values()).map((p: any) => p.type); + } catch { + // Unknown/unsupported params shape + paramTypes = null; + } + } else { + paramTypes = []; + } + + return ( + <> + {typingModule["."]["Callable"]}[ + {paramTypes === null ? ( + <>... + ) : paramTypes.length > 0 ? ( + <> + [ + + {(t) => } + + ] + + ) : ( + <>[] + )} + {", "} + ] + + ); + } + default: + reportPythonDiagnostic($.program, { code: "python-unsupported-type", target: type }); + // Return empty fragment - the diagnostic has already been reported + return <>; + } +} + +/** + * Checks if a type is a literal (string, numeric, or boolean). + */ +function isLiteral($: Typekit, type: Type): boolean { + return $.literal.isString(type) || $.literal.isNumeric(type) || $.literal.isBoolean(type); +} + +/** + * Formats a literal type value for use in Python's Literal[...] syntax. + */ +function formatLiteralValue(type: { kind: string; value: unknown }): string { + switch (type.kind) { + case "String": + return JSON.stringify(type.value); + case "Boolean": + return type.value ? "True" : "False"; + case "Number": + return String(type.value); + default: + return String(type.value); + } +} + +const intrinsicNameToPythonType = new Map([ + // Core types + ["unknown", "Any"], // Matches Python's `Any` + ["string", "str"], // Matches Python's `str` + ["boolean", "bool"], // Matches Python's `bool` + ["null", "None"], // Matches Python's `None` + ["void", "None"], // Matches Python's `None` + ["never", "Never"], // Matches Python's `Never` + ["bytes", "bytes"], // Matches Python's `bytes` + + // Numeric types + ["numeric", "number"], // Parent type for all numeric types + ["integer", "int"], // Broad integer category, maps to `int` + ["float", "float"], // Broad float category, maps to `float` + ["decimal", "Decimal"], // Broad decimal category, maps to `Decimal` + ["decimal128", "Decimal"], // 128-bit decimal category, maps to `Decimal` + ["int64", "int"], // Use `int` to handle large 64-bit integers + ["int32", "int"], // 32-bit integer fits in Python's `int` + ["int16", "int"], // 16-bit integer + ["int8", "int"], // 8-bit integer + ["safeint", "int"], // Safe integer fits within Python limits + ["uint64", "int"], // Use `int` for unsigned 64-bit integers + ["uint32", "int"], // 32-bit unsigned integer + ["uint16", "int"], // 16-bit unsigned integer + ["uint8", "int"], // 8-bit unsigned integer + ["float32", "float"], // Maps to Python's `float` + ["float64", "float"], // Maps to Python's `float`. + + // Date and time types + ["plainDate", "str"], // Use `str` for plain calendar dates + ["plainTime", "str"], // Use `str` for plain clock times + ["utcDateTime", "datetime"], // Use `datetime` for UTC date-times + ["offsetDateTime", "str"], // Use `str` for timezone-specific date-times + ["duration", "str"], // Duration as an ISO 8601 string or custom format + + // String types + ["url", "str"], // Matches Python's `str` +]); + +const pythonTypeToImport = new Map([ + ["Any", typingModule["."]["Any"]], + ["Never", typingModule["."]["Never"]], + ["datetime", datetimeModule["."]["datetime"]], + ["Decimal", decimalModule["."]["Decimal"]], +]); + +function getScalarIntrinsicExpression($: Typekit, type: Scalar | IntrinsicType): string | null { + let intrinsicName: string; + if ($.scalar.is(type)) { + if ($.scalar.isUtcDateTime(type) || $.scalar.extendsUtcDateTime(type)) { + const encoding = $.scalar.getEncoding(type); + intrinsicName = "utcDateTime"; + switch (encoding?.encoding) { + case "unixTimestamp": + case "rfc7231": + case "rfc3339": + default: + intrinsicName = `utcDateTime`; + break; + } + } + intrinsicName = $.scalar.getStdBase(type)?.name ?? ""; + } else { + intrinsicName = type.name; + } + + let pythonType = intrinsicNameToPythonType.get(intrinsicName); + const importModule = pythonTypeToImport.get(pythonType ?? ""); + pythonType = importModule ? importModule : pythonType; + + if (!pythonType) { + reportPythonDiagnostic($.program, { code: "python-unsupported-scalar", target: type }); + return "any"; + } + + return pythonType; +} + +// TODO: When TypeSpec adds true generics support, add helper to detect generic type instances. +// Currently, TypeSpec templates expand to concrete types at compile time, so we treat all +// template instances as regular concrete types. + +function isDeclaration($: Typekit, type: Type): boolean { + switch (type.kind) { + case "Namespace": + case "Interface": + case "Enum": + case "Operation": + case "EnumMember": + case "UnionVariant": + return false; + + case "Model": + if ($.array.is(type) || $.record.is(type)) { + return false; + } + + return Boolean(type.name); + case "Union": + return Boolean(type.name); + default: + return false; + } +} diff --git a/packages/emitter-framework/src/python/index.ts b/packages/emitter-framework/src/python/index.ts new file mode 100644 index 00000000000..0b6cde696f0 --- /dev/null +++ b/packages/emitter-framework/src/python/index.ts @@ -0,0 +1,3 @@ +export * from "./builtins.js"; +export * from "./components/index.js"; +export * from "./utils/index.js"; diff --git a/packages/emitter-framework/src/python/lib.ts b/packages/emitter-framework/src/python/lib.ts new file mode 100644 index 00000000000..df457071e5e --- /dev/null +++ b/packages/emitter-framework/src/python/lib.ts @@ -0,0 +1,40 @@ +import { createTypeSpecLibrary } from "@typespec/compiler"; + +export const $pythonLib = createTypeSpecLibrary({ + name: "emitter-framework", + diagnostics: { + "python-unsupported-scalar": { + severity: "warning", + messages: { + default: "Unsupported scalar type, falling back to Any", + }, + }, + "python-unsupported-type": { + severity: "error", + messages: { + default: "Unsupported type, falling back to Any", + }, + description: "This type is not supported by the Python emitter", + }, + "python-unsupported-model-discriminator": { + severity: "error", + messages: { + default: + "Unsupported model discriminator, falling back to not discriminating on serialization/deserialization", + }, + description: "Discriminators at the model are not supported", + }, + "python-unsupported-type-transform": { + severity: "error", + messages: { + default: "Unsupported type for transformation, falling back to not transforming this type", + }, + description: "This type cannot be transformed", + }, + }, +}); + +export const { + reportDiagnostic: reportPythonDiagnostic, + createDiagnostic: createPythonDiagnostic, +} = $pythonLib; diff --git a/packages/emitter-framework/src/python/test-utils.tsx b/packages/emitter-framework/src/python/test-utils.tsx new file mode 100644 index 00000000000..c94e9f66dfd --- /dev/null +++ b/packages/emitter-framework/src/python/test-utils.tsx @@ -0,0 +1,31 @@ +import { Output } from "#core/components/index.js"; +import { type Children } from "@alloy-js/core"; +import * as py from "@alloy-js/python"; +import type { Program } from "@typespec/compiler"; +import { abcModule, datetimeModule, decimalModule, typingModule } from "./builtins.js"; + +export const renderOptions = { + printWidth: 80, + tabWidth: 4, +}; + +export function getOutput(program: Program, children: Children[]): Children { + const policy = py.createPythonNamePolicy(); + return ( + + {children} + + ); +} diff --git a/packages/emitter-framework/src/python/utils/index.ts b/packages/emitter-framework/src/python/utils/index.ts new file mode 100644 index 00000000000..3640f7478ef --- /dev/null +++ b/packages/emitter-framework/src/python/utils/index.ts @@ -0,0 +1,3 @@ +export * from "./operation.js"; +export * from "./refkey.js"; +export * from "./type.js"; diff --git a/packages/emitter-framework/src/python/utils/operation.ts b/packages/emitter-framework/src/python/utils/operation.ts new file mode 100644 index 00000000000..0dea9f5f69a --- /dev/null +++ b/packages/emitter-framework/src/python/utils/operation.ts @@ -0,0 +1,161 @@ +import { typingModule } from "#python/builtins.js"; +import { refkey, type Children, type Refkey } from "@alloy-js/core"; +import * as py from "@alloy-js/python"; +import type { Model, ModelProperty, Operation, Type } from "@typespec/compiler"; +import { useTsp } from "../../core/index.js"; +import { Atom } from "../components/atom/atom.js"; +import { TypeExpression } from "../components/type-expression/type-expression.js"; +import { efRefkey } from "./refkey.js"; + +export function getReturnType( + type: Operation, + options: { skipErrorFiltering: boolean } = { skipErrorFiltering: false }, +): Type { + const { $ } = useTsp(); + let returnType = type.returnType; + + if (!options.skipErrorFiltering && type.returnType.kind === "Union") { + returnType = $.union.filter(type.returnType, (variant) => !$.type.isError(variant.type)); + } + + return returnType; +} + +export interface BuildParameterDescriptorsOptions { + params?: (py.ParameterDescriptor | string)[]; + suffixRefkey?: Refkey; + /** If true, params replaces operation parameters instead of adding to them */ + replaceParameters?: boolean; +} + +/** + * Build a parameter descriptor array from a TypeSpec Model. + * + * Parameter ordering (unless replaceParameters is true): + * - Operation params without defaults: positional (e.g., URL path params) + * - "*" marker (if any keyword-only params exist) + * - Operation params with defaults: keyword-only (e.g., query params) + * - Additional params: keyword-only + */ +export function buildParameterDescriptors( + type: Model, + options: BuildParameterDescriptorsOptions = {}, +): (py.ParameterDescriptor | string)[] | undefined { + const { $ } = useTsp(); + const suffixRefkey = options.suffixRefkey ?? refkey(); + const optionsParams: py.ParameterDescriptor[] = normalizeParameters(options.params ?? []); + + // If replaceParameters is true, ignore operation params and just return options params + // All replacement parameters are keyword-only (following "additional parameters are keyword-only" principle) + if (options.replaceParameters) { + const withoutDefaults = optionsParams.filter((p) => p.default === undefined); + const withDefaults = optionsParams.filter((p) => p.default !== undefined); + + // Always add "*" marker since all replacement params should be keyword-only + const allParams: (py.ParameterDescriptor | string)[] = + optionsParams.length > 0 ? ["*", ...withoutDefaults, ...withDefaults] : []; + + return allParams; + } + + const modelProperties = $.model.getProperties(type); + const operationParams: py.ParameterDescriptor[] = [...modelProperties.values()].map((m) => + buildParameterDescriptor(m, suffixRefkey), + ); + + // Split operation params: params without defaults are positional, params with defaults are keyword-only + const opParamsWithoutDefaults = operationParams.filter((p) => p.default === undefined); + const opParamsWithDefaults = operationParams.filter((p) => p.default !== undefined); + + // Reorder additional params: params without defaults before params with defaults + const optionsWithoutDefaults = optionsParams.filter((p) => p.default === undefined); + const optionsWithDefaults = optionsParams.filter((p) => p.default !== undefined); + + // Build final parameter list: + // - Operation params without defaults: positional (e.g., URL path params) + // - "*" marker (if there are keyword-only params) + // - Operation params with defaults: keyword-only (e.g., query params with defaults) + // - Additional params: keyword-only + + // If there are no operation params at all, treat additional params without defaults as positional + const hasOperationParams = operationParams.length > 0; + + const positionalParams = hasOperationParams + ? opParamsWithoutDefaults + : [...opParamsWithoutDefaults, ...optionsWithoutDefaults]; + + const keywordOnlyParams = hasOperationParams + ? [...optionsWithoutDefaults, ...opParamsWithDefaults, ...optionsWithDefaults] + : [...opParamsWithDefaults, ...optionsWithDefaults]; + + // Add "*" marker if we have keyword-only params + // This enforces that params with defaults are truly keyword-only + const allParams: (py.ParameterDescriptor | string)[] = + keywordOnlyParams.length > 0 + ? [...positionalParams, "*", ...keywordOnlyParams] + : [...positionalParams]; + + return allParams; +} + +/** + * Convert a TypeSpec ModelProperty into a Python ParameterDescriptor. + */ +export function buildParameterDescriptor( + modelProperty: ModelProperty, + suffixRefkey: Refkey, +): py.ParameterDescriptor { + const { $ } = useTsp(); + const namePolicy = py.usePythonNamePolicy(); + const paramName = namePolicy.getName(modelProperty.name, "parameter"); + const isOptional = modelProperty.optional || modelProperty.defaultValue !== undefined; + const doc = $.type.getDoc(modelProperty); + let defaultValueNode: Children | undefined = undefined; + const hasDefault = + modelProperty.defaultValue !== undefined && modelProperty.defaultValue !== null; + if (hasDefault) { + defaultValueNode = Atom({ value: (modelProperty as any).defaultValue }); + } else if (isOptional) { + // Render Python None for optional parameters without explicit default + defaultValueNode = py.Atom({ jsValue: null }) as any; + } + return { + doc, + name: paramName, + refkey: efRefkey(modelProperty, suffixRefkey), + type: TypeExpression({ type: modelProperty.type }), + ...(defaultValueNode !== undefined ? { default: defaultValueNode } : {}), + }; +} + +const rawTypeMap = { + string: "str", + number: "float", + boolean: "bool", + any: typingModule["."]["Any"], + never: typingModule["."]["Never"], +}; + +/** + * Convert a parameter descriptor array to normalized parameter descriptors. + * String parameter names are converted to basic parameter descriptors. + */ +function normalizeParameters( + params: (py.ParameterDescriptor | string)[], +): py.ParameterDescriptor[] { + if (!params) return []; + + return params.map((param) => { + if (typeof param === "string") { + // Convert string names to parameter descriptors + return { name: param }; + } + if (typeof (param as any).type === "string") { + return { + ...param, + type: rawTypeMap[param.type as keyof typeof rawTypeMap] ?? param.type, + } as py.ParameterDescriptor; + } + return param; + }); +} diff --git a/packages/emitter-framework/src/python/utils/refkey.ts b/packages/emitter-framework/src/python/utils/refkey.ts new file mode 100644 index 00000000000..8beaf1edcdc --- /dev/null +++ b/packages/emitter-framework/src/python/utils/refkey.ts @@ -0,0 +1,36 @@ +import { refkey as ayRefkey, type Refkey } from "@alloy-js/core"; + +const refKeyPrefix = Symbol.for("emitter-framework:python"); + +/** + * A wrapper around `refkey` that uses a custom symbol to avoid collisions with + * other libraries that use `refkey`. + * + * @remarks + * + * The underlying refkey function is called with the {@link refKeyPrefix} symbol as the first argument. + * + * @param args The parameters of the refkey. + * @returns A refkey object that can be used to identify the value. + */ +export function efRefkey(...args: unknown[]): Refkey { + if (args.length === 0) { + return ayRefkey(); // Generates a unique refkey + } + return ayRefkey(refKeyPrefix, ...args); +} + +/** + * Creates a refkey for a declaration by combining the provided refkey with an internal + * refkey generated from the provided arguments. + * + * @param refkey The refkey provided by the user to be passed as is. + * @param args The parameters of the refkey. + * @returns An array of refkeys that can be passed to an Alloy declaration. + */ +export function declarationRefkeys(refkey?: Refkey | Refkey[], ...args: unknown[]): Refkey[] { + if (refkey) { + return [refkey, efRefkey(...args)].flat(); + } + return [efRefkey(...args)]; +} diff --git a/packages/emitter-framework/src/python/utils/type.ts b/packages/emitter-framework/src/python/utils/type.ts new file mode 100644 index 00000000000..8058f3a37af --- /dev/null +++ b/packages/emitter-framework/src/python/utils/type.ts @@ -0,0 +1,31 @@ +import type { Typekit } from "@typespec/compiler/typekit"; + +/** + * Check if a type is a literal type (string, numeric, boolean, or union variant). + * This is useful for determining if a type can be used in a Python Literal[] type. + * + * @param $ - The Typekit instance + * @param type - The type to check + * @returns true if the type is a literal type + */ +export function isLiteral($: Typekit, type: any): boolean { + if (!type) return false; + + return ( + $.literal.isString(type) || + $.literal.isNumeric(type) || + $.literal.isBoolean(type) || + type.kind === "UnionVariant" + ); +} + +/** + * Check if all types in an array are literal types. + * + * @param $ - The Typekit instance + * @param types - Array of types to check + * @returns true if all types are literals + */ +export function areAllLiterals($: Typekit, types: any[]): boolean { + return types.every((type) => isLiteral($, type)); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 88b2f19eb07..8af70bccd41 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -421,6 +421,9 @@ importers: '@alloy-js/core': specifier: ^0.22.0 version: 0.22.0 + '@alloy-js/python': + specifier: ^0.3.0 + version: 0.3.0 '@alloy-js/rollup-plugin': specifier: ^0.1.0 version: 0.1.0(@babel/core@7.28.5)(@types/babel__core@7.20.5)(rollup@4.49.0) @@ -2732,6 +2735,9 @@ packages: '@alloy-js/msbuild@0.22.0': resolution: {integrity: sha512-Zi4bezFzQveW4u78qwSlIlscZd8y5OKCRitSOAiw0qLfF95L4+3wgQ6QpbYuqeRRZIa5kimhbogKTSrSxycrUg==} + '@alloy-js/python@0.3.0': + resolution: {integrity: sha512-VujQyh6aGC5oAXvmtKcDGYg3rRgU01IvN5uOu6u5m/kXR7rUTsndrv960r0bBniIL+EvRF3CMu7E6EEcbb3Pyg==} + '@alloy-js/rollup-plugin@0.1.0': resolution: {integrity: sha512-MXR8mBdSh/pxMP8kIXAcMYKsm5yOWZ+igxcaRX1vBXFiHU4eK7gE/5q6Fk8Vdydh+MItWtgekwIhUWvcszdNFQ==} engines: {node: '>=18.0.0'} @@ -13740,6 +13746,12 @@ snapshots: marked: 16.4.2 pathe: 2.0.3 + '@alloy-js/python@0.3.0': + dependencies: + '@alloy-js/core': 0.22.0 + change-case: 5.4.4 + pathe: 2.0.3 + '@alloy-js/rollup-plugin@0.1.0(@babel/core@7.28.5)(@types/babel__core@7.20.5)(rollup@4.49.0)': dependencies: '@alloy-js/babel-preset': 0.2.1(@babel/core@7.28.5)