mirror of
https://github.com/modelscope/FunASR.git
synced 2026-10-12 02:19:18 +00:00
1172 lines
73 KiB
Python
1172 lines
73 KiB
Python
"""Source-backed service docs checks without loading models or starting servers."""
|
|
|
|
import argparse
|
|
import ast
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import shlex
|
|
import subprocess
|
|
import tempfile
|
|
from types import SimpleNamespace
|
|
import unittest
|
|
from urllib.parse import unquote, urlsplit
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
EXAMPLE = ROOT / "examples/openai_api"
|
|
READMES = [EXAMPLE / name for name in ("README.md", "README_zh.md", "README_ja.md", "README_ko.md")]
|
|
DOCS = [EXAMPLE / "CLIENTS.md", *READMES]
|
|
PACKAGED = ROOT / "funasr/bin/_server_app.py"
|
|
WORKFLOW_DOCS = [EXAMPLE / name for name in ("WORKFLOWS.md", "WORKFLOWS_zh.md")]
|
|
JAVASCRIPT_DOCS = [EXAMPLE / name for name in ("JAVASCRIPT.md", "JAVASCRIPT_zh.md")]
|
|
SECURITY_DOCS = [EXAMPLE / name for name in ("SECURITY.md", "SECURITY_zh.md")]
|
|
|
|
# Frozen from the two pre-edit workflow guides, not translated from one language.
|
|
WORKFLOW_HEADINGS = {
|
|
"WORKFLOWS.md": "Low-code Workflow Recipes for the FunASR OpenAI-Compatible API|Server preflight|Postman smoke test|Multipart HTTP request|Dify custom tool or HTTP node|Direct file upload path|Audio URL path|n8n HTTP Request node|n8n OpenAI Audio node|Webhook worker pattern|Production guardrails|Troubleshooting",
|
|
"WORKFLOWS_zh.md": "FunASR OpenAI 兼容 API 低代码工作流配方|服务预检|Postman smoke test|Multipart HTTP 请求|Dify 自定义工具或 HTTP 节点|直接上传文件|音频 URL 转写|n8n HTTP Request 节点|n8n OpenAI Audio 节点|Webhook worker 模式|生产环境护栏|故障排查",
|
|
}
|
|
|
|
# Frozen from the pre-edit README inventory, excluding fenced code comments.
|
|
LEGACY_HEADINGS = {
|
|
"README.md": "FunASR OpenAI-Compatible API Server|API Contract|Quick Start|End-to-end smoke test|Browser demo with Gradio|Usage with OpenAI SDK (Python)|Usage with curl|Available Models|API Endpoints|Agent Framework Integration|LangChain Example|Docker Deployment|Kubernetes Deployment|Configuration|Troubleshooting",
|
|
"README_zh.md": "FunASR OpenAI 兼容 API 服务|API Contract|快速开始|端到端 smoke test|Gradio 浏览器 Demo|使用 OpenAI SDK|使用 curl|可用模型|API 端点|Agent 与低代码工作流|Docker 部署|Kubernetes 部署|配置|故障排查",
|
|
"README_ja.md": "FunASR OpenAI 互換 API サーバー|クイックスタート|エンドツーエンド smoke test|Gradio ブラウザデモ|OpenAI SDK で使う|curl で使う|利用できるモデル|API エンドポイント|エージェントとローコードワークフロー|Docker デプロイ|Kubernetes デプロイ|設定|トラブルシューティング",
|
|
"README_ko.md": "FunASR OpenAI 호환 API 서버|빠른 시작|엔드투엔드 smoke test|Gradio 브라우저 데모|OpenAI SDK로 사용하기|curl로 사용하기|사용 가능한 모델|API 엔드포인트|에이전트 및 로우코드 워크플로|Docker 배포|Kubernetes 배포|설정|문제 해결",
|
|
}
|
|
|
|
|
|
def supported_module_command(args):
|
|
"""Allow only the documented environment setup commands, not arbitrary modules."""
|
|
return args in (["python3.11", "-m", "venv", ".venv"],
|
|
["python", "-m", "pip", "install", "-e", "."],
|
|
["python", "-m", "pip", "install", "fastapi", "uvicorn", "python-multipart"],
|
|
["python", "-m", "pip", "install", "openai"],
|
|
["python", "-m", "pip", "install", "gradio"],
|
|
["python", "-m", "pip", "check"])
|
|
|
|
|
|
def blocks(text, language):
|
|
return re.findall(r"^```" + language + r"\n(.*?)^```", text, re.M | re.S)
|
|
|
|
|
|
def tree(path):
|
|
return ast.parse(path.read_text(encoding="utf-8"))
|
|
|
|
|
|
def function(path, name):
|
|
return next(node for node in ast.walk(tree(path))
|
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
and node.name == name)
|
|
|
|
|
|
def form_defaults(node):
|
|
defaults = {}
|
|
for arg, value in zip(node.args.args[-len(node.args.defaults):], node.args.defaults):
|
|
if isinstance(value, ast.Call) and isinstance(value.func, ast.Name) and value.func.id == "Form":
|
|
defaults[arg.arg] = ast.literal_eval(next(
|
|
keyword.value for keyword in value.keywords if keyword.arg == "default"))
|
|
return defaults
|
|
|
|
|
|
def headings(text):
|
|
text = re.sub(r"^```[^\n]*\n.*?^```[^\n]*$", "", text, flags=re.M | re.S)
|
|
result = set()
|
|
counts = {}
|
|
for heading in re.findall(r"^#{1,6}\s+(.+?)\s*#*\s*$", text, re.M):
|
|
slug = re.sub(r"[^\w\- ]", "", heading.lower()).replace(" ", "-")
|
|
count = counts.get(slug, 0)
|
|
counts[slug] = count + 1
|
|
result.add(f"{slug}-{count}" if count else slug)
|
|
return result
|
|
|
|
|
|
class ServiceDocsContract(unittest.TestCase):
|
|
def test_preserves_existing_readme_headings(self):
|
|
for path in READMES:
|
|
text = path.read_text(encoding="utf-8")
|
|
source = re.sub(r"^```[^\n]*\n.*?^```[^\n]*$", "", text, flags=re.M | re.S)
|
|
actual = re.findall(r"^#{1,6}\s+(.+?)\s*#*\s*$", source, re.M)
|
|
expected = LEGACY_HEADINGS[path.name].split("|")
|
|
self.assertEqual([value for value in actual if value in expected], expected, path.name)
|
|
|
|
def test_long_contract_and_model_explanations_are_readable_lists(self):
|
|
model_headings = ("Available Models", "可用模型", "利用できるモデル", "사용 가능한 모델")
|
|
configs = next(node for node in ast.walk(tree(EXAMPLE / "server.py"))
|
|
if isinstance(node, ast.Assign) and any(isinstance(target, ast.Name)
|
|
and target.id == "MODEL_CONFIGS" for target in node.targets))
|
|
aliases = [ast.literal_eval(key) for key in configs.value.keys]
|
|
for path, model_heading in zip(READMES, model_headings):
|
|
text = path.read_text(encoding="utf-8")
|
|
for heading in ("API Contract", model_heading):
|
|
section = text.split("## " + heading + "\n", 1)[1].split("\n## ", 1)[0]
|
|
self.assertNotRegex(section, r"(?m)^\|", path.name)
|
|
if heading == model_heading:
|
|
self.assertEqual(re.findall(r"(?m)^- `([^`]+)`", section), aliases, path.name)
|
|
|
|
def test_module_command_allowlist_rejects_unchecked_variants(self):
|
|
self.assertTrue(supported_module_command(["python", "-m", "pip", "check"]))
|
|
for args in (["python", "-m", "missing"], ["python", "-m", "pip", "uninstall", "funasr"],
|
|
["python3.11", "-m", "venv"], ["python", "-m", "pip", "check", "--bogus"]):
|
|
self.assertFalse(supported_module_command(args))
|
|
|
|
def test_readme_checkout_and_loopback_recipes(self):
|
|
for path in READMES:
|
|
text = path.read_text(encoding="utf-8")
|
|
quick_start = blocks(text, "bash")[0]
|
|
ordered = ["git clone https://github.com/modelscope/FunASR.git FunASR-api", "cd FunASR-api",
|
|
"git checkout --detach d91d961e37a005837b1523bcc6b09f087877be54",
|
|
"python3.11 -m venv .venv", "source .venv/bin/activate",
|
|
"python -m pip install -e .", "python -m pip install fastapi uvicorn python-multipart",
|
|
"python -m pip check", "cd examples/openai_api",
|
|
"python server.py --host 127.0.0.1 --model sensevoice --device cpu --port 8000"]
|
|
positions = [quick_start.index(command) for command in ordered]
|
|
self.assertEqual(positions, sorted(positions), path.name)
|
|
self.assertIn("SECURITY", text[:text.index("```bash")])
|
|
for marker in ("/v1/models", "/openapi.json", "Fun-ASR-MLT-Nano", '--model-path', '--hub',
|
|
'model="custom"', 'whisper-1', 'sentence_info', 'ctc_timestamps', '0.0.0.0'):
|
|
self.assertIn(marker, text, path.name)
|
|
self.assertIn("FUNASR_HOST_PORT=127.0.0.1:8000 docker compose up --build", text)
|
|
for code in blocks(text, "bash"):
|
|
for line in code.replace("\\\n", " ").splitlines():
|
|
args = shlex.split(line, comments=True)
|
|
if args[:2] == ["docker", "run"]:
|
|
self.assertEqual(args[args.index("-p") + 1], "127.0.0.1:8000:8000")
|
|
self.assertNotRegex(text, r"(?:170|120|3\.6)[x×倍]")
|
|
|
|
def test_readme_clients_close_files_and_use_supported_form_fields(self):
|
|
allowed = set(form_defaults(function(EXAMPLE / "server.py", "transcribe"))) | {"file"}
|
|
for path in READMES:
|
|
formats = []
|
|
for code in blocks(path.read_text(encoding="utf-8"), "python"):
|
|
parsed = ast.parse(code)
|
|
calls = [node for node in ast.walk(parsed) if isinstance(node, ast.Call)
|
|
and isinstance(node.func, ast.Attribute) and node.func.attr == "create"]
|
|
for call in calls:
|
|
kwargs = {kw.arg: kw.value for kw in call.keywords}
|
|
formats.append(ast.literal_eval(kwargs["response_format"]) if "response_format" in kwargs else None)
|
|
self.assertEqual(ast.literal_eval(kwargs["model"]), "sensevoice")
|
|
self.assertLessEqual(set(kwargs), allowed)
|
|
self.assertIsInstance(kwargs["file"], ast.Name)
|
|
contexts = [node for node in ast.walk(parsed) if isinstance(node, ast.With)
|
|
and call in list(ast.walk(node))]
|
|
self.assertTrue(any(isinstance(item.optional_vars, ast.Name)
|
|
and item.optional_vars.id == kwargs["file"].id
|
|
and isinstance(item.context_expr, ast.Call)
|
|
and isinstance(item.context_expr.func, ast.Name)
|
|
and item.context_expr.func.id == "open"
|
|
and len(item.context_expr.args) == 2
|
|
and ast.literal_eval(item.context_expr.args[1]) == "rb"
|
|
for node in contexts for item in node.items))
|
|
self.assertGreaterEqual(len(formats), 2, path.name)
|
|
self.assertEqual(set(formats), {None, "verbose_json"}, path.name)
|
|
|
|
def test_relative_links_and_owned_page_anchors(self):
|
|
owned = {path.resolve() for path in DOCS}
|
|
for path in DOCS:
|
|
text = path.read_text(encoding="utf-8")
|
|
self.assertIn("## API Contract", text)
|
|
self.assertIn("](#api-contract)", text)
|
|
for link in re.findall(r"\[[^\]]+\]\(([^)]+)\)", text):
|
|
parts = urlsplit(link)
|
|
if parts.scheme or parts.netloc:
|
|
continue
|
|
target = (path.parent / unquote(parts.path)).resolve() if parts.path else path.resolve()
|
|
self.assertIn(ROOT.resolve(), target.parents)
|
|
self.assertNotIn(".mcp-tasks", target.parts)
|
|
self.assertTrue(target.exists(), f"{path.name}: {link}")
|
|
if parts.fragment and target in owned:
|
|
self.assertIn(unquote(parts.fragment), headings(target.read_text(encoding="utf-8")), link)
|
|
|
|
def test_sdk_links_and_unsupported_claims(self):
|
|
for path in DOCS:
|
|
text = path.read_text(encoding="utf-8")
|
|
for link in ("../../docs/python_api.md", "../../docs/python_api_zh.md"):
|
|
self.assertIn(f"]({link})", text)
|
|
for marker in ("spk=true", "segments=[]", "--model auto", "OpenAI API"):
|
|
self.assertIn(marker, text)
|
|
for stale in ("170x realtime", "120x realtime", "3.6x realtime",
|
|
"Server starts in ~20s", "Works with **any agent framework**",
|
|
"Fast default with language, emotion, and event tags",
|
|
"Returns: text, segments (with start/end/speaker), duration"):
|
|
self.assertNotIn(stale, text)
|
|
|
|
def test_python_and_shell_examples_are_syntactically_valid(self):
|
|
for path in DOCS:
|
|
text = path.read_text(encoding="utf-8")
|
|
for index, code in enumerate(blocks(text, "python")):
|
|
compile(code, f"{path}:{index}", "exec")
|
|
for index, code in enumerate(blocks(text, "bash")):
|
|
result = subprocess.run(["bash", "-n"], input=code, text=True, capture_output=True)
|
|
self.assertEqual(result.returncode, 0, f"{path}:{index}: {result.stderr}")
|
|
|
|
def test_local_script_commands_and_options_match_source(self):
|
|
for path in DOCS:
|
|
text = path.read_text(encoding="utf-8")
|
|
if path.name.startswith("README"):
|
|
quick_start = blocks(text, "bash")[0]
|
|
self.assertIn("cd examples/openai_api", quick_start)
|
|
self.assertLess(quick_start.index("cd examples/openai_api"), quick_start.index("python server.py"))
|
|
for code in blocks(text, "bash"):
|
|
for line in code.replace("\\\n", " ").splitlines():
|
|
args = shlex.split(line, comments=True)
|
|
if len(args) < 2 or args[0] not in ("python", "python3.11", "bash"):
|
|
continue
|
|
if args[1] == "-m":
|
|
self.assertTrue(supported_module_command(args), args)
|
|
continue
|
|
script = EXAMPLE / args[1]
|
|
self.assertTrue(script.is_file(), str(script))
|
|
if args[0] != "python":
|
|
continue
|
|
options = {ast.literal_eval(arg) for node in ast.walk(tree(script))
|
|
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
|
|
and node.func.attr == "add_argument"
|
|
for arg in node.args if isinstance(arg, ast.Constant) and isinstance(arg.value, str)}
|
|
for arg in args[2:]:
|
|
if arg.startswith("--"):
|
|
self.assertIn(arg.split("=", 1)[0], options, str(script))
|
|
|
|
def test_form_defaults_and_alias_boundaries_match_source(self):
|
|
example = form_defaults(function(EXAMPLE / "server.py", "transcribe"))
|
|
packaged = form_defaults(function(PACKAGED, "transcribe"))
|
|
self.assertEqual(example["model"], "sensevoice")
|
|
self.assertNotIn("spk", example)
|
|
self.assertEqual(packaged["model"], "fun-asr-nano")
|
|
self.assertIs(packaged["spk"], False)
|
|
for path, name, expected in ((EXAMPLE / "server.py", "MODEL_CONFIGS", True),
|
|
(PACKAGED, "FALLBACK_CONFIGS", False)):
|
|
assignment = next(node for node in ast.walk(tree(path))
|
|
if isinstance(node, ast.Assign) and any(
|
|
isinstance(target, ast.Name) and target.id == name for target in node.targets))
|
|
aliases = [ast.literal_eval(key) for key in assignment.value.keys]
|
|
self.assertEqual("paraformer-en" in aliases, expected)
|
|
|
|
def test_example_serializes_sentence_info_not_raw_sdk_timestamps(self):
|
|
handler = function(EXAMPLE / "server.py", "transcribe")
|
|
branch = next(node for node in ast.walk(handler) if isinstance(node, ast.If)
|
|
and isinstance(node.test, ast.Compare)
|
|
and isinstance(node.test.left, ast.Name)
|
|
and node.test.left.id == "response_format")
|
|
|
|
class CaptureReturn(ast.NodeTransformer):
|
|
def visit_Return(self, node):
|
|
return ast.copy_location(ast.Assign(
|
|
targets=[ast.Name(id="response", ctx=ast.Store())], value=node.value), node)
|
|
|
|
executable = ast.fix_missing_locations(CaptureReturn().visit(ast.Module(body=[branch], type_ignores=[])))
|
|
clean = function(EXAMPLE / "server.py", "clean_text")
|
|
namespace = {"re": re, "JSONResponse": lambda value: value, "response_format": "verbose_json",
|
|
"elapsed": 0.42, "language": None, "model": "sensevoice", "text": "hello"}
|
|
exec(compile(ast.Module(body=[clean], type_ignores=[]), "clean-text", "exec"), namespace)
|
|
raw_token = {"token": "hello", "start_time": 100, "end_time": 400}
|
|
cases = [({"timestamp": [[100, 400]], "timestamps": [raw_token], "ctc_timestamps": [raw_token]}, []),
|
|
({"sentence_info": [{"start": 100, "end": 400, "text": "<|HAPPY|>hello"}]},
|
|
[{"start": 0.1, "end": 0.4, "text": "hello", "speaker": None}])]
|
|
for result, expected in cases:
|
|
namespace["result"] = [result]
|
|
exec(compile(executable, "example-verbose-branch", "exec"), namespace)
|
|
self.assertEqual(namespace["response"]["segments"], expected)
|
|
self.assertEqual(namespace["response"]["language"], "auto")
|
|
self.assertEqual(namespace["response"]["duration"], 0.42)
|
|
|
|
def test_workflow_alias_and_compose_host_binding_match_source(self):
|
|
resolver = function(EXAMPLE / "server.py", "resolve_openai_transcription_model")
|
|
namespace = {"N8N_OPENAI_MODEL_ALIAS": "whisper-1", "DEFAULT_MODEL": "sensevoice"}
|
|
exec(compile(ast.Module(body=[resolver], type_ignores=[]), "workflow-alias", "exec"), namespace)
|
|
self.assertEqual(namespace[resolver.name]("whisper-1"), "sensevoice")
|
|
self.assertEqual(namespace[resolver.name]("paraformer"), "paraformer")
|
|
compose = (EXAMPLE / "docker-compose.yml").read_text(encoding="utf-8")
|
|
self.assertIn('"${FUNASR_HOST_PORT:-8000}:8000"', compose)
|
|
dockerfile = (EXAMPLE / "Dockerfile").read_text(encoding="utf-8")
|
|
command = next(json.loads(line[4:]) for line in dockerfile.splitlines() if line.startswith("CMD "))
|
|
args = shlex.split(command[-1])
|
|
self.assertEqual(args[args.index("--host") + 1], "0.0.0.0")
|
|
|
|
def test_startup_defaults_match_source(self):
|
|
for path, expected in ((EXAMPLE / "server.py", "sensevoice"),
|
|
(ROOT / "funasr/bin/server.py", "auto")):
|
|
option = next(node for node in ast.walk(tree(path)) if isinstance(node, ast.Call)
|
|
and isinstance(node.func, ast.Attribute) and node.func.attr == "add_argument"
|
|
and node.args and isinstance(node.args[0], ast.Constant)
|
|
and node.args[0].value == "--model")
|
|
self.assertEqual(next(ast.literal_eval(kw.value) for kw in option.keywords
|
|
if kw.arg == "default"), expected)
|
|
startup = function(PACKAGED, "create_app")
|
|
selection = next(node for node in ast.walk(startup) if isinstance(node, ast.IfExp)
|
|
and isinstance(node.body, ast.Constant) and node.body.value == "fun-asr-nano")
|
|
for device, expected in (("cuda:0", "fun-asr-nano"), ("cpu", "sensevoice"), ("mps", "sensevoice")):
|
|
self.assertEqual(eval(compile(ast.Expression(selection), str(PACKAGED), "eval"), {"device": device}), expected)
|
|
|
|
def test_packaged_verbose_example_matches_real_builder(self):
|
|
samples = [json.loads(code) for code in blocks(DOCS[0].read_text(encoding="utf-8"), "json")]
|
|
self.assertEqual(len(samples), 3)
|
|
namespace = {"resolve_transcription_language": lambda requested, result: requested or result["language"]}
|
|
builder = function(PACKAGED, "build_openai_verbose_json")
|
|
exec(compile(ast.Module(body=[builder], type_ignores=[]), str(PACKAGED), "exec"), namespace)
|
|
result = {"text": "recognized speech", "language": "en", "duration": 3.2,
|
|
"segments": [{"start": 0.0, "end": 3.2, "text": "recognized speech"}]}
|
|
self.assertEqual(namespace[builder.name](result), samples[1])
|
|
self.assertNotIn("speaker", samples[1]["segments"][0])
|
|
self.assertLessEqual(samples[1]["segments"][0]["end"], samples[1]["duration"])
|
|
|
|
def test_example_verbose_sample_and_duration_meaning(self):
|
|
samples = [json.loads(code) for code in blocks(DOCS[0].read_text(encoding="utf-8"), "json")]
|
|
handler = function(EXAMPLE / "server.py", "transcribe")
|
|
response = next(node for node in ast.walk(handler) if isinstance(node, ast.Dict)
|
|
and any(isinstance(key, ast.Constant) and key.value == "duration" for key in node.keys))
|
|
expected = eval(compile(ast.Expression(response), "example-response", "eval"),
|
|
{"text": "recognized speech", "segments": [], "language": None,
|
|
"elapsed": 0.42, "model": "sensevoice"})
|
|
self.assertEqual(expected, samples[2])
|
|
source = PACKAGED.read_text(encoding="utf-8")
|
|
self.assertIn("float(sf.info(audio_path).duration)", source)
|
|
self.assertIn('"duration": len(audio_data) / sr', source)
|
|
clients = DOCS[0].read_text(encoding="utf-8")
|
|
self.assertIn("not audio duration", clients)
|
|
self.assertIn("Audio duration in seconds", clients)
|
|
self.assertIn("does not enable diarization", clients)
|
|
|
|
|
|
class WorkflowDocsContract(unittest.TestCase):
|
|
def test_workflow_explanatory_sections_use_complete_readable_lists(self):
|
|
sections = [
|
|
(("Multipart HTTP request", "Multipart HTTP 请求"),
|
|
["Method", "URL", "Body type", "File field", "Text field", "Text field", "Timeout"],
|
|
[("POST",), ("/v1/audio/transcriptions",), ("multipart/form-data",), ("`file`",),
|
|
("model=sensevoice",), ("response_format=verbose_json",), ("300",)]),
|
|
(("n8n HTTP Request node", "n8n HTTP Request 节点"),
|
|
["Method", "URL", "Send Body", "Body Content Type", "Binary file field", "Additional form fields", "Response Format", "Timeout"],
|
|
[("POST",), ("/v1/audio/transcriptions",), ("enabled",), ("Form-Data", "multipart"),
|
|
("`file`",), ("model=sensevoice", "response_format=verbose_json"), ("JSON",), ()]),
|
|
]
|
|
symptoms = [
|
|
["Workflow can call `/health` but transcription fails", "`localhost` connection fails from Dify or n8n",
|
|
"Response has no usable `segments`", "Requests time out", "First request is slow", "Unknown model alias"],
|
|
["工作流能访问 `/health`,但转写失败", "Dify 或 n8n 访问 `localhost` 失败", "响应中没有可用 `segments`",
|
|
"请求超时", "第一次请求很慢", "模型别名未知"],
|
|
]
|
|
fixes = [("multipart/form-data", "`file`"), ("Compose", "Kubernetes"),
|
|
("sentence_info", "verbose_json"), ("HTTP timeout",), ("--model sensevoice", "/health"), ("/v1/models",)]
|
|
for language, path in enumerate(WORKFLOW_DOCS):
|
|
text = path.read_text(encoding="utf-8")
|
|
cases = [*sections, (("Troubleshooting", "故障排查"), symptoms[language], fixes)]
|
|
for titles, labels, tokens in cases:
|
|
with self.subTest(path=path.name, section=titles[language]):
|
|
section = text.split("## " + titles[language] + "\n", 1)[1].split("\n## ", 1)[0]
|
|
self.assertNotRegex(section, r"(?m)^\s*\|")
|
|
self.assertNotRegex(section, r"(?i)<table\b")
|
|
lines = section.splitlines()
|
|
start = next((i for i, line in enumerate(lines) if line.startswith("- **")), None)
|
|
self.assertIsNotNone(start, "Missing explanation list")
|
|
items = []
|
|
for line in lines[start:]:
|
|
if not line.startswith("- "):
|
|
break
|
|
match = re.fullmatch(r"- \*\*(.+?):\*\* (.+)", line)
|
|
self.assertIsNotNone(match, line)
|
|
items.append(match.groups())
|
|
self.assertEqual([label for label, _ in items], labels)
|
|
for (_, value), required in zip(items, tokens):
|
|
for token in required:
|
|
self.assertIn(token, value)
|
|
|
|
def test_preserves_workflow_headings_and_links(self):
|
|
for path in WORKFLOW_DOCS:
|
|
text = path.read_text(encoding="utf-8")
|
|
source = re.sub(r"^```[^\n]*\n.*?^```[^\n]*$", "", text, flags=re.M | re.S)
|
|
actual = re.findall(r"^(#{1,6}) (.+)$", source, re.M)
|
|
titles = WORKFLOW_HEADINGS[path.name].split("|")
|
|
levels = [1, 2, 2, 2, 2, 3, 3, 2, 3, 2, 2, 2]
|
|
self.assertEqual([(len(level), title) for level, title in actual], list(zip(levels, titles)))
|
|
suffix = "_zh" if path.stem.endswith("_zh") else ""
|
|
preserved = {"WORKFLOWS.md" if suffix else "WORKFLOWS_zh.md",
|
|
f"POSTMAN{suffix}.md", f"OPENAPI{suffix}.md", f"SECURITY{suffix}.md",
|
|
f"../../docs/moss_transcribe_diarize{suffix}.md"}
|
|
links = set(re.findall(r"\[[^\]]+\]\(([^)]+)\)", text))
|
|
self.assertLessEqual(preserved, links)
|
|
for link in links:
|
|
parts = urlsplit(link)
|
|
if parts.scheme or parts.netloc:
|
|
continue
|
|
target = (path.parent / unquote(parts.path)).resolve()
|
|
self.assertIn(ROOT.resolve(), target.parents)
|
|
self.assertNotIn(".mcp-tasks", target.parts)
|
|
self.assertTrue(target.is_file(), link)
|
|
if parts.fragment:
|
|
self.assertIn(unquote(parts.fragment), headings(target.read_text(encoding="utf-8")), link)
|
|
|
|
def test_workflow_preflight_uses_prepared_loopback_server(self):
|
|
options = {ast.literal_eval(arg) for node in ast.walk(tree(EXAMPLE / "server.py"))
|
|
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
|
|
and node.func.attr == "add_argument" for arg in node.args
|
|
if isinstance(arg, ast.Constant) and isinstance(arg.value, str)}
|
|
for path in WORKFLOW_DOCS:
|
|
text = path.read_text(encoding="utf-8")
|
|
prefix = text.split("```bash", 1)[0]
|
|
suffix = "_zh" if path.stem.endswith("_zh") else ""
|
|
self.assertIn(f"](README{suffix}.md", prefix)
|
|
self.assertIn(f"](SECURITY{suffix}.md)", prefix)
|
|
self.assertIn(f"](../../docs/agent_integration{suffix}.md)", text)
|
|
startup = blocks(text, "bash")[0]
|
|
commands = [shlex.split(line, comments=True) for line in startup.splitlines() if line.strip()]
|
|
self.assertIn(["source", "../../.venv/bin/activate"], commands)
|
|
launches = [args for args in commands if args[:2] == ["python", "server.py"]]
|
|
self.assertEqual(len(launches), 1)
|
|
args = launches[0]
|
|
for option, expected in (("--host", "127.0.0.1"), ("--model", "sensevoice"), ("--device", "cpu")):
|
|
self.assertIn(option, args)
|
|
self.assertEqual(args[args.index(option) + 1], expected)
|
|
self.assertLessEqual({arg for arg in args if arg.startswith("--")}, options)
|
|
|
|
def test_workflow_url_assignments_are_copyable_and_checks_are_explicit(self):
|
|
for path in WORKFLOW_DOCS:
|
|
codes = blocks(path.read_text(encoding="utf-8"), "bash")
|
|
assignments = []
|
|
for code in codes:
|
|
self.assertNotRegex(code, r"<[^\n>]+>")
|
|
result = subprocess.run(["bash", "-n"], input=code, text=True, capture_output=True)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
for line in code.replace("\\\n", " ").splitlines():
|
|
args = shlex.split(line, comments=True)
|
|
if args[:1] == ["export"]:
|
|
assignments.extend(arg for arg in args[1:] if arg.startswith("FUNASR_BASE_URL="))
|
|
self.assertEqual(assignments, ["FUNASR_BASE_URL=http://127.0.0.1:8000"])
|
|
for endpoint in ("health", "v1/models", "openapi.json"):
|
|
self.assertIn(f'"$FUNASR_BASE_URL/{endpoint}"', "\n".join(codes))
|
|
|
|
def test_workflow_json_examples_match_actual_service_response_builders(self):
|
|
handler = function(EXAMPLE / "server.py", "transcribe")
|
|
response = next(node for node in ast.walk(handler) if isinstance(node, ast.Dict)
|
|
and any(isinstance(key, ast.Constant) and key.value == "duration" for key in node.keys))
|
|
example = eval(compile(ast.Expression(response), "example-response", "eval"),
|
|
{"text": "recognized speech", "segments": [], "language": None,
|
|
"elapsed": 0.42, "model": "sensevoice"})
|
|
namespace = {}
|
|
functions = [function(PACKAGED, name) for name in
|
|
("_split_text_for_openai_segments", "build_openai_fallback_segments",
|
|
"resolve_transcription_language", "build_openai_verbose_json")]
|
|
exec(compile(ast.Module(body=functions, type_ignores=[]), str(PACKAGED), "exec"), namespace)
|
|
result = {"text": "recognized speech", "duration": 3.2, "language": "en",
|
|
"segments": namespace["build_openai_fallback_segments"]("recognized speech", 3.2)}
|
|
packaged = namespace["build_openai_verbose_json"](result)
|
|
for path in WORKFLOW_DOCS:
|
|
text = path.read_text(encoding="utf-8")
|
|
samples = [json.loads(code) for code in blocks(text, "json")]
|
|
self.assertEqual(samples, [example, packaged], path.name)
|
|
self.assertIn("](CLIENTS.md#response-formats)", text)
|
|
for marker in ("sentence_info", "segments=[]", "spk=true", "ctc_timestamps", "use_itn", "hotwords"):
|
|
self.assertIn(marker, text)
|
|
for speaker in (None, 0, "SPK0", "S01"):
|
|
result["segments"][0]["speaker"] = speaker
|
|
segment = namespace["build_openai_verbose_json"](result)["segments"][0]
|
|
if speaker is None:
|
|
self.assertNotIn("speaker", segment)
|
|
else:
|
|
self.assertEqual(segment["speaker"], speaker)
|
|
|
|
def test_workflow_url_worker_warns_before_code_and_declares_dependency(self):
|
|
for path in WORKFLOW_DOCS:
|
|
text = path.read_text(encoding="utf-8")
|
|
section = text.split("### Audio URL path" if path.name == "WORKFLOWS.md" else "### 音频 URL 转写", 1)[1]
|
|
warning = section.split("```python", 1)[0]
|
|
markers = ("not implemented", "redirect", "private-network", "byte", "trusted") if path.name == "WORKFLOWS.md" else (
|
|
"未实现", "重定向", "私网", "字节", "可信")
|
|
for marker in markers:
|
|
self.assertIn(marker, warning)
|
|
self.assertIn("python -m pip install requests", text[:text.index("```python")])
|
|
|
|
def test_workflow_python_examples_are_equivalent_and_send_real_multipart_fields(self):
|
|
snippets = [blocks(path.read_text(encoding="utf-8"), "python") for path in WORKFLOW_DOCS]
|
|
self.assertEqual([len(items) for items in snippets], [2, 2])
|
|
self.assertEqual([ast.dump(ast.parse(code)) for code in snippets[0]],
|
|
[ast.dump(ast.parse(code)) for code in snippets[1]])
|
|
allowed = set(form_defaults(function(EXAMPLE / "server.py", "transcribe"))) | {"file"}
|
|
for code, name in zip(snippets[0], ("transcribe_from_url", "transcribe_bytes")):
|
|
calls = []
|
|
handles = []
|
|
payload = b"test audio bytes, no acoustic inference"
|
|
|
|
def post(url, *, files, data, timeout):
|
|
self.assertEqual(urlsplit(url).path, "/v1/audio/transcriptions")
|
|
self.assertEqual(set(files), {"file"})
|
|
self.assertLessEqual(set(data) | set(files), allowed)
|
|
self.assertEqual(data, {"model": "sensevoice", "response_format": "verbose_json"})
|
|
self.assertGreater(timeout, 0)
|
|
body = files["file"][1]
|
|
if hasattr(body, "read"):
|
|
handles.append(body)
|
|
body = body.read()
|
|
self.assertEqual(body, payload)
|
|
calls.append(url)
|
|
return SimpleNamespace(raise_for_status=lambda: None, json=lambda: {"text": "hello", "segments": []})
|
|
|
|
downloads = []
|
|
|
|
def get(url, *, timeout):
|
|
downloads.append(url)
|
|
self.assertGreater(timeout, 0)
|
|
return SimpleNamespace(content=payload, raise_for_status=lambda: None)
|
|
|
|
parsed = ast.parse(code)
|
|
# Substitute only the HTTP boundary; execute the documented worker body unchanged.
|
|
parsed.body = [node for node in parsed.body if not isinstance(node, (ast.Import, ast.ImportFrom))]
|
|
namespace = {"Path": Path, "tempfile": tempfile, "requests": SimpleNamespace(get=get, post=post)}
|
|
exec(compile(parsed, name, "exec"), namespace)
|
|
args = ("https://storage.example.invalid/approved.wav",) if name == "transcribe_from_url" else ("audio.wav", payload)
|
|
self.assertEqual(namespace[name](*args), {"text": "hello", "segments": []})
|
|
self.assertEqual(len(calls), 1)
|
|
self.assertEqual(downloads, list(args) if name == "transcribe_from_url" else [])
|
|
self.assertTrue(all(handle.closed for handle in handles))
|
|
|
|
|
|
class JavaScriptDocsContract(unittest.TestCase):
|
|
"""Source/text contracts only; actual JS execution and TS checking are separate."""
|
|
|
|
def test_javascript_preserves_headings_and_resolves_guide_links(self):
|
|
legacy = [
|
|
"JavaScript and TypeScript Recipes for the FunASR OpenAI-Compatible API|Preflight|OpenAI JavaScript SDK|Built-in fetch without an SDK|TypeScript helper|Next.js route handler|Production checklist|Troubleshooting",
|
|
"FunASR OpenAI 兼容 API JavaScript/TypeScript 接入配方|预检查|OpenAI JavaScript SDK|不依赖 SDK 的内置 fetch 写法|TypeScript helper|Next.js route handler|生产检查清单|故障排查",
|
|
]
|
|
for path, expected in zip(JAVASCRIPT_DOCS, legacy):
|
|
text = path.read_text(encoding="utf-8")
|
|
prose = re.sub(r"^```[^\n]*\n.*?^```[^\n]*$", "", text, flags=re.M | re.S)
|
|
self.assertEqual(re.findall(r"(?m)^#{1,6} (.+)$", prose), expected.split("|"))
|
|
self.assertEqual([len(level) for level in re.findall(r"(?m)^(#{1,6}) ", prose)], [1] + [2] * 7)
|
|
suffix = "_zh" if path.stem.endswith("_zh") else ""
|
|
links = re.findall(r"\[[^\]]+\]\(([^)]+)\)", text)
|
|
self.assertIn(f"../../docs/moss_transcribe_diarize{suffix}.md", links)
|
|
for link in links:
|
|
parts = urlsplit(link)
|
|
if parts.scheme or parts.netloc:
|
|
continue
|
|
target = (path.parent / unquote(parts.path)).resolve()
|
|
self.assertIn(ROOT.resolve(), target.parents)
|
|
self.assertNotIn(".mcp-tasks", target.parts)
|
|
self.assertTrue(target.is_file(), link)
|
|
if parts.fragment:
|
|
self.assertIn(unquote(parts.fragment), headings(target.read_text(encoding="utf-8")), link)
|
|
|
|
def test_javascript_startup_and_smoke_use_explicit_source_options(self):
|
|
for path in JAVASCRIPT_DOCS:
|
|
text = path.read_text(encoding="utf-8")
|
|
prefix = text.split("```bash", 1)[0]
|
|
suffix = "_zh" if path.stem.endswith("_zh") else ""
|
|
self.assertIn(f"](README{suffix}.md", prefix)
|
|
self.assertIn(f"](SECURITY{suffix}.md)", prefix)
|
|
startup = blocks(text, "bash")[0]
|
|
self.assertIn("cd examples/openai_api", startup)
|
|
self.assertIn("source ../../.venv/bin/activate", startup)
|
|
found = set()
|
|
for code in blocks(text, "bash"):
|
|
parsed = subprocess.run(["bash", "-n"], input=code, text=True, capture_output=True)
|
|
self.assertEqual(parsed.returncode, 0, parsed.stderr)
|
|
for line in code.replace("\\\n", " ").splitlines():
|
|
args = shlex.split(line, comments=True)
|
|
if len(args) < 2 or args[0] != "python":
|
|
continue
|
|
self.assertIn(args[1], ("server.py", "smoke_test.py"))
|
|
found.add(args[1])
|
|
options = {ast.literal_eval(arg) for node in ast.walk(tree(EXAMPLE / args[1]))
|
|
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
|
|
and node.func.attr == "add_argument" for arg in node.args
|
|
if isinstance(arg, ast.Constant) and isinstance(arg.value, str)}
|
|
self.assertLessEqual({arg for arg in args[2:] if arg.startswith("--")}, options)
|
|
self.assertIn("--model", args)
|
|
self.assertEqual(args[args.index("--model") + 1], "sensevoice")
|
|
if args[1] == "server.py":
|
|
self.assertIn("--host", args)
|
|
self.assertEqual(args[args.index("--host") + 1], "127.0.0.1")
|
|
self.assertEqual(args[args.index("--device") + 1], "cpu")
|
|
else:
|
|
self.assertEqual(args[args.index("--base-url") + 1], "http://127.0.0.1:8000")
|
|
self.assertEqual(found, {"server.py", "smoke_test.py"})
|
|
for endpoint in ("/health", "/v1/models", "/openapi.json"):
|
|
self.assertIn(endpoint, text)
|
|
|
|
def test_javascript_examples_keep_bilingual_code_and_runnable_module_entries(self):
|
|
texts = [path.read_text(encoding="utf-8") for path in JAVASCRIPT_DOCS]
|
|
for language in ("javascript", "typescript"):
|
|
examples = [blocks(text, language) for text in texts]
|
|
self.assertEqual([len(items) for items in examples], [2, 2])
|
|
self.assertEqual(examples[0], examples[1])
|
|
for text in texts:
|
|
shell = "\n".join(blocks(text, "bash"))
|
|
self.assertIn("node transcribe.mjs meeting.wav", shell)
|
|
self.assertIn("node transcribe-fetch.mjs meeting.wav", shell)
|
|
self.assertRegex(shell, r"npm install openai@\d+\.\d+\.\d+")
|
|
self.assertIn("app/api/transcribe/route.ts", text)
|
|
self.assertIn("funasr-client.ts", text)
|
|
for code in blocks(text, "javascript") + blocks(text, "typescript"):
|
|
self.assertNotRegex(code, r"[\"']Content-Type[\"']\s*:\s*[\"']multipart/form-data")
|
|
|
|
def test_javascript_sdk_stream_lifecycle_is_owned_by_the_caller(self):
|
|
for path in JAVASCRIPT_DOCS:
|
|
text = path.read_text(encoding="utf-8")
|
|
for code in (blocks(text, "javascript")[0], blocks(text, "typescript")[0]):
|
|
with self.subTest(path=path.name, helper="typescript" if "interface" in code else "javascript"):
|
|
self.assertIn('from "node:fs/promises"', code)
|
|
self.assertIn('from "node:stream/promises"', code)
|
|
self.assertRegex(code, r'await open\(audioPath, ["\']r["\']\)')
|
|
self.assertIn("handle.createReadStream()", code)
|
|
self.assertRegex(code, r"finished\(file\)\.catch\(")
|
|
self.assertIn("toStreamingFile(file, basename(audioPath))", code)
|
|
self.assertLess(code.index("finished(file)"), code.index("client.audio.transcriptions.create"))
|
|
self.assertRegex(code, r"finally\s*\{\s*file\.destroy\(\);\s*await closed;")
|
|
self.assertRegex(code, r"finally\s*\{\s*await handle\.close\(\);")
|
|
self.assertNotIn("createReadStream(audioPath)", code)
|
|
self.assertNotRegex(code, r"\b(?:access|existsSync)\(audioPath")
|
|
|
|
def test_javascript_speaker_union_covers_actual_serialized_values(self):
|
|
functions = [function(PACKAGED, name) for name in
|
|
("resolve_transcription_language", "build_openai_verbose_json")]
|
|
namespace = {}
|
|
exec(compile(ast.Module(body=functions, type_ignores=[]), str(PACKAGED), "exec"), namespace)
|
|
kinds = set()
|
|
for speaker in (0, "SPK0", "S01"):
|
|
result = {"text": "hello", "segments": [{"start": 0, "end": 1, "text": "hello", "speaker": speaker}]}
|
|
actual = namespace["build_openai_verbose_json"](result)["segments"][0]["speaker"]
|
|
kinds.add("number" if isinstance(actual, int) else "string")
|
|
handler = function(EXAMPLE / "server.py", "transcribe")
|
|
speaker_value = next(node.values[node.keys.index(key)] for node in ast.walk(handler)
|
|
if isinstance(node, ast.Dict) for key in node.keys
|
|
if isinstance(key, ast.Constant) and key.value == "speaker")
|
|
self.assertIsNone(eval(compile(ast.Expression(speaker_value), "example-speaker", "eval"), {"seg": {}}))
|
|
kinds.add("null")
|
|
for path in JAVASCRIPT_DOCS:
|
|
helper = blocks(path.read_text(encoding="utf-8"), "typescript")[0]
|
|
match = re.search(r"speaker\?\s*:\s*([^;}]+)", helper)
|
|
self.assertIsNotNone(match)
|
|
self.assertEqual({value.strip() for value in match.group(1).split("|")}, kinds)
|
|
|
|
def test_javascript_guides_explain_response_and_forwarding_boundaries(self):
|
|
for path in JAVASCRIPT_DOCS:
|
|
text = path.read_text(encoding="utf-8")
|
|
self.assertIn("](CLIENTS.md#response-formats)", text)
|
|
for marker in ("sentence_info", "segments=[]", "spk=true", "ctc_timestamps", "use_itn", "hotwords", "Node.js 22"):
|
|
self.assertIn(marker, text)
|
|
before_route = text.split("## Next.js route handler", 1)[1].split("```typescript", 1)[0]
|
|
markers = ("not implemented", "authentication", "upload", "build") if path.name == "JAVASCRIPT.md" else (
|
|
"未实现", "鉴权", "上传", "build")
|
|
for marker in markers:
|
|
self.assertIn(marker, before_route)
|
|
|
|
def test_javascript_native_route_has_fixed_upstream_and_bounded_error_contract(self):
|
|
for path in JAVASCRIPT_DOCS:
|
|
code = blocks(path.read_text(encoding="utf-8"), "typescript")[1]
|
|
self.assertRegex(code, r"export const runtime\s*=\s*[\"']nodejs[\"']")
|
|
self.assertIn("process.env.FUNASR_UPSTREAM_URL", code)
|
|
self.assertIn("process.env.FUNASR_MODEL", code)
|
|
self.assertNotIn("NEXT_PUBLIC_", code)
|
|
self.assertNotRegex(code, r'incoming\.get\([\"\'](?:model|target|url)[\"\']\)')
|
|
self.assertRegex(code, r"try\s*\{\s*incoming = await request\.formData\(\)")
|
|
self.assertRegex(code, r"AbortSignal\.timeout\(120_?000\)")
|
|
self.assertRegex(code, r'redirect:\s*[\"\']error[\"\']')
|
|
self.assertRegex(code, r"\bsignal\s*[,}]")
|
|
self.assertRegex(code, r"response\.status\s*>=\s*400")
|
|
self.assertRegex(code, r"response\.status\s*<=\s*599")
|
|
self.assertIn("await response.json()", code)
|
|
self.assertIn("signal.aborted", code)
|
|
self.assertNotIn("clearTimeout", code)
|
|
self.assertNotIn("await response.text()", code)
|
|
self.assertNotIn("error.message", code)
|
|
self.assertNotIn("from \"next/", code)
|
|
|
|
def test_javascript_fetch_cli_does_not_log_raw_upstream_errors(self):
|
|
for path in JAVASCRIPT_DOCS:
|
|
code = blocks(path.read_text(encoding="utf-8"), "javascript")[1]
|
|
self.assertRegex(code, r"AbortSignal\.timeout\(120_?000\)")
|
|
self.assertRegex(code, r'redirect:\s*[\"\']error[\"\']')
|
|
self.assertIn("process.exitCode = 1", code)
|
|
self.assertNotIn("await response.text()", code)
|
|
self.assertNotIn("error.message", code)
|
|
fields = re.findall(r'form\.append\([\"\']([^\"\']+)[\"\']', code)
|
|
self.assertEqual(fields, ["file", "model", "response_format"])
|
|
allowed = set(form_defaults(function(EXAMPLE / "server.py", "transcribe"))) | {"file"}
|
|
self.assertLessEqual(set(fields), allowed)
|
|
|
|
|
|
class SecurityDocsContract(unittest.TestCase):
|
|
"""Documentation/source contracts, not proxy syntax or authentication execution."""
|
|
|
|
def test_security_preserves_headings_levels_and_existing_links(self):
|
|
expected = [
|
|
"Security and Gateway Guide for the FunASR OpenAI-Compatible API|Recommended topology|Minimum controls before sharing|NGINX reverse proxy sketch|Caddy reverse proxy sketch|Kubernetes notes|Client configuration|Data handling checklist|Rollout checklist",
|
|
"FunASR OpenAI 兼容 API 安全与网关指南|推荐拓扑|对团队开放前的最低控制项|NGINX 反向代理示例|Caddy 反向代理示例|Kubernetes 注意事项|客户端配置|数据处理清单|上线检查清单",
|
|
]
|
|
for path, legacy in zip(SECURITY_DOCS, expected):
|
|
text = path.read_text(encoding="utf-8")
|
|
prose = re.sub(r"^```[^\n]*\n.*?^```[^\n]*$", "", text, flags=re.M | re.S)
|
|
self.assertEqual(re.findall(r"(?m)^#{1,6} (.+)$", prose), legacy.split("|"))
|
|
self.assertEqual([len(level) for level in re.findall(r"(?m)^(#{1,6}) ", prose)], [1] + [2] * 8)
|
|
suffix = "_zh" if path.stem.endswith("_zh") else ""
|
|
links = re.findall(r"\[[^\]]+\]\(([^)]+)\)", text)
|
|
for link in (f"README{suffix}.md", "CLIENTS.md", f"WORKFLOWS{suffix}.md",
|
|
f"GRADIO{suffix}.md", f"kubernetes/README{suffix}.md", "../../SECURITY.md"):
|
|
self.assertIn(link, links)
|
|
for link in links:
|
|
parsed = urlsplit(link)
|
|
if parsed.scheme or parsed.netloc:
|
|
continue
|
|
target = (path.parent / unquote(parsed.path)).resolve()
|
|
self.assertIn(ROOT.resolve(), target.parents)
|
|
self.assertNotIn(".mcp-tasks", target.parts)
|
|
self.assertTrue(target.is_file(), link)
|
|
if parsed.fragment:
|
|
self.assertIn(unquote(parsed.fragment), headings(target.read_text(encoding="utf-8")), link)
|
|
|
|
def test_security_proxy_and_python_examples_are_bilingual(self):
|
|
texts = [path.read_text(encoding="utf-8") for path in SECURITY_DOCS]
|
|
for language in ("nginx", "caddyfile", "bash"):
|
|
examples = [blocks(text, language) for text in texts]
|
|
self.assertTrue(examples[0], language)
|
|
normalized = [["\n".join(line.strip() for line in code.splitlines()
|
|
if line.strip() and not line.lstrip().startswith("#"))
|
|
for code in items] for items in examples]
|
|
self.assertEqual(normalized[0], normalized[1], language)
|
|
examples = [blocks(text, "python") for text in texts]
|
|
self.assertEqual([len(items) for items in examples], [1, 1])
|
|
self.assertEqual(ast.dump(ast.parse(examples[0][0])), ast.dump(ast.parse(examples[1][0])))
|
|
|
|
def test_security_backend_binding_matches_source_and_is_explicit(self):
|
|
for source in (EXAMPLE / "server.py", ROOT / "funasr/bin/server.py"):
|
|
host = next(node for node in ast.walk(tree(source)) if isinstance(node, ast.Call)
|
|
and isinstance(node.func, ast.Attribute) and node.func.attr == "add_argument"
|
|
and node.args and isinstance(node.args[0], ast.Constant) and node.args[0].value == "--host")
|
|
self.assertEqual(ast.literal_eval(next(k.value for k in host.keywords if k.arg == "default")), "0.0.0.0")
|
|
self.assertIn('${FUNASR_HOST_PORT:-8000}:8000', (EXAMPLE / "docker-compose.yml").read_text())
|
|
for path in SECURITY_DOCS:
|
|
text = path.read_text(encoding="utf-8")
|
|
prefix = text.split("```nginx", 1)[0]
|
|
for marker in ("127.0.0.1", "0.0.0.0", "FUNASR_HOST_PORT=127.0.0.1:8000", "ClusterIP"):
|
|
self.assertIn(marker, prefix)
|
|
recipes = blocks(text, "bash")
|
|
for recipe in recipes:
|
|
parsed = subprocess.run(["bash", "-n"], input=recipe, text=True, capture_output=True)
|
|
self.assertEqual(parsed.returncode, 0, parsed.stderr)
|
|
startup = next(code for code in recipes if "python server.py" in code)
|
|
self.assertIn("cd examples/openai_api", startup)
|
|
self.assertIn("source ../../.venv/bin/activate", startup)
|
|
args = shlex.split(next(line for line in startup.splitlines() if line.startswith("python server.py")))
|
|
self.assertEqual(args[args.index("--host") + 1], "127.0.0.1")
|
|
self.assertEqual(args[args.index("--model") + 1], "sensevoice")
|
|
self.assertEqual(args[args.index("--device") + 1], "cpu")
|
|
|
|
def test_security_nginx_requires_basic_auth_and_restricts_route(self):
|
|
for path in SECURITY_DOCS:
|
|
code = blocks(path.read_text(encoding="utf-8"), "nginx")[0]
|
|
code = "\n".join(line for line in code.splitlines() if not line.lstrip().startswith("#"))
|
|
for directive in ("ssl_certificate ", "ssl_certificate_key ", "auth_basic ",
|
|
"auth_basic_user_file ", "client_max_body_size "):
|
|
self.assertIn(directive, code)
|
|
realms = re.findall(r"(?m)^\s*auth_basic\s+([^;\n]+);", code)
|
|
self.assertEqual(len(realms), 1)
|
|
realm = shlex.split(realms[0])
|
|
self.assertEqual(len(realm), 1)
|
|
self.assertTrue(realm[0].strip())
|
|
self.assertNotEqual(realm[0].lower(), "off")
|
|
self.assertRegex(code, r"location\s+=\s+/v1/audio/transcriptions\s*\{")
|
|
self.assertRegex(code, r"limit_except\s+POST\s*\{\s*deny\s+all;")
|
|
self.assertRegex(code, r"location\s+/\s*\{\s*return\s+404;\s*\}")
|
|
self.assertEqual(len(re.findall(r"\bproxy_pass\b", code)), 1)
|
|
self.assertRegex(code, r"proxy_pass\s+http://127\.0\.0\.1:8000;")
|
|
self.assertIn('proxy_set_header Authorization "";', code)
|
|
self.assertNotIn("$http_authorization", code)
|
|
|
|
def test_security_caddy_orders_auth_before_body_and_proxy(self):
|
|
for path in SECURITY_DOCS:
|
|
code = blocks(path.read_text(encoding="utf-8"), "caddyfile")[0]
|
|
self.assertRegex(code, r"@transcribe\s*\{\s*path /v1/audio/transcriptions\s+method POST\s*\}")
|
|
self.assertRegex(code, r"handle @transcribe\s*\{\s*route\s*\{")
|
|
self.assertIn("basic_auth {", code)
|
|
self.assertLess(code.index("basic_auth {"), code.index("request_body {"))
|
|
self.assertLess(code.index("request_body {"), code.index("reverse_proxy "))
|
|
self.assertEqual(code.count("reverse_proxy "), 1)
|
|
self.assertIn("reverse_proxy 127.0.0.1:8000", code)
|
|
self.assertIn("header_up -Authorization", code)
|
|
self.assertRegex(code, r'handle\s*\{\s*respond "Not found" 404\s*\}')
|
|
self.assertRegex(code, r"(?m)^\s*tls /\S+ /\S+$")
|
|
|
|
def test_security_covers_registered_routes_and_unimplemented_controls(self):
|
|
routes = set()
|
|
for source in (EXAMPLE / "server.py", PACKAGED):
|
|
for node in ast.walk(tree(source)):
|
|
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr in ("get", "post"):
|
|
if node.args and isinstance(node.args[0], ast.Constant) and isinstance(node.args[0].value, str):
|
|
if node.args[0].value.startswith("/"):
|
|
routes.add(node.args[0].value)
|
|
self.assertEqual(routes, {"/health", "/v1/models", "/v1/audio/transcriptions", "/asr"})
|
|
for path in SECURITY_DOCS:
|
|
text = path.read_text(encoding="utf-8")
|
|
for route in routes | {"/openapi.json", "/docs", "/redoc"}:
|
|
inline_code = re.findall(r"`([^`\n]+)`", text)
|
|
self.assertTrue(any(route in value.split() for value in inline_code), route)
|
|
for marker in ("200m", "600s", "NetworkPolicy", "CORS"):
|
|
self.assertIn(marker, text)
|
|
markers = ("not implemented", "model admission", "temporary", "cancel") if path.name == "SECURITY.md" else (
|
|
"未实现", "模型准入", "临时", "取消")
|
|
for marker in markers:
|
|
self.assertIn(marker, text)
|
|
|
|
def test_security_client_distinguishes_basic_from_bearer_and_local_smoke(self):
|
|
for path in SECURITY_DOCS:
|
|
text = path.read_text(encoding="utf-8")
|
|
client_section = text.split("## Client configuration" if path.name == "SECURITY.md" else "## 客户端配置", 1)[1].split("\n## ", 1)[0]
|
|
for marker in ("Basic", "Bearer", "api_key", "OIDC", "mTLS"):
|
|
self.assertIn(marker, client_section)
|
|
curl = next(code for code in blocks(text, "bash") if "curl " in code)
|
|
args = shlex.split(curl.replace("\\\n", " "))
|
|
self.assertEqual(args[0], "curl")
|
|
# Parse only this recipe's option set; argparse also expands short-flag clusters.
|
|
parser = argparse.ArgumentParser(add_help=False, allow_abbrev=False)
|
|
for short, long in (("-k", "--insecure"), ("-L", "--location"),
|
|
("-f", "--fail"), ("-S", "--show-error"), ("-s", "--silent")):
|
|
parser.add_argument(short, long, action="store_true")
|
|
for short, long in (("-u", "--user"), ("-m", "--max-time"),
|
|
("-o", "--output"), ("-w", "--write-out")):
|
|
parser.add_argument(short, long)
|
|
parser.add_argument("-F", "--form", action="append")
|
|
options, operands = parser.parse_known_args(args[1:])
|
|
self.assertFalse(options.insecure)
|
|
self.assertFalse(options.location)
|
|
self.assertEqual(operands, ["https://funasr.example.com/v1/audio/transcriptions"])
|
|
self.assertEqual(options.user, "team_user")
|
|
self.assertEqual(options.output, "/dev/null")
|
|
markers = ("unauthenticated local", "does not send", "not a Bearer") if path.name == "SECURITY.md" else (
|
|
"无鉴权本地", "不发送", "不是 Bearer")
|
|
for marker in markers:
|
|
self.assertIn(marker, text)
|
|
|
|
|
|
GRADIO_DOCS = [EXAMPLE / name for name in ("GRADIO.md", "GRADIO_zh.md")]
|
|
GRADIO_HEADINGS = {
|
|
"GRADIO.md": "Gradio Browser Demo for the FunASR OpenAI-Compatible API|1. Start the API server|2. Install and launch the browser UI|3. Verify the backend first|Model aliases|Production notes",
|
|
"GRADIO_zh.md": "FunASR OpenAI 兼容 API Gradio 浏览器 Demo|1. 启动 API 服务|2. 安装并启动浏览器 UI|3. 先验证后端服务|模型别名|生产注意事项",
|
|
}
|
|
|
|
|
|
def shell_commands(text):
|
|
return [shlex.split(line, comments=True) for code in blocks(text, "bash")
|
|
for line in code.replace("\\\n", " ").splitlines()
|
|
if shlex.split(line, comments=True)]
|
|
|
|
|
|
def gradio_client_module():
|
|
# The client imports Gradio only inside build_app, not during CLI parsing.
|
|
spec = importlib.util.spec_from_file_location("gradio_docs_client", EXAMPLE / "gradio_app.py")
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
class GradioDocsContract(unittest.TestCase):
|
|
def test_preserves_gradio_headings_and_existing_links(self):
|
|
for path in GRADIO_DOCS:
|
|
text = path.read_text(encoding="utf-8")
|
|
source = re.sub(r"^```[^\n]*\n.*?^```[^\n]*$", "", text, flags=re.M | re.S)
|
|
actual = re.findall(r"^(#{1,6}) (.+)$", source, re.M)
|
|
self.assertEqual([heading for _, heading in actual], GRADIO_HEADINGS[path.name].split("|"))
|
|
self.assertEqual([len(level) for level, _ in actual], [1] + [2] * 5)
|
|
suffix = "_zh" if path.name.endswith("_zh.md") else ""
|
|
for relative in (f"../../docs/moss_transcribe_diarize{suffix}.md",
|
|
f"../../docs/model_selection{suffix}.md", f"SECURITY{suffix}.md"):
|
|
self.assertIn(f"]({relative})", text)
|
|
for link in re.findall(r"\[[^\]]+\]\(([^)]+)\)", text):
|
|
parts = urlsplit(link)
|
|
if parts.scheme or parts.netloc:
|
|
continue
|
|
target = (path.parent / unquote(parts.path)).resolve() if parts.path else path.resolve()
|
|
self.assertIn(ROOT.resolve(), target.parents)
|
|
self.assertNotIn(".mcp-tasks", target.parts)
|
|
self.assertTrue(target.is_file(), link)
|
|
if parts.fragment:
|
|
self.assertIn(unquote(parts.fragment), headings(target.read_text(encoding="utf-8")), link)
|
|
|
|
def test_gradio_commands_are_bilingual_equivalent(self):
|
|
self.assertEqual(*(shell_commands(path.read_text(encoding="utf-8")) for path in GRADIO_DOCS))
|
|
|
|
def test_gradio_preflight_reuses_pinned_loopback_checkout(self):
|
|
for path in GRADIO_DOCS:
|
|
text = path.read_text(encoding="utf-8")
|
|
self.assertIn("SECURITY", text[:text.index("```bash")])
|
|
suffix = "_zh" if path.name.endswith("_zh.md") else ""
|
|
readme = (EXAMPLE / f"README{suffix}.md").read_text(encoding="utf-8")
|
|
self.assertEqual(shell_commands("```bash\n" + blocks(text, "bash")[0] + "```\n"),
|
|
shell_commands("```bash\n" + blocks(readme, "bash")[0] + "```\n"))
|
|
for command in shell_commands(text):
|
|
if command[:2] == ["python", "server.py"]:
|
|
self.assertEqual(command[command.index("--host") + 1], "127.0.0.1")
|
|
self.assertEqual(command[command.index("--device") + 1], "cpu")
|
|
|
|
def test_gradio_client_environment_is_separate_and_reachable(self):
|
|
for path in GRADIO_DOCS:
|
|
text = path.read_text(encoding="utf-8")
|
|
setup = next((code for code in blocks(text, "bash") if "-m venv .venv-gradio" in code), None)
|
|
self.assertIsNotNone(setup, path.name)
|
|
commands = shell_commands("```bash\n" + setup + "```\n")
|
|
expected = [["cd", "FunASR-api"], ["python3.12", "-m", "venv", ".venv-gradio"],
|
|
["source", ".venv-gradio/bin/activate"],
|
|
["python", "-m", "pip", "install", "gradio==6.26.0"],
|
|
["python", "-m", "pip", "check"], ["cd", "examples/openai_api"]]
|
|
self.assertEqual(commands[:len(expected)], expected)
|
|
self.assertEqual(commands[-1][:2], ["python", "gradio_app.py"])
|
|
self.assertNotIn(["python", "-m", "pip", "install", "-e", "."], commands)
|
|
self.assertNotIn("--share", commands[-1])
|
|
|
|
def test_gradio_recipes_cover_profiles_and_explicit_served_model(self):
|
|
client = gradio_client_module()
|
|
for path in GRADIO_DOCS:
|
|
launches = [command[2:] for command in shell_commands(path.read_text(encoding="utf-8"))
|
|
if command[:2] == ["python", "gradio_app.py"]]
|
|
self.assertGreaterEqual(len(launches), 5, path.name)
|
|
pairs = set()
|
|
for command in launches:
|
|
self.assertLessEqual({"--backend", "--model", "--base-url", "--host", "--port"}, set(command))
|
|
options = client.parse_args(command)
|
|
self.assertEqual(options.host, "127.0.0.1")
|
|
self.assertEqual(options.port, 7860)
|
|
self.assertFalse(options.share)
|
|
url = urlsplit(options.base_url)
|
|
self.assertEqual((url.scheme, url.hostname, url.path, url.query, url.fragment),
|
|
("http", "127.0.0.1", "", "", ""))
|
|
self.assertIn(url.port, (8000, 8898))
|
|
pairs.add((options.backend, options.model))
|
|
self.assertLessEqual({("funasr", "sensevoice"), ("funasr", "moss-transcribe-diarize"),
|
|
("vllm", "moss-transcribe-diarize"),
|
|
("sglang-omni", "OpenMOSS-Team/MOSS-Transcribe-Diarize"),
|
|
("vllm", "meeting-asr")}, pairs)
|
|
|
|
def test_gradio_model_lists_distinguish_service_contracts(self):
|
|
profiles = gradio_client_module().BACKEND_PROFILES
|
|
configs = next(node for node in ast.walk(tree(EXAMPLE / "server.py"))
|
|
if isinstance(node, ast.Assign) and any(isinstance(target, ast.Name)
|
|
and target.id == "MODEL_CONFIGS" for target in node.targets))
|
|
aliases = [ast.literal_eval(key) for key in configs.value.keys]
|
|
self.assertEqual(list(profiles["funasr"]["models"]), aliases)
|
|
for path in GRADIO_DOCS:
|
|
text = path.read_text(encoding="utf-8")
|
|
heading = "Model aliases" if path.name == "GRADIO.md" else "模型别名"
|
|
section = text.split("## " + heading + "\n", 1)[1].split("\n## ", 1)[0]
|
|
self.assertNotRegex(section, r"(?m)^\|", path.name)
|
|
self.assertEqual(re.findall(r"(?m)^- `([^`]+)`", section), aliases)
|
|
for backend, profile in profiles.items():
|
|
paragraph = text.split(f"**{backend}**", 1)[1].split("\n\n", 1)[0]
|
|
identifiers = re.findall(r"`([^`]+)`", paragraph)
|
|
self.assertEqual(identifiers[:2], [profile["models"][0], profile["default_format"]])
|
|
self.assertLessEqual(set(profile["formats"]), set(identifiers))
|
|
for marker in ("funasr", "vllm", "sglang-omni", "json", "verbose_json", "diarized_json",
|
|
"OpenMOSS-Team/MOSS-Transcribe-Diarize", "--model", "speaker", "[Sxx]",
|
|
"segments", "duration", "sentence_info", "Fun-ASR-MLT-Nano"):
|
|
self.assertIn(marker, text, path.name)
|
|
|
|
def test_gradio_guides_explain_remote_privacy_and_check_boundaries(self):
|
|
markers = {
|
|
"GRADIO.md": ("Authorization", "Basic", "Bearer", "allowlist", "redirect", "--share",
|
|
"Gradio process", "temporary", "redact", "not streaming", "does not cancel",
|
|
"Chinese sample", "downloads", "not an authentication", "metadata",
|
|
"not a model-readiness", "not an identity"),
|
|
"GRADIO_zh.md": ("Authorization", "Basic", "Bearer", "allowlist", "重定向", "--share",
|
|
"Gradio 进程", "临时", "脱敏", "不是流式", "不会取消",
|
|
"中文样本", "下载", "不是鉴权", "metadata", "不代表模型就绪", "不是身份"),
|
|
}
|
|
for path in GRADIO_DOCS:
|
|
text = path.read_text(encoding="utf-8")
|
|
for marker in markers[path.name]:
|
|
self.assertIn(marker.lower(), text.lower(), path.name)
|
|
|
|
def test_readmes_delegate_gradio_install_to_isolated_guide(self):
|
|
sections = ("Browser demo with Gradio", "Gradio 浏览器 Demo", "Gradio ブラウザデモ", "Gradio 브라우저 데모")
|
|
for path, heading in zip(READMES, sections):
|
|
text = path.read_text(encoding="utf-8")
|
|
section = text.split("## " + heading + "\n", 1)[1].split("\n## ", 1)[0]
|
|
self.assertNotIn("```", section, path.name)
|
|
self.assertNotIn("pip install", section, path.name)
|
|
self.assertNotIn("python gradio_app.py", section, path.name)
|
|
guide = "GRADIO_zh.md" if path.name == "README_zh.md" else "GRADIO.md"
|
|
self.assertIn(f"]({guide})", section)
|
|
self.assertIn(".venv-gradio", section)
|
|
self.assertIn("Python 3.12", section)
|
|
|
|
|
|
KUBERNETES_DOCS = [EXAMPLE / "kubernetes" / name for name in ("README.md", "README_zh.md")]
|
|
|
|
|
|
class KubernetesDocsContract(unittest.TestCase):
|
|
def test_bilingual_commands_match_and_parse(self):
|
|
self.assertEqual(*(shell_commands(path.read_text(encoding="utf-8")) for path in KUBERNETES_DOCS))
|
|
for path in KUBERNETES_DOCS:
|
|
for code in blocks(path.read_text(encoding="utf-8"), "bash"):
|
|
result = subprocess.run(["bash", "-n"], input=code, text=True, capture_output=True)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
|
|
def test_builds_use_explicit_source_backed_contexts(self):
|
|
expected = {
|
|
"examples/openai_api/Dockerfile": ("examples/openai_api", "server.py", "cpu-latest"),
|
|
"examples/openai_api/Dockerfile.moss": (".", "pyproject.toml", "moss-local"),
|
|
}
|
|
for path in KUBERNETES_DOCS:
|
|
commands = shell_commands(path.read_text(encoding="utf-8"))
|
|
self.assertFalse(any(args[0] == "cd" for args in commands))
|
|
builds = [args[2:] for args in commands if args[:2] == ["docker", "build"]]
|
|
self.assertEqual(len(builds), 2)
|
|
seen = set()
|
|
parser = argparse.ArgumentParser(allow_abbrev=False)
|
|
parser.add_argument("-f", "--file", required=True)
|
|
parser.add_argument("-t", "--tag", required=True)
|
|
parser.add_argument("context")
|
|
for args in builds:
|
|
options = parser.parse_args(args)
|
|
context, source, tag = expected[options.file]
|
|
seen.add(options.file)
|
|
self.assertEqual(options.context, context)
|
|
self.assertTrue((ROOT / context / source).is_file())
|
|
self.assertTrue((ROOT / options.file).is_file())
|
|
dockerfile = (ROOT / options.file).read_text(encoding="utf-8")
|
|
copy_source = "." if source == "pyproject.toml" else source
|
|
self.assertRegex(dockerfile, rf"(?m)^COPY {re.escape(copy_source)} ")
|
|
self.assertTrue(options.tag.endswith(":" + tag))
|
|
self.assertIn(["docker", "push", options.tag], commands)
|
|
self.assertEqual(seen, set(expected))
|
|
|
|
def test_apply_paths_resolve_from_checkout_root(self):
|
|
for path in KUBERNETES_DOCS:
|
|
commands = shell_commands(path.read_text(encoding="utf-8"))
|
|
applies = [args for args in commands if args[:4] == ["kubectl", "-n", "speech", "apply"]]
|
|
self.assertEqual(applies, [
|
|
["kubectl", "-n", "speech", "apply", "-k", "examples/openai_api/kubernetes"],
|
|
["kubectl", "-n", "speech", "apply", "-f", "examples/openai_api/kubernetes/funasr-moss-api.yaml"],
|
|
])
|
|
self.assertTrue((ROOT / applies[0][-1] / "kustomization.yaml").is_file())
|
|
self.assertTrue((ROOT / applies[1][-1]).is_file())
|
|
for deployment in ("funasr-api", "funasr-moss-api"):
|
|
self.assertIn(["kubectl", "-n", "speech", "rollout", "status", "deploy/" + deployment,
|
|
"--timeout=15m"], commands)
|
|
|
|
def test_port_forward_and_smoke_match_each_service(self):
|
|
smoke_path = "examples/openai_api/smoke_test.py"
|
|
options = {ast.literal_eval(arg) for node in ast.walk(tree(ROOT / smoke_path))
|
|
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
|
|
and node.func.attr == "add_argument" for arg in node.args
|
|
if isinstance(arg, ast.Constant) and isinstance(arg.value, str)}
|
|
for path in KUBERNETES_DOCS:
|
|
commands = shell_commands(path.read_text(encoding="utf-8"))
|
|
smokes = [args for args in commands if args[0] == "python3"]
|
|
self.assertEqual(len(smokes), 2)
|
|
for args, service, model, port in zip(smokes, ("funasr-api", "funasr-moss-api"),
|
|
("sensevoice", "moss-transcribe-diarize"), (8000, 8001)):
|
|
self.assertEqual(args[1], smoke_path)
|
|
self.assertLessEqual({arg for arg in args[2:] if arg.startswith("--")}, options)
|
|
self.assertEqual(args[args.index("--model") + 1], model)
|
|
self.assertEqual(args[args.index("--response-format") + 1], "verbose_json")
|
|
self.assertEqual(args[args.index("--base-url") + 1], f"http://127.0.0.1:{port}")
|
|
self.assertIn(["kubectl", "-n", "speech", "port-forward", "--address", "127.0.0.1",
|
|
"svc/" + service, f"{port}:8000"], commands)
|
|
|
|
def test_guides_link_owned_security_and_model_contracts(self):
|
|
for path in KUBERNETES_DOCS:
|
|
text = path.read_text(encoding="utf-8")
|
|
suffix = "_zh" if path.stem.endswith("_zh") else ""
|
|
for link in (f"../SECURITY{suffix}.md", f"../../../docs/moss_transcribe_diarize{suffix}.md"):
|
|
self.assertIn(f"]({link})", text)
|
|
self.assertIn(f"](../SECURITY{suffix}.md)", text.split("```bash", 1)[0])
|
|
for link in re.findall(r"\[[^\]]+\]\(([^)]+)\)", text):
|
|
parts = urlsplit(link)
|
|
if parts.scheme or parts.netloc:
|
|
continue
|
|
target = (path.parent / unquote(parts.path)).resolve()
|
|
self.assertIn(ROOT.resolve(), target.parents)
|
|
self.assertNotIn(".mcp-tasks", target.parts)
|
|
self.assertTrue(target.is_file(), link)
|
|
if parts.fragment:
|
|
self.assertIn(unquote(parts.fragment), headings(target.read_text(encoding="utf-8")))
|
|
|
|
def test_operational_limits_are_explicit(self):
|
|
expected = {
|
|
"README.md": ("repository root", "ClusterIP is not authentication", "PyPI",
|
|
"not an inference check", "does not send", "Python 3.10", "immutable digest",
|
|
"not native vLLM", "speaker identity", "not included in", "kustomization.yaml"),
|
|
"README_zh.md": ("仓库根目录", "ClusterIP 不是鉴权", "PyPI", "不代表推理验收",
|
|
"不发送", "Python 3.10", "不可变 digest", "不是原生 vLLM", "说话人身份",
|
|
"不包含", "kustomization.yaml"),
|
|
}
|
|
for path in KUBERNETES_DOCS:
|
|
text = path.read_text(encoding="utf-8")
|
|
for marker in expected[path.name]:
|
|
self.assertIn(marker, text, path.name)
|
|
|
|
|
|
MATRIX_DOCS = [ROOT / "docs" / ("deployment_matrix" + suffix + ".md")
|
|
for suffix in ("", "_zh", "_ja", "_ko")]
|
|
|
|
|
|
class DeploymentMatrixDocsContract(unittest.TestCase):
|
|
def test_four_language_shell_recipes_match(self):
|
|
expected = shell_commands(MATRIX_DOCS[0].read_text(encoding="utf-8"))
|
|
for path in MATRIX_DOCS:
|
|
self.assertEqual(shell_commands(path.read_text(encoding="utf-8")), expected)
|
|
|
|
def test_compose_recipe_overrides_ambient_settings_in_real_shell(self):
|
|
expected = ["FUNASR_HOST_PORT=127.0.0.1:8000", "FUNASR_DEVICE=cpu", "FUNASR_MODEL=sensevoice",
|
|
"docker", "compose", "-f", "examples/openai_api/docker-compose.yml", "up", "--build"]
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
docker = Path(directory) / "docker"
|
|
docker.write_text('#!/bin/sh\nprintf "%s\\n" "$FUNASR_HOST_PORT" "$FUNASR_DEVICE" '
|
|
'"$FUNASR_MODEL" "$PWD" "$@"\n', encoding="utf-8")
|
|
docker.chmod(0o700)
|
|
env = dict(os.environ, PATH=directory + os.pathsep + os.environ.get("PATH", ""),
|
|
FUNASR_HOST_PORT="0.0.0.0:9999", FUNASR_DEVICE="cuda", FUNASR_MODEL="auto")
|
|
for path in MATRIX_DOCS:
|
|
code = next(code for code in blocks(path.read_text(encoding="utf-8"), "bash")
|
|
if "docker compose" in code)
|
|
# Only the single reviewed launch is executed; the Docker binary is a capture fixture.
|
|
self.assertEqual(shell_commands("```bash\n" + code + "```\n"), [expected])
|
|
result = subprocess.run(["sh", "-c", code], cwd=ROOT, env=env,
|
|
capture_output=True, text=True)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertEqual(result.stdout.splitlines(), ["127.0.0.1:8000", "cpu", "sensevoice",
|
|
str(ROOT), "compose", "-f", "examples/openai_api/docker-compose.yml",
|
|
"up", "--build"])
|
|
self.assertTrue((ROOT / expected[-3]).is_file())
|
|
self.assertTrue((ROOT / expected[-3]).with_name("Dockerfile").is_file())
|
|
|
|
def test_second_terminal_smoke_uses_root_path_and_explicit_model(self):
|
|
source = ROOT / "examples/openai_api/smoke_test.py"
|
|
options = {ast.literal_eval(arg) for node in ast.walk(tree(source))
|
|
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
|
|
and node.func.attr == "add_argument" for arg in node.args
|
|
if isinstance(arg, ast.Constant) and isinstance(arg.value, str)}
|
|
expected = ["python3", str(source.relative_to(ROOT)), "--base-url", "http://127.0.0.1:8000",
|
|
"--model", "sensevoice", "--response-format", "verbose_json"]
|
|
for path in MATRIX_DOCS:
|
|
commands = shell_commands(path.read_text(encoding="utf-8"))
|
|
self.assertEqual(len(commands), 2)
|
|
self.assertEqual(commands[1], expected)
|
|
self.assertLessEqual({arg for arg in expected[2:] if arg.startswith("--")}, options)
|
|
|
|
def test_compose_guidance_explains_scope_and_preserves_owned_links(self):
|
|
markers = {
|
|
"deployment_matrix.md": ("repository root", "second terminal", "does not overwrite", "PyPI",
|
|
"not a locked", "Authorization", "Python 3.10", "sample.wav"),
|
|
"deployment_matrix_zh.md": ("仓库根目录", "第二个终端", "不会覆盖", "PyPI", "未锁定",
|
|
"Authorization", "Python 3.10", "sample.wav"),
|
|
"deployment_matrix_ja.md": ("リポジトリのルート", "別のターミナル", "上書きしません", "PyPI",
|
|
"固定されていません", "Authorization", "Python 3.10", "sample.wav"),
|
|
"deployment_matrix_ko.md": ("저장소 루트", "두 번째 터미널", "덮어쓰지 않습니다", "PyPI",
|
|
"고정되지", "Authorization", "Python 3.10", "sample.wav"),
|
|
}
|
|
for path in MATRIX_DOCS:
|
|
text = path.read_text(encoding="utf-8")
|
|
for marker in markers[path.name]:
|
|
self.assertIn(marker, text, path.name)
|
|
security = "SECURITY_zh.md" if path.stem.endswith("_zh") else "SECURITY.md"
|
|
self.assertIn(f"](../examples/openai_api/{security})", text)
|
|
self.assertTrue((EXAMPLE / security).is_file())
|
|
self.assertNotIn("cp .env.example .env", text)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|