mirror of
https://github.com/modelscope/FunASR.git
synced 2026-10-12 02:19:18 +00:00
332 lines
16 KiB
Python
332 lines
16 KiB
Python
"""Small, model-free builds using the real Sphinx configuration and link adapter.
|
|
|
|
Run with Sphinx and the extensions listed in docs/conf.py installed.
|
|
"""
|
|
|
|
import os
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
|
|
from bs4 import BeautifulSoup
|
|
import pytest
|
|
|
|
pytest.importorskip('sphinx')
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
def test_vllm_compatibility_pages_preserve_rendered_section_targets(tmp_path):
|
|
docs = tmp_path / 'docs'
|
|
docs.mkdir()
|
|
shutil.copy2(ROOT / 'docs/conf.py', docs / 'conf.py')
|
|
shutil.copytree(ROOT / 'docs/_ext', docs / '_ext')
|
|
names = ('vllm_guide_zh', 'vllm_guide_zh_v2')
|
|
(docs / 'index.rst').write_text(
|
|
'Guides\n======\n\n.. toctree::\n\n vllm_guide_zh\n vllm_guide_zh_v2\n')
|
|
for name in names:
|
|
shutil.copy2(ROOT / 'docs' / (name + '.md'), docs / (name + '.md'))
|
|
output = tmp_path / 'html'
|
|
result = subprocess.run(
|
|
[sys.executable, '-m', 'sphinx', '-b', 'html', str(docs), str(output)],
|
|
text=True, capture_output=True, timeout=90)
|
|
assert result.returncode == 0, result.stdout + result.stderr
|
|
for name in names:
|
|
soup = BeautifulSoup((output / (name + '.html')).read_text(), 'html.parser')
|
|
article = soup.select_one('[role=main]')
|
|
pairs = []
|
|
for section in article.select('section[id],div.section[id]'):
|
|
heading = section.find(['h1', 'h2', 'h3', 'h4', 'h5', 'h6'], recursive=False)
|
|
ids = [section['id']] + [
|
|
node['id'] for node in section.find_all(id=True, recursive=False)
|
|
if node.name == 'span']
|
|
pairs.append([ids, heading.get_text() if heading else None])
|
|
# Published pre-consolidation Sphinx output: 40 heading/target pairs,
|
|
# including numeric ids and the duplicate Chinese heading alias.
|
|
assert len(pairs) == 40
|
|
digest = hashlib.sha256(json.dumps(pairs, ensure_ascii=False).encode()).hexdigest()
|
|
assert digest == 'fe2775e9be07ba6a8bc7cb899a41fe7441ca9d1710ec7b7aee425dfff26a095e', name
|
|
mirror = BeautifulSoup((output / 'vllm_guide_zh_v2.html').read_text(), 'html.parser')
|
|
assert mirror.select_one('a[href="vllm_guide_zh.html"]')
|
|
|
|
GUIDE = '''# Link fixture
|
|
|
|
[Body code](../../examples/demo.py)
|
|
[Body model guide](../../examples/guide.md)
|
|
[Body directory](../../examples/)
|
|
[Body license](../../LICENSE)
|
|
[Body encoded](../../examples/folder%20name/guide.md?view=1&lang=zh#usage)
|
|
[Body Unicode](../../examples/中文/guide.md)
|
|
[Body internal](../training.md)
|
|
[Body query](../training.md?view=1#details)
|
|
[Body query without fragment](../training.md?view=2)
|
|
[Body query raw anchor](../training.md?view=1#%72aw-anchor)
|
|
[Body query missing](../training.md?view=1#body-query-ABSENT)
|
|
[Body later target](../zz_later.md?view=1#details)
|
|
[Body external](https://docs.example.test/guide.md?view=1#usage)
|
|
[Body cross-host](//docs.example.test/guide.md)
|
|
[Body root](../../../outside.txt)
|
|
[Body missing](../../examples/missing.py)
|
|
[Body missing fragment](../training.md#absent-section)
|
|
[Numbered heading](../training.md#1-installation--environment)
|
|
[Unicode heading](../training.md#安装或-import-失败)
|
|
[Encoded heading](../training.md#%E5%AE%89%E8%A3%85%E6%88%96-import-%E5%A4%B1%E8%B4%A5)
|
|
[Punctuation heading](../training.md#llamacpp-or-gguf)
|
|
[Repeated heading](../training.md#repeat-1)
|
|
[Local numbered heading](#2-local--section)
|
|
[Colliding heading](../training.md#collision-1)
|
|
|
|
## 2. Local & section
|
|
|
|
| Case | Destination |
|
|
| --- | --- |
|
|
| code | [Table code](../../examples/demo.py) |
|
|
| markdown | [Table model guide](../../examples/guide.md) |
|
|
| directory | [Table directory](../../examples/) |
|
|
| encoded | [Table encoded](../../examples/folder%20name/guide.md?view=1&lang=zh#usage) |
|
|
| Unicode | [Table Unicode](../../examples/中文/guide.md) |
|
|
| internal | [Table internal](../training.md?view=1#details) |
|
|
| query | [Table query without fragment](../training.md?view=2) |
|
|
| raw anchor | [Table raw anchor](../training.md?view=1#%72aw-anchor) |
|
|
| missing fragment | [Table missing fragment](../training.md#table-ABSENT) |
|
|
| missing query fragment | [Table missing query fragment](../training.md?view=1#table-query-ABSENT) |
|
|
| later target | [Table later target](../zz_later.md?view=1#details) |
|
|
| rst | [Table RST](../reference.rst) |
|
|
| external | [Table external](https://docs.example.test/guide.md?view=1#usage) |
|
|
| cross-host | [Table cross-host](//docs.example.test/guide.md) |
|
|
| missing | [Table missing](../../examples/table-missing.py) |
|
|
| escape | [Table escape](../../../outside.txt) |
|
|
| symlink | [Table symlink](../../examples/escape.txt) |
|
|
| numbered | [Table numbered](../training.md#1-installation--environment) |
|
|
| Unicode | [Table Unicode heading](../training.md#安装或-import-失败) |
|
|
| collision | [Table colliding heading](../training.md#collision-1) |
|
|
|
|
<a href="//docs.example.test/raw.md" data-label="do not alter">Raw cross-host</a>
|
|
<a href="/other-site/guide.md">Root-relative</a>
|
|
<a href="mailto:[email protected]">Mail</a>
|
|
<a href="#local-anchor">Local fragment</a>
|
|
<span id="local-anchor"></span>
|
|
<a href="../training.md#raw-ABSENT">Raw missing fragment</a>
|
|
<a href="#self-ABSENT">Raw missing self fragment</a>
|
|
<a href="../training.md#collision-1">Raw colliding heading</a>
|
|
|
|
```python
|
|
print("../examples/demo.py is code, not a link")
|
|
```
|
|
'''
|
|
|
|
|
|
@pytest.fixture(scope='module')
|
|
def built(tmp_path_factory):
|
|
work = tmp_path_factory.mktemp('sphinx-links')
|
|
repo = work / 'repo'
|
|
docs = repo / 'docs'
|
|
(docs / 'tutorial').mkdir(parents=True)
|
|
shutil.copy2(ROOT / 'docs/conf.py', docs / 'conf.py')
|
|
if (ROOT / 'docs/_ext').is_dir():
|
|
shutil.copytree(ROOT / 'docs/_ext', docs / '_ext')
|
|
(docs / 'index.rst').write_text(
|
|
'Fixture\n=======\n\n.. toctree::\n\n tutorial/guide\n tutorial/guide_zh\n training\n reference\n zz_later\n')
|
|
for name in ('guide.md', 'guide_zh.md'):
|
|
(docs / 'tutorial' / name).write_text(GUIDE, encoding='utf-8')
|
|
(docs / 'training.md').write_text(
|
|
'# Training\n\n## Details\n\nReal destination.\n\n<span id="raw-anchor"></span>\n'
|
|
'\n## 1. Installation & Environment\n\nInstall.\n'
|
|
'\n## 安装或 `import` 失败\n\nDiagnose.\n'
|
|
'\n## llama.cpp or GGUF\n\nRuntime.\n'
|
|
'\n## Repeat\n\nFirst.\n\n## Repeat\n\nSecond.\n'
|
|
'\n## Collision\n\nFirst collision.\n\n## Collision\n\nSecond collision.\n'
|
|
'\n## Collision-1\n\nThird collision.\n'
|
|
'\n<span id="rawcollision"></span>\n\n## Raw.Collision\n\nRaw collision.\n',
|
|
encoding='utf-8')
|
|
(docs / 'reference.rst').write_text('Reference\n=========\n\nExisting RST page.\n')
|
|
# Sphinx reads this target after tutorial/*, so early doctree access fails.
|
|
(docs / 'zz_later.md').write_text('# Later target\n\n## Details\n\nLate-read destination.\n')
|
|
for name in ('examples/demo.py', 'examples/guide.md', 'LICENSE',
|
|
'examples/folder name/guide.md', 'examples/中文/guide.md'):
|
|
path = repo / name
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text('fixture\n', encoding='utf-8')
|
|
(work / 'outside.txt').write_text('outside repository\n')
|
|
(repo / 'examples/escape.txt').symlink_to(work / 'outside.txt')
|
|
original = {p: p.read_bytes() for p in docs.rglob('*') if p.is_file()}
|
|
output = work / 'html'
|
|
command = [sys.executable, '-m', 'sphinx', '-b', 'html', '-n', '-E',
|
|
'-d', str(work / 'doctrees'), str(docs), str(output)]
|
|
result = subprocess.run(command, text=True, capture_output=True, timeout=90,
|
|
env={**os.environ, 'PYTHONDONTWRITEBYTECODE': '1'})
|
|
assert result.returncode == 0, result.stdout + result.stderr
|
|
assert all(p.read_bytes() == text for p, text in original.items())
|
|
pages = {name: BeautifulSoup((output / f'tutorial/{name}.html').read_text(), 'html.parser')
|
|
for name in ('guide', 'guide_zh')}
|
|
return output, pages, result.stdout + result.stderr
|
|
|
|
|
|
@pytest.mark.parametrize('language', ('guide', 'guide_zh'))
|
|
@pytest.mark.parametrize('label,path', (
|
|
('Body code', 'blob/main/examples/demo.py'),
|
|
('Body model guide', 'blob/main/examples/guide.md'),
|
|
('Body directory', 'tree/main/examples'),
|
|
('Body license', 'blob/main/LICENSE'),
|
|
('Body encoded', 'blob/main/examples/folder%20name/guide.md?view=1&lang=zh#usage'),
|
|
('Body Unicode', 'blob/main/examples/%E4%B8%AD%E6%96%87/guide.md'),
|
|
('Table code', 'blob/main/examples/demo.py'),
|
|
('Table model guide', 'blob/main/examples/guide.md'),
|
|
('Table directory', 'tree/main/examples'),
|
|
('Table encoded', 'blob/main/examples/folder%20name/guide.md?view=1&lang=zh#usage'),
|
|
('Table Unicode', 'blob/main/examples/%E4%B8%AD%E6%96%87/guide.md'),
|
|
))
|
|
def test_existing_repository_targets_use_github(built, language, label, path):
|
|
_, pages, _ = built
|
|
link = pages[language].find('a', string=label)
|
|
assert link is not None
|
|
assert link['href'] == 'https://github.com/modelscope/FunASR/' + path
|
|
|
|
|
|
@pytest.mark.parametrize('language', ('guide', 'guide_zh'))
|
|
def test_internal_table_links_use_builder_routes(built, language):
|
|
output, pages, _ = built
|
|
page = pages[language]
|
|
assert page.find('a', string='Body internal')['href'] == '../training.html'
|
|
assert page.find('a', string='Table internal')['href'] == '../training.html?view=1#details'
|
|
assert page.find('a', string='Table RST')['href'] == '../reference.html'
|
|
assert (output / 'training.html').is_file()
|
|
assert BeautifulSoup((output / 'training.html').read_text(), 'html.parser').find(id='details')
|
|
|
|
|
|
@pytest.mark.parametrize('language', ('guide', 'guide_zh'))
|
|
def test_body_and_table_queries_resolve_identically(built, language):
|
|
_, pages, log = built
|
|
page = pages[language]
|
|
for body, table, uri in (
|
|
('Body query', 'Table internal', '../training.html?view=1#details'),
|
|
('Body query without fragment', 'Table query without fragment', '../training.html?view=2'),
|
|
('Body query raw anchor', 'Table raw anchor', '../training.html?view=1#%72aw-anchor'),
|
|
('Body later target', 'Table later target', '../zz_later.html?view=1#details'),
|
|
):
|
|
assert page.find('a', string=body)['href'] == uri
|
|
assert page.find('a', string=table)['href'] == uri
|
|
warnings = [line for line in log.splitlines() if 'WARNING:' in line]
|
|
assert not any('raw-anchor' in line or '%72aw-anchor' in line or '#details' in line
|
|
or 'docs.example.test' in line or '#local-anchor' in line for line in warnings)
|
|
|
|
|
|
@pytest.mark.parametrize('fragment', (
|
|
'body-query-ABSENT', 'table-ABSENT', 'table-query-ABSENT', 'raw-ABSENT', 'self-ABSENT',
|
|
))
|
|
def test_unresolved_query_and_raw_fragments_warn(built, fragment):
|
|
_, _, log = built
|
|
warnings = [line for line in log.splitlines() if 'WARNING:' in line]
|
|
assert any(fragment in line and '[repository_links.fragment]' in line for line in warnings)
|
|
|
|
|
|
@pytest.mark.parametrize('language', ('guide', 'guide_zh'))
|
|
def test_external_hosts_root_urls_and_code_are_untouched(built, language):
|
|
_, pages, _ = built
|
|
page = pages[language]
|
|
for label, uri in (
|
|
('Body external', 'https://docs.example.test/guide.md?view=1#usage'),
|
|
('Table external', 'https://docs.example.test/guide.md?view=1#usage'),
|
|
('Body cross-host', '//docs.example.test/guide.md'),
|
|
('Table cross-host', '//docs.example.test/guide.md'),
|
|
('Raw cross-host', '//docs.example.test/raw.md'),
|
|
('Root-relative', '/other-site/guide.md'),
|
|
('Mail', 'mailto:[email protected]'),
|
|
('Local fragment', '#local-anchor'),
|
|
):
|
|
assert page.find('a', string=label)['href'] == uri
|
|
assert page.find('a', string='Raw cross-host')['data-label'] == 'do not alter'
|
|
assert any('print("../examples/demo.py is code, not a link")' in pre.get_text()
|
|
for pre in page.select('pre'))
|
|
|
|
|
|
def test_missing_targets_and_fragments_still_warn(built):
|
|
_, pages, log = built
|
|
warnings = [line for line in log.splitlines() if 'WARNING:' in line]
|
|
for marker in ('missing.py', 'table-missing.py', 'outside.txt', 'escape.txt', 'absent-section'):
|
|
assert any(marker in line for line in warnings), (marker, warnings)
|
|
assert not any('examples/demo.py' in line or 'examples/guide' in line for line in warnings)
|
|
for page in pages.values():
|
|
assert page.find('a', string='Body missing')['href'] == '../../examples/missing.py'
|
|
assert page.find('a', string='Table missing')['href'] == '../../examples/table-missing.py'
|
|
assert page.find('a', string='Table escape')['href'] == '../../../outside.txt'
|
|
assert page.find('a', string='Table symlink')['href'] == '../../examples/escape.txt'
|
|
|
|
|
|
@pytest.mark.parametrize('language', ('guide', 'guide_zh'))
|
|
def test_source_heading_fragments_remain_clickable(built, language):
|
|
from urllib.parse import unquote
|
|
|
|
output, pages, log = built
|
|
target = BeautifulSoup((output / 'training.html').read_text(), 'html.parser')
|
|
for label, fragment in (
|
|
('Numbered heading', '1-installation--environment'),
|
|
('Unicode heading', '安装或-import-失败'),
|
|
('Encoded heading', '安装或-import-失败'),
|
|
('Punctuation heading', 'llamacpp-or-gguf'),
|
|
('Repeated heading', 'repeat-1'),
|
|
('Table numbered', '1-installation--environment'),
|
|
('Table Unicode heading', '安装或-import-失败'),
|
|
):
|
|
link = pages[language].find('a', string=label)
|
|
assert link is not None, label
|
|
assert unquote(link['href']) == '../training.html#' + fragment
|
|
assert target.find(id=fragment), fragment
|
|
assert not any(fragment in line for line in log.splitlines() if 'WARNING:' in line)
|
|
local = pages[language].find('a', string='Local numbered heading')
|
|
assert local is not None
|
|
assert local['href'] == '#2-local--section'
|
|
assert pages[language].find(id='2-local--section')
|
|
# Existing Sphinx IDs are public URLs too; aliases must not replace them.
|
|
assert target.find(id='details')
|
|
assert target.find(id='llama-cpp-or-gguf')
|
|
ids = [element['id'] for element in target.select('[id]')]
|
|
assert len(ids) == len(set(ids))
|
|
|
|
|
|
@pytest.mark.parametrize('language', ('guide', 'guide_zh'))
|
|
@pytest.mark.parametrize('label', ('Colliding heading', 'Table colliding heading', 'Raw colliding heading'))
|
|
def test_colliding_alias_links_reach_the_intended_heading(built, language, label):
|
|
output, pages, log = built
|
|
page = BeautifulSoup((output / 'training.html').read_text(), 'html.parser')
|
|
fragment = pages[language].find('a', string=label)['href'].split('#')[1]
|
|
target = page.find(id=fragment)
|
|
section = target if target.name == 'section' else target.find_parent('section')
|
|
assert 'Second collision.' in section.get_text()
|
|
assert 'Third collision.' not in section.get_text()
|
|
assert len(page.select('[id="rawcollision"]')) == 1
|
|
assert 'rawcollision' in log and '[repository_links.collision]' in log
|
|
assert page.find(id='collision-1'), 'Do not remove existing Sphinx URLs'
|
|
|
|
|
|
@pytest.mark.parametrize('builder', ('text', 'latex'))
|
|
def test_non_html_builders_keep_sphinx_fragment_resolution(tmp_path, builder):
|
|
docs = tmp_path / 'repo' / 'docs'
|
|
docs.mkdir(parents=True)
|
|
shutil.copy2(ROOT / 'docs/conf.py', docs / 'conf.py')
|
|
shutil.copytree(ROOT / 'docs/_ext', docs / '_ext')
|
|
(docs / 'index.rst').write_text('Fixture\n=======\n\n.. toctree::\n\n guide\n target\n')
|
|
(docs / 'guide.md').write_text('# Guide\n\n[Valid](target.md#details)\n\n[Missing](target.md#ABSENT)\n')
|
|
(docs / 'target.md').write_text('# Target\n\n## Details\n\nDestination.\n')
|
|
result = subprocess.run(
|
|
[sys.executable, '-m', 'sphinx', '-b', builder, '-n', '-E',
|
|
str(docs), str(tmp_path / 'output')],
|
|
text=True, capture_output=True, timeout=90,
|
|
env={**os.environ, 'PYTHONDONTWRITEBYTECODE': '1'},
|
|
)
|
|
assert result.returncode == 0, result.stdout + result.stderr
|
|
assert 'ABSENT' in result.stderr or 'absent' in result.stderr
|
|
assert 'WARNING' in result.stderr
|
|
|
|
|
|
def test_faq_points_to_the_existing_speaker_contract():
|
|
source = (ROOT / 'docs/reference/FQA.md').read_text()
|
|
assert 'speaker-verification--diarization-eres2netv2' not in source
|
|
assert '../python_api.md#vad-timestamps-and-speakers' in source
|
|
from markdown import markdown
|
|
page = BeautifulSoup(markdown((ROOT / 'docs/python_api.md').read_text(),
|
|
extensions=['extra', 'toc']), 'html.parser')
|
|
assert page.find(id='vad-timestamps-and-speakers')
|