"""从包源码树中静态提取 API 数据。 本模块只做「静态解析」:读源码文本 + ast 解析,不 import 被解析的包。 因此不需要安装 numpy / torch / transformers 等重依赖, 也不会触发包内 statistics_of_guan_package() 的联网行为。 提取的内容全部来自源码本身,**不假设任何固定的包名、目录名、模块名或函数名**, 这样即使源码结构或内容大幅变动,网站仍然能自动识别并生成: * 包目录自动发现(支持 src/ 布局、任意包名、嵌套子包) * 模块名 / 模块头注释(形如 "# Module: basic_functions"),缺失时回退到模块名 * 每个函数的签名、参数与默认值、装饰器、返回类型 * 函数说明:优先取定义正上方紧邻的注释块,其次取 docstring * 分节注释(形如 "# --- 某某某 ---") * 类及其方法、基类 * 函数体内对包内其它函数的调用(用于生成交叉参考) * 弃用标记(函数体中出现 'deprecated' 提示,或 @deprecated 装饰器) * 字面量形式的数据表(函数返回的,或模块级定义的 dict / list) * 包的导出关系:__init__.py 中的 import * / 显式导入 / __all__ * 解析失败的文件不会中断构建,只会被记录为错误 """ from __future__ import annotations import ast import configparser import json import re import tomllib import warnings from dataclasses import dataclass, field from pathlib import Path from typing import Any # "# --- 标题 ---" 形式的分节注释 SECTION_RE = re.compile(r"^#\s*-{3,}\s*(.*?)\s*-{3,}\s*$") # 模块头注释 "# Module: xxx" MODULE_HEADER_RE = re.compile(r"^#\s*Module:\s*(.+?)\s*$") # 弃用相关的关键字(装饰器名、警告文字等) DEPRECATED_RE = re.compile(r"deprecat", re.IGNORECASE) def _parse(text: str, filename: str) -> ast.Module: """解析源码,屏蔽被解析文件自身的 SyntaxWarning 等噪音。""" with warnings.catch_warnings(): warnings.simplefilter("ignore") return ast.parse(text, filename=filename) # -------------------------------------------------------------------------- # 数据结构 # -------------------------------------------------------------------------- @dataclass class Param: """函数的一个参数。""" name: str default: str | None = None # 默认值的源码文本,None 表示无默认值 annotation: str | None = None kind: str = "positional" # positional / vararg / keyword_only / kwarg @property def required(self) -> bool: """是否必须传参(*args / **kwargs 不算)。""" return ( self.default is None and self.kind in ("positional", "keyword_only") ) @dataclass class Function: """一个函数(或方法)。""" name: str module: str signature: str params: list[Param] = field(default_factory=list) description: str = "" docstring: str = "" decorators: list[str] = field(default_factory=list) lineno: int = 0 end_lineno: int = 0 source: str = "" section: str = "" deprecated: bool = False deprecated_note: str = "" calls: list[str] = field(default_factory=list) # 该函数调用到的包内函数 returns: str | None = None is_async: bool = False exported: bool = True # 是否出现在包命名空间中 owner: str = "" # 所属类(方法才有) @property def anchor(self) -> str: return f"{self.module}.{self.name}" @property def required_params(self) -> list[Param]: return [p for p in self.params if p.required] @dataclass class Class: """一个类。""" name: str module: str description: str = "" docstring: str = "" bases: list[str] = field(default_factory=list) methods: list[Function] = field(default_factory=list) attributes: list[str] = field(default_factory=list) lineno: int = 0 exported: bool = True @dataclass class DataTable: """源码中以字面量形式定义的数据表。""" name: str module: str kind: str # dict / list description: str = "" entries: list[tuple[str, str]] = field(default_factory=list) # (键, 值的源码文本) lineno: int = 0 origin: str = "function" # function(函数返回)/ module(模块级常量) @dataclass class Module: """一个 Python 模块。""" name: str file: str path: str = "" # 相对包根目录的路径 source_path: str = "" # 磁盘上的绝对路径 title: str = "" description: str = "" functions: list[Function] = field(default_factory=list) classes: list[Class] = field(default_factory=list) data: list[DataTable] = field(default_factory=list) line_count: int = 0 is_package: bool = False # 该文件所在目录是一个包(有 __init__.py) is_init: bool = False # 该文件本身是不是 __init__.py is_entry: bool = False # 是否是包入口 __init__.py error: str = "" # 解析失败时的错误信息 @property def anchor(self) -> str: return self.name @dataclass class PackageInfo: """包的基本信息,来自 pyproject.toml / setup.cfg / __init__.py。""" name: str = "" import_name: str = "" # 导入时使用的名字(包目录名) version: str = "" description: str = "" summary: str = "" requires_python: str = "" url: str = "" urls: dict[str, str] = field(default_factory=dict) author: str = "" author_email: str = "" license: str = "" classifiers: list[str] = field(default_factory=list) # -------------------------------------------------------------------------- # 文本小工具 # -------------------------------------------------------------------------- def _read_text(path: Path) -> tuple[str, str]: """读源码文本,返回 (文本, 编码)。对编码问题尽量宽容。""" raw = path.read_bytes() for encoding in ("utf-8-sig", "utf-8", "gbk", "latin-1"): try: return raw.decode(encoding), encoding except UnicodeDecodeError: continue return raw.decode("utf-8", errors="replace"), "utf-8" def _first_paragraph(text: str) -> str: """取文本的第一段,压成一行。""" text = (text or "").strip() if not text: return "" parts = re.split(r"\n\s*\n", text, maxsplit=1) return " ".join(line.strip() for line in parts[0].splitlines() if line.strip()) def _is_dunder(name: str) -> bool: """是否是 __xxx__ 形式的内部名字。""" return len(name) > 4 and name.startswith("__") and name.endswith("__") def _definition_start(node: ast.AST) -> int: """定义真正开始的行号:装饰器之上的那一行。 带装饰器时 ast 的 lineno 指向 def 那一行, 上方的注释块会被装饰器隔开,取说明文字时要以装饰器为起点。 """ start = getattr(node, "lineno", 0) for decorator in getattr(node, "decorator_list", []) or []: start = min(start, decorator.lineno) return start def _comment_block_above(lines: list[str], lineno: int) -> str: """取函数/类定义正上方紧邻的注释块(自下而上收集连续的 # 注释行)。""" collected: list[str] = [] index = lineno - 2 # lineno 为 1-based,转换成上一行的 0-based 下标 while index >= 0: stripped = lines[index].strip() if stripped.startswith("#"): collected.append(stripped.lstrip("#").strip()) index -= 1 else: break collected.reverse() return " ".join(part for part in collected if part) def _docstring_of(node: ast.AST) -> str: """取节点 docstring(没有则为空串)。""" try: return ast.get_docstring(node) or "" except TypeError: return "" # -------------------------------------------------------------------------- # 签名与参数 # -------------------------------------------------------------------------- def _format_params(args: ast.arguments) -> list[Param]: """把 ast.arguments 转成参数列表。""" params: list[Param] = [] positional = list(args.posonlyargs) + list(args.args) defaults: list[ast.expr | None] = [None] * ( len(positional) - len(args.defaults) ) + list(args.defaults) for arg, default in zip(positional, defaults): params.append( Param( name=arg.arg, default=ast.unparse(default) if default is not None else None, annotation=ast.unparse(arg.annotation) if arg.annotation else None, kind="positional", ) ) if args.vararg: params.append( Param( name="*" + args.vararg.arg, annotation=ast.unparse(args.vararg.annotation) if args.vararg.annotation else None, kind="vararg", ) ) for arg, default in zip(args.kwonlyargs, args.kw_defaults): params.append( Param( name=arg.arg, default=ast.unparse(default) if default is not None else None, annotation=ast.unparse(arg.annotation) if arg.annotation else None, kind="keyword_only", ) ) if args.kwarg: params.append( Param( name="**" + args.kwarg.arg, annotation=ast.unparse(args.kwarg.annotation) if args.kwarg.annotation else None, kind="kwarg", ) ) return params def _format_signature( name: str, args: ast.arguments, returns: ast.expr | None = None ) -> str: """还原出可读的函数签名文本。 优先用 ast.unparse 直接还原参数表(能正确处理仅位置参数分隔符 "/"、 关键字限定、注解与默认值),失败时退回手工拼接。 """ try: text = ast.unparse(args) except Exception: # pragma: no cover - ast.unparse 极少失败 text = _format_params_text(args) signature = f"{name}({text})" if returns is not None: try: signature += " -> " + ast.unparse(returns) except Exception: # pragma: no cover pass return signature def _format_params_text(args: ast.arguments) -> str: """手工拼接参数列表(ast.unparse 不可用时的退路)。""" parts: list[str] = [] positional = list(args.posonlyargs) + list(args.args) defaults: list[ast.expr | None] = [None] * ( len(positional) - len(args.defaults) ) + list(args.defaults) for arg, default in zip(positional, defaults): text = arg.arg if arg.annotation is not None: text += ": " + ast.unparse(arg.annotation) if default is not None: text += "=" + ast.unparse(default) parts.append(text) if args.vararg: text = "*" + args.vararg.arg if args.vararg.annotation is not None: text += ": " + ast.unparse(args.vararg.annotation) parts.append(text) for arg, default in zip(args.kwonlyargs, args.kw_defaults): text = arg.arg if arg.annotation is not None: text += ": " + ast.unparse(arg.annotation) if default is not None: text += "=" + ast.unparse(default) parts.append(text) if args.kwarg: text = "**" + args.kwarg.arg if args.kwarg.annotation is not None: text += ": " + ast.unparse(args.kwarg.annotation) parts.append(text) return ", ".join(parts) # -------------------------------------------------------------------------- # 弃用、调用、数据表 # -------------------------------------------------------------------------- def _deprecation(node: ast.AST) -> tuple[bool, str]: """判断是否已弃用,返回 (是否弃用, 说明文字)。 识别几种写法: * 装饰器名里含 deprecated,例如 @deprecated * 函数体里的字符串含 deprecated,例如提示信息 * 出现 DeprecationWarning / PendingDeprecationWarning, 无论是直接写名字还是写成 warnings.DeprecationWarning """ note = "" for decorator in getattr(node, "decorator_list", []) or []: text = ast.unparse(decorator) if DEPRECATED_RE.search(text): return True, text for child in ast.walk(node): if isinstance(child, ast.Constant) and isinstance(child.value, str): if DEPRECATED_RE.search(child.value): note = child.value.strip() break elif isinstance(child, ast.Name) and DEPRECATED_RE.search(child.id): if "Warning" in child.id: return True, note or child.id elif isinstance(child, ast.Attribute) and DEPRECATED_RE.search(child.attr): if "Warning" in child.attr: return True, note or child.attr return False, note def _package_aliases(tree: ast.Module, names: set[str]) -> set[str]: """找出源码里指向本包的别名,例如 import guan / import guan as g。""" aliases: set[str] = set() for node in ast.walk(tree): if isinstance(node, ast.Import): for alias in node.names: root = alias.name.split(".")[0] if root in names: aliases.add(alias.asname or alias.name) elif isinstance(node, ast.ImportFrom): root = (node.module or "").split(".")[0] if root in names: for alias in node.names: aliases.add(alias.asname or alias.name) return aliases def _iter_calls(node: ast.AST): """遍历节点里的调用,但不下钻到嵌套的函数 / 类 / lambda 内部。 嵌套函数体内的调用属于嵌套函数,不该算到外层函数头上。 """ for child in ast.iter_child_nodes(node): if isinstance( child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda), ): continue if isinstance(child, ast.Call): yield child yield from _iter_calls(child) def _package_calls( node: ast.AST, aliases: set[str], direct_names: set[str] | None = None, shadowed: set[str] | None = None, ) -> list[str]: """收集函数体内出现的包内函数调用(去重且保持顺序)。 两种形式都算: * 别名调用:``guan.foo()`` / ``core.foo()``,别名须在 aliases 里 * 直接调用:``from .core import foo`` 之后的 ``foo()``, 名字须在 direct_names 里,且没有被本模块的同名定义遮蔽 """ found: list[str] = [] direct_names = direct_names or set() shadowed = shadowed or set() for child in _iter_calls(node): func = child.func name = "" if ( isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name) and func.value.id in aliases ): name = func.attr elif ( isinstance(func, ast.Name) and func.id in direct_names and func.id not in shadowed ): name = func.id if name and name not in found: found.append(name) return found def _literal_value(node: ast.AST) -> Any: """尝试把 AST 节点求值成 Python 字面量,失败则返回 None。""" try: return ast.literal_eval(node) except (ValueError, SyntaxError, TypeError, MemoryError, RecursionError): return None def _format_value(value: Any) -> str: """把字面量转成适合网页展示的文本。""" if isinstance(value, bool): return "True" if value else "False" if value is None: return "None" if isinstance(value, float): return repr(value) if isinstance(value, str): return value if isinstance(value, (dict, list, tuple)): try: return json.dumps(value, ensure_ascii=False, default=str) except (TypeError, ValueError): return repr(value) return str(value) def _table_entries(value: Any) -> list[tuple[str, str]]: """把 dict / list 字面量转成 (键, 值) 列表。""" if isinstance(value, dict): return [(str(k), _format_value(v)) for k, v in value.items()] if isinstance(value, (list, tuple)): return [(str(i), _format_value(v)) for i, v in enumerate(value)] return [] def _collect_data_tables(node: ast.FunctionDef, module_name: str) -> list[DataTable]: """从一个函数里提取以字面量形式写死的数据表。 覆盖常见的几种写法: * 直接 return 一个 dict / list 字面量 * 先建一个容器变量,再逐项 constants['x'] = ... 赋值,最后 return 该变量 * 用 container.update({...}) 追加 * return 一个元组,其中的元素是 dict / list """ tables: list[DataTable] = [] variables: dict[str, Any] = {} for statement in node.body: if isinstance(statement, ast.Assign) and len(statement.targets) == 1: target = statement.targets[0] value = _literal_value(statement.value) if isinstance(target, ast.Name) and value is not None: variables[target.id] = value elif isinstance(target, ast.Subscript) and isinstance(target.value, ast.Name): container = variables.get(target.value.id) key = _literal_value(target.slice) item = _literal_value(statement.value) if isinstance(container, dict) and key is not None and item is not None: container[key] = item elif isinstance(statement, ast.AugAssign) and isinstance(statement.target, ast.Name): container = variables.get(statement.target.id) item = _literal_value(statement.value) if isinstance(container, list) and isinstance(item, list): container.extend(item) variables[statement.target.id] = container elif isinstance(statement, ast.Expr) and isinstance(statement.value, ast.Call): call = statement.value if ( isinstance(call.func, ast.Attribute) and isinstance(call.func.value, ast.Name) and call.args ): container = variables.get(call.func.value.id) patch = _literal_value(call.args[0]) if call.func.attr == "update" and isinstance(container, dict): if isinstance(patch, dict): container.update(patch) elif call.func.attr == "append" and isinstance(container, list): if patch is not None: container.append(patch) elif call.func.attr == "extend" and isinstance(container, list): if isinstance(patch, (list, tuple)): container.extend(patch) elif isinstance(statement, ast.Return) and statement.value is not None: value = _literal_value(statement.value) if value is None and isinstance(statement.value, ast.Name): value = variables.get(statement.value.id) candidates: list[Any] = [] if isinstance(value, (list, tuple)): candidates = [v for v in value if isinstance(v, (dict, list, tuple))] elif value is not None: candidates = [value] for candidate in candidates: if not isinstance(candidate, (dict, list, tuple)) or not candidate: continue kind = "dict" if isinstance(candidate, dict) else "list" entries = _table_entries(candidate) if entries: tables.append( DataTable( name=node.name, module=module_name, kind=kind, entries=entries, lineno=node.lineno, origin="function", ) ) return tables def _collect_module_tables(tree: ast.Module, module_name: str) -> list[DataTable]: """提取模块级以字面量定义的数据表(常量表)。""" tables: list[DataTable] = [] for statement in tree.body: if isinstance(statement, ast.Assign) and len(statement.targets) == 1: target = statement.targets[0] value_node = statement.value elif isinstance(statement, ast.AnnAssign) and statement.value is not None: target = statement.target value_node = statement.value else: continue if not isinstance(target, ast.Name): continue if _is_dunder(target.id): continue # __all__ / __version__ 之类的内部名字不算数据表 value = _literal_value(value_node) if not isinstance(value, (dict, list)) or not value: continue kind = "dict" if isinstance(value, dict) else "list" tables.append( DataTable( name=target.id, module=module_name, kind=kind, entries=_table_entries(value), lineno=statement.lineno, origin="module", ) ) return tables # -------------------------------------------------------------------------- # 模块解析 # -------------------------------------------------------------------------- def _find_sections(lines: list[str]) -> list[tuple[int, str]]: """找出所有分节注释,返回 (行号, 标题)。""" sections: list[tuple[int, str]] = [] for number, line in enumerate(lines, start=1): match = SECTION_RE.match(line.strip()) if match: sections.append((number, match.group(1))) return sections def _module_all(tree: ast.Module) -> set[str] | None: """取模块级的 __all__(没有则返回 None)。""" for node in tree.body: if isinstance(node, ast.Assign): for target in node.targets: if isinstance(target, ast.Name) and target.id == "__all__": value = _literal_value(node.value) if isinstance(value, (list, tuple, set)): return {str(item) for item in value} elif isinstance(node, ast.AnnAssign): if isinstance(node.target, ast.Name) and node.target.id == "__all__": value = _literal_value(node.value) if node.value else None if isinstance(value, (list, tuple, set)): return {str(item) for item in value} return None def parse_module(path: Path, module_name: str, relative_path: str = "") -> Module: """解析单个模块文件。 解析失败不会抛出异常,而是在返回的 Module 上记录 error, 以免一个文件的语法问题导致整个网站构建失败。 """ module = Module( name=module_name, file=path.name, path=relative_path or path.name, source_path=str(path), ) try: text, _ = _read_text(path) except OSError as exc: module.error = f"无法读取文件:{exc}" return module module.line_count = len(text.splitlines()) try: tree = _parse(text, str(path)) except SyntaxError as exc: module.error = f"语法错误:第 {exc.lineno} 行 {exc.msg}" return module lines = text.splitlines() for line in lines: header = MODULE_HEADER_RE.match(line.strip()) if header: module.title = header.group(1) break module.description = _first_paragraph(_docstring_of(tree)) if not module.title: module.title = module_name sections = _find_sections(lines) aliases = _package_aliases(tree, {module_name.split(".")[0]}) | {module_name} def section_for(lineno: int) -> str: current = "" for number, title in sections: if number < lineno: current = title else: break return current def describe(node: ast.AST) -> str: """函数说明:注释块优先,其次 docstring。 注释块从装饰器往上找,这样「@装饰器」不会把说明文字挡掉。 """ comment = _comment_block_above(lines, _definition_start(node)) if comment: return comment return _first_paragraph(_docstring_of(node)) def build_function( node: ast.FunctionDef | ast.AsyncFunctionDef, owner: str = "" ) -> Function: end = node.end_lineno or node.lineno # 装饰器在 def 之上,源码片段要从装饰器开始 start = _definition_start(node) source = "\n".join(lines[start - 1 : end]) deprecated, note = _deprecation(node) docstring = _docstring_of(node) return Function( name=node.name, module=module_name, signature=_format_signature(node.name, node.args, node.returns), params=_format_params(node.args), description=describe(node), docstring=docstring, decorators=[ast.unparse(d) for d in node.decorator_list], lineno=node.lineno, end_lineno=end, source=source, section=section_for(node.lineno), deprecated=deprecated, deprecated_note=note, calls=_package_calls(node, aliases), returns=ast.unparse(node.returns) if node.returns else None, is_async=isinstance(node, ast.AsyncFunctionDef), owner=owner, ) for node in tree.body: if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): module.functions.append(build_function(node)) module.data.extend(_collect_data_tables(node, module_name)) elif isinstance(node, ast.ClassDef): klass = Class( name=node.name, module=module_name, description=describe(node), docstring=_docstring_of(node), bases=[ast.unparse(base) for base in node.bases], lineno=node.lineno, ) for item in node.body: if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)): klass.methods.append(build_function(item, owner=node.name)) elif isinstance(item, ast.Assign) and len(item.targets) == 1: if isinstance(item.targets[0], ast.Name): klass.attributes.append(item.targets[0].id) module.classes.append(klass) module.data.extend(_collect_module_tables(tree, module_name)) # 让数据表带上说明文字 for table in module.data: if table.origin == "function": for function in module.functions: if function.name == table.name: table.description = function.description break return module # -------------------------------------------------------------------------- # 包解析 # -------------------------------------------------------------------------- def module_name_for(path: Path, package_root: Path) -> str: """由文件路径推出模块名(相对包根目录,支持子包)。""" relative = path.relative_to(package_root) parts = list(relative.parts) if parts[-1] == "__init__.py": parts = parts[:-1] else: parts[-1] = parts[-1].removesuffix(".py") return ".".join(parts) def _is_build_dir(path: Path) -> bool: """判断目录是不是网站构建脚本所在目录(那不是被发布的包)。""" return (path / "build_site.py").is_file() or (path / "extract.py").is_file() def discover_package_dir(search_roots: list[Path]) -> Path | None: """在候选目录里找出包目录(含 __init__.py 的那一层)。 依次检查每个候选根目录下的子目录;若根目录本身就是包,也算命中。 这样仓库把源码放在 PyPI/src、src 或根目录都能被认出来。 """ for root in search_roots: root = Path(root) if not root.is_dir() or _is_build_dir(root): continue if (root / "__init__.py").is_file(): return root candidates = [ child for child in sorted(root.iterdir()) if child.is_dir() and not child.name.startswith((".", "_")) and not _is_build_dir(child) and (child / "__init__.py").is_file() ] if len(candidates) == 1: return candidates[0] if candidates: # 多个候选时,优先挑模块文件最多的那个 return max(candidates, key=lambda d: len(list(d.rglob("*.py")))) # 退一步:支持没有 __init__.py 的命名空间包(PEP 420) for root in search_roots: root = Path(root) if not root.is_dir() or _is_build_dir(root): continue loose = [ child for child in sorted(root.iterdir()) if child.is_dir() and not child.name.startswith((".", "_")) and not _is_build_dir(child) and any(child.glob("*.py")) ] if loose: return max(loose, key=lambda d: len(list(d.rglob("*.py")))) return None def _import_order(entry_module: Module) -> list[str]: """从包入口 __init__.py 解析导入顺序(from .xxx import * 等)。""" order: list[str] = [] path = Path(entry_module.source_path or entry_module.path) try: text, _ = _read_text(path) tree = _parse(text, str(path)) except (OSError, SyntaxError): return order for node in tree.body: if isinstance(node, ast.ImportFrom) and node.level and node.module: order.append(node.module) elif isinstance(node, ast.ImportFrom) and node.level and node.names: for alias in node.names: order.append(alias.name) return order _TREE_CACHE: dict[str, ast.Module | None] = {} def _tree_of(module: Module) -> ast.Module | None: """取模块的 AST(带缓存,解析失败返回 None)。""" path = Path(module.source_path or module.path) key = str(path) if key in _TREE_CACHE: return _TREE_CACHE[key] tree: ast.Module | None = None try: text, _ = _read_text(path) tree = _parse(text, str(path)) except (OSError, SyntaxError): tree = None _TREE_CACHE[key] = tree return tree def _package_context(module: Module) -> list[str]: """模块在包内的相对位置(用于解析 from .xxx import)。""" if module.is_entry: return [] if module.is_init: return module.name.split(".") parts = module.name.split(".") return parts[:-1] def _resolve_relative(module: Module, level: int, name: str) -> str: """把 `from .xxx import y` 里的 xxx 解析成包内的完整模块名。""" context = _package_context(module) if level > 1: context = context[: max(0, len(context) - (level - 1))] if name: context = context + name.split(".") return ".".join(context) def _public_names( module_name: str, by_name: dict[str, Module], stack: tuple[str, ...] = (), ) -> set[str]: """一个模块通过 `from .x import *` 会带出哪些名字。 除了本模块定义的函数与类,还包括它自己再导入的名字 (比如子包的 __init__.py 里 `from .extra import *`), 这样包入口 `from .sub import *` 也能把子包里的函数带出来。 """ module = by_name.get(module_name) if module is None or module_name in stack: return set() tree = _tree_of(module) if tree is None: return set() explicit = _module_all(tree) if explicit is not None: return set(explicit) names: set[str] = set() for node in tree.body: if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): if not node.name.startswith("_"): names.add(node.name) elif isinstance(node, ast.ImportFrom): if node.level: if node.module: target = _resolve_relative(module, node.level, node.module) names |= _public_names(target, by_name, stack + (module_name,)) else: # from . import mod:带出子模块本身 for alias in node.names: if alias.name != "*": names.add(alias.asname or alias.name) elif node.module: for alias in node.names: if alias.name != "*": names.add(alias.asname or alias.name) elif isinstance(node, ast.Import): for alias in node.names: names.add(alias.asname or alias.name.split(".")[0]) return names def _module_constants(module: Module) -> set[str]: """模块顶层定义的常量名(大写或普通赋值)。""" tree = _tree_of(module) if tree is None: return set() names: set[str] = set() for node in tree.body: if isinstance(node, ast.Assign): for target in node.targets: if isinstance(target, ast.Name): names.add(target.id) elif isinstance(node, ast.AnnAssign): if isinstance(node.target, ast.Name): names.add(node.target.id) return names def _definition_index(modules: list[Module]) -> dict[str, str]: """名字 → 真正定义它的模块。 同名时以先出现的模块为准;跨子包转发时也能指对真正定义的地方。 """ index: dict[str, str] = {} for module in modules: for function in module.functions: index.setdefault(function.name, module.name) for klass in module.classes: index.setdefault(klass.name, module.name) return index def compute_exports( package_root: Path, modules: list[Module] ) -> tuple[dict[str, str], set[str]]: """计算包命名空间里可见的名字。 返回 (名字 → 定义所在模块, 包级显式 __all__)。 解析 __init__.py 里的几种导入写法: * from .mod import * → 整个模块的公开名字 * from .mod import name → 单个名字 * from .mod import name as x→ 别名 * from . import mod → 子模块本身 只有包内真正定义出来的名字才算导出,第三方库(numpy、os 之类) 即使在 __init__.py 里被 import,也不会出现在导出表里。 """ exports: dict[str, str] = {} explicit_all: set[str] = set() by_name = {module.name: module for module in modules} entry = next((m for m in modules if m.is_entry), None) if entry is None: return exports, explicit_all tree = _tree_of(entry) if tree is None: return exports, explicit_all # 包里真正定义出来的名字(排除 import 进来的第三方名字,如 numpy) owners = _definition_index(modules) defined_names: set[str] = set(owners) for module in modules: defined_names.update(_module_constants(module)) def owner_of(name: str) -> str: return owners.get(name, entry.name) def is_public(name: str) -> bool: """下划线开头的名字不算公开 API。""" return bool(name) and not name.startswith("_") for node in tree.body: if isinstance(node, ast.Assign): for target in node.targets: if isinstance(target, ast.Name) and target.id == "__all__": value = _literal_value(node.value) if isinstance(value, (list, tuple, set)): explicit_all = {str(item) for item in value} elif isinstance(node, ast.ImportFrom): source = node.module or "" if node.level and not source: # from . import mod:把子模块本身挂到包命名空间上 for alias in node.names: if alias.name == "*": continue target = _resolve_relative(entry, node.level, alias.name) if target in by_name: exports.setdefault(alias.asname or alias.name, target) elif node.level and source: source = _resolve_relative(entry, node.level, source) if source not in by_name: continue for alias in node.names: if alias.name == "*": for name in _public_names(source, by_name): if name in defined_names and is_public(name): exports.setdefault(name, owner_of(name)) elif is_public(alias.asname or alias.name): exports.setdefault( alias.asname or alias.name, owner_of(alias.name) ) elif source: # from 包名 import xxx:只认包内真正定义过的名字 for alias in node.names: if alias.name == "*": continue if alias.name in defined_names and is_public(alias.asname or alias.name): exports.setdefault( alias.asname or alias.name, owner_of(alias.name) ) elif isinstance(node, ast.Import): # 直接 import 进来的多半是第三方库,只有包内定义的名字才算导出 for alias in node.names: name = alias.asname or alias.name.split(".")[0] if alias.name.count(".") == 0 and name in defined_names and is_public(name): exports.setdefault(name, owner_of(name)) # 入口自身定义的函数、类与常量总是可见 for function in entry.functions: if is_public(function.name): exports.setdefault(function.name, entry.name) for klass in entry.classes: if is_public(klass.name): exports.setdefault(klass.name, entry.name) for name in _module_constants(entry): if is_public(name): exports.setdefault(name, entry.name) # 注意:__all__ 只影响 `from 包 import *`, # 不影响 `包.名字` 的访问,所以这里不据此裁剪导出表。 return exports, explicit_all def parse_modules(source_dir: Path) -> list[Module]: """解析包目录下的所有模块(只做语法解析,不涉及导出关系)。 递归处理子包;包入口 __init__.py 也作为一个模块返回(is_entry=True)。 顺序按 __init__.py 中的导入顺序排列,其余模块按名字追加。 """ package_root = Path(source_dir) modules: list[Module] = [] files = [ p for p in sorted(package_root.rglob("*.py")) if "__pycache__" not in p.parts and p.name != "setup.py" ] for path in files: relative = path.relative_to(package_root) name = module_name_for(path, package_root) if not name: # 包入口 __init__.py:模块名就是包名 name = package_root.name module = parse_module(path, name, relative.as_posix()) module.is_init = path.name == "__init__.py" module.is_entry = module.is_init and path.parent == package_root module.is_package = (path.parent / "__init__.py").is_file() if module.is_entry: module.title = module.title or package_root.name modules.append(module) # 排序:按 __init__.py 的导入顺序 entry = next((m for m in modules if m.is_entry), None) order: list[str] = [] if entry is not None: order = _import_order(entry) rank = {name: index for index, name in enumerate(order)} def sort_key(module: Module) -> tuple: if module.is_entry: return (1, 0, module.name) return (0, rank.get(module.name, len(rank)), module.name) modules.sort(key=sort_key) return modules def apply_exports(modules: list[Module], exports: dict[str, str]) -> None: """按导出表标记每个函数 / 类是否出现在包命名空间里。""" for module in modules: for function in module.functions: function.exported = function.name in exports for klass in module.classes: klass.exported = klass.name in exports def _direct_package_names(module: Module, by_name: dict[str, Module]) -> set[str]: """本模块用 `from .xxx import y` 直接引进来的包内名字。 这些名字在函数体里是以裸名字调用的(``y()``), 只靠「包名.xxx」的形式是找不到它们的。 """ tree = _tree_of(module) if tree is None: return set() names: set[str] = set() for node in ast.walk(tree): if not isinstance(node, ast.ImportFrom) or not node.level: continue for alias in node.names: if alias.name == "*": continue name = alias.asname or alias.name if name in by_name: continue # 这是子模块本身,不是函数 names.add(name) return names def resolve_calls( modules: list[Module], package_root: Path, package_aliases: set[str] | None = None, ) -> None: """算出每个函数调用到的包内函数(用于生成交叉参考)。""" package_root = Path(package_root) by_name = {module.name: module for module in modules} # 包内的调用(交叉参考)应使用整个包的别名集合: # 包名本身、模块名、以及源码里显式写下的别名。 all_aliases: set[str] = {package_root.name} all_aliases |= {m.name for m in modules} all_aliases |= {m.name.split(".")[0] for m in modules} if package_aliases: all_aliases |= package_aliases for module in modules: all_aliases |= _explicit_aliases(module) for module in modules: tree = _tree_of(module) if tree is None: continue direct = _direct_package_names(module, by_name) # 本模块自己定义的名字会遮住直接引入的同名函数 shadowed = {f.name for f in module.functions} shadowed |= {c.name for c in module.classes} calls_by_function: dict[tuple[str, int], list[str]] = {} for node in ast.walk(tree): if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): calls_by_function[(node.name, node.lineno)] = _package_calls( node, all_aliases, direct, shadowed ) for function in module.functions: function.calls = calls_by_function.get( (function.name, function.lineno), function.calls ) def parse_package( source_dir: Path, package_aliases: set[str] | None = None ) -> list[Module]: """解析整个包目录,返回模块列表。 解析、导出关系、交叉参考三步都做完了。 """ package_root = Path(source_dir) modules = parse_modules(package_root) exports, _ = compute_exports(package_root, modules) apply_exports(modules, exports) resolve_calls(modules, package_root, package_aliases) return modules def _explicit_aliases(module: Module) -> set[str]: """源码里指向包内模块的别名。 只认相对导入(``from .core import foo as f``、``from . import core as c``), 第三方库的别名(``import numpy as np``)不算, 否则 ``np.array(...)`` 会被误认成对包内 ``array`` 的调用。 """ tree = _tree_of(module) if tree is None: return set() aliases: set[str] = set() for node in ast.walk(tree): if isinstance(node, ast.ImportFrom) and node.level: for alias in node.names: if alias.asname: aliases.add(alias.asname) return aliases # -------------------------------------------------------------------------- # 包元信息 # -------------------------------------------------------------------------- def _read_pyproject(path: Path) -> dict[str, Any]: if not path.exists(): return {} try: text, _ = _read_text(path) return tomllib.loads(text) except (OSError, ValueError, tomllib.TOMLDecodeError): return {} def _read_setup_cfg(path: Path) -> dict[str, dict[str, str]]: if not path.exists(): return {} parser = configparser.ConfigParser() try: text, _ = _read_text(path) parser.read_string(text) except (OSError, ValueError, configparser.Error): return {} return {section: dict(parser[section]) for section in parser.sections()} def _version_from_init(path: Path) -> str: """从 __init__.py 里找 __version__ = "..."。""" if not path.exists(): return "" try: text, _ = _read_text(path) tree = _parse(text, str(path)) except (OSError, SyntaxError): return "" for node in tree.body: if isinstance(node, ast.Assign): for target in node.targets: if isinstance(target, ast.Name) and target.id == "__version__": value = _literal_value(node.value) if isinstance(value, str): return value return "" def _module_attr_value(path: Path, attr: str) -> str: """在模块里找某个赋值(``attr = "..."``)的字面量值。""" try: text, _ = _read_text(path) tree = _parse(text, str(path)) except (OSError, SyntaxError): return "" for node in tree.body: value = None if isinstance(node, ast.Assign): for target in node.targets: if isinstance(target, ast.Name) and target.id == attr: value = _literal_value(node.value) elif isinstance(node, ast.AnnAssign): if ( isinstance(node.target, ast.Name) and node.target.id == attr and node.value is not None ): value = _literal_value(node.value) if isinstance(value, str): return value return "" def _attr_value(package_dir: Path | None, spec: str) -> str: """解析 ``attr: 包名.__version__``,静态读出那个值。 不 import 被解析的包,只按路径找文件读字面量。 """ if not package_dir: return "" spec = spec.strip() if "." not in spec: return "" module_part, _, attr = spec.rpartition(".") parts = module_part.split(".") candidates = [] if module_part == package_dir.name: # 指的就是包自己 candidates.append(package_dir / "__init__.py") candidates.append(package_dir.joinpath(*parts).with_suffix(".py")) candidates.append(package_dir.joinpath(*parts, "__init__.py")) for path in candidates: if path.is_file(): value = _module_attr_value(path, attr) if value: return value return "" def _resolve_version_directive( value: str, package_dir: Path | None, base_dir: Path | None ) -> str: """把 ``attr:`` / ``file:`` 形式的版本号写法解析成真正的值。 setup.cfg 里常写 ``version = attr: pkg.__version__``, 直接显示出来会很难看,这里静态地把它读出来。 """ value = value.strip() if value.startswith("attr:"): return _attr_value(package_dir, value[5:]) or value if value.startswith("file:"): name = value[5:].strip() if base_dir: path = Path(base_dir) / name if path.is_file(): try: text, _ = _read_text(path) except OSError: return value first = text.strip().splitlines()[0].strip() if text.strip() else "" if first: return first return value def parse_package_info( pyproject: Path, setup_cfg: Path | None = None, init_file: Path | None = None, package_dir: Path | None = None, import_name: str = "", ) -> PackageInfo: """读取包信息。 同时兼容三种来源:PEP 621 的 [project] 表、setuptools 的 [metadata] 表、 以及 setup.cfg。本仓库的元数据写在 setup.cfg 的 [metadata] 段里。 """ sources: list[dict[str, Any]] = [] data = _read_pyproject(Path(pyproject)) project = data.get("project") if isinstance(project, dict): sources.append(project) metadata = data.get("metadata") if isinstance(metadata, dict): sources.append(metadata) cfg = _read_setup_cfg(Path(setup_cfg)) if setup_cfg else {} for section in ("metadata", "options"): if section in cfg: sources.append(cfg[section]) def pick(*keys: str, default: str = "") -> str: for source in sources: for key in keys: value = source.get(key) if isinstance(value, str) and value: return value return default urls: dict[str, str] = {} for source in sources: raw = source.get("project_urls") or source.get("urls") if not raw: continue if isinstance(raw, dict): # pyproject 的 [project.urls] 表 for key, value in raw.items(): if isinstance(value, str): urls.setdefault(str(key), value) continue raw = str(raw) if raw.strip().startswith("{"): try: urls = {str(k): str(v) for k, v in json.loads(raw).items()} break except (ValueError, TypeError): continue for line in raw.splitlines(): if "=" in line: key, _, value = line.partition("=") urls.setdefault(key.strip(), value.strip()) classifiers: list[str] = [] for source in sources: value = source.get("classifiers") if isinstance(value, list): classifiers = [str(item) for item in value] break if isinstance(value, str) and value.strip(): classifiers = [line.strip() for line in value.splitlines() if line.strip()] break author = pick("author", "maintainer") author_email = pick("author_email", "maintainer_email") if not author or not author_email: # PEP 621 的 authors / maintainers 是 [{name, email}, ...] 列表 for source in sources: for key in ("authors", "maintainers"): people = source.get(key) if not isinstance(people, list): continue for person in people: if not isinstance(person, dict): continue if not author and person.get("name"): author = str(person["name"]) if not author_email and person.get("email"): author_email = str(person["email"]) license_text = pick("license") if not license_text: for source in sources: value = source.get("license") if isinstance(value, dict): license_text = str(value.get("text") or value.get("file") or "") if license_text: break version = pick("version") if version: base_dir = Path(setup_cfg).parent if setup_cfg else None version = _resolve_version_directive(version, package_dir, base_dir) if not version and init_file is not None: version = _version_from_init(Path(init_file)) name = pick("name") if not name and package_dir is not None: name = Path(package_dir).name if not import_name and package_dir is not None: import_name = Path(package_dir).name description = pick("description", "summary") if description.startswith("file:"): # 描述指向一个文件时(比如 `description = file: README.md`), # 取那个文件的第一段文字,而不是把 "file: ..." 摆在网站上 base_dir = Path(setup_cfg).parent if setup_cfg else None if base_dir: path = base_dir / description[5:].strip() if path.is_file(): try: text, _ = _read_text(path) except OSError: text = "" description = _first_paragraph(text) or description return PackageInfo( name=name, import_name=import_name or name, version=version, description=description, summary=pick("summary"), requires_python=pick("requires-python", "requires_python", "python_requires"), url=pick("url", "home-page", "homepage"), urls=urls, author=author, author_email=author_email, license=license_text, classifiers=classifiers, ) # -------------------------------------------------------------------------- # 序列化 # -------------------------------------------------------------------------- def _function_to_dict(function: Function) -> dict[str, Any]: """把函数(或方法)序列化成统一的字典结构。""" return { "name": function.name, "signature": function.signature, "description": function.description, "docstring": function.docstring, "decorators": function.decorators, "lineno": function.lineno, "end_lineno": function.end_lineno, "section": function.section, "deprecated": function.deprecated, "deprecated_note": function.deprecated_note, "calls": function.calls, "returns": function.returns, "is_async": function.is_async, "exported": function.exported, "owner": function.owner, "params": [ { "name": p.name, "default": p.default, "annotation": p.annotation, "kind": p.kind, "required": p.required, } for p in function.params ], "source": function.source, } def to_dict( modules: list[Module], info: PackageInfo, exports: dict[str, str] | None = None, ) -> dict[str, Any]: """整理成可直接序列化为 JSON 的结构。""" exported_names = set(exports or {}) return { "package": { "name": info.name, "import_name": info.import_name or info.name, "version": info.version, "description": info.description, "summary": info.summary, "requires_python": info.requires_python, "url": info.url, "urls": info.urls, "author": info.author, "author_email": info.author_email, "license": info.license, "classifiers": info.classifiers, }, "stats": { "modules": len(modules), "functions": sum(len(m.functions) for m in modules), "classes": sum(len(m.classes) for m in modules), "lines": sum(m.line_count for m in modules), "data_tables": sum(len(m.data) for m in modules), "exported_functions": sum( 1 for m in modules for f in m.functions if f.exported or f.name in exported_names ), }, "exports": dict(sorted((exports or {}).items())), "modules": [ { "name": m.name, "file": m.file, "path": m.path, "title": m.title, "description": m.description, "line_count": m.line_count, "is_package": m.is_package, "is_entry": m.is_entry, "error": m.error, "data": [ { "name": d.name, "kind": d.kind, "description": d.description, "lineno": d.lineno, "origin": d.origin, "entries": [{"key": k, "value": v} for k, v in d.entries], } for d in m.data ], "classes": [ { "name": c.name, "description": c.description, "docstring": c.docstring, "bases": c.bases, "lineno": c.lineno, "exported": c.exported, "attributes": list(c.attributes), "methods": [_function_to_dict(f) for f in c.methods], } for c in m.classes ], "functions": [_function_to_dict(f) for f in m.functions], } for m in modules ], }