Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .chronus/changes/enum-to-ts-2026-06-02.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
changeKind: internal
packages:
- "@typespec/emitter-framework"
- "@typespec/http-client-python"
---

Refine Python enum generation plumbing in emitter-framework and http-client-python.
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useTsp } from "#core/context/index.js";
import { For, Prose } from "@alloy-js/core";
import { type Children, 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";
Expand All @@ -9,6 +9,18 @@ import { EnumMember } from "./enum-member.js";
export interface EnumDeclarationProps extends Omit<py.BaseDeclarationProps, "name"> {
name?: string;
type: Union | Enum;
/**
* Additional base classes to include in the class declaration.
* These are rendered before the enum base type in the class signature.
* For example, to render `class Foo(str, Enum, metaclass=Meta):`,
* pass `extraBases={["str"]}` and `keywords={{ metaclass: metaRef }}`.
*/
extraBases?: Children[];
/**
* Keyword arguments for the class declaration (e.g., `metaclass=CaseInsensitiveEnumMeta`).
* Each key-value pair is rendered as `key=value` after the base classes.
*/
keywords?: Record<string, Children>;
}

// Determine the appropriate enum type based on the member values
Expand Down Expand Up @@ -56,6 +68,38 @@ export function EnumDeclaration(props: EnumDeclarationProps) {
const docElement = doc ? <py.ClassDoc description={[<Prose>{doc}</Prose>]} /> : undefined;
const enumType = determineEnumType(members);

// When extraBases or keywords are provided, use ClassDeclaration with
// explicit bases instead of ClassEnumDeclaration (which only supports
// a single base type from the enum module).
if (props.extraBases || props.keywords) {
const bases: Children[] = [...(props.extraBases ?? [])];
bases.push(py.enumModule["."][enumType]);
if (props.keywords) {
for (const [key, value] of Object.entries(props.keywords)) {
bases.push(<>{key}={value}</>);
}
}

return (
<py.ClassDeclaration doc={docElement} name={name} refkey={refkeys} bases={bases}>
<For each={members} hardline>
{([key, value]) => {
const memberDoc = $.type.getDoc(value);
return (
<EnumMember
doc={memberDoc}
type={value}
refkey={
$.union.is(props.type) ? efRefkey(props.type.variants.get(key)) : efRefkey(value)
}
/>
);
}}
</For>
</py.ClassDeclaration>
);
}

return (
<py.ClassEnumDeclaration doc={docElement} name={name} refkey={refkeys} baseType={enumType}>
<For each={members} hardline>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { For } from "@alloy-js/core";
import * as py from "@alloy-js/python";
import type { Enum, Namespace, Type, Union } from "@typespec/compiler";
import { useTsp } from "@typespec/emitter-framework";
import { EnumDeclaration, efRefkey } from "@typespec/emitter-framework/python";
import { azureCoreModule, coreHttpModule } from "../external-packages/corehttp.js";

/**
* Returns the CaseInsensitiveEnumMeta reference for the given flavor.
*/
function getCaseInsensitiveEnumMetaRef(flavor: string) {
if (flavor === "azure") {
return azureCoreModule["."].CaseInsensitiveEnumMeta;
}
return coreHttpModule.utils.CaseInsensitiveEnumMeta;
}

/**
* Recursively collects all user-defined Enum and enum-like Union types
* from the TypeSpec program's namespace tree.
*/
function collectEnumTypes(
ns: Namespace,
isUserDefined: (t: Type) => boolean,
isValidEnum: (u: Union) => boolean,
): (Enum | Union)[] {
const result: (Enum | Union)[] = [];
for (const e of ns.enums.values()) {
if (isUserDefined(e)) {
result.push(e);
}
}
for (const u of ns.unions.values()) {
if (isUserDefined(u) && isValidEnum(u)) {
result.push(u);
}
}
for (const sub of ns.namespaces.values()) {
result.push(...collectEnumTypes(sub, isUserDefined, isValidEnum));
}
return result;
}

export interface EnumsProps {
path?: string;
/** "azure" or "unbranded" */
flavor?: string;
}

/**
* Emits a `_enums.py` file with enum declarations for all Enum and
* enum-like Union types discovered in the TypeSpec program.
*
* Each enum is rendered as:
* class Foo(str, Enum, metaclass=CaseInsensitiveEnumMeta): ...
*/
export function Enums(props: EnumsProps) {
const { $, program } = useTsp();
const globalNs = program.getGlobalNamespaceType();
const enumTypes = collectEnumTypes(
globalNs,
(t) => $.type.isUserDefined(t),
(u) => $.union.isValidEnum(u),
);
const flavor = props.flavor ?? "unbranded";
const metaRef = getCaseInsensitiveEnumMetaRef(flavor);

return (
<py.SourceFile path={props.path ?? "_enums.py"}>
<For each={enumTypes} hardline>
{(type: Enum | Union) => {
return (
<EnumDeclaration
type={type}
refkey={efRefkey(type)}
extraBases={["str"]}
keywords={{ metaclass: metaRef }}
/>
);
}}
</For>
</py.SourceFile>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { Children } from "@alloy-js/core";
import * as py from "@alloy-js/python";
import type { Program } from "@typespec/compiler";
import { Output as EFOutput, TransformNamePolicyContext } from "@typespec/emitter-framework";
import {
datetimeModule,
decimalModule,
abcModule as pyAbcBuiltin,
typingModule,
} from "@typespec/emitter-framework/python";
import { azureCoreModule, coreHttpModule } from "../external-packages/corehttp.js";
import { createTransformNamePolicy } from "../transforms/transform-name-policy.js";

export interface OutputProps {
program: Program;
children?: Children;
}

export function Output(props: OutputProps) {
const pythonNamePolicy = py.createPythonNamePolicy();
const defaultTransformNamePolicy = createTransformNamePolicy();
return (
<EFOutput
namePolicy={pythonNamePolicy}
externals={[
pyAbcBuiltin,
datetimeModule,
decimalModule,
typingModule,
py.abcModule,
py.dataclassesModule,
py.enumModule,
coreHttpModule,
azureCoreModule,
]}
program={props.program}
>
<TransformNamePolicyContext.Provider value={defaultTransformNamePolicy}>
{props.children}
</TransformNamePolicyContext.Provider>
</EFOutput>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import type { Children } from "@alloy-js/core";
import { SourceDirectory, SourceFile } from "@alloy-js/core";
import * as py from "@alloy-js/python";

function toModuleName(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9_]+/g, "_")
.replace(/^_+|_+$/g, "");
}

export interface PythonPackageDirectoryProps {
name: string;
version: string;
path: string;
moduleName?: string;
extraDependencies?: string[];
children?: Children;
}

/**
* Emits a Python package layout at `props.path`:
*
* ```
* <path>/
* pyproject.toml
* README.md
* <moduleName>/
* __init__.py
* _version.py
* py.typed
* {children}
* ```
*/
export function PythonPackageDirectory(props: PythonPackageDirectoryProps) {
const moduleName = props.moduleName ?? toModuleName(props.name);
const deps = ["corehttp>=1.0.0b6", "isodate>=0.6.1", ...(props.extraDependencies ?? [])];
const depsToml = deps.map((d) => ` "${d}",`).join("\n");
const pyproject = `[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "${props.name}"
version = "${props.version}"
description = "Generated Python client for ${props.name}"
readme = "README.md"
requires-python = ">=3.9"
dependencies = [
${depsToml}
]

[tool.setuptools.packages.find]
include = ["${moduleName}*"]
`;
const readme = `# ${props.name}\n\nThis package was generated by \`@typespec/http-client-python\`.\n`;
return (
<SourceDirectory path={props.path}>
<SourceFile path="pyproject.toml" filetype="text">
{pyproject}
</SourceFile>
<SourceFile path="README.md" filetype="text">
{readme}
</SourceFile>
<SourceDirectory path={moduleName}>
<py.SourceFile path="__init__.py">
{`from ._version import VERSION\n\n__version__ = VERSION`}
</py.SourceFile>
<py.SourceFile path="_version.py">{`VERSION = "${props.version}"`}</py.SourceFile>
<SourceFile path="py.typed" filetype="text">
{""}
</SourceFile>
{props.children}
</SourceDirectory>
</SourceDirectory>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { createModule } from "@alloy-js/python";

/**
* Descriptor for the `corehttp` runtime package, the unbranded equivalent of
* `azure-core`. We expose only the symbols this emitter currently references;
* additional symbols should be added as needed when new features are wired up.
*/
export const coreHttpModule = createModule({
name: "corehttp",
descriptor: {
runtime: ["PipelineClient", "AsyncPipelineClient"],
rest: ["HttpRequest", "HttpResponse", "AsyncHttpResponse"],
exceptions: ["HttpResponseError"],
credentials: [
"TokenCredential",
"AsyncTokenCredential",
"AzureKeyCredential",
],
polling: [
"LROPoller",
"AsyncLROPoller",
"LROBasePolling",
"AsyncLROBasePolling",
"PollingMethod",
"AsyncPollingMethod",
"NoPolling",
"AsyncNoPolling",
],
utils: ["CaseInsensitiveEnumMeta"],
},
});

/**
* Descriptor for the `azure.core` runtime package (used when `flavor: azure`).
*/
export const azureCoreModule = createModule({
name: "azure.core",
descriptor: {
".": [
"PipelineClient",
"AsyncPipelineClient",
"CaseInsensitiveEnumMeta",
],
rest: ["HttpRequest", "HttpResponse", "AsyncHttpResponse"],
exceptions: ["HttpResponseError"],
credentials: ["TokenCredential", "AzureKeyCredential"],
polling: [
"LROPoller",
"AsyncLROPoller",
"LROBasePolling",
"AsyncLROBasePolling",
"PollingMethod",
"AsyncPollingMethod",
"NoPolling",
"AsyncNoPolling",
],
},
});

/**
* Returns the appropriate runtime module for the requested flavor.
*/
export function getRuntimeModule(flavor: string) {
return flavor === "azure" ? azureCoreModule : coreHttpModule;
}
29 changes: 29 additions & 0 deletions packages/http-client-python/emitter/src/alloy/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { EmitContext } from "@typespec/compiler";
import { writeOutput } from "@typespec/emitter-framework";
import type { PythonEmitterOptions } from "../lib.js";
import { Enums } from "./components/enums.js";
import { Output } from "./components/output.js";
import { PythonPackageDirectory } from "./components/package-directory.js";

/**
* Render only enum files via Alloy. Called from the hybrid path in
* `onEmitMain` when `use-alloy-enums` is enabled.
*/
export async function $onEmitEnums(context: EmitContext<PythonEmitterOptions>) {
if (context.program.compilerOptions.noEmit) {
return;
}
const packageName = context.options["package-name"] ?? "test-package";
const packageVersion = context.options["package-version"] ?? "1.0.0";
const flavor = (context.options as any).flavor ?? "unbranded";

const output = (
<Output program={context.program}>
<PythonPackageDirectory name={packageName} version={packageVersion} path=".">
<Enums flavor={flavor} />
</PythonPackageDirectory>
</Output>
);

await writeOutput(context.program, output, context.emitterOutputDir);
}
Loading
Loading