Files
FunASR/tests/test_cli_docs_contract.py

219 lines
9.3 KiB
Python

"""Check CLI documentation against the real parser and formatter, without models."""
import argparse
import importlib.util
import json
import os
from pathlib import Path
import re
import shlex
import subprocess
import sys
import types
from bs4 import BeautifulSoup
from markdown import markdown
import pytest
ROOT = Path(__file__).resolve().parents[1]
DOCS = [ROOT / 'docs/cli.md', ROOT / 'docs/cli_zh.md']
spec = importlib.util.spec_from_file_location('cli_docs_subject', ROOT / 'funasr/cli.py')
cli = importlib.util.module_from_spec(spec)
spec.loader.exec_module(cli)
docs_spec = importlib.util.spec_from_file_location(
'cli_docs_renderer', ROOT / 'web-pages/product-site/documentation.py')
renderer = importlib.util.module_from_spec(docs_spec)
docs_spec.loader.exec_module(renderer)
def source(path):
assert path.is_file(), f'Missing bilingual CLI source: {path.name}'
return path.read_text(encoding='utf-8')
def blocks(text, language):
return re.findall(r'^```' + language + r'\n(.*?)^```', text, re.M | re.S)
@pytest.fixture
def parser(monkeypatch):
captured = []
class Captured(Exception):
pass
def capture(self, *args, **kwargs):
captured.append(self)
raise Captured
with monkeypatch.context() as patch:
patch.setattr(cli, '_get_version', lambda: 'fixture')
patch.setattr(argparse.ArgumentParser, 'parse_args', capture)
with pytest.raises(Captured):
cli.main()
assert len(captured) == 1
return captured[0]
@pytest.mark.parametrize('path', DOCS, ids=lambda p: p.name)
def test_json_example_is_real_formatter_output_not_old_duration_field(path, monkeypatch):
examples = blocks(source(path), 'json')
assert len(examples) == 1
monkeypatch.setitem(sys.modules, 'soundfile', types.SimpleNamespace(
info=lambda _: types.SimpleNamespace(duration=1.2)))
segments = [{'start': 0, 'end': 1200, 'text': 'Example.', 'timestamp': [[0, 1200]]}]
actual = cli._format_output('Example.', segments, [[0, 1200]], 'json',
'audio.wav', 'sensevoice', None, 0.01)
assert json.loads(examples[0]) == json.loads(actual)
assert '"duration_s"' not in source(path)
def test_formatter_missing_audio_metadata_is_null(monkeypatch):
def missing(_):
raise OSError('fixture metadata unavailable')
monkeypatch.setitem(sys.modules, 'soundfile', types.SimpleNamespace(info=missing))
payload = json.loads(cli._format_output('Example.', [], None, 'json',
'missing.wav', 'sensevoice', None, 0.01))
assert payload['audio_duration_s'] is None
assert payload['processing_s'] == 0.01
assert 'segments' not in payload and 'timestamps' not in payload
@pytest.mark.parametrize('path', DOCS, ids=lambda p: p.name)
def test_reference_matches_actual_options_defaults_and_model_choices(path, parser):
text = source(path)
soup = BeautifulSoup(markdown(text, extensions=['extra']), 'html.parser')
rows = {row.select_one('code').get_text(): row.get_text(' ', strip=True)
for table in soup.select('table') for row in table.select('tr')
if row.select_one('code') and row.select_one('code').get_text().startswith('--')}
defaults = parser.parse_args(['audio.wav'])
assert defaults.language is None and defaults.device is None
assert defaults.model == 'sensevoice' and defaults.hub == 'ms'
for action in parser._actions:
long = next((option for option in action.option_strings if option.startswith('--')), None)
if long:
assert long in rows, long
for alias in action.option_strings:
assert alias in rows[long]
if action.dest in ('language', 'device', 'output_dir', 'hotwords'):
assert 'None' in rows[long]
if action.dest in ('model', 'hub', 'output_format', 'subtitle_segment_mode'):
assert str(action.default) in rows[long]
if isinstance(action.default, bool):
assert str(action.default) in rows[long]
for choice in action.choices or []:
assert choice in rows[long]
model_action = next(a for a in parser._actions if a.dest == 'model')
assert set(model_action.choices) == set(cli.MODEL_CONFIGS)
for model in model_action.choices:
assert model in text
with pytest.raises(SystemExit) as stopped:
parser.parse_args(['audio.wav', '--model', 'moss-transcribe-diarize'])
assert stopped.value.code == 2
@pytest.mark.parametrize('path', DOCS, ids=lambda p: p.name)
def test_documented_shell_commands_parse_without_running_inference(path, parser):
for block in blocks(source(path), 'bash'):
checked = subprocess.run(['bash', '-n'], input=block, text=True,
capture_output=True, timeout=10)
assert checked.returncode == 0, checked.stderr
for line in block.splitlines():
args = shlex.split(line, comments=True)
if not args or args[0] != 'funasr':
continue
stop = next((i for i, arg in enumerate(args) if arg in ('|', '>')), len(args))
options = args[1:stop]
if options == ['--help'] or options == ['--version']:
continue
parser.parse_args(options)
def test_bilingual_commands_are_identical():
assert blocks(source(DOCS[0]), 'bash') == blocks(source(DOCS[1]), 'bash')
def test_bilingual_cli_navigation_uses_own_sources():
catalogue = renderer.load_catalogue()
entry = next(page for page in catalogue['pages'] if page['slug'] == 'command-line')
assert entry['source_en'] == 'docs/cli.md'
assert entry['source_zh'] == 'docs/cli_zh.md'
index = (ROOT / 'docs/index.rst').read_text()
assert re.search(r'^\s+cli_zh\s*$', index, re.M)
def test_sphinx_cli_pages_keep_unique_legacy_anchors(tmp_path):
pytest.importorskip('sphinx')
output = tmp_path / 'html'
result = subprocess.run(
[sys.executable, '-m', 'sphinx', '-b', 'html', '-q', str(ROOT / 'docs'), str(output)],
text=True, capture_output=True, timeout=90,
env={**os.environ, 'PYTHONDONTWRITEBYTECODE': '1'})
assert result.returncode == 0, result.stdout + result.stderr
for name in ('cli', 'cli_zh'):
soup = BeautifulSoup((output / f'{name}.html').read_text(), 'html.parser')
ids = [node['id'] for node in soup.select('[id]')]
assert len(ids) == len(set(ids)), name
for anchor in ('command-line-interface', 'installation', 'basic-usage', 'options',
'output-formats', 'text-default', 'json', 'srt', 'tsv',
'advanced-examples', 'models', 'legacy-cli'):
assert anchor in ids, (name, anchor)
@pytest.mark.parametrize('path', DOCS, ids=lambda p: p.name)
def test_existing_public_anchors_and_local_links_remain_valid(path):
from urllib.parse import unquote, urlsplit
soup = BeautifulSoup(markdown(source(path), extensions=['extra', 'toc'],
extension_configs={'toc': {'slugify': renderer.source_slug}}),
'html.parser')
ids = [node['id'] for node in soup.select('[id]')]
expected = {'command-line-interface', 'installation', 'basic-usage', 'options',
'output-formats', 'text-default', 'json', 'srt', 'tsv',
'advanced-examples', 'models', 'legacy-cli'}
assert expected <= set(ids)
assert len(ids) == len(set(ids))
for link in soup.select('a[href]'):
parts = urlsplit(link['href'])
if parts.scheme or parts.netloc:
continue
target = (path.parent / unquote(parts.path)).resolve() if parts.path else path
assert target.is_relative_to(ROOT) and target.is_file(), link['href']
if parts.fragment and target == path:
assert unquote(parts.fragment) in ids
@pytest.mark.parametrize('path', DOCS, ids=lambda p: p.name)
def test_scoped_claims_and_required_boundaries(path):
text = source(path)
for stale in ('~70ms/10s', '~60ms/10s', 'Include word-level timestamps'):
assert stale not in text
for marker in ('audio_duration_s', 'processing_s', 'moss-transcribe-diarize',
'moss_transcribe_diarize', 'AutoModel', 'funasr-server',
'None', '500', '42', 'sentence_info'):
assert marker in text
def test_timestamps_flag_retains_existing_json_data_without_requesting_alignment(tmp_path, monkeypatch, capsys):
audio = tmp_path / 'audio.wav'
audio.write_bytes(b'fixture; no decoder invoked')
seen = []
class Model:
def __init__(self, **kwargs):
pass
def generate(self, **kwargs):
seen.append(kwargs)
return [{'text': 'Example.', 'timestamp': [[0, 1200]]}]
monkeypatch.setitem(sys.modules, 'torch', types.SimpleNamespace(
cuda=types.SimpleNamespace(is_available=lambda: False)))
monkeypatch.setitem(sys.modules, 'funasr', types.SimpleNamespace(AutoModel=Model))
monkeypatch.setitem(sys.modules, 'soundfile', types.SimpleNamespace(
info=lambda _: types.SimpleNamespace(duration=1.2)))
monkeypatch.setattr(sys, 'argv', ['funasr', str(audio), '--timestamps', '-f', 'json'])
cli.main()
payload = json.loads(capsys.readouterr().out)
assert payload['timestamps'] == [[0, 1200]]
assert seen == [{'input': str(audio), 'batch_size': 1}]