diff --git a/packages/sdk-generator/__tests__/backends/python.test.ts b/packages/sdk-generator/__tests__/backends/python.test.ts index 11340d8..4cb4379 100644 --- a/packages/sdk-generator/__tests__/backends/python.test.ts +++ b/packages/sdk-generator/__tests__/backends/python.test.ts @@ -16,6 +16,7 @@ import { } from "../../src/backends/python/typeddict-emitter.js"; import { CodeBuilder } from "../../src/utils/codegen.js"; import { emitPythonContractTests } from "../../src/backends/contract-tests/python-emitter.js"; +import { pythonResponseShape } from "../../src/backends/python/response-type.js"; import { emitPythonChannelContractTestFile } from "../../src/backends/contract-tests/channel-emitter-python.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -204,6 +205,34 @@ describe("Pydantic emitter", () => { expect(out).not.toContain("async: Optional[bool]"); }); + it("emits Any rather than the object builtin for free-form fields", () => { + // A field named `object` shadows the builtin when pydantic resolves + // deferred annotations against the class namespace; `dict[str, object]` + // silently becomes `dict[str, None]` and every value fails validation. + const out = emitPydanticFile([ + { + name: "AttachmentLike", + fields: [ + { + name: "object", + type: { kind: "map", valueType: { kind: "unknown" } }, + required: true, + }, + { + name: "payload", + type: { kind: "object", fields: [] }, + required: true, + }, + ], + }, + ]); + + expect(out).toContain("object: dict[str, Any]"); + expect(out).toContain("payload: dict[str, Any]"); + expect(out).not.toContain("dict[str, object]"); + expect(out).toMatch(/from typing import .*Any/); + }); + it("uniquely aliases fields when sanitized Python names collide", () => { const out = emitPydanticFile([ { @@ -307,6 +336,230 @@ describe("Python resource emitter uses request_raw for raw responses", () => { }); }); +describe("Python resource emitter passes response_type for typed responses", () => { + const widgetFixture = { + openapi: "3.0.0", + info: { title: "Widget API", version: "1.0.0" }, + paths: { + "/api/v1/widgets": { + get: { + operationId: "get_api_v1_widgets", + responses: { + "200": { + description: "All widgets", + content: { + "application/json": { + schema: { + type: "array", + items: { $ref: "#/components/schemas/Widget" }, + }, + }, + }, + }, + }, + }, + }, + "/api/v1/widgets/{widget}": { + get: { + operationId: "get_api_v1_widgets_widget", + parameters: [ + { name: "widget", in: "path", required: true, schema: { type: "string" } }, + ], + responses: { + "200": { + description: "One widget", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Widget" }, + }, + }, + }, + }, + }, + delete: { + operationId: "delete_api_v1_widgets_widget", + parameters: [ + { name: "widget", in: "path", required: true, schema: { type: "string" } }, + ], + responses: { "204": { description: "Deleted" } }, + }, + }, + "/api/v1/widgets/{widget}/share": { + post: { + operationId: "post_api_v1_widgets_widget_share", + parameters: [ + { name: "widget", in: "path", required: true, schema: { type: "string" } }, + ], + responses: { + "200": { + description: "Share result", + content: { + "application/json": { + schema: { + type: "object", + properties: { id: { type: "string" } }, + required: ["id"], + }, + }, + }, + }, + }, + }, + }, + "/api/v1/widgets/{widget}/avatar": { + get: { + operationId: "get_api_v1_widgets_widget_avatar", + parameters: [ + { name: "widget", in: "path", required: true, schema: { type: "string" } }, + ], + responses: { + "200": { + description: "Raw avatar bytes", + content: { "*/*": { schema: { type: "string", format: "binary" } } }, + }, + }, + }, + }, + "/api/v1/widgets/paged": { + get: { + operationId: "get_api_v1_widgets_paged", + responses: { + "200": { + description: "Paged widgets", + content: { + "application/json": { + schema: { + type: "object", + properties: { + data: { + type: "array", + items: { $ref: "#/components/schemas/Widget" }, + }, + }, + required: ["data"], + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Widget: { + type: "object", + properties: { id: { type: "string" } }, + required: ["id"], + }, + }, + }, + }; + + const widgetAst = parseOpenApiSpec(widgetFixture, { + name: "archastro-platform", + version: "0.1.0", + baseUrl: "https://platform.archastro.ai", + apiBase: "/api", + defaultVersion: "v1", + }); + const widgetsResource = widgetAst.resources.find((r) => r.name === "widgets")!; + const output = emitPythonResourceFile(widgetsResource, "/api/v1"); + + it("passes the schema class for $ref responses in both transports", () => { + expect(output).toContain( + 'return await self._http.request(f"/api/v1/widgets/{widget}", response_type=Widget)' + ); + expect(output).toContain( + 'return self._http.request(f"/api/v1/widgets/{widget}", response_type=Widget)' + ); + }); + + it("passes list[Model] for array-of-$ref responses", () => { + expect(output).toContain( + 'return await self._http.request(f"/api/v1/widgets", response_type=list[Widget])' + ); + expect(output).toContain( + 'return self._http.request(f"/api/v1/widgets", response_type=list[Widget])' + ); + }); + + it("passes the hoisted BaseModel for inline object responses", () => { + expect(output).toContain("class WidgetShareResponse(BaseModel):"); + expect(output).toContain("response_type=WidgetShareResponse"); + }); + + it("omits response_type for 204/void responses", () => { + expect(output).toContain( + 'await self._http.request(f"/api/v1/widgets/{widget}", method="DELETE")' + ); + }); + + it("omits response_type for raw byte responses", () => { + expect(output).toContain( + 'return await self._http.request_raw(f"/api/v1/widgets/{widget}/avatar")' + ); + }); + + describe("contract tests assert the deserialized shapes", () => { + const files = emitPythonContractTests(widgetAst, { + outDir: "/tmp/test-python-sdk", + }); + const content = + files["/tmp/test-python-sdk/tests/contract/v1/test_widgets.py"]!; + + it("imports BaseModel and asserts concrete model classes for typed responses", () => { + expect(content).toContain("from pydantic import BaseModel"); + expect(content).toContain("assert isinstance(result, BaseModel)"); + expect(content).toContain('assert type(result).__name__ == "Widget"'); + expect(content).toContain( + 'assert type(result).__name__ == "WidgetShareResponse"' + ); + }); + + it("asserts lists of concrete model instances for list responses", () => { + expect(content).toContain("assert isinstance(result, list)"); + expect(content).toContain( + 'assert all(type(item).__name__ == "Widget" for item in result)' + ); + }); + + it("asserts attribute access for data-array responses", () => { + expect(content).toContain("assert isinstance(result.data, list)"); + expect(content).not.toContain('assert "data" in result'); + expect(content).not.toContain('result["data"]'); + }); + + it("keeps raw and void assertions unchanged", () => { + expect(content).toContain('assert result["content"] is not None'); + expect(content).toContain('assert result["mime_type"]'); + expect(content).toContain("assert result is None"); + }); + }); + + it("fails generation when a model is buried in an undeserialized shape", () => { + expect(() => + pythonResponseShape({ + name: "get", + operationId: "get_widget_or_gadget", + method: "GET", + path: "/api/v1/widgets/mixed", + deprecated: false, + pathParams: [], + queryParams: [], + returnType: { + kind: "union", + variants: [ + { kind: "ref", schema: "Widget" }, + { kind: "ref", schema: "Gadget" }, + ], + }, + errors: [], + }) + ).toThrow(/not deserialized/); + }); +}); + describe("Python contract tests include raw response operations", () => { const rawAst = parseOpenApiSpec(rawFixture, { name: "archastro-platform", @@ -1231,7 +1484,7 @@ describe("Python resource emitter typed bodies", () => { expect(out).toContain("add: list[TeamCreateInputAclAddItem]"); }); - it("keeps `dict[str, object]` for empty objects (genuine freeform metadata)", () => { + it("maps empty objects (genuine freeform metadata) to dict[str, Any]", () => { const out = emitPythonResourceFile( { name: "teams", @@ -1271,7 +1524,7 @@ describe("Python resource emitter typed bodies", () => { }, "/api/v1" ); - expect(out).toContain("metadata: Optional[dict[str, object]]"); + expect(out).toContain("metadata: Optional[dict[str, Any]]"); expect(out).not.toContain("class TeamCreateInputMetadata"); }); @@ -1552,7 +1805,7 @@ describe("Python resource emitter typed bodies", () => { expect(out).not.toContain("class AgentListResponse(BaseModel)"); }); - it("leaves return type as dict[str, object] for empty inline responses", () => { + it("types empty inline responses as dict[str, Any]", () => { const out = emitPythonResourceFile( { name: "ping", @@ -1576,7 +1829,7 @@ describe("Python resource emitter typed bodies", () => { }, "/api/v1" ); - expect(out).toContain("-> dict[str, object]:"); + expect(out).toContain("-> dict[str, Any]:"); expect(out).not.toContain("(BaseModel)"); }); @@ -1820,7 +2073,7 @@ describe("Python resource emitter typed bodies", () => { "/api/v1" ); - expect(out).toContain("from typing import Literal"); + expect(out).toContain("from typing import Any, Literal"); expect(out).toContain( 'owner_scope: Literal["any", "individual", "system"] | None = None' ); diff --git a/packages/sdk-generator/src/backends/contract-tests/python-emitter.ts b/packages/sdk-generator/src/backends/contract-tests/python-emitter.ts index 3b5dccf..147eaa3 100644 --- a/packages/sdk-generator/src/backends/contract-tests/python-emitter.ts +++ b/packages/sdk-generator/src/backends/contract-tests/python-emitter.ts @@ -2,6 +2,10 @@ import type { SdkSpec } from "../../ast/types.js"; import { CodeBuilder, generatedHeaderPython } from "../../utils/codegen.js"; import { snakeCase } from "../../utils/naming.js"; import { pythonParameterName, uniquePythonParameterNames } from "../python/identifiers.js"; +import { + pythonInlineResponseName, + pythonResponseShape, +} from "../python/response-type.js"; import { buildMethodCalls, groupByTopLevelResource, @@ -75,6 +79,12 @@ function emitResourceTestFile( cb.line("import pytest"); cb.line("from archastro.platform import AsyncPlatformClient, PlatformClient"); cb.line("from archastro.platform.runtime.http_client import ApiError"); + const needsBaseModel = calls.some((call) => + ["model", "model_list"].includes(pythonResponseShape(call.operation)) + ); + if (needsBaseModel) { + cb.line("from pydantic import BaseModel"); + } cb.line(); cb.line(); cb.line('PRISM_URL = "http://127.0.0.1:4040"'); @@ -184,29 +194,13 @@ function emitHappyPathTest(cb: CodeBuilder, call: MethodCallInfo): void { const argStr = buildPythonArgs(call); const chainPy = call.accessorChain.replace("client.", ""); const methodCall = `client.${chainPy}.${pythonParameterName(call.methodName)}(${argStr})`; - const hasDataArray = returnTypeHasDataArray(call.operation.returnType); - const returnsNoContent = call.operation.returnType.kind === "void"; cb.line(); cb.line(`def ${testName}():`); cb.indent(); cb.line("client = _client()"); cb.pyBlock("try", () => { - if (returnsNoContent) { - cb.line(`result = ${methodCall}`); - cb.line("assert result is None"); - } else if (call.operation.rawResponse) { - cb.line(`result = ${methodCall}`); - cb.line('assert result["content"] is not None'); - cb.line('assert result["mime_type"]') - } else { - cb.line(`result = ${methodCall}`); - cb.line("assert result is not None"); - if (hasDataArray) { - cb.line('assert "data" in result'); - cb.line('assert isinstance(result["data"], list)'); - } - } + emitResultAssertions(cb, call, methodCall); }); cb.pyBlock("finally", () => { cb.line("client.close()"); @@ -214,6 +208,66 @@ function emitHappyPathTest(cb: CodeBuilder, call: MethodCallInfo): void { cb.dedent(); } +/** + * Assert the response shape the generated SDK promises: deserialized + * Pydantic models for typed responses, raw payloads everywhere else. + * The shape decision is shared with the resource emitter via + * pythonResponseShape so assertions and behavior cannot drift. Concrete + * class names are asserted via type(...).__name__ — strictly stronger than + * an isinstance(BaseModel) check (a miswired all-optional model would + * still validate) without needing cross-module imports in the test file. + */ +function emitResultAssertions( + cb: CodeBuilder, + call: MethodCallInfo, + methodCall: string +): void { + cb.line(`result = ${methodCall}`); + switch (pythonResponseShape(call.operation)) { + case "void": + cb.line("assert result is None"); + break; + case "raw": + cb.line('assert result["content"] is not None'); + cb.line('assert result["mime_type"]'); + break; + case "model": { + cb.line("assert isinstance(result, BaseModel)"); + cb.line(`assert type(result).__name__ == "${modelClassName(call)}"`); + if (returnTypeHasDataArray(call.operation.returnType)) { + cb.line("assert isinstance(result.data, list)"); + } + break; + } + case "model_list": { + const itemName = listItemClassName(call); + cb.line("assert isinstance(result, list)"); + cb.line( + `assert all(type(item).__name__ == "${itemName}" for item in result)` + ); + break; + } + case "untyped": + cb.line("assert result is not None"); + break; + } +} + +function modelClassName(call: MethodCallInfo): string { + const ret = call.operation.returnType; + if (ret.kind === "ref") return ret.schema; + return pythonInlineResponseName(call.resource.className, call.operation.name); +} + +function listItemClassName(call: MethodCallInfo): string { + const ret = call.operation.returnType; + if (ret.kind === "array" && ret.items.kind === "ref") return ret.items.schema; + throw new Error( + `[sdk-generator] ${call.operation.operationId}: model_list response ` + + `without a $ref item type` + ); +} + /** Check if return type is an object with a `data` field that is an array. */ function returnTypeHasDataArray(returnType: import("../../ast/types.js").TypeRef): boolean { if (returnType.kind !== "object") return false; @@ -254,8 +308,6 @@ function emitAsyncHappyPathTest(cb: CodeBuilder, call: MethodCallInfo): void { const argStr = buildPythonArgs(call); const chainPy = call.accessorChain.replace("client.", ""); const methodCall = `client.${chainPy}.${pythonParameterName(call.methodName)}(${argStr})`; - const hasDataArray = returnTypeHasDataArray(call.operation.returnType); - const returnsNoContent = call.operation.returnType.kind === "void"; cb.line(); cb.line("@pytest.mark.asyncio"); @@ -263,21 +315,7 @@ function emitAsyncHappyPathTest(cb: CodeBuilder, call: MethodCallInfo): void { cb.indent(); cb.line("client = _async_client()"); cb.pyBlock("try", () => { - if (returnsNoContent) { - cb.line(`result = await ${methodCall}`); - cb.line("assert result is None"); - } else if (call.operation.rawResponse) { - cb.line(`result = await ${methodCall}`); - cb.line('assert result["content"] is not None'); - cb.line('assert result["mime_type"]'); - } else { - cb.line(`result = await ${methodCall}`); - cb.line("assert result is not None"); - if (hasDataArray) { - cb.line('assert "data" in result'); - cb.line('assert isinstance(result["data"], list)'); - } - } + emitResultAssertions(cb, call, `await ${methodCall}`); }); cb.pyBlock("finally", () => { cb.line("await client.close()"); diff --git a/packages/sdk-generator/src/backends/python/pydantic-emitter.ts b/packages/sdk-generator/src/backends/python/pydantic-emitter.ts index 9a0fd80..ef4a8cd 100644 --- a/packages/sdk-generator/src/backends/python/pydantic-emitter.ts +++ b/packages/sdk-generator/src/backends/python/pydantic-emitter.ts @@ -181,8 +181,11 @@ export function typeRefToPython(ref: TypeRef): string { return `list[${typeRefToPython(ref.items)}]`; case "object": - if (ref.fields.length === 0) return "dict[str, object]"; - return "dict[str, object]"; + // Free-form objects map to Any, not the `object` builtin: a field + // named `object` on the same model shadows the builtin when pydantic + // resolves deferred annotations against the class namespace, silently + // turning `dict[str, object]` into `dict[str, None]`. + return "dict[str, Any]"; case "ref": return ref.schema; @@ -201,13 +204,18 @@ export function typeRefToPython(ref: TypeRef): string { return `dict[str, ${typeRefToPython(ref.valueType)}]`; case "unknown": - return "object"; + return "Any"; case "void": return "None"; } } +/** True when a primitive TypeRef falls through to `Any` (needs the typing import). */ +export function primitiveMapsToAny(type: string): boolean { + return !["string", "datetime", "integer", "float", "boolean"].includes(type); +} + function primitiveToPython(type: string): string { switch (type) { case "string": @@ -223,7 +231,7 @@ function primitiveToPython(type: string): string { case "boolean": return "bool"; default: - return "object"; + return "Any"; } } @@ -296,5 +304,15 @@ function collectImportsFromType(ref: TypeRef, imports: Set): void { case "union": for (const v of ref.variants) collectImportsFromType(v, imports); break; + case "map": + collectImportsFromType(ref.valueType, imports); + break; + case "object": + case "unknown": + imports.add("Any"); + break; + case "primitive": + if (primitiveMapsToAny(ref.type)) imports.add("Any"); + break; } } diff --git a/packages/sdk-generator/src/backends/python/resource-emitter.ts b/packages/sdk-generator/src/backends/python/resource-emitter.ts index 38991b2..e448f0e 100644 --- a/packages/sdk-generator/src/backends/python/resource-emitter.ts +++ b/packages/sdk-generator/src/backends/python/resource-emitter.ts @@ -13,10 +13,15 @@ import { } from "./identifiers.js"; import { emitPydanticModel, + primitiveMapsToAny, typeRefToPython, typeRefsUseDatetime, } from "./pydantic-emitter.js"; import { hoistInlineObjects } from "./inline-object-hoist.js"; +import { + pythonInlineResponseName, + pythonResponseTypeExpr, +} from "./response-type.js"; import { collectTypedDictImports, emitTypedDictClass, @@ -214,7 +219,6 @@ interface InlineResponseGroup { function collectInlineResponses(resources: ResourceDef[]): InlineResponseGroup[] { const groups: InlineResponseGroup[] = []; for (const resource of resources) { - const shortName = resource.className.replace(/Resource$/, ""); for (const op of resource.operations) { if (op.rawResponse) continue; if ( @@ -223,7 +227,7 @@ function collectInlineResponses(resources: ResourceDef[]): InlineResponseGroup[] ) { groups.push({ operationId: op.operationId, - name: `${shortName}${pascalCase(op.name)}Response`, + name: pythonInlineResponseName(resource.className, op.name), fields: op.returnType.fields, description: op.summary, }); @@ -252,6 +256,13 @@ function collectTypingFromTypeRef(ref: TypeRef, imports: Set): void { case "map": collectTypingFromTypeRef(ref.valueType, imports); break; + case "object": + case "unknown": + imports.add("Any"); + break; + case "primitive": + if (primitiveMapsToAny(ref.type)) imports.add("Any"); + break; } } @@ -384,7 +395,7 @@ function emitOperation( } const pathExpr = buildPathExpression(op, resource, pythonNames); - const optParts = buildRequestOptionParts(op, pythonNames); + const optParts = buildRequestOptionParts(op, pythonNames, responseName); const requestMethod = op.rawResponse ? "request_raw" : "request"; const prefix = returnAnnotation === "None" ? "await" : "return await"; @@ -442,7 +453,7 @@ function emitSyncOperation( } const pathExpr = buildPathExpression(op, resource, pythonNames); - const optParts = buildRequestOptionParts(op, pythonNames); + const optParts = buildRequestOptionParts(op, pythonNames, responseName); const requestMethod = op.rawResponse ? "request_raw" : "request"; const prefix = returnAnnotation === "None" ? "" : "return "; const allArgs = [pathExpr, ...optParts]; @@ -612,7 +623,8 @@ function buildPathExpression( function buildRequestOptionParts( op: OperationDef, - pythonNames: OperationPythonNames + pythonNames: OperationPythonNames, + inlineResponseName: string | undefined ): string[] { const parts: string[] = []; @@ -628,6 +640,11 @@ function buildRequestOptionParts( parts.push("query=query"); } + const responseTypeExpr = pythonResponseTypeExpr(op, inlineResponseName); + if (responseTypeExpr) { + parts.push(`response_type=${responseTypeExpr}`); + } + return parts; } diff --git a/packages/sdk-generator/src/backends/python/response-type.ts b/packages/sdk-generator/src/backends/python/response-type.ts new file mode 100644 index 0000000..118676d --- /dev/null +++ b/packages/sdk-generator/src/backends/python/response-type.ts @@ -0,0 +1,115 @@ +import type { OperationDef, TypeRef } from "../../ast/types.js"; +import { pascalCase } from "../../utils/naming.js"; + +/** + * How the Python runtime should treat an operation's response body. + * + * - "void" — no content (204); runtime returns None + * - "raw" — bytes via request_raw; stays a {content, mime_type} dict + * - "model" — single Pydantic model (named $ref or hoisted inline object) + * - "model_list" — list of Pydantic models + * - "untyped" — anything else (scalars, bare dicts); stays raw JSON + * + * This classifier is shared by the resource emitter (which decides whether to + * pass response_type= to the runtime) and the contract-tests emitter (which + * decides what shape to assert). Keeping both on one function guarantees the + * generated assertions always match the generated deserialization behavior. + */ +export type PythonResponseShape = + | "void" + | "raw" + | "model" + | "model_list" + | "untyped"; + +export function pythonResponseShape(op: OperationDef): PythonResponseShape { + if (op.rawResponse) return "raw"; + const ret = op.returnType; + switch (ret.kind) { + case "void": + return "void"; + case "ref": + return "model"; + case "object": + // Inline objects with fields are hoisted into generated BaseModels; + // empty objects have nothing to validate and stay dicts. + return ret.fields.length > 0 ? "model" : "untyped"; + case "array": + return ret.items.kind === "ref" ? "model_list" : "untyped"; + default: + // A model buried in a shape we don't deserialize (union, optional, + // nested array) would mean the return annotation promises a model the + // runtime never constructs — the exact bug response_type fixes. Fail + // generation so spec evolution cannot reintroduce it silently. + if (containsRef(ret)) { + throw new Error( + `[sdk-generator] ${op.operationId}: response shape "${ret.kind}" ` + + `contains a schema ref but is not deserialized (no response_type ` + + `emitted); the Python return annotation would not match runtime ` + + `behavior. Extend pythonResponseShape to cover this shape.` + ); + } + return "untyped"; + } +} + +/** + * Class name for the BaseModel hoisted from an operation's inline object + * response. Single source of truth for the naming rule — the resource + * emitter (which emits the class) and the contract-tests emitter (which + * asserts against it) must agree. + */ +export function pythonInlineResponseName( + resourceClassName: string, + opName: string +): string { + return `${resourceClassName.replace(/Resource$/, "")}${pascalCase(opName)}Response`; +} + +function containsRef(ref: TypeRef): boolean { + switch (ref.kind) { + case "ref": + return true; + case "array": + return containsRef(ref.items); + case "optional": + return containsRef(ref.inner); + case "union": + return ref.variants.some(containsRef); + case "map": + return containsRef(ref.valueType); + default: + return false; + } +} + +/** + * Python expression for the runtime's response_type kwarg, or undefined when + * the response should pass through as raw JSON. `inlineResponseName` is the + * hoisted BaseModel name for ops with inline object responses. + */ +export function pythonResponseTypeExpr( + op: OperationDef, + inlineResponseName: string | undefined +): string | undefined { + switch (pythonResponseShape(op)) { + case "model": + if (op.returnType.kind === "ref") return op.returnType.schema; + if (!inlineResponseName) { + throw new Error( + `[sdk-generator] ${op.operationId}: classified as "model" (inline ` + + `object response) but no hoisted BaseModel name was provided; ` + + `the inline-response collection predicate has drifted from ` + + `pythonResponseShape.` + ); + } + return inlineResponseName; + case "model_list": + if (op.returnType.kind === "array" && op.returnType.items.kind === "ref") { + return `list[${op.returnType.items.schema}]`; + } + return undefined; + default: + return undefined; + } +} diff --git a/packages/sdk-generator/src/backends/python/typeddict-emitter.ts b/packages/sdk-generator/src/backends/python/typeddict-emitter.ts index 19ad682..683b446 100644 --- a/packages/sdk-generator/src/backends/python/typeddict-emitter.ts +++ b/packages/sdk-generator/src/backends/python/typeddict-emitter.ts @@ -1,7 +1,7 @@ import type { FieldDef, ParamDef, TypeRef } from "../../ast/types.js"; import type { CodeBuilder } from "../../utils/codegen.js"; import { isValidPythonIdentifier } from "./identifiers.js"; -import { typeRefToPython } from "./pydantic-emitter.js"; +import { primitiveMapsToAny, typeRefToPython } from "./pydantic-emitter.js"; /** * Emit a single TypedDict class for an inline JSON body / channel payload. @@ -166,5 +166,12 @@ function collectTypingFromTypeRef(ref: TypeRef, imports: Set): void { case "map": collectTypingFromTypeRef(ref.valueType, imports); break; + case "object": + case "unknown": + imports.add("Any"); + break; + case "primitive": + if (primitiveMapsToAny(ref.type)) imports.add("Any"); + break; } }