Skip to content

Commit ea96ce0

Browse files
iscai-msftCopilot
andcommitted
fix: address PR review comments for api.md generation
- Use stable token filename ({package_name}_python.json) instead of scanning for any .json file in the output directory - Add emitter tests for export_apiview_markdown.py script - Add changeset for the feature Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 06519bc commit ea96ce0

3 files changed

Lines changed: 174 additions & 5 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
changeKind: feature
3+
packages:
4+
- "@typespec/http-client-python"
5+
---
6+
7+
Add `generate-api-md` emitter option to generate an `api.md` file containing the public API surface. When enabled, the emitter runs `apiview-stub-generator` to produce a token JSON file and converts it to markdown. Requires `apiview-stub-generator` to be installed in the Python environment.
8+
9+
```yaml
10+
# tspconfig.yaml
11+
options:
12+
"@typespec/http-client-python":
13+
generate-api-md: true
14+
```

packages/http-client-python/emitter/src/emitter.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -333,7 +333,14 @@ async function onEmitMain(context: EmitContext<PythonEmitterOptions>) {
333333
await checkForPylintIssues(outputDir, excludePattern);
334334

335335
if (resolvedOptions["generate-api-md"]) {
336-
await generateApiMd(program, venvPath, yamlPath, outputDir, root);
336+
await generateApiMd(
337+
program,
338+
venvPath,
339+
yamlPath,
340+
outputDir,
341+
root,
342+
resolvedOptions["package-name"]!,
343+
);
337344
}
338345
}
339346
}
@@ -346,6 +353,7 @@ async function generateApiMd(
346353
yamlPath: string,
347354
outputDir: string,
348355
root: string,
356+
packageName: string,
349357
): Promise<void> {
350358
const apiviewOutDir = path.join(os.tmpdir(), `tsp-apiview-${randomUUID()}`);
351359
try {
@@ -362,9 +370,9 @@ async function generateApiMd(
362370
"--skip-pylint",
363371
]);
364372

365-
// Find the generated token JSON file
366-
const tokenFiles = fs.readdirSync(apiviewOutDir).filter((f: string) => f.endsWith(".json"));
367-
if (tokenFiles.length === 0) {
373+
// apistubgen outputs {package_name}_python.json
374+
const tokenJsonPath = path.join(apiviewOutDir, `${packageName}_python.json`);
375+
if (!fs.existsSync(tokenJsonPath)) {
368376
reportDiagnostic(program, {
369377
code: "api-md-generation-failed",
370378
target: NoTarget,
@@ -374,7 +382,6 @@ async function generateApiMd(
374382
}
375383

376384
// Convert token JSON to api.md using the Python conversion script
377-
const tokenJsonPath = path.join(apiviewOutDir, tokenFiles[0]);
378385
const mdScript = path.join(root, "eng", "scripts", "setup", "export_apiview_markdown.py");
379386
execFileSync(venvPath, [mdScript, tokenJsonPath, outputDir]);
380387
} catch (e: any) {
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { ok, strictEqual } from "assert";
2+
import { execFileSync } from "child_process";
3+
import fs from "fs";
4+
import os from "os";
5+
import path from "path";
6+
import { afterEach, beforeEach, describe, it } from "vitest";
7+
8+
describe("export_apiview_markdown.py", () => {
9+
const root = path.resolve(import.meta.dirname, "../..");
10+
const scriptPath = path.join(root, "eng/scripts/setup/export_apiview_markdown.py");
11+
let tmpDir: string;
12+
13+
beforeEach(() => {
14+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "apimd-test-"));
15+
});
16+
17+
afterEach(() => {
18+
fs.rmSync(tmpDir, { recursive: true, force: true });
19+
});
20+
21+
function writeTokenJson(data: object): string {
22+
const tokenPath = path.join(tmpDir, "token.json");
23+
fs.writeFileSync(tokenPath, JSON.stringify(data));
24+
return tokenPath;
25+
}
26+
27+
it("generates api.md from a simple token file", () => {
28+
const tokenPath = writeTokenJson({
29+
Language: "Python",
30+
ReviewLines: [
31+
{
32+
Tokens: [
33+
{ Value: "class", HasSuffixSpace: true },
34+
{ Value: "MyClient", HasPrefixSpace: false },
35+
],
36+
},
37+
{
38+
Tokens: [
39+
{ Value: "def", HasSuffixSpace: true },
40+
{ Value: "send(self)", HasPrefixSpace: false },
41+
],
42+
Children: [
43+
{
44+
Tokens: [{ Value: "..." }],
45+
},
46+
],
47+
},
48+
],
49+
});
50+
51+
const outDir = path.join(tmpDir, "output");
52+
fs.mkdirSync(outDir);
53+
execFileSync("python3", [scriptPath, tokenPath, outDir]);
54+
55+
const apiMd = fs.readFileSync(path.join(outDir, "api.md"), "utf-8");
56+
ok(apiMd.startsWith("```py"), "Should start with python code fence");
57+
ok(apiMd.includes("class"), "Should contain class token");
58+
ok(apiMd.includes("MyClient"), "Should contain MyClient token");
59+
ok(apiMd.endsWith("```"), "Should end with code fence");
60+
});
61+
62+
it("writes api.md directly when output path is a .md file", () => {
63+
const tokenPath = writeTokenJson({
64+
Language: "Python",
65+
ReviewLines: [{ Tokens: [{ Value: "class Foo" }] }],
66+
});
67+
68+
const outFile = path.join(tmpDir, "custom.md");
69+
execFileSync("python3", [scriptPath, tokenPath, outFile]);
70+
ok(fs.existsSync(outFile), "Should write to the specified .md file");
71+
const content = fs.readFileSync(outFile, "utf-8");
72+
ok(content.includes("class Foo"));
73+
});
74+
75+
it("exits with error for empty ReviewLines", () => {
76+
const tokenPath = writeTokenJson({
77+
Language: "Python",
78+
ReviewLines: [],
79+
});
80+
81+
const outDir = path.join(tmpDir, "output");
82+
fs.mkdirSync(outDir);
83+
// Empty ReviewLines is treated as missing by the script
84+
let threw = false;
85+
try {
86+
execFileSync("python3", [scriptPath, tokenPath, outDir], { stdio: "pipe" });
87+
} catch {
88+
threw = true;
89+
}
90+
ok(threw, "Should exit with error for empty ReviewLines");
91+
});
92+
93+
it("resolves language aliases correctly", () => {
94+
const tokenPath = writeTokenJson({
95+
Language: "JavaScript",
96+
ReviewLines: [{ Tokens: [{ Value: "function foo() {}" }] }],
97+
});
98+
99+
const outDir = path.join(tmpDir, "output");
100+
fs.mkdirSync(outDir);
101+
execFileSync("python3", [scriptPath, tokenPath, outDir]);
102+
103+
const apiMd = fs.readFileSync(path.join(outDir, "api.md"), "utf-8");
104+
ok(apiMd.startsWith("```js"), "Should use 'js' alias for JavaScript");
105+
});
106+
107+
it("renders nested children with indentation", () => {
108+
const tokenPath = writeTokenJson({
109+
Language: "Python",
110+
ReviewLines: [
111+
{
112+
Tokens: [{ Value: "class Foo:" }],
113+
Children: [
114+
{
115+
Tokens: [{ Value: "def bar(self):" }],
116+
Children: [{ Tokens: [{ Value: "pass" }] }],
117+
},
118+
],
119+
},
120+
],
121+
});
122+
123+
const outDir = path.join(tmpDir, "output");
124+
fs.mkdirSync(outDir);
125+
execFileSync("python3", [scriptPath, tokenPath, outDir]);
126+
127+
const apiMd = fs.readFileSync(path.join(outDir, "api.md"), "utf-8");
128+
const lines = apiMd.split("\n");
129+
// Children should be indented
130+
ok(
131+
lines.some((l: string) => l.startsWith(" ") && l.includes("def bar")),
132+
"First-level children should have 4-space indent",
133+
);
134+
ok(
135+
lines.some((l: string) => l.startsWith(" ") && l.includes("pass")),
136+
"Second-level children should have 8-space indent",
137+
);
138+
});
139+
});
140+
141+
describe("generateApiMd token file lookup", () => {
142+
it("expected token filename follows {package_name}_python.json pattern", () => {
143+
// Verify the naming convention used by apistubgen
144+
const packageName = "azure-ai-inference";
145+
const expectedFilename = `${packageName}_python.json`;
146+
strictEqual(expectedFilename, "azure-ai-inference_python.json");
147+
});
148+
});

0 commit comments

Comments
 (0)