Skip to content
Draft
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
14 changes: 14 additions & 0 deletions .chronus/changes/feat-python-api-md-2026-6-2-16-31-0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
changeKind: feature
packages:
- "@typespec/http-client-python"
---

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.

```yaml
# tspconfig.yaml
options:
"@typespec/http-client-python":
generate-api-md: true
```
71 changes: 70 additions & 1 deletion packages/http-client-python/emitter/src/emitter.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createSdkContext } from "@azure-tools/typespec-client-generator-core";
import { EmitContext, emitFile, joinPaths, NoTarget } from "@typespec/compiler";
import { execSync } from "child_process";
import { execFileSync, execSync } from "child_process";
import { randomUUID } from "crypto";
import fs from "fs";
import jsyaml from "js-yaml";
import os from "os";
Expand Down Expand Up @@ -29,6 +30,7 @@ function addDefaultOptions(sdkContext: PythonSdkContext) {
const defaultOptions = {
"package-version": "1.0.0b1",
"generate-packaging-files": true,
"generate-api-md": true,
"validate-versioning": true,
"clear-output-folder": false,
};
Expand Down Expand Up @@ -330,11 +332,78 @@ async function onEmitMain(context: EmitContext<PythonEmitterOptions>) {
`${venvPath} -m black --line-length=120 --quiet --fast ${outputDir} --exclude "${excludePattern}"`,
);
await checkForPylintIssues(outputDir, excludePattern);

if (resolvedOptions["generate-api-md"]) {
await generateApiMd(
program,
venvPath,
yamlPath,
outputDir,
root,
resolvedOptions["package-name"]!,
);
}
}
}
}
}

async function generateApiMd(
program: any,
venvPath: string,
yamlPath: string,
outputDir: string,
root: string,
packageName: string,
): Promise<void> {
// Check if apiview-stub-generator is installed before attempting
try {
execFileSync(venvPath, ["-c", "import apistub"], { stdio: "pipe" });
} catch {
// apiview-stub-generator not installed — silently skip
return;
}

const apiviewOutDir = path.join(os.tmpdir(), `tsp-apiview-${randomUUID()}`);
try {
fs.mkdirSync(apiviewOutDir, { recursive: true });

// Run apistubgen with --code-model-path to generate token JSON
execFileSync(venvPath, [
"-m",
"apistub",
"--code-model-path",
yamlPath,
"--out-path",
apiviewOutDir,
"--skip-pylint",
]);

// apistubgen outputs {package_name}_python.json
const tokenJsonPath = path.join(apiviewOutDir, `${packageName}_python.json`);
if (!fs.existsSync(tokenJsonPath)) {
reportDiagnostic(program, {
code: "api-md-generation-failed",
target: NoTarget,
format: { details: "apistubgen did not produce a token JSON file" },
});
return;
Copy link
Copy Markdown
Contributor

@msyyc msyyc Jun 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better to add test cases for the new option.

}

// Convert token JSON to api.md using the Python conversion script
const mdScript = path.join(root, "eng", "scripts", "setup", "export_apiview_markdown.py");
execFileSync(venvPath, [mdScript, tokenJsonPath, outputDir]);
} catch (e: any) {
reportDiagnostic(program, {
code: "api-md-generation-failed",
target: NoTarget,
format: { details: e.message || String(e) },
});
} finally {
fs.rmSync(apiviewOutDir, { recursive: true, force: true });
}
}

const browserPyodidePromise: Promise<PyodideInterface> | null =
typeof window !== "undefined" ? setupPyodideCallBrowser() : null;

Expand Down
13 changes: 13 additions & 0 deletions packages/http-client-python/emitter/src/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export interface PythonEmitterOptions {
"keep-setup-py"?: boolean;
"clear-output-folder"?: boolean;
"emit-yaml-only"?: boolean;
"generate-api-md"?: boolean;
}

export interface PythonSdkContext extends SdkContext<PythonEmitterOptions> {
Expand Down Expand Up @@ -117,6 +118,12 @@ export const PythonEmitterOptionsSchema: JSONSchemaType<PythonEmitterOptions> =
description:
"Emit YAML code model only, without running Python generator. For batch processing.",
},
"generate-api-md": {
type: "boolean",
nullable: true,
description:
"Whether to generate an api.md file containing the public API surface. Defaults to `true`. Requires `apiview-stub-generator` to be installed.",
},
},
required: [],
};
Expand Down Expand Up @@ -175,6 +182,12 @@ const libDef = {
default: paramMessage`No valid continuation token in '${"direction"}' for operation '${"operationId"}'.`,
},
},
"api-md-generation-failed": {
severity: "warning",
messages: {
default: paramMessage`Failed to generate api.md: ${"details"}. SDK generation was not affected.`,
},
},
},
emitter: {
options: PythonEmitterOptionsSchema,
Expand Down
148 changes: 148 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,148 @@
import { ok, strictEqual } from "assert";
import { execFileSync } from "child_process";
import fs from "fs";
import os from "os";
import path from "path";
import { afterEach, beforeEach, describe, it } from "vitest";

describe("export_apiview_markdown.py", () => {
const root = path.resolve(import.meta.dirname, "../..");
const scriptPath = path.join(root, "eng/scripts/setup/export_apiview_markdown.py");
let tmpDir: string;

beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "apimd-test-"));
});

afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});

function writeTokenJson(data: object): string {
const tokenPath = path.join(tmpDir, "token.json");
fs.writeFileSync(tokenPath, JSON.stringify(data));
return tokenPath;
}

it("generates api.md from a simple token file", () => {
const tokenPath = writeTokenJson({
Language: "Python",
ReviewLines: [
{
Tokens: [
{ Value: "class", HasSuffixSpace: true },
{ Value: "MyClient", HasPrefixSpace: false },
],
},
{
Tokens: [
{ Value: "def", HasSuffixSpace: true },
{ Value: "send(self)", HasPrefixSpace: false },
],
Children: [
{
Tokens: [{ Value: "..." }],
},
],
},
],
});

const outDir = path.join(tmpDir, "output");
fs.mkdirSync(outDir);
execFileSync("python3", [scriptPath, tokenPath, outDir]);

const apiMd = fs.readFileSync(path.join(outDir, "api.md"), "utf-8");
ok(apiMd.startsWith("```py"), "Should start with python code fence");
ok(apiMd.includes("class"), "Should contain class token");
ok(apiMd.includes("MyClient"), "Should contain MyClient token");
ok(apiMd.endsWith("```"), "Should end with code fence");
});

it("writes api.md directly when output path is a .md file", () => {
const tokenPath = writeTokenJson({
Language: "Python",
ReviewLines: [{ Tokens: [{ Value: "class Foo" }] }],
});

const outFile = path.join(tmpDir, "custom.md");
execFileSync("python3", [scriptPath, tokenPath, outFile]);
ok(fs.existsSync(outFile), "Should write to the specified .md file");
const content = fs.readFileSync(outFile, "utf-8");
ok(content.includes("class Foo"));
});

it("exits with error for empty ReviewLines", () => {
const tokenPath = writeTokenJson({
Language: "Python",
ReviewLines: [],
});

const outDir = path.join(tmpDir, "output");
fs.mkdirSync(outDir);
// Empty ReviewLines is treated as missing by the script
let threw = false;
try {
execFileSync("python3", [scriptPath, tokenPath, outDir], { stdio: "pipe" });
} catch {
threw = true;
}
ok(threw, "Should exit with error for empty ReviewLines");
});

it("resolves language aliases correctly", () => {
const tokenPath = writeTokenJson({
Language: "JavaScript",
ReviewLines: [{ Tokens: [{ Value: "function foo() {}" }] }],
});

const outDir = path.join(tmpDir, "output");
fs.mkdirSync(outDir);
execFileSync("python3", [scriptPath, tokenPath, outDir]);

const apiMd = fs.readFileSync(path.join(outDir, "api.md"), "utf-8");
ok(apiMd.startsWith("```js"), "Should use 'js' alias for JavaScript");
});

it("renders nested children with indentation", () => {
const tokenPath = writeTokenJson({
Language: "Python",
ReviewLines: [
{
Tokens: [{ Value: "class Foo:" }],
Children: [
{
Tokens: [{ Value: "def bar(self):" }],
Children: [{ Tokens: [{ Value: "pass" }] }],
},
],
},
],
});

const outDir = path.join(tmpDir, "output");
fs.mkdirSync(outDir);
execFileSync("python3", [scriptPath, tokenPath, outDir]);

const apiMd = fs.readFileSync(path.join(outDir, "api.md"), "utf-8");
const lines = apiMd.split("\n");
// Children should be indented
ok(
lines.some((l: string) => l.startsWith(" ") && l.includes("def bar")),
"First-level children should have 4-space indent",
);
ok(
lines.some((l: string) => l.startsWith(" ") && l.includes("pass")),
"Second-level children should have 8-space indent",
);
});
});

describe("generateApiMd token file lookup", () => {
it("expected token filename follows {package_name}_python.json pattern", () => {
// Verify the naming convention used by apistubgen
const packageName = "azure-ai-inference";
const expectedFilename = `${packageName}_python.json`;
strictEqual(expectedFilename, "azure-ai-inference_python.json");
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
#!/usr/bin/env python
# -------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for
# license information.
# --------------------------------------------------------------------------
"""Convert an APIView token JSON file to a markdown file.
This is a Python port of Export-APIViewMarkdown.ps1 from azure-sdk-tools,
so that api.md generation does not require PowerShell to be installed.
"""

import json
import os
import sys


LANGUAGE_ALIASES = {
"python": "py",
"javascript": "js",
"typescript": "ts",
}


def render_token(token):
prefix = " " if token.get("HasPrefixSpace") else ""
suffix = " " if token.get("HasSuffixSpace") else ""
return f"{prefix}{token.get('Value', '')}{suffix}"


def render_review_lines(review_lines, indent_level=0):
result = []
indent = " " * indent_level

for line in review_lines:
tokens = line.get("Tokens", [])
if not tokens:
result.append("")
else:
line_text = "".join(render_token(t) for t in tokens)
if line_text.strip():
result.append(f"{indent}{line_text}")
else:
result.append("")

children = line.get("Children")
if children:
child_lines = render_review_lines(children, indent_level + 1)
result.extend(child_lines)

return result


def main():
if len(sys.argv) < 3:
print("Usage: export_apiview_markdown.py <token_json_path> <output_path>", file=sys.stderr)
sys.exit(1)

token_json_path = sys.argv[1]
output_path = sys.argv[2]

if not os.path.exists(token_json_path):
print(f"Token JSON file not found: {token_json_path}", file=sys.stderr)
sys.exit(1)

with open(token_json_path, "r", encoding="utf-8") as f:
token_json = json.load(f)

review_lines = token_json.get("ReviewLines")
if not review_lines:
print("The token JSON file does not contain a 'ReviewLines' property.", file=sys.stderr)
sys.exit(1)

# Resolve output path
if os.path.isdir(output_path):
output_path = os.path.join(output_path, "api.md")
elif not os.path.splitext(output_path)[1]:
output_path = os.path.join(output_path, "api.md")

# Get language for code fence
language = (token_json.get("Language") or "").lower()
language = LANGUAGE_ALIASES.get(language, language)

rendered_lines = render_review_lines(review_lines)

output_lines = [f"```{language}"]
output_lines.extend(rendered_lines)
output_lines.append("```")

output_dir = os.path.dirname(output_path)
if output_dir and not os.path.exists(output_dir):
os.makedirs(output_dir, exist_ok=True)

with open(output_path, "w", encoding="utf-8", newline="\n") as f:
f.write("\n".join(output_lines))

print(f"Generated markdown: {output_path}")


if __name__ == "__main__":
main()
Loading
Loading