diff --git a/.github/workflows/product-site.yml b/.github/workflows/product-site.yml index f463aa189..5ee0cb5fa 100644 --- a/.github/workflows/product-site.yml +++ b/.github/workflows/product-site.yml @@ -15,6 +15,7 @@ on: - 'examples/openai_api/**/*.md' - 'scripts/gen_api_docs.py' - 'tests/test_*docs*.py' + - 'tests/test_api_signatures.py' - 'gh-pages-output/**' - 'web-pages/product-site/**' - 'web-pages/scripts/**' @@ -35,6 +36,7 @@ on: - 'examples/openai_api/**/*.md' - 'scripts/gen_api_docs.py' - 'tests/test_*docs*.py' + - 'tests/test_api_signatures.py' - 'gh-pages-output/**' - 'web-pages/product-site/**' - 'web-pages/scripts/**' @@ -89,6 +91,7 @@ jobs: tests/test_python_api_docs_contract.py tests/test_service_docs_contract.py tests/test_generated_api_docs.py + tests/test_api_signatures.py tests/test_docs_funasr_install_commands.py tests/test_moss_transcribe_diarize_docs.py -k 'not no_download_toy_against_checkout and not browser_navigation_and_responsive_overflow and not skip_link_preserves_selection_playwright' -q diff --git a/scripts/gen_api_docs.py b/scripts/gen_api_docs.py index c01abe971..9b8aa80f8 100644 --- a/scripts/gen_api_docs.py +++ b/scripts/gen_api_docs.py @@ -26,7 +26,10 @@ STYLESHEET = REPO_ROOT / "gh-pages-output" / "api-reference.css" os.chdir(REPO_ROOT) tree_structure = { - "funasr.auto": {"auto_model": "funasr/auto/auto_model.py"}, + "funasr.auto": { + "auto_model": "funasr/auto/auto_model.py", + "auto_model_vllm": "funasr/auto/auto_model_vllm.py", + }, "funasr.register": {"register": "funasr/register.py"}, "funasr.models": {}, "funasr.frontends": {}, @@ -115,6 +118,43 @@ def get_source_lines(filepath, node): def github_url(filepath, lineno): return f"{REPO_URL}/blob/{BRANCH}/{filepath}#L{lineno}" +def callable_signature(node, source, bound=False, constructor=False): + """Render source expressions without importing code or evaluating defaults.""" + def expression(value): + return ast.get_source_segment(source, value).strip() + + def argument(value, default=None, prefix=""): + result = prefix + value.arg + if value.annotation is not None: + result += ": " + expression(value.annotation) + if default is not None: + result += (" = " if value.annotation is not None else "=") + expression(default) + return result + + args = node.args + positional = args.posonlyargs + args.args + defaults = [None] * (len(positional) - len(args.defaults)) + list(args.defaults) + static = any(isinstance(d, ast.Name) and d.id == "staticmethod" + for d in node.decorator_list) + start = 1 if bound and not static and positional else 0 + parts = [] + for index in range(start, len(positional)): + parts.append(argument(positional[index], defaults[index])) + if index + 1 == len(args.posonlyargs): + parts.append("/") + if args.vararg is not None: + parts.append(argument(args.vararg, prefix="*")) + elif args.kwonlyargs: + parts.append("*") + parts.extend(argument(value, default) for value, default + in zip(args.kwonlyargs, args.kw_defaults)) + if args.kwarg is not None: + parts.append(argument(args.kwarg, prefix="**")) + signature = "(" + ", ".join(parts) + ")" + if node.returns is not None and not constructor: + signature += " -> " + expression(node.returns) + return signature + # Extract all data all_data = {} for top_level, sub_modules in tree_structure.items(): @@ -136,27 +176,30 @@ for top_level, sub_modules in tree_structure.items(): class_doc = ast.get_docstring(node) or "" src, lineno, trunc = get_source_lines(filepath, node) methods = [] + constructor = None for item in node.body: if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)): + if item.name == "__init__": + constructor = { + "signature": callable_signature(item, source, bound=True, constructor=True), + "doc": ast.get_docstring(item) or "", + "lineno": item.lineno, + } if item.name.startswith("_"): continue mdoc = ast.get_docstring(item) or "" - args = [a.arg for a in item.args.args if a.arg != "self"] - if item.args.vararg: args.append("*" + item.args.vararg.arg) - if item.args.kwarg: args.append("**" + item.args.kwarg.arg) msrc, mline, mtrunc = get_source_lines(filepath, item) - methods.append({"name": item.name, "args": args, "doc": mdoc, + methods.append({"name": item.name, + "signature": callable_signature(item, source, bound=True), "doc": mdoc, "source": msrc, "lineno": mline, "truncated": mtrunc}) entries.append({"type": "class", "name": node.name, "doc": class_doc, - "methods": methods, "source": src, "lineno": lineno, + "methods": methods, "constructor": constructor, "source": src, "lineno": lineno, "truncated": trunc, "filepath": filepath}) elif isinstance(node, ast.FunctionDef) and not node.name.startswith("_"): fdoc = ast.get_docstring(node) or "" - args = [a.arg for a in node.args.args] - if node.args.vararg: args.append("*" + node.args.vararg.arg) - if node.args.kwarg: args.append("**" + node.args.kwarg.arg) fsrc, fline, ftrunc = get_source_lines(filepath, node) - entries.append({"type": "function", "name": node.name, "args": args, + entries.append({"type": "function", "name": node.name, + "signature": callable_signature(node, source), "doc": fdoc, "source": fsrc, "lineno": fline, "truncated": ftrunc, "filepath": filepath}) if entries: @@ -169,7 +212,20 @@ print(f"Extracted: {len(all_data)} modules, {total_entries} entries") sidebar = "" content = "" overview_links = "" +# Allocate newly documented vLLM entries after legacy entries, even though their +# navigation belongs beside AutoModel. Existing numeric deep links stay stable. +new_module = "funasr.auto.auto_model_vllm" +anchor_modules = [key for key in all_data if key != new_module] +if new_module in all_data: + anchor_modules.append(new_module) eid = 0 +for key in anchor_modules: + for entry in all_data[key]: + eid += 1 + entry["anchor"] = f"e{eid}" + for method in entry.get("methods", []): + eid += 1 + method["anchor"] = f"e{eid}" for group_index, top_level in enumerate(tree_structure): sub_modules = tree_structure[top_level] @@ -182,23 +238,30 @@ for group_index, top_level in enumerate(tree_structure): module_id = f"{group_id}-module-{module_index}" sidebar += f'
' + esc(entry["name"] + entry["constructor"]["signature"]) + ''
+ content += f'{esc(entry["source"])}'
if entry["truncated"]:
content += f'View full source on GitHub →'
content += '{esc(sig)} L{m["lineno"]}{esc(m["source"])}'
@@ -207,10 +270,9 @@ for group_index, top_level in enumerate(tree_structure):
content += '{esc(sig)}{esc(m["source"])}'
@@ -218,10 +280,10 @@ for group_index, top_level in enumerate(tree_structure):
content += f'View full source on GitHub →'
content += '{esc(entry["name"])}({esc(args_str)}){esc(signature)}{esc(entry["source"])}'
if entry["truncated"]:
content += f'View full source on GitHub →'
diff --git a/tests/test_api_signatures.py b/tests/test_api_signatures.py
new file mode 100644
index 000000000..d03037b7b
--- /dev/null
+++ b/tests/test_api_signatures.py
@@ -0,0 +1,118 @@
+"""Source-only API signatures must describe the callable without importing it."""
+
+import html
+from pathlib import Path
+import re
+import shutil
+import subprocess
+import sys
+import tempfile
+import unittest
+
+
+ROOT = Path(__file__).resolve().parents[1]
+FIXTURE = '''raise RuntimeError("Documentation must never import this module")
+
+class Example:
+ def __init__(self, model: str, *, device: str = "cpu"):
+ """Construct without loading weights in the documentation builder."""
+
+ def generate(self, input, input_len=None, progress_callback=None, **cfg):
+ pass
+
+ def typed(this, value: "Tensor", /, limit: int = 3, *items: str,
+ required: bool, label: str = "