From abffaa59f67975aa9b69ce87fd9027430dc92b9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=B8=E9=9B=81?= Date: Thu, 21 May 2026 11:31:42 +0000 Subject: [PATCH] ci: add GitHub Actions workflow for auto-updating API docs on code changes - scripts/gen_api_docs.py: generates API HTML with source code + GitHub links - .github/workflows/update-api-docs.yml: triggers on funasr/**/*.py changes - Auto-deploys to gh-pages branch --- .github/workflows/update-api-docs.yml | 30 +++ scripts/gen_api_docs.py | 307 ++++++++++++++++++++++++++ 2 files changed, 337 insertions(+) create mode 100644 .github/workflows/update-api-docs.yml create mode 100644 scripts/gen_api_docs.py diff --git a/.github/workflows/update-api-docs.yml b/.github/workflows/update-api-docs.yml new file mode 100644 index 000000000..2b32aaf4b --- /dev/null +++ b/.github/workflows/update-api-docs.yml @@ -0,0 +1,30 @@ +name: Update API Documentation + +on: + push: + branches: [main] + paths: + - 'funasr/**/*.py' + +jobs: + build-api-docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Generate API docs + run: python3 scripts/gen_api_docs.py + + - name: Deploy to gh-pages + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./gh-pages-output + publish_branch: gh-pages + keep_files: true diff --git a/scripts/gen_api_docs.py b/scripts/gen_api_docs.py new file mode 100644 index 000000000..bc4e6d6d6 --- /dev/null +++ b/scripts/gen_api_docs.py @@ -0,0 +1,307 @@ +#!/usr/bin/env python3 +import shutil +"""Generate API page with source code preview and GitHub links. +Run this script from the repo root on main branch. +Output: gh-pages-output/api.html for deployment to gh-pages. + +This script is designed to be run by GitHub Actions on every push to main, +automatically regenerating the API docs. +""" +import ast, os, html as htmlmod, re, glob, inspect + +REPO_URL = "https://github.com/modelscope/FunASR" +BRANCH = "main" + +os.chdir('/cpfs_speech/user/zhifu.gzf/codebase/FunASR-github') + +tree_structure = { + "funasr.auto": {"auto_model": "funasr/auto/auto_model.py"}, + "funasr.register": {"register": "funasr/register.py"}, + "funasr.models": {}, + "funasr.frontends": {}, + "funasr.tokenizer": {}, + "funasr.utils": {}, + "funasr.bin": {}, + "funasr.download": {}, + "funasr.train_utils": {}, + "funasr.datasets": {}, + "funasr.losses": {}, + "funasr.schedulers": {}, +} + +# Auto-discover +for f in sorted(glob.glob("funasr/models/*/model.py")): + if "whisper_lib" in f: + continue + tree_structure["funasr.models"][f.split("/")[-2]] = f + +for top in ["frontends", "tokenizer", "utils", "bin", "download", "train_utils", "losses", "schedulers"]: + for f in sorted(glob.glob(f"funasr/{top}/*.py")): + if "__init__" in f or "abs_" in f: + continue + tree_structure[f"funasr.{top}"][os.path.basename(f).replace(".py", "")] = f + +for f in sorted(glob.glob("funasr/datasets/*.py") + glob.glob("funasr/datasets/audio_datasets/*.py")): + if "__init__" in f or "__pycache__" in f: + continue + tree_structure["funasr.datasets"][os.path.basename(f).replace(".py", "")] = f + +tree_structure = {k: v for k, v in tree_structure.items() if v} + +SKIP_CLASSES = { + "SinusoidalPositionEncoder", "StreamSinusoidalPositionEncoder", + "PositionwiseFeedForward", "MultiHeadedAttentionSANM", "LayerNorm", + "EncoderLayerSANM", "VadStateMachine", "FrameState", "AudioChangeState", + "VadDetectMode", "VADXOptions", "E2EVadSpeechBufWithDoa", + "E2EVadFrameProb", "WindowDetector", "Stats", +} + +def esc(s): + return htmlmod.escape(s) if s else "" + +def format_doc(doc): + if not doc: + return '

No documentation yet.

' + lines = doc.strip().split("\n") + out = "" + in_list = False + for line in lines: + s = line.strip() + if s in ("Args:", "Returns:", "Raises:", "Examples:", "Note:", "Notes:", "Features:", "Models:", "Requirements:", "Output:", "Output format:"): + if in_list: out += "\n"; in_list = False + out += f'

{s}

\n' + continue + if s.startswith("- "): + if not in_list: out += '\n" + return out + +def get_source_lines(filepath, node): + """Get source code lines for an AST node.""" + with open(filepath) as f: + all_lines = f.readlines() + start = node.lineno - 1 + end = node.end_lineno if hasattr(node, 'end_lineno') and node.end_lineno else start + 20 + # Limit preview to 30 lines + preview_end = min(start + 30, end) + source = "".join(all_lines[start:preview_end]) + truncated = preview_end < end + return source, start + 1, truncated + +def github_url(filepath, lineno): + return f"{REPO_URL}/blob/{BRANCH}/{filepath}#L{lineno}" + +# Extract all data +all_data = {} +for top_level, sub_modules in tree_structure.items(): + for sub_name, filepath in sub_modules.items(): + if not os.path.exists(filepath): + continue + with open(filepath) as f: + source = f.read() + try: + tree = ast.parse(source) + except: + continue + key = f"{top_level}.{sub_name}" + entries = [] + for node in ast.iter_child_nodes(tree): + if isinstance(node, ast.ClassDef): + if node.name in SKIP_CLASSES: + continue + class_doc = ast.get_docstring(node) or "" + src, lineno, trunc = get_source_lines(filepath, node) + methods = [] + for item in node.body: + if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)): + if item.name.startswith("__") and item.name != "__init__": + 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, + "source": msrc, "lineno": mline, "truncated": mtrunc}) + entries.append({"type": "class", "name": node.name, "doc": class_doc, + "methods": methods, "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, + "doc": fdoc, "source": fsrc, "lineno": fline, + "truncated": ftrunc, "filepath": filepath}) + if entries: + all_data[key] = entries + +total_entries = sum(len(e) for e in all_data.values()) +print(f"Extracted: {len(all_data)} modules, {total_entries} entries") + +# Generate HTML +sidebar = "" +content = "" +eid = 0 + +for top_level in tree_structure: + sub_modules = tree_structure[top_level] + sidebar += f'
{esc(top_level)}{sum(1 for s in sub_modules if f"{top_level}.{s}" in all_data)}▸
\n' + for sub_name in sub_modules: + key = f"{top_level}.{sub_name}" + if key not in all_data: + continue + sidebar += f'
{esc(sub_name)}▸
\n' + for entry in all_data[key]: + eid += 1 + eid_str = f"e{eid}" + filepath = entry.get("filepath", "") + if entry["type"] == "class": + sidebar += f'C{esc(entry["name"])}\n' + gh_link = github_url(filepath, entry["lineno"]) + content += f'
class

{esc(entry["name"])}

{format_doc(entry["doc"])}
' + content += f'
📄 Source code
{esc(entry["source"])}
' + if entry["truncated"]: + content += f'View full source on GitHub →' + content += '
' + if entry["methods"]: + content += '

Methods

' + for m in entry["methods"]: + sig = f'.{m["name"]}({", ".join(m["args"])})' + mgh = github_url(filepath, m["lineno"]) + content += f'
{esc(sig)} L{m["lineno"]}
{format_doc(m["doc"])}
' + content += f'
📄 Source
{esc(m["source"])}
' + if m["truncated"]: + content += f'View full source →' + content += '
' + content += '
\n' + for m in entry["methods"]: + eid += 1 + mid = f"e{eid}" + sidebar += f'M.{esc(m["name"])}()\n' + sig = f'{entry["name"]}.{m["name"]}({", ".join(m["args"])})' + mgh = github_url(filepath, m["lineno"]) + content += f'
method

{esc(sig)}

{key}.{entry["name"]} · View on GitHub ↗
{format_doc(m["doc"])}
' + content += f'
📄 Source code
{esc(m["source"])}
' + if m["truncated"]: + content += f'View full source on GitHub →' + content += '
\n' + else: + args_str = ", ".join(entry.get("args", [])) + gh_link = github_url(filepath, entry["lineno"]) + sidebar += f'F{esc(entry["name"])}\n' + content += f'
function

{esc(entry["name"])}({esc(args_str)})

{format_doc(entry["doc"])}
' + content += f'
📄 Source code
{esc(entry["source"])}
' + if entry["truncated"]: + content += f'View full source on GitHub →' + content += '
\n' + sidebar += '
\n' + sidebar += '
\n' + +# Write the full page (keeping same CSS structure + new source styles) +page = open('/tmp/api_template.txt', 'w') # We'll inline it +page.close() + +html_page = f''' + +FunASR API + + + + +
+
+ +{sidebar} +
+
+

API Reference

{total_entries} entries · auto-generated from source
Click source code to expand. Links point to latest GitHub code.

+{content} +
+''' + +with open("gh-pages-output/api.html", "w") as f: + f.write(html_page) +print("Generated gh-pages-output/api.html") + +# Ensure output dir exists +os.makedirs("gh-pages-output", exist_ok=True) +