Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: feature
packages:
- "@typespec/emitter-framework"
---

Add Python Pydantic helpers to the emitter framework: `PydanticClassDeclaration`, `PydanticSettingsClassDeclaration`, and `PydanticRootModelDeclaration` components, along with `field_validator`, `model_validator`, `field_serializer`, and `computed_field` decorator helpers.
16 changes: 16 additions & 0 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"permissions": {
"allow": [
"Bash(npx vitest *)",
"Bash(node -e \"const p=require\\('./packages/compiler/package.json'\\); console.log\\(JSON.stringify\\(p.exports['./testing'],null,2\\)\\); console.log\\('---main---'\\); console.log\\(JSON.stringify\\(p.exports['.'],null,2\\)\\)\")",
"Bash(node -e \"const p=require\\('./packages/compiler/package.json'\\); console.log\\('declared:', p.dependencies['vscode-languageserver']\\)\")",
"Bash(node -e \"const p=require\\('./node_modules/.pnpm/vscode-languageserver@9.0.1/node_modules/vscode-languageserver/package.json'\\); console.log\\(JSON.stringify\\(p.exports,null,2\\)\\)\")",
"Bash(npm run *)",
"Bash(node -e \"const p=require\\('./node_modules/.pnpm/lock'\\);\")",
"Bash(pnpm chronus *)",
"Bash(xargs cat)",
"Bash(npx prettier *)",
"Bash(pnpm run *)"
]
}
}
1 change: 1 addition & 0 deletions cspell.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ words:
- ptvsd
- pwsh
- pycache
- pydantic
- pyexpat
- pygen
- pygments
Expand Down
3 changes: 0 additions & 3 deletions packages/emitter-framework/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,12 @@

- [#9879](https://github.com/microsoft/typespec/pull/9879) Add the missing export in index for extensible-enum in csharp


## 0.16.0

### Bump dependencies

- [#9446](https://github.com/microsoft/typespec/pull/9446) Upgrade dependencies


## 0.15.0

### Features
Expand All @@ -42,7 +40,6 @@
- [#9202](https://github.com/microsoft/typespec/pull/9202) Update to alloy 0.22
- [#9223](https://github.com/microsoft/typespec/pull/9223) Upgrade dependencies


## 0.14.0

No changes, version bump only.
Expand Down
3 changes: 3 additions & 0 deletions packages/emitter-framework/src/python/builtins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,18 @@ export const typingModule = createModule({
name: "typing",
descriptor: {
".": [
"Annotated",
"Any",
"Callable",
"ClassVar",
"Generic",
"Literal",
"Never",
"Optional",
"Protocol",
"TypeAlias",
"TypeVar",
"Union",
],
},
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type Children, createContext, splitProps, useContext } from "@alloy-js/core";
import { createContext, For, splitProps, useContext, type Children } from "@alloy-js/core";
import * as py from "@alloy-js/python";
import type { Operation } from "@typespec/compiler";
import { useTsp } from "../../../core/index.js";
Expand All @@ -15,11 +15,14 @@ export interface MethodPropsWithType extends Omit<py.MethodDeclarationBaseProps,
doc?: Children;
methodType?: "method" | "class" | "static";
abstract?: boolean;
decorators?: Children[];
/** If true, parameters replaces operation parameters instead of adding to them as keyword-only */
replaceParameters?: boolean;
}

export type MethodProps = MethodPropsWithType | py.MethodDeclarationBaseProps;
export type MethodProps =
| MethodPropsWithType
| (py.MethodDeclarationBaseProps & { decorators?: Children[] });

/**
* Get the method component based on the resolved method type.
Expand Down Expand Up @@ -59,18 +62,35 @@ export function Method(props: Readonly<MethodProps>) {
const explicit = (props as any).abstract as boolean | undefined;
return explicit ?? (!isTypeSpecTyped ? false : undefined);
})();
const decorators = (props as { decorators?: Children[] }).decorators;
const decoratorsBlock = decorators ? (
<For each={decorators} skipFalsy>
{(decorator) => (
<>
{decorator}
<hbr />
</>
)}
</For>
) : 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 <MethodComponent {...props} doc={docElement} abstract={abstractFlag} />;
const { decorators: _decorators, ...methodProps } = props;
return (
<>
{decoratorsBlock}
<MethodComponent {...methodProps} doc={docElement} abstract={abstractFlag} />
</>
);
}

const [efProps, updateProps, forwardProps] = splitProps(
props,
["type"],
["type", "decorators"],
["returnType", "parameters"],
);

Expand All @@ -83,6 +103,7 @@ export function Method(props: Readonly<MethodProps>) {

return (
<>
{decoratorsBlock}
<MethodComponent
{...forwardProps}
{...updateProps}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@ export * from "./class-declaration.js";
export * from "./class-member.js";
export * from "./class-method.js";
export * from "./primitive-initializer.js";
export * from "./pydantic-class-declaration.js";
export * from "./pydantic-decorators.js";
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { Tester } from "#test/test-host.js";
import { code } from "@alloy-js/core";
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 { ClassDeclaration } from "./class-declaration.js";
import { Method } from "./class-method.js";
import {
PydanticClassDeclaration,
PydanticRootModelDeclaration,
PydanticSettingsClassDeclaration,
} from "./pydantic-class-declaration.js";

describe("Python PydanticClassDeclaration", () => {
it("creates a pydantic class from a model", async () => {
const { program, User } = await Tester.compile(t.code`
model ${t.model("User")} {
id: string;
}
`);

expect(getOutput(program, [<PydanticClassDeclaration type={User} />])).toRenderTo(`
from pydantic import BaseModel


class User(BaseModel):
id: str

`);
});

it("emits model_config from structured modelConfig", async () => {
const { program, User } = await Tester.compile(t.code`
model ${t.model("User")} {
id: string;
}
`);

expect(
getOutput(program, [
<PydanticClassDeclaration
type={User}
modelConfig={{ frozen: true, extra: "forbid", validateAssignment: true }}
/>,
]),
).toRenderTo(`
from pydantic import BaseModel
from pydantic import ConfigDict


class User(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid", validate_assignment=True)
id: str

`);
});

it("places pydantic validator decorators above @classmethod", async () => {
const { program, stripName } = await Tester.compile(
t.code`@test op ${t.op("stripName")}(value: string): string;`,
);

expect(
getOutput(program, [
<PydanticClassDeclaration name="User">
<Method
type={stripName}
methodType="class"
decorators={[code`@${py.pydanticModule["."].field_validator}("name", mode="before")`]}
/>
</PydanticClassDeclaration>,
]),
).toRenderTo(`
from pydantic import BaseModel
from pydantic import field_validator


class User(BaseModel):
@field_validator("name", mode="before")
@classmethod
def strip_name(cls, value: str) -> str:
pass


`);
});

it("supports BaseSettings via pydantic_settings module", async () => {
const { program } = await Tester.compile(``);

expect(
getOutput(program, [
<ClassDeclaration
name="AppSettings"
bases={[py.pydanticSettingsModule["."].BaseSettings]}
/>,
]),
).toRenderTo(`
from pydantic_settings import BaseSettings


class AppSettings(BaseSettings):
pass

`);
});

it("creates a settings class with SettingsConfigDict", async () => {
const { program } = await Tester.compile(``);

expect(
getOutput(program, [
<PydanticSettingsClassDeclaration
name="AppSettings"
settingsConfig={{ envPrefix: "APP_", envFile: ".env" }}
/>,
]),
).toRenderTo(`
from pydantic_settings import BaseSettings
from pydantic_settings import SettingsConfigDict


class AppSettings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="APP_", env_file=".env")


`);
});

it("creates a RootModel declaration from a root type", async () => {
const { program } = await Tester.compile(``);

expect(
getOutput(program, [
<PydanticRootModelDeclaration name="TagList" rootType={code`list[str]`} />,
]),
).toRenderTo(`
from pydantic import RootModel


class TagList(RootModel[list[str]]):
pass

`);
});
});
Loading
Loading