"Python Analyzer" 这个词可以指代很多不同的工具,从简单的静态代码检查工具(如 Pylint, Flake8)到复杂的静态分析框架(如 LibCST, Semgrep),本教程将从一个实用的、自定义的代码分析器入手,教你如何一步步构建一个能够理解 Python 代码并提取有用信息的工具。

我们将构建一个名为 pyanalyzer 的工具,它能够:
- 遍历指定目录下的所有 Python 文件。
- 解析每个文件,提取出函数和类的定义。
- 统计每个文件和整个项目的代码行数。
- 生成一个清晰的报告,展示分析结果。
这个教程将带你从零开始,最终拥有一个功能完整且可扩展的分析器。
目录
- 第一步:项目规划与环境准备
- 我们要做什么?
- 为什么使用
ast模块? - 搭建项目环境
- 第二步:核心解析器 - 使用
ast模块ast模块简介- 编写第一个解析函数
- 遍历抽象语法树
- 第三步:构建分析引擎
- 设计数据结构
- 实现文件分析逻辑
- 实现项目遍历逻辑
- 第四步:整合与报告生成
- 编写主程序入口
- 生成格式化输出
- 处理命令行参数
- 第五步:扩展与进阶
- 添加更多分析功能(如检测未使用的变量)
- 输出为 JSON 或 CSV
- 使用第三方库增强功能
- 完整代码与总结
第一步:项目规划与环境准备
1 我们要做什么?
我们的目标是创建一个命令行工具,用户可以通过运行 python pyanalyzer.py <path_to_project> 来分析一个 Python 项目,工具将输出类似下面的报告:
Analyzing project: /path/to/your/python/project
--- File: src/my_module.py ---
- Functions: 3
- Classes: 1
- Total Lines: 45
--- File: tests/test_analyzer.py ---
- Functions: 5
- Classes: 0
- Total Lines: 28
--- Project Summary ---
- Total Files: 2
- Total Functions: 8
- Total Classes: 1
- Total Lines: 73
2 为什么使用 ast 模块?
直接用正则表达式解析 Python 代码是非常困难且不可靠的(想想注释、字符串、多行语句等),Python 标准库中的 ast (Abstract Syntax Trees) 模块是完美的解决方案,它可以将 Python 源代码解析成一个结构化的树形对象,我们可以轻松地遍历这棵树来理解代码的结构。

3 搭建项目环境
创建一个新的项目目录和文件结构:
pyanalyzer_project/
├── pyanalyzer.py # 我们的主程序
└── sample_project/ # 一个用来测试的小项目
├── main.py
└── utils.py
我们有了基本的结构,可以开始写代码了。
第二步:核心解析器 - 使用 ast 模块
1 ast 模块简介
ast 模块的核心是 ast.NodeVisitor 类,我们可以继承它,并重写其 visit_NodeType 方法,NodeType 是 AST 节点的类型(如 ast.FunctionDef, ast.ClassDef),当 ast.NodeVisitor 遍历树时,它会自动调用我们定义的相应方法。
2 编写第一个解析函数
让我们创建一个简单的解析器,它能统计一个文件中函数和类的数量。
创建 pyanalyzer.py 文件,并加入以下代码:
import ast
# 继承 ast.NodeVisitor 来创建我们自己的访问器
class CodeAnalyzer(ast.NodeVisitor):
def __init__(self):
self.function_count = 0
self.class_count = 0
# 当访问到 FunctionDef 节点时,此方法会被自动调用
def visit_FunctionDef(self, node):
print(f"Found a function: {node.name}")
self.function_count += 1
# 必须调用这个,以便访问器可以继续遍历函数体内的节点
self.generic_visit(node)
# 当访问到 ClassDef 节点时,此方法会被自动调用
def visit_ClassDef(self, node):
print(f"Found a class: {node.name}")
self.class_count += 1
self.generic_visit(node)
def analyze_file(filepath):
"""读取并分析单个 Python 文件"""
with open(filepath, 'r', encoding='utf-8') as f:
source_code = f.read()
# 将源代码解析成 AST
tree = ast.parse(source_code, filename=filepath)
# 创建我们的分析器实例并开始遍历
analyzer = CodeAnalyzer()
analyzer.visit(tree)
return analyzer.function_count, analyzer.class_count
# --- 测试 ---
if __name__ == '__main__':
# 分析 sample_project/main.py
# 假设 sample_project/main.py 内容如下:
# def greet(name):
# print(f"Hello, {name}!")
#
# class Greeter:
# def __init__(self):
# self.message = "Welcome"
#
# def say_hello(self):
# print(self.message)
functions, classes = analyze_file('sample_project/main.py')
print(f"\n--- Summary for main.py ---")
print(f"Functions: {functions}")
print(f"Classes: {classes}")
运行 python pyanalyzer.py,你应该能看到输出:
Found a function: greet
Found a class: Greeter
Found a function: __init__
Found a function: say_hello
--- Summary for main.py ---
Functions: 3
Classes: 1
注意:__init__ 方法也被识别为一个函数,这正是 ast 的强大之处。
第三步:构建分析引擎
现在我们有了一个基础的解析器,接下来需要扩展它,使其能统计代码行数,并能处理整个项目。
1 设计数据结构
为了更好地组织数据,我们定义一个简单的类来存储单个文件的分析结果。
# 在 pyanalyzer.py 中添加
class FileInfo:
def __init__(self, filepath):
self.filepath = filepath
self.function_count = 0
self.class_count = 0
self.total_lines = 0
2 实现文件分析逻辑
我们将 CodeAnalyzer 类集成到 FileInfo 的分析过程中,并增加行数统计。
# 修改 CodeAnalyzer 类,让它将结果存入 FileInfo 对象
class CodeAnalyzer(ast.NodeVisitor):
def __init__(self, file_info):
self.file_info = file_info
def visit_FunctionDef(self, node):
self.file_info.function_count += 1
self.generic_visit(node)
def visit_ClassDef(self, node):
self.file_info.class_count += 1
self.generic_visit(node)
def analyze_file(filepath):
"""读取并分析单个 Python 文件,返回 FileInfo 对象"""
try:
with open(filepath, 'r', encoding='utf-8') as f:
source_code = f.read()
except (UnicodeDecodeError, FileNotFoundError):
print(f"Warning: Could not read file {filepath}")
return None
tree = ast.parse(source_code, filename=filepath)
file_info = FileInfo(filepath)
analyzer = CodeAnalyzer(file_info)
analyzer.visit(tree)
# 统计总行数
file_info.total_lines = len(source_code.splitlines())
return file_info
3 实现项目遍历逻辑
我们需要一个函数来递归地查找一个目录下的所有 .py 文件。
import os
def analyze_project(project_path):
"""分析一个项目目录下的所有 Python 文件"""
all_files_info = []
for root, _, files in os.walk(project_path):
for file in files:
if file.endswith('.py'):
filepath = os.path.join(root, file)
file_info = analyze_file(filepath)
if file_info:
all_files_info.append(file_info)
return all_files_info
第四步:整合与报告生成
现在我们有分析文件和分析项目的所有组件,最后一步是将它们整合起来,生成用户友好的报告。
1 编写主程序入口
我们将所有逻辑放在一起,并添加一个 main 函数。
def generate_report(all_files_info):
"""生成并打印分析报告"""
if not all_files_info:
print("No Python files found to analyze.")
return
total_functions = 0
total_classes = 0
total_lines = 0
print("\n--- Detailed Report ---")
for file_info in all_files_info:
print(f"\n--- File: {file_info.filepath} ---")
print(f"- Functions: {file_info.function_count}")
print(f"- Classes: {file_info.class_count}")
print(f"- Total Lines: {file_info.total_lines}")
total_functions += file_info.function_count
total_classes += file_info.class_count
total_lines += file_info.total_lines
print("\n--- Project Summary ---")
print(f"- Total Files: {len(all_files_info)}")
print(f"- Total Functions: {total_functions}")
print(f"- Total Classes: {total_classes}")
print(f"- Total Lines: {total_lines}")
def main():
"""主函数"""
# 为了简单起见,我们暂时写死项目路径
# 在下一步我们会改进这一点
project_path = 'sample_project'
print(f"Analyzing project: {project_path}")
files_info = analyze_project(project_path)
generate_report(files_info)
if __name__ == '__main__':
main()
2 处理命令行参数
让用户能从命令行传入项目路径会更实用,我们可以使用 sys.argv 来实现。
import sys
# ... (其他代码保持不变) ...
def main():
"""主函数,处理命令行参数"""
if len(sys.argv) != 2:
print("Usage: python pyanalyzer.py <path_to_project>")
sys.exit(1) # 退出程序,返回错误码 1
project_path = sys.argv[1]
if not os.path.isdir(project_path):
print(f"Error: Directory '{project_path}' not found.")
sys.exit(1)
print(f"Analyzing project: {project_path}")
files_info = analyze_project(project_path)
generate_report(files_info)
if __name__ == '__main__':
main()
你可以这样运行你的工具:
python pyanalyzer.py sample_project
3 生成格式化输出
我们的报告已经很好了,但可以稍微美化一下。generate_report 函数中的打印语句已经足够清晰。
第五步:扩展与进阶
你的 pyanalyzer 已经可以工作了!下面是一些让它变得更强大的方向。
1 添加更多分析功能
让我们添加一个新功能:检测未使用的变量,这需要更复杂的 AST 分析,因为我们不仅要定义变量,还要跟踪它的使用情况。
# 在 CodeAnalyzer 类中添加新方法
class CodeAnalyzer(ast.NodeVisitor):
def __init__(self, file_info):
self.file_info = file_info
self.defined_vars = set()
self.used_vars = set()
def visit_FunctionDef(self, node):
self.defined_vars.clear() # 函数作用域
self.used_vars.clear()
self.generic_visit(node)
# 分析函数结束后,比较已定义和已使用的变量
unused_vars = self.defined_vars - self.used_vars
if unused_vars:
print(f" [Warning] Unused variables in {node.name}: {', '.join(unused_vars)}")
def visit_Assign(self, node):
# 简单处理:只考虑单变量赋值,如 x = 1
if isinstance(node.targets[0], ast.Name):
var_name = node.targets[0].id
self.defined_vars.add(var_name)
self.generic_visit(node)
def visit_Name(self, node):
# 当看到一个变量名时,认为它被使用了
if isinstance(node.ctx, ast.Load): # 确保是加载(使用)而非存储(定义)
self.used_vars.add(node.id)
self.generic_visit(node)
# ... (visit_FunctionDef, visit_ClassDef 保持不变) ...
将这个新的 CodeAnalyzer 版本替换掉旧的,然后运行分析器,它现在会报告未使用的变量!
2 输出为 JSON 或 CSV
为了方便其他程序处理结果,我们可以将报告输出为结构化数据。
输出 JSON:
import json
def generate_json_report(all_files_info):
"""生成 JSON 格式的报告"""
report_data = {
"project_summary": {
"total_files": len(all_files_info),
"total_functions": sum(f.function_count for f in all_files_info),
"total_classes": sum(f.class_count for f in all_files_info),
"total_lines": sum(f.total_lines for f in all_files_info),
},
"files": [
{
"path": f.filepath,
"functions": f.function_count,
"classes": f.class_count,
"lines": f.total_lines
} for f in all_files_info
]
}
print(json.dumps(report_data, indent=4))
# 在 main 函数中添加一个选项来选择报告格式
# ...
输出 CSV:
import csv
def generate_csv_report(all_files_info):
"""生成 CSV 格式的报告"""
import io
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["Filepath", "Functions", "Classes", "Lines"])
for f in all_files_info:
writer.writerow([f.filepath, f.function_count, f.class_count, f.total_lines])
print(output.getvalue())
# 在 main 函数中添加一个选项来选择报告格式
# ...
3 使用第三方库增强功能
argparse: 对于更复杂的命令行参数(如--format json, --path ./my_projectargparse是标准库中的首选工具,比sys.argv` 更强大、更易用。libcst: 这是一个由 Meta (Facebook) 开发的 CST (Concrete Syntax Tree) 库,与 AST 不同,CST 保留了代码的所有格式信息(如空格、换行),如果你想构建代码格式化工具(如 Black)或进行基于代码样式的分析,libcst是更好的选择。semgrep: 这是一个非常强大的、可定制的静态分析工具,你可以学习它的规则语法,并编写自己的semgrep规则来实现复杂的代码检查,这通常比从头构建一个分析器要快得多。
第六步:完整代码与总结
下面是 pyanalyzer.py 的完整代码,整合了所有功能。
import ast
import os
import sys
# --- 数据结构 ---
class FileInfo:
def __init__(self, filepath):
self.filepath = filepath
self.function_count = 0
self.class_count = 0
self.total_lines = 0
# --- 核心分析器 ---
class CodeAnalyzer(ast.NodeVisitor):
def __init__(self, file_info):
self.file_info = file_info
# 用于检测未使用变量(进阶功能)
self.defined_vars = set()
self.used_vars = set()
def visit_FunctionDef(self, node):
# 函数作用域重置
self.defined_vars.clear()
self.used_vars.clear()
self.file_info.function_count += 1
self.generic_visit(node)
# 检查未使用变量
unused_vars = self.defined_vars - self.used_vars
if unused_vars:
print(f" [Warning] Unused variables in '{node.name}': {', '.join(unused_vars)}")
def visit_ClassDef(self, node):
self.file_info.class_count += 1
self.generic_visit(node)
def visit_Assign(self, node):
# 简单处理:只考虑单变量赋值
if isinstance(node.targets[0], ast.Name):
var_name = node.targets[0].id
self.defined_vars.add(var_name)
self.generic_visit(node)
def visit_Name(self, node):
# 当看到一个变量名时,认为它被使用了
if isinstance(node.ctx, ast.Load):
self.used_vars.add(node.id)
self.generic_visit(node)
# --- 文件分析逻辑 ---
def analyze_file(filepath):
"""读取并分析单个 Python 文件,返回 FileInfo 对象"""
try:
with open(filepath, 'r', encoding='utf-8') as f:
source_code = f.read()
except (UnicodeDecodeError, FileNotFoundError):
print(f"Warning: Could not read file {filepath}")
return None
tree = ast.parse(source_code, filename=filepath)
file_info = FileInfo(filepath)
analyzer = CodeAnalyzer(file_info)
analyzer.visit(tree)
file_info.total_lines = len(source_code.splitlines())
return file_info
# --- 项目分析逻辑 ---
def analyze_project(project_path):
"""分析一个项目目录下的所有 Python 文件"""
all_files_info = []
for root, _, files in os.walk(project_path):
for file in files:
if file.endswith('.py'):
filepath = os.path.join(root, file)
file_info = analyze_file(filepath)
if file_info:
all_files_info.append(file_info)
return all_files_info
# --- 报告生成 ---
def generate_report(all_files_info):
"""生成并打印分析报告"""
if not all_files_info:
print("No Python files found to analyze.")
return
total_functions = 0
total_classes = 0
total_lines = 0
print("\n--- Detailed Report ---")
for file_info in all_files_info:
print(f"\n--- File: {file_info.filepath} ---")
print(f"- Functions: {file_info.function_count}")
print(f"- Classes: {file_info.class_count}")
print(f"- Total Lines: {file_info.total_lines}")
total_functions += file_info.function_count
total_classes += file_info.class_count
total_lines += file_info.total_lines
print("\n--- Project Summary ---")
print(f"- Total Files: {len(all_files_info)}")
print(f"- Total Functions: {total_functions}")
print(f"- Total Classes: {total_classes}")
print(f"- Total Lines: {total_lines}")
# --- 主程序入口 ---
def main():
"""主函数,处理命令行参数"""
if len(sys.argv) != 2:
print("Usage: python pyanalyzer.py <path_to_project>")
sys.exit(1)
project_path = sys.argv[1]
if not os.path.isdir(project_path):
print(f"Error: Directory '{project_path}' not found.")
sys.exit(1)
print(f"Analyzing project: {project_path}")
files_info = analyze_project(project_path)
generate_report(files_info)
if __name__ == '__main__':
main()
恭喜!你已经成功地从零开始构建了一个功能齐全的 Python 代码分析器。
通过这个教程,你学到了:
ast模块是进行 Python 静态分析的核心工具。- 继承
ast.NodeVisitor是遍历和解析代码结构的标准模式。 - 如何将分析逻辑模块化,分离文件分析、项目遍历和报告生成。
- 如何通过扩展
NodeVisitor来添加新的分析功能。 - 构建命令行工具的基本流程。
这个 pyanalyzer 是一个很好的起点,你可以继续扩展它,比如添加对复杂导入、代码复杂度(圈复杂度)、甚至是安全漏洞的检测,希望这个教程能帮助你开启 Python 代码分析的大门!
