"""候选代码的轻量静态检查(不执行候选的任何一行代码)。 实验员需要一个便宜的办法确认代码能跑;不给他这个办法,他就会自己跑一遍完整实验 去验证。这个脚本就是那个便宜的办法:只做三件事,全部基于抽象语法树。 1. 入口文件在不在 2. 候选目录下每个 ``*.py`` 能不能 ``ast.parse`` 3. 只对入口文件:顶层恰好有一个叫 ``run_packing`` 的函数,且零个必填参数 用法:``python -B check-candidate.py <候选目录> <入口文件相对候选目录的路径> <结果文件>``。 结果写进 <结果文件>,一个 JSON 对象:``{"valid": bool, "reasons": [...]}``。 对候选代码的每一种意见都走"退出码 0 加 reasons";非零退出说明这个脚本自己没跑成。 两条调用约定,改的时候不要破坏: - **本文件不能放进候选目录。** 第 2 步从候选目录往下扫 ``*.py``, 脚本自己在里面就会把自己也扫进去。宿主把它放在候选目录的上一层。 - **只用标准库,``-B`` 要保留。** 不写 ``__pycache__``:检查本身不该往候选目录里添文件。 """ import ast import json import pathlib import sys ENTRY_FUNCTION = "run_packing" # 两种定义形式都要认,否则 `async def run_packing()` 会被判成"不存在"。 FUNCTION_NODES = (ast.FunctionDef, ast.AsyncFunctionDef) def has_zero_required_arguments(node) -> bool: """判断函数是不是可以零参数调用。 位置参数(含仅位置参数)里没有默认值的那些必须为 0 个; 仅关键字参数里 kw_defaults 为 None 的表示没有默认值,一个都不能有。 """ positional = len(node.args.posonlyargs) + len(node.args.args) required_positional = positional - len(node.args.defaults) keyword_without_default = any( default is None for default in node.args.kw_defaults ) return required_positional <= 0 and not keyword_without_default def main() -> int: if len(sys.argv) != 4: print( "用法: check-candidate.py <候选目录> <入口文件相对路径> <结果文件>", file=sys.stderr, ) return 2 root = pathlib.Path(sys.argv[1]) entry_point = sys.argv[2] reasons: list[str] = [] if not (root / entry_point).is_file(): reasons.append(f"入口文件不存在: {entry_point}") # 第 3 步挂在第 2 步的 rglob("*.py") 上:入口文件名不以 .py 结尾, # 循环扫不到它,入口函数那条判定会整个被跳过,却照样报通过。 if not entry_point.endswith(".py"): reasons.append( f"入口文件必须以 .py 结尾,否则入口函数检查扫不到它: {entry_point}" ) for path in root.rglob("*.py"): relative = str(path.relative_to(root)) try: tree = ast.parse(path.read_text(encoding="utf-8"), filename=relative) except (SyntaxError, ValueError) as error: # SyntaxError:语法错;ValueError:含空字节或不是 UTF-8(UnicodeDecodeError 是它的子类)。 reasons.append(f"{relative} 解析失败: {error}") continue if relative != entry_point: continue candidates = [ node for node in tree.body if isinstance(node, FUNCTION_NODES) and node.name == ENTRY_FUNCTION ] if len(candidates) != 1: reasons.append( f"{entry_point} 顶层的 {ENTRY_FUNCTION}() 应当恰好有一个定义," f"实际有 {len(candidates)} 个" ) elif not has_zero_required_arguments(candidates[0]): reasons.append( f"{entry_point} 的 {ENTRY_FUNCTION}() 必须能零参数调用," f"当前有必填参数" ) with open(sys.argv[3], "w", encoding="utf-8") as handle: json.dump({"valid": not reasons, "reasons": reasons}, handle) return 0 if __name__ == "__main__": sys.exit(main())