Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
55 changes: 54 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 @@ -330,11 +331,63 @@ 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);
}
}
}
}
}

async function generateApiMd(
program: any,
venvPath: string,
yamlPath: string,
outputDir: string,
root: string,
): Promise<void> {
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",
]);

// Find the generated token JSON file
const tokenFiles = fs.readdirSync(apiviewOutDir).filter((f: string) => f.endsWith(".json"));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Does apiview tool generate a stable name like apiview_python.json? If yes, we could check that name directly to avoid find wrong json file.

if (tokenFiles.length === 0) {
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 tokenJsonPath = path.join(apiviewOutDir, tokenFiles[0]);
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 `false`. 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
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