Skip to content
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 6 additions & 4 deletions packages/http-client-python/emitter/src/emitter.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -181,13 +181,15 @@ async function onEmitMain(context: EmitContext<PythonEmitterOptions>) {
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) {
Comment thread
msyyc marked this conversation as resolved.
reportDiagnostic(program, {
code: "no-sdk-clients",
target: NoTarget,
target:
listServices(program)[0]?.type ??
sdkContext.sdkPackage.models[0]?.__raw ??
program.getGlobalNamespaceType(),
});
Comment thread
iscai-msft marked this conversation as resolved.
return;
}

const resolvedOptions = sdkContext.emitContext.options;
Expand Down
4 changes: 2 additions & 2 deletions packages/http-client-python/emitter/src/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
32 changes: 32 additions & 0 deletions packages/http-client-python/emitter/test/emitter.test.ts
Original file line number Diff line number Diff line change
@@ -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,
);
});
83 changes: 83 additions & 0 deletions packages/http-client-python/emitter/test/test-host.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
}