diff --git a/.chronus/changes/fix-python-no-sdk-clients-suppression-2026-09-02.md b/.chronus/changes/fix-python-no-sdk-clients-suppression-2026-09-02.md new file mode 100644 index 00000000000..fdde2184628 --- /dev/null +++ b/.chronus/changes/fix-python-no-sdk-clients-suppression-2026-09-02.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/http-client-python" +--- + +Allow `no-sdk-clients` warnings to be suppressed by reporting them on the TypeSpec service namespace. \ No newline at end of file diff --git a/packages/http-client-python/emitter/src/emitter.ts b/packages/http-client-python/emitter/src/emitter.ts index ab9bd654a08..c68517f99f4 100644 --- a/packages/http-client-python/emitter/src/emitter.ts +++ b/packages/http-client-python/emitter/src/emitter.ts @@ -1,6 +1,6 @@ import { createSdkContext } from "@azure-tools/typespec-client-generator-core"; import type { EmitContext } from "@typespec/compiler"; -import { emitFile, joinPaths, NoTarget } from "@typespec/compiler"; +import { emitFile, joinPaths, listServices, NoTarget } from "@typespec/compiler"; import pkgJson from "../../package.json" with { type: "json" }; import { emitCodeModel } from "./code-model.js"; import { @@ -181,13 +181,15 @@ async function onEmitMain(context: EmitContext) { const yamlMap = emitCodeModel(sdkContext); const parsedYamlMap = walkThroughNodes(yamlMap); - // Python emitter requires an SDK client in the TypeSpec + // Warn when no SDK clients are present, while still allowing model-only generation. if (sdkContext.sdkPackage.clients.length === 0) { reportDiagnostic(program, { code: "no-sdk-clients", - target: NoTarget, + target: + listServices(program)[0]?.type ?? + sdkContext.sdkPackage.models[0]?.__raw ?? + program.getGlobalNamespaceType(), }); - return; } const resolvedOptions = sdkContext.emitContext.options; diff --git a/packages/http-client-python/emitter/src/lib.ts b/packages/http-client-python/emitter/src/lib.ts index 047db866e28..0ee8f1bf41f 100644 --- a/packages/http-client-python/emitter/src/lib.ts +++ b/packages/http-client-python/emitter/src/lib.ts @@ -180,10 +180,10 @@ const libDef = { }, }, "no-sdk-clients": { - severity: "error", + severity: "warning", messages: { default: - "The Python emitter did not find any SDK clients in this TypeSpec program. The current Python generator expects at least one client/service to generate code.", + "The Python emitter did not find any SDK clients in this TypeSpec program. No client code will be generated (models can still be emitted). Suppress this warning if this is expected.", }, }, "browser-runtime-load-failed": { diff --git a/packages/http-client-python/emitter/test/emitter.test.ts b/packages/http-client-python/emitter/test/emitter.test.ts new file mode 100644 index 00000000000..ecb2081d5a1 --- /dev/null +++ b/packages/http-client-python/emitter/test/emitter.test.ts @@ -0,0 +1,32 @@ +import { expectDiagnostics, t } from "@typespec/compiler/testing"; +import { expect, it } from "vitest"; +import { emitCodeModel } from "./test-host.js"; + +it("targets the service namespace when no SDK clients are found", async () => { + const { diagnostics } = await emitCodeModel(t.code` + #suppress "@typespec/http-client-python/no-sdk-clients" "This service intentionally has no client." + @service namespace ${t.namespace("Service")} {} + `); + + expectDiagnostics(diagnostics, []); +}); + +it("generates models when no service exists", async () => { + const { codeModel, diagnostics } = await emitCodeModel(` + import "@azure-tools/typespec-client-generator-core"; + using Azure.ClientGenerator.Core; + + #suppress "@typespec/http-client-python/no-sdk-clients" "This model-only package intentionally has no client." + @access(Access.public) + @usage(Usage.input | Usage.output) + @clientNamespace("Models") + model Widget {} + `); + + expectDiagnostics(diagnostics, []); + // A model-only package has no clients but must still emit its models into the code model. + expect(codeModel.clients).toHaveLength(0); + expect(codeModel.types.some((type) => type.type === "model" && type.name === "Widget")).toBe( + true, + ); +}); diff --git a/packages/http-client-python/emitter/test/test-host.ts b/packages/http-client-python/emitter/test/test-host.ts new file mode 100644 index 00000000000..07a762f578d --- /dev/null +++ b/packages/http-client-python/emitter/test/test-host.ts @@ -0,0 +1,83 @@ +import type { Diagnostic } from "@typespec/compiler"; +import { resolvePath } from "@typespec/compiler"; +import { createTester, mockFile } from "@typespec/compiler/testing"; +import { mkdtemp, readdir, readFile, rm } from "fs/promises"; +import jsyaml from "js-yaml"; +import { tmpdir } from "os"; +import { join } from "path"; +import { $onEmit } from "../src/emitter.js"; + +const PythonTester = createTester(resolvePath(import.meta.dirname, "../.."), { + libraries: ["@azure-tools/typespec-client-generator-core"], +}); + +export const Tester = PythonTester; + +// `@typespec/http-client-python` is intentionally excluded from the pnpm workspace, so it isn't +// linked into its own `node_modules` and `.emit()` can't resolve it by name. Register a minimal +// virtual package whose entrypoint re-exports the real `$onEmit`, so the emitter under test runs. +export const EmitterTester = PythonTester.files({ + "node_modules/@typespec/http-client-python/package.json": JSON.stringify({ + name: "@typespec/http-client-python", + version: "0.0.0", + exports: { ".": "./index.js" }, + }), + "node_modules/@typespec/http-client-python/index.js": mockFile.js({ + $onEmit, + }), +}).emit("@typespec/http-client-python", { + "generate-packaging-files": false, +}); + +/** A type entry in the emitted code model. */ +export interface CodeModelType { + type: string; + name?: string; +} + +/** The subset of the emitted code model that the emitter tests assert against. */ +export interface CodeModel { + clients: unknown[]; + types: CodeModelType[]; +} + +/** + * Run the emitter's TypeScript step only (via `emit-yaml-only`) and return the code model it + * produced along with any diagnostics. This stops right after `emitCodeModel`, so it exercises the + * real emitter logic that decides what gets generated without booting the Python/Pyodide generator. + */ +export async function emitCodeModel( + code: string, +): Promise<{ codeModel: CodeModel; diagnostics: readonly Diagnostic[] }> { + const outputDir = await mkdtemp(join(tmpdir(), "typespec-python-")); + let yamlPath: string | undefined; + try { + const [, diagnostics] = await EmitterTester.compileAndDiagnose(code, { + compilerOptions: { + options: { + "@typespec/http-client-python": { + "emit-yaml-only": true, + "emitter-output-dir": outputDir, + }, + }, + }, + }); + + // `emit-yaml-only` writes a `.tsp-codegen-*.json` pointer into the output dir that references + // the serialized code model YAML written to the OS temp dir. + const pointerName = (await readdir(outputDir)).find( + (name) => name.startsWith(".tsp-codegen-") && name.endsWith(".json"), + ); + if (!pointerName) { + throw new Error("Emitter did not produce a code model."); + } + ({ yamlPath } = JSON.parse(await readFile(join(outputDir, pointerName), "utf-8"))); + const codeModel = jsyaml.load(await readFile(yamlPath!, "utf-8")) as CodeModel; + return { codeModel, diagnostics }; + } finally { + await rm(outputDir, { recursive: true, force: true }); + if (yamlPath) { + await rm(yamlPath, { force: true }); + } + } +}