{e(shown_name)}{badges_html}
{e(function['signature'])}
{e(function["description"])}
' if function["description"] else ""} {doc_html} {params_html} {calls_html}{e(t("view_source"))}
{e(function['source'])}
"""生成软件包的网站。 用法: python website/build_site.py # 输出到 dist/site python website/build_site.py -o build/site # 指定输出目录 python website/build_site.py --source PyPI/src/guan 网站的内容有四个来源,都不是手工维护的: * 包源码(静态解析,见 extract.py)—— API 参考 * 仓库根目录的 README.md(渲染,见 md_render.py)—— 关于、用法、引用 * 仓库根目录的 INSTALLATION.md(渲染,见 md_render.py)—— 安装 * 仓库根目录的 tutorials/(读取,见 tutorials.py)—— 教程与示例 因此不会与实际发布的包脱节。 构建过程不假设任何固定的包名、目录名或函数名: * 包目录自动发现(PyPI/src、src 等常见布局都能找到) * 页面与导航按实际有内容与否动态生成 * 首页示例代码从源码里真实的函数挑出来,而不是写死 站点有中英文两套界面(见 i18n.py):中文在根目录,英文在 en/ 子目录。 只有网站框架(导航、按钮、标题这些)会翻译,文件内容一律保持原样。 """ from __future__ import annotations import argparse import html import json import re import shutil import sys import urllib.parse from dataclasses import dataclass from pathlib import Path from typing import Callable # 允许 `python -m website.build_site` 与 `python website/build_site.py` 两种调用方式 sys.path.insert(0, str(Path(__file__).resolve().parent)) import extract # noqa: E402 import i18n # noqa: E402 import md_render # noqa: E402 import tutorials # noqa: E402 ROOT = Path(__file__).resolve().parent.parent PYPI_DIR = ROOT / "PyPI" TEMPLATE_DIR = Path(__file__).resolve().parent / "templates" STATIC_DIR = Path(__file__).resolve().parent / "static" # 源码可能放在这些地方,按顺序找 SOURCE_CANDIDATES = ( PYPI_DIR / "src", ROOT / "src", ROOT, ) # 教程目录(示例脚本)可能放在这些地方,按顺序找 TUTORIALS_CANDIDATES = ( ROOT / "tutorials", PYPI_DIR / "tutorials", ) # 首页文件名。首页在地址栏里显示成目录(/ 与 /en/),不带 index.html HOME_FILENAME = "index.html" def e(text: object) -> str: """HTML 转义。""" return html.escape(str(text), quote=True) def display_name(package: dict) -> str: """站点上展示的名字(品牌名)。 PyPI 上的包名与导入名必须是小写的(pip install guan、import guan), 但网站标题、页头品牌这类地方用大写更醒目——所以展示名与包名分开。 展示名仍由包名推导,不写死具体的名字。 """ name = package.get("display_name") or package["name"] if name.islower() and name.isalnum(): return name.upper() return name def load_template(name: str) -> str: return (TEMPLATE_DIR / name).read_text(encoding="utf-8") def render(template: str, **context: object) -> str: """极简模板替换:{{ key }} 形式。 只扫描模板本身一次,替换进去的内容不会被二次替换—— 否则源码或 README 里出现 {{version}} 之类会被误改。 """ return re.sub( r"\{\{\s*(\w+)\s*\}\}", lambda match: str(context.get(match.group(1), match.group(0))), template, ) # -------------------------------------------------------------------------- # 页面定义 # -------------------------------------------------------------------------- @dataclass class PageSpec: """一个页面的定义。 页面是否出现在导航里,由 available 决定——这样源码里没有函数时, 「API 参考」页会自动消失,而不是留下一个空页面。 标题与导航文字都用 i18n.py 里的文案键,两种语言各写一份; title_fn 用于标题里要带包名这类动态内容的情况。 """ key: str filename: str label_key: str title_key: str builder: Callable[[dict], str] description_fn: Callable[[dict], str] title_fn: Callable[[dict], str] | None = None available: Callable[[dict], bool] | None = None def page_title(self, data: dict) -> str: if self.title_fn is not None: return self.title_fn(data) return data["t"](self.title_key) def description(self, data: dict) -> str: return self.description_fn(data) def _has_functions(data: dict) -> bool: """包里有没有可展示的函数(类的方法也算)。""" if data["stats"]["functions"]: return True return any(module["classes"] for module in data["modules"]) def _has_installation(data: dict) -> bool: """有没有安装说明文档可渲染。""" return Path(data["meta"]["installation_file"]).exists() def _has_tutorials(data: dict) -> bool: """tutorials/ 目录里有没有可展示的示例脚本。""" tutorials_data = data.get("tutorials") or {} return bool(tutorials_data.get("files")) PAGE_SPECS: list[PageSpec] = [ PageSpec( key="index", filename="index.html", label_key="nav_home", title_key="page_home", title_fn=lambda data: ( f"{display_name(data['package'])} — " f"{data['package']['description'] or data['package'].get('summary') or ''}" ).strip(" —"), builder=lambda data: build_home(data), description_fn=lambda data: data["t"]( "page_home", name=display_name(data["package"]) ), ), PageSpec( key="installation", filename="installation.html", label_key="nav_installation", title_key="page_installation", builder=lambda data: build_installation(data), description_fn=lambda data: data["t"]( "desc_installation", name=display_name(data["package"]) ), available=_has_installation, ), PageSpec( key="api", filename="api.html", label_key="nav_api", title_key="page_api", builder=lambda data: build_api(data), description_fn=lambda data: data["t"]( "desc_api", name=display_name(data["package"]) ), available=_has_functions, ), PageSpec( key="tutorials", filename="tutorials.html", label_key="nav_tutorials", title_key="page_tutorials", builder=lambda data: build_tutorials(data), description_fn=lambda data: data["t"]( "desc_tutorials", name=display_name(data["package"]) ), available=_has_tutorials, ), PageSpec( key="about", filename="about.html", label_key="nav_about", title_key="page_about", builder=lambda data: build_readme(data), description_fn=lambda data: data["t"]( "desc_about", name=display_name(data["package"]) ), ), ] def active_pages(data: dict) -> list[PageSpec]: """返回实际要生成的页面列表。""" pages = [] for spec in PAGE_SPECS: if spec.available is not None and not spec.available(data): continue pages.append(spec) return pages def lang_url(lang: str, filename: str, from_lang: str) -> str: """另一种语言里同一个页面的相对路径(相对于当前页面)。 中文页面在根目录,英文页面在 en/ 子目录,所以两个方向要分别处理。 首页用目录地址表示(/ 与 /en/),不带 index.html。 """ target_dir = i18n.LANG_DIRS[lang] current_dir = i18n.LANG_DIRS[from_lang] if filename == HOME_FILENAME: # 首页指向目录本身,而不是 index.html if target_dir == current_dir: return "./" if not target_dir: return "../" if not current_dir: return target_dir + "/" return "../" + target_dir + "/" if target_dir == current_dir: return filename if not target_dir: return "../" + filename if not current_dir: return target_dir + "/" + filename return "../" + target_dir + "/" + filename def lang_dir(output: Path, lang: str) -> Path: """某种语言的页面所在目录。""" directory = i18n.LANG_DIRS[lang] return output / directory if directory else output def absolute_page_url(base: str, lang: str, filename: str) -> str: """页面的绝对地址(canonical 与 hreflang 用)。 首页用目录地址表示:/ 与 /en/,不带 index.html; 其余页面是 /api.html 这种带后缀的地址。 不知道站点根地址时退回到相对地址,页面至少还能互相跳转。 """ directory = i18n.LANG_DIRS[lang] if filename == HOME_FILENAME: if not base: return "./" return base + "/" + (directory + "/" if directory else "") if not base: return filename return base + "/" + "/".join(part for part in (directory, filename) if part) def site_base_url(package: dict, override: str | None = None) -> str: """站点根地址。 优先用命令行给的 --base-url;没给就从包元数据里的主页地址推。 末尾的斜杠去掉,免得拼出 "https://x.com//api.html" 这种双斜杠。 """ candidate = override or package.get("url") or "" return candidate.strip().rstrip("/") def _lang_context(data: dict, lang: str, pages: list[PageSpec]) -> dict: """按语言准备一份数据:文案翻译器,加上该语言的导航标签。""" t = i18n.translator(lang) meta = dict(data["meta"]) meta["page_links"] = [ {"key": spec.key, "filename": spec.filename, "label": t(spec.label_key)} for spec in pages if spec.key != "index" ] return {**data, "meta": meta, "t": t, "lang": lang} def nav_html(pages: list[PageSpec], active_key: str, t) -> str: """生成导航条(顶栏与页脚共用)。 首页指向当前目录(./),也就是 / 与 /en/,不带 index.html。 """ items = [] for spec in pages: class_attr = "is-active" if spec.key == active_key else "" href = home_href() if spec.key == "index" else spec.filename items.append( f'{e(t(spec.label_key))}' ) return "\n ".join(items) def home_href() -> str: """首页链接(相对于当前页面所在的目录)。 页面都在自己语言的目录里(中文在根目录,英文在 en/), 所以 "./" 正好指向该语言的首页:/ 与 /en/。 """ return "./" def page( template: str, *, title: str, description: str, content: str, info: dict, stats: dict, pages: list[PageSpec], active: str, lang: str, filename: str, site_url: str = "", extra_js: str = "", ) -> str: """套上 base.html 外壳。""" package = info t = i18n.translator(lang) other = next(item for item in i18n.LANGS if item != lang) return render( template, # 标题与描述会进到
{e(t("hero_eyebrow"))}
{e(package['description'] or package.get('summary') or '')}
pip install --upgrade {e(package['name'])}
{hero_links}
| {e(t("field_display_name"))} | {e(display_name(package))} |
| {e(t("field_pypi_name"))} | {e(package['name'])} |
| {e(t("field_import_name"))} | {e(import_name)} |
| {e(t("field_version"))} | {e(package['version'])} |
| {e(t("field_python"))} | {e(package['requires_python'] or '>=3.6')} |
| {e(t("field_homepage"))} | {e(package.get('url') or '—')} |
{e(default)}'
if default
else f'{e(t("param_required"))}'
)
annotation = (
f'{e(param["annotation"])}'
if param["annotation"]
else ""
)
param_rows.append(
f'{e(param["name"])}| {e(t("param_name"))} | {e(t("param_type"))} | {e(t("param_default"))} |
|---|
{e(function["section"])}
' if function.get("section") else "" ) calls_html = "" if function.get("calls"): links = [] for call in function["calls"]: target = home_of.get(call) if target: links.append( f'{e(import_name)}.{e(call)}'
)
else:
links.append(f"{e(import_name)}.{e(call)}")
calls_html = f'{e(t("calls"))}{", ".join(links)}
' doc_html = _docstring_html(function) anchor_id = anchor or f"{module_name}.{function['name']}" owner = function.get("owner") or "" shown_name = f"{owner}.{function['name']}" if owner else function["name"] return f"""{e(shown_name)}{badges_html}{e(function['signature'])}
{e(function["description"])}
' if function["description"] else ""} {doc_html} {params_html} {calls_html}{e(function['source'])}
{e(p)}
" for p in rest) return f'{e(t("base_classes"))}{", ".join(f"{e(b)}" for b in klass["bases"])}
{e(klass["description"])}
' if klass["description"] else ""} {bases} {methods}{e(t("parse_error"))}:{e(module["error"])}
' if module.get("error") else "" ) # 只有真的有函数时才写「函数」这个分组标题, # 否则像包根目录那种空模块会留下一对空标题和空网格 functions_html = ( f'{e(t("file_not_found", path=source_path))}
' ) try: markdown = doc_path.read_text(encoding="utf-8") except (OSError, UnicodeDecodeError): try: markdown = doc_path.read_text(encoding="gbk") except (OSError, UnicodeDecodeError): markdown = "" rendered, toc = md_render.render_document(markdown) toc_html = "" if toc and show_toc: items = "\n".join( f'{lead}
\n' if lead else "" layout_class = "doc-layout" if toc_html else "doc-layout doc-layout-flat" return f"""{md_render.render_inline(description)}
' if description else "" ) source_link = ( f' · {e(t("view_original"))}' if item.get("source") else "" ) return f"""{e(item['code'])}
{e(t("download_script"))}{source_link}
{e(subtitle_text)}
' if subtitle_text else "" ) body.append( f"""{title}:{target}
""" def write_redirect_stubs(output: Path, pages: list[PageSpec], site_url: str) -> list[str]: """给每个页面生成一个不带 .html 的入口。 访问 /about 时,服务器会去找 /about 这个目录里的 index.html, 找到的就是这里生成的重定向页,它会跳到 /about.html。 服务器上配了重定向规则(把 /about 直接 301 到 /about.html)时走不到这一层; 这里生成的文件是纯静态部署时的兜底。 """ written: list[str] = [] for lang in i18n.LANGS: directory = lang_dir(output, lang) for spec in pages: if spec.filename == HOME_FILENAME: continue # 首页用目录地址(/ 与 /en/),不需要重定向 name = Path(spec.filename).stem stub_dir = directory / name stub_dir.mkdir(parents=True, exist_ok=True) target = "../" + spec.filename title = f"正在跳转到 {target}" # 不知道站点根地址时,规范地址用相对地址(相对于这个重定向页) canonical = ( absolute_page_url(site_url, lang, spec.filename) if site_url else target ) stub = stub_dir / "index.html" stub.write_text( REDIRECT_TEMPLATE.format( lang=i18n.HTML_LANG[lang], title=e(title), target=e(target), canonical=e(canonical), target_js=json.dumps(target), ), encoding="utf-8", ) written.append(stub.relative_to(output).as_posix()) return written def clean_output(output: Path) -> None: """清空输出目录,只留下本次要生成的东西。 先删文件、再按层级从深到浅删目录:某个文件被占用时, 不影响其余文件的重建;实在删不掉的目录最后再整体试一次。 """ if not output.exists(): return paths = sorted(output.rglob("*"), key=lambda p: len(p.parts), reverse=True) for path in paths: try: if path.is_symlink() or path.is_file(): path.unlink() elif path.is_dir(): path.rmdir() except OSError: pass for path in paths: if path.is_dir(): try: shutil.rmtree(path) except OSError: pass def verify_output(output: Path) -> list[str]: """检查生成的页面:内部链接指向的文件与锚点都必须存在。 源码一旦大改,很容易出现「函数改名后旧锚点失效」这类问题, 构建时顺手查一遍,可以第一时间发现。 链接都是相对于页面自己所在目录的,所以每个语言目录都要单独查。 """ problems: list[str] = [] pages = sorted(output.rglob("*.html")) anchors: dict[str, set[str]] = {} for page_file in pages: text = page_file.read_text(encoding="utf-8") key = page_file.relative_to(output).as_posix() anchors[key] = set(re.findall(r'id="([^"]+)"', text)) for page_file in pages: text = page_file.read_text(encoding="utf-8") key = page_file.relative_to(output).as_posix() for href in re.findall(r'href="([^"]+)"', text): href = html.unescape(href) if href.startswith(("http://", "https://", "mailto:", "data:", "javascript:")): continue if href.startswith("#"): anchor = urllib.parse.unquote(href[1:]) if anchor and anchor not in anchors[key]: problems.append(f"{key}:锚点 {href} 不存在") continue target, _, fragment = href.partition("#") target = urllib.parse.unquote(target) if not target: continue target_path = (page_file.parent / target).resolve() if not target_path.exists(): problems.append(f"{key}:链接 {href} 指向的文件不存在") continue if fragment and target.endswith(".html"): try: target_key = target_path.relative_to(output.resolve()).as_posix() except ValueError: continue if urllib.parse.unquote(fragment) not in anchors.get(target_key, set()): problems.append(f"{key}:链接 {href} 的锚点不存在") # 页面里引用的本地文件(图片等)也要在 for src in re.findall(r'src="([^"]+)"', text): src = html.unescape(src).split("#")[0].split("?")[0] if not src or src.startswith(("http:", "https:", "data:", "//", "/")): continue if not (page_file.parent / urllib.parse.unquote(src)).exists(): problems.append(f"{key}:引用的文件 {src} 不存在") return problems def generate( source_dir: Path | None = None, output: Path = ROOT / "dist" / "site", readme_path: Path | None = None, installation_path: Path | None = None, tutorials_dir: Path | None = None, base_url: str | None = None, quiet: bool = False, ) -> dict: """生成网站,返回提取到的数据字典。 source_dir 包源码目录;为 None 时自动发现 output 网站输出目录 readme_path README.md 路径,用于生成「关于」页面 installation_path INSTALLATION.md 路径,用于生成「安装」页面 tutorials_dir 示例脚本目录;为 None 时自动发现,没有就不生成「教程」页 base_url 站点根地址;为 None 时从包元数据里的主页地址推 """ if source_dir is None: source_dir = extract.discover_package_dir(list(SOURCE_CANDIDATES)) if source_dir is None: raise SystemExit( "找不到包源码目录。请用 --source 指定,例如 --source PyPI/src/<包名>" ) source_dir = Path(source_dir).resolve() if not source_dir.is_dir(): raise SystemExit(f"包源码目录不存在或不是目录:{source_dir}") output = Path(output).resolve() clean_output(output) output.mkdir(parents=True, exist_ok=True) # 界面文案必须两种语言都写全,缺了就直接报错, # 免得构建出「一半中文一半英文」的页面 incomplete = i18n.missing_keys() if incomplete: raise SystemExit( "界面文案不完整:\n " + "\n ".join(incomplete) ) # 解析、导出关系、交叉参考分三步做,每一步只用做一次 modules = extract.parse_modules(source_dir) metadata_dir = _metadata_dir(source_dir) info = extract.parse_package_info( metadata_dir / "pyproject.toml", metadata_dir / "setup.cfg", source_dir / "__init__.py", package_dir=source_dir, import_name=source_dir.name, ) exports, _ = extract.compute_exports(source_dir, modules) extract.apply_exports(modules, exports) extract.resolve_calls(modules, source_dir) data = extract.to_dict(modules, info, exports) readme = Path(readme_path) if readme_path else _readme_file() installation = Path(installation_path) if installation_path else _installation_file() data["meta"] = { "source_path": _relative(source_dir), "readme_path": _relative(readme), "readme_file": str(readme), "installation_path": _relative(installation), "installation_file": str(installation), "metadata_dir": _relative(metadata_dir), } # 教程:tutorials/ 目录里的示例脚本,没有这个目录就不生成「教程」页 if tutorials_dir is None: tutorials_dir = next( (path for path in TUTORIALS_CANDIDATES if path.is_dir()), None ) if tutorials_dir is not None: tutorials_dir = Path(tutorials_dir).resolve() data["tutorials"] = tutorials.collect(tutorials_dir) data["meta"]["tutorials_path"] = _relative(tutorials_dir) # 站点根地址:hreflang、canonical、sitemap 都要用绝对地址 site_url = site_base_url(data["package"], base_url) data["meta"]["site_url"] = site_url stats = data["stats"] shell = load_template("base.html") pages = active_pages(data) # 每种语言生成一套页面:中文在根目录,英文在 en/ 子目录。 # 页面里的链接都相对于自己所在目录,所以每个目录都是自洽的。 for lang in i18n.LANGS: context = _lang_context(data, lang, pages) directory = lang_dir(output, lang) directory.mkdir(parents=True, exist_ok=True) for spec in pages: content = spec.builder(context) html_text = page( shell, title=spec.page_title(context), description=spec.description(context), content=content, info=data["package"], stats=stats, pages=pages, active=spec.key, lang=lang, filename=spec.filename, site_url=site_url, ) (directory / spec.filename).write_text(html_text, encoding="utf-8") # 机器可读的完整 API 数据 # 对外发布的产物里只放相对路径,不泄露本机的绝对路径 api_data = dict(data) api_data["meta"] = { **data["meta"], "readme_file": data["meta"]["readme_path"], "installation_file": data["meta"]["installation_path"], } if "tutorials" in api_data: # 源码正文太大,索引里只留标题、说明这些摘要信息 api_data["tutorials"] = tutorials.without_code(api_data["tutorials"]) (output / "api.json").write_text( json.dumps(api_data, ensure_ascii=False, indent=2), encoding="utf-8" ) # 静态资源:每个语言目录里都放一份 for lang in i18n.LANGS: static_output = lang_dir(output, lang) / "static" static_output.mkdir(parents=True, exist_ok=True) for asset in STATIC_DIR.glob("*"): if asset.is_file(): shutil.copy2(asset, static_output / asset.name) # README / INSTALLATION / 教程里引用的本地图片、文档与脚本 base_dirs = [readme.parent, installation.parent, ROOT] if tutorials_dir is not None: base_dirs.append(Path(tutorials_dir).parent) copied, missing = copy_local_assets(output, base_dirs) # 不带 .html 的入口:/about 也能访问,会跳到 /about.html stubs = write_redirect_stubs(output, pages, site_url) errors = [m for m in modules if m.error] problems = verify_output(output) if not quiet: print(f"网站已生成:{output}") print( f" {stats['modules']} 个模块 · {stats['functions']} 个函数 " f"· {stats['classes']} 个类 · {stats['lines']} 行源码" ) print( " 语言:" + " · ".join( f"{i18n.LANG_NAMES[lang]}({lang_dir(output, lang).relative_to(output) or '.'})" for lang in i18n.LANGS ) ) print(f" 页面:{', '.join(spec.filename for spec in pages)}") if data.get("tutorials"): groups = data["tutorials"]["groups"] names = "、".join(f"{group['title']}({len(group['files'])})" for group in groups) print(f" 教程:{data['tutorials']['count']} 个示例脚本 — {names}") for module in errors: print(f" ! {module.path}:{module.error}") for name in missing: print(f" ! 找不到图片资源:{name}") for problem in problems: print(f" ! {problem}") if problems: print(f" (共 {len(problems)} 处链接问题)") if copied: print(f" 已复制 {len(copied)} 个本地资源:{', '.join(sorted(set(copied)))}") if stubs: print(f" 已生成 {len(stubs)} 个不带 .html 的入口(跳转到对应的 .html 页面)") # 供调用方判断构建是否干净 data["problems"] = problems return data def main() -> None: parser = argparse.ArgumentParser(description="生成软件包的网站") parser.add_argument( "-o", "--output", default=str(ROOT / "dist" / "site"), help="网站输出目录(默认 dist/site)", ) parser.add_argument( "--source", default=None, help="包源码目录(默认自动发现,例如 PyPI/src/<包名>)", ) parser.add_argument( "--readme", default=None, help="README.md 路径(默认仓库根目录的 README.md)", ) parser.add_argument( "--installation", default=None, help="INSTALLATION.md 路径(默认仓库根目录的 INSTALLATION.md)", ) parser.add_argument( "--tutorials", default=None, help="示例脚本目录(默认仓库根目录的 tutorials/,没有则不生成教程页)", ) parser.add_argument( "-q", "--quiet", action="store_true", help="只生成网站,不打印检查结果", ) parser.add_argument( "--strict", action="store_true", help="发现断链或解析错误时以非零状态退出(用于 CI)", ) args = parser.parse_args() data = generate( source_dir=Path(args.source) if args.source else None, output=Path(args.output), readme_path=Path(args.readme) if args.readme else None, installation_path=Path(args.installation) if args.installation else None, tutorials_dir=Path(args.tutorials) if args.tutorials else None, quiet=args.quiet, ) if args.strict: problems = list(data.get("problems") or []) problems += [ f"源码解析失败:{module.path}" for module in data.get("modules", []) if module.get("error") ] if problems: print(f"严格模式下发现 {len(problems)} 个问题,构建失败。") raise SystemExit(1) if __name__ == "__main__": main()