Python跨文件调用函数的5种实战场景:从同级目录到上级文件夹

刚接触Python项目时,很多人都会遇到一个看似简单却让人头疼的问题:明明函数就在那里,为什么调用时总是报ModuleNotFoundError?我记得自己第一次尝试在一个项目中组织代码时,把不同功能的函数分到了不同文件里,结果在main.py里怎么都导入不了隔壁文件夹里的工具函数。那种感觉就像你知道钥匙就在隔壁房间,但门被锁上了。

实际上,Python的模块导入系统设计得相当优雅,只是需要理解它的规则。这篇文章就是为你准备的实战指南,我会用真实的项目结构作为例子,带你一步步解决从简单到复杂的跨文件调用问题。无论你是刚开始学习Python的新手,还是已经有一定经验但在这个问题上踩过坑的开发者,都能在这里找到清晰的解决方案。

1. 理解Python的模块与包系统

在深入具体调用方法之前,我们需要先搞清楚Python是如何组织代码的。这就像你要在一个大型图书馆里找书,如果不知道图书分类系统,就会迷失在书架之间。

1.1 模块、包与普通文件夹的区别

很多人刚开始会混淆这三个概念,其实它们的区别很明确:

  • 模块(Module):一个.py文件就是一个模块。比如utils.py就是一个名为utils的模块。
  • 包(Package):一个包含__init__.py文件的文件夹。这个文件可以是空的,也可以包含初始化代码。
  • 普通文件夹:没有__init__.py文件的文件夹,Python不会把它当作包来处理。

注意:从Python 3.3开始,__init__.py对于命名空间包(Namespace Package)不再是必需的,但对于常规包,保持这个文件仍然是好习惯。

让我用一个实际的例子来说明。假设我们有一个电商项目的目录结构:

ecommerce_project/
├── __init__.py
├── main.py
├── utils/
│   ├── __init__.py
│   ├── validators.py
│   └── formatters.py
├── models/
│   ├── __init__.py
│   ├── product.py
│   └── user.py
└── services/
    ├── __init__.py
    ├── payment.py
    └── shipping.py

在这个结构中:

  • ecommerce_project是一个包(因为有__init__.py
  • utilsmodelsservices都是子包
  • validators.pyproduct.pypayment.py等都是模块

1.2 Python的导入搜索路径

当你在代码中写import something时,Python会按照特定顺序搜索这个模块。这个搜索路径存储在sys.path列表中。你可以通过以下代码查看当前的搜索路径:

import sys
print("当前Python搜索路径:")
for path in sys.path:
    print(f"  - {path}")

典型的输出可能像这样:

当前Python搜索路径:
  - /home/user/ecommerce_project
  - /usr/lib/python3.9
  - /usr/lib/python3.9/lib-dynload
  - /home/user/.local/lib/python3.9/site-packages
  - /usr/local/lib/python3.9/dist-packages
  - /usr/lib/python3/dist-packages

Python会按顺序在这些路径中查找你要导入的模块。第一个找到的就会被使用。这就是为什么有时候即使文件存在,也会报ModuleNotFoundError——文件所在的目录不在搜索路径中。

1.3 相对导入与绝对导入

Python支持两种导入方式,理解它们的区别很重要:

绝对导入:从项目根目录或已安装的包开始指定完整路径

from utils.validators import validate_email
from models.product import Product

相对导入:使用点号表示相对位置(只能在包内部使用)

from .validators import validate_email  # 同一包内的模块
from ..models.product import Product    # 上级包中的模块

相对导入在组织大型项目时很有用,但它有一个限制:只能在作为包一部分的模块中使用。如果你直接运行一个使用相对导入的脚本(如python my_module.py),会收到错误提示。

2. 同级目录下的函数调用

这是最简单也是最常见的情况。当两个文件在同一个目录下时,Python的导入机制几乎总是能正常工作。

2.1 基础导入方式

假设我们有一个简单的项目结构:

project/
├── calculator.py
└── main.py

calculator.py的内容:

def add(a, b):
    """返回两个数的和"""
    return a + b

def multiply(a, b):
    """返回两个数的乘积"""
    return a * b

def power(base, exponent):
    """返回base的exponent次幂"""
    return base ** exponent

main.py中调用这些函数,有几种不同的方式:

方式一:导入整个模块

import calculator

result = calculator.add(5, 3)
print(f"5 + 3 = {result}")  # 输出: 5 + 3 = 8

product = calculator.multiply(4, 6)
print(f"4 × 6 = {product}")  # 输出: 4 × 6 = 24

方式二:导入特定函数

from calculator import add, multiply

sum_result = add(10, 20)
print(f"10 + 20 = {sum_result}")  # 输出: 10 + 20 = 30

方式三:使用别名

from calculator import power as pow_func

result = pow_func(2, 8)
print(f"2的8次方是: {result}")  # 输出: 2的8次方是: 256

2.2 实际项目中的最佳实践

在真实项目中,我通常遵循这些原则:

  1. 按功能组织导入:将相关的导入分组,标准库导入在前,第三方库其次,最后是自己的模块
  2. 避免使用通配符导入from module import *会让代码难以理解和调试
  3. 使用有意义的别名:特别是当模块名很长或与现有名称冲突时

下面是一个实际项目中的导入示例:

# 标准库导入
import os
import sys
from datetime import datetime
from typing import List, Dict, Optional

# 第三方库导入
import pandas as pd
import numpy as np
from sqlalchemy import create_engine

# 本地模块导入
from data_processor import clean_data, normalize_columns
from report_generator import generate_summary_report
from config import DATABASE_URL, LOG_LEVEL

2.3 处理循环导入问题

有时候,两个模块需要相互导入对方的函数,这就形成了循环导入。Python可以处理简单的循环导入,但复杂的循环导入会导致问题。

假设有两个文件:

user.py:

from post import get_user_posts

class User:
    def __init__(self, name):
        self.name = name
    
    def get_recent_posts(self):
        # 这里需要导入Post相关功能
        return get_user_posts(self.name)

post.py:

from user import User

class Post:
    def __init__(self, content, author):
        self.content = content
        self.author = author
    
def get_user_posts(username):
    # 这里需要User类
    user = User(username)
    # ... 获取帖子的逻辑

这种情况会导致导入错误。解决方法有几种:

  1. 重构代码:将公共功能提取到第三个模块中
  2. 局部导入:在函数内部导入需要的模块
  3. 使用类型提示的字符串字面量(Python 3.7+)

对于上面的例子,我们可以这样修改post.py

# 在文件顶部移除 from user import User

class Post:
    def __init__(self, content, author):
        self.content = content
        self.author = author
    
def get_user_posts(username):
    # 局部导入,避免循环导入
    from user import User
    user = User(username)
    # ... 获取帖子的逻辑

或者使用类型提示的字符串字面量:

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from user import User

class Post:
    def __init__(self, content, author: 'User'):
        self.content = content
        self.author = author

3. 调用子目录中的函数

当项目规模增长时,我们自然会把相关功能组织到子目录中。这是Python包系统真正发挥作用的地方。

3.1 标准包结构导入

让我们回到之前的电商项目例子。假设我们想在main.py中调用utils/validators.py中的函数:

utils/validators.py:

def validate_email(email: str) -> bool:
    """验证电子邮件地址格式"""
    import re
    pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
    return bool(re.match(pattern, email))

def validate_phone(phone: str) -> bool:
    """验证手机号码格式(简单版本)"""
    import re
    # 移除所有非数字字符
    digits = re.sub(r'\D', '', phone)
    return len(digits) >= 10

def validate_password(password: str) -> dict:
    """验证密码强度,返回包含检查结果的字典"""
    result = {
        'length_ok': len(password) >= 8,
        'has_upper': any(c.isupper() for c in password),
        'has_lower': any(c.islower() for c in password),
        'has_digit': any(c.isdigit() for c in password),
        'has_special': any(not c.isalnum() for c in password)
    }
    result['all_checks_passed'] = all(result.values())
    return result

现在从main.py中调用这些函数:

方式一:直接导入函数

from utils.validators import validate_email, validate_password

# 测试电子邮件验证
test_emails = [
    "user@example.com",
    "invalid-email",
    "name@company.co.uk",
    "missing@tld"
]

print("电子邮件验证测试:")
for email in test_emails:
    is_valid = validate_email(email)
    print(f"  {email:30} -> {'有效' if is_valid else '无效'}")

方式二:通过包导入

import utils.validators as validators

# 测试密码强度验证
passwords = ["weak", "Strong123", "Very$tr0ngP@ss"]

print("\n密码强度验证测试:")
for pwd in passwords:
    result = validators.validate_password(pwd)
    print(f"\n密码: {pwd}")
    for check, passed in result.items():
        status = "✓" if passed else "✗"
        print(f"  {check:20} {status}")

3.2 使用__init__.py简化导入

__init__.py文件不仅仅是一个标记文件,它还可以用来简化包的导入接口。我们可以通过在这个文件中导入子模块的内容,让用户更容易访问它们。

utils/__init__.py:

"""
工具函数包
"""

from .validators import (
    validate_email,
    validate_phone,
    validate_password
)

from .formatters import (
    format_currency,
    format_date,
    truncate_text
)

# 可以定义包级别的变量
PACKAGE_VERSION = "1.0.0"

# 或者定义包级别的函数
def get_version():
    """返回工具包的版本信息"""
    return f"utils package version {PACKAGE_VERSION}"

现在,我们可以用更简洁的方式导入:

from utils import validate_email, format_currency

# 或者导入整个包
import utils

print(f"工具包版本: {utils.get_version()}")
print(f"100美元格式化为: {utils.format_currency(100, 'USD')}")

3.3 处理多层嵌套的子目录

对于更深层的目录结构,导入方式类似。假设我们有这样的结构:

project/
├── main.py
└── core/
    ├── __init__.py
    ├── database/
    │   ├── __init__.py
    │   ├── connection.py
    │   └── queries.py
    └── analytics/
        ├── __init__.py
        ├── metrics.py
        └── visualizations.py

main.py导入database/queries.py中的函数:

from core.database.queries import get_user_by_id, get_recent_orders

# 或者使用相对导入(在包内部)
# 在core/analytics/metrics.py中导入database/queries.py:
# from ..database.queries import get_user_by_id

3.4 实际案例:Web应用中的模块组织

让我们看一个Flask Web应用的典型结构:

flask_app/
├── app.py
├── config.py
├── requirements.txt
├── static/
├── templates/
└── app/
    ├── __init__.py
    ├── models/
    │   ├── __init__.py
    │   ├── user.py
    │   └── post.py
    ├── views/
    │   ├── __init__.py
    │   ├── auth.py
    │   └── blog.py
    ├── forms/
    │   ├── __init__.py
    │   ├── login_form.py
    │   └── post_form.py
    └── utils/
        ├── __init__.py
        ├── validators.py
        └── helpers.py

app/__init__.py中,我们通常会这样组织:

from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager

db = SQLAlchemy()
login_manager = LoginManager()

def create_app(config_class='config.Config'):
    app = Flask(__name__)
    app.config.from_object(config_class)
    
    # 初始化扩展
    db.init_app(app)
    login_manager.init_app(app)
    
    # 注册蓝图
    from app.views.auth import auth_bp
    from app.views.blog import blog_bp
    
    app.register_blueprint(auth_bp)
    app.register_blueprint(blog_bp)
    
    return app

然后在app/views/auth.py中,我们可以这样导入其他模块:

from flask import Blueprint, render_template, redirect, url_for, flash
from flask_login import login_user, logout_user, login_required
from app import db, login_manager
from app.models.user import User
from app.forms.login_form import LoginForm
from app.utils.validators import validate_email

auth_bp = Blueprint('auth', __name__)

@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
    form = LoginForm()
    if form.validate_on_submit():
        # 使用导入的验证函数
        if not validate_email(form.email.data):
            flash('无效的电子邮件地址', 'error')
            return render_template('auth/login.html', form=form)
        
        user = User.query.filter_by(email=form.email.data).first()
        if user and user.check_password(form.password.data):
            login_user(user)
            return redirect(url_for('blog.index'))
    
    return render_template('auth/login.html', form=form)

这种结构清晰地将不同功能的代码分开,同时通过合理的导入机制让它们能够协同工作。

4. 调用上级目录中的函数

这是Python初学者经常遇到问题的地方。当你需要从子目录中的文件调用上级目录中的函数时,标准的导入方式不再有效,因为上级目录不在Python的搜索路径中。

4.1 理解问题所在

假设我们有这样的目录结构:

project/
├── common_utils.py
├── config.py
└── modules/
    ├── __init__.py
    └── processor.py

modules/processor.py中,我们想要使用common_utils.pyconfig.py中的函数。如果直接尝试导入:

# 在modules/processor.py中
import common_utils  # 这会失败!

你会得到ModuleNotFoundError: No module named 'common_utils'。这是因为Python只在sys.path列出的目录中搜索模块,而project目录的父目录(包含common_utils.py的目录)不在这个列表中。

4.2 解决方案:动态修改sys.path

最直接的解决方案是在运行时将上级目录添加到Python的搜索路径中。有几种方法可以实现:

方法一:使用绝对路径(不推荐)

import sys
sys.path.append('/home/user/project')  # 绝对路径
import common_utils

这种方法的问题很明显:路径是硬编码的,代码无法在不同机器或不同目录位置正常工作。

方法二:使用相对路径和os模块(推荐)

import os
import sys

# 获取当前文件的目录
current_dir = os.path.dirname(os.path.abspath(__file__))
# 获取上级目录
parent_dir = os.path.dirname(current_dir)
# 将上级目录添加到搜索路径
sys.path.insert(0, parent_dir)

# 现在可以导入了
import common_utils
import config

让我们分解一下这段代码:

  1. __file__是当前文件的路径
  2. os.path.abspath(__file__)获取绝对路径
  3. os.path.dirname()获取父目录
  4. sys.path.insert(0, ...)将路径插入到搜索路径的开头(这样Python会优先搜索这个目录)

方法三:创建工具函数 在实际项目中,我通常会创建一个专门处理路径的工具函数:

# 在modules/path_utils.py中
import os
import sys
from pathlib import Path

def add_parent_to_path(levels_up=1):
    """
    将指定层级的上级目录添加到Python路径中
    
    参数:
        levels_up: 向上回溯的层级数,默认为1(父目录)
    """
    current_file = Path(__file__).resolve()
    target_dir = current_file.parents[levels_up]
    sys.path.insert(0, str(target_dir))
    
    return target_dir

然后在需要的地方使用:

from .path_utils import add_parent_to_path

# 添加父目录到路径
add_parent_to_path(1)

# 现在可以导入上级目录中的模块了
import common_utils
import config

# 如果需要上两级目录
add_parent_to_path(2)

4.3 实际项目示例

让我们看一个更复杂的例子。假设我们有一个数据科学项目的结构:

data_science_project/
├── config.py
├── data_loader.py
├── utils/
│   ├── __init__.py
│   ├── preprocessing.py
│   └── visualization.py
├── notebooks/
│   ├── exploration.ipynb
│   └── modeling.ipynb
└── scripts/
    ├── __init__.py
    ├── train_model.py
    └── evaluate.py

scripts/train_model.py中,我们需要使用项目根目录下的config.pydata_loader.py,以及utils/目录中的函数:

# scripts/train_model.py
import os
import sys
from pathlib import Path

# 将项目根目录添加到Python路径
project_root = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(project_root))

# 现在可以导入项目根目录和utils中的模块
import config
import data_loader
from utils.preprocessing import clean_data, normalize_features
from utils.visualization import plot_training_history

def train_model():
    """训练机器学习模型"""
    # 加载配置
    model_config = config.get_model_config()
    
    # 加载数据
    raw_data = data_loader.load_dataset(config.DATASET_PATH)
    
    # 预处理数据
    cleaned_data = clean_data(raw_data)
    X, y = normalize_features(cleaned_data)
    
    # 训练模型(简化示例)
    print(f"使用配置训练模型: {model_config}")
    print(f"数据形状: X={X.shape}, y={y.shape}")
    
    # 这里实际会调用机器学习库如scikit-learn
    # model = SomeModel(**model_config)
    # history = model.fit(X, y)
    
    # 可视化训练历史
    # plot_training_history(history)
    
    return "模型训练完成"

if __name__ == "__main__":
    result = train_model()
    print(result)

4.4 处理多层上级目录

有时候,你可能需要访问不止一级的上级目录。使用pathlib模块可以让这变得简单:

from pathlib import Path

# 获取当前文件的绝对路径
current_file = Path(__file__).resolve()

# 获取不同层级的上级目录
parent_dir = current_file.parent  # 直接父目录
grandparent_dir = current_file.parents[1]  # 上两级目录
great_grandparent_dir = current_file.parents[2]  # 上三级目录

# 使用parents属性可以方便地获取任意层级的上级目录
project_root = current_file.parents[2]  # 假设项目根目录在上两级

# 添加到sys.path
import sys
sys.path.insert(0, str(project_root))

4.5 在Jupyter Notebook中处理导入

在Jupyter Notebook中,情况稍有不同,因为notebook的运行目录可能不是文件所在目录。这里有一个可靠的方法:

# 在notebook的单元格中
import os
import sys
from pathlib import Path

# 方法1:使用绝对路径(如果知道项目结构)
project_path = Path.cwd().parent  # 假设notebook在项目子目录中
sys.path.insert(0, str(project_path))

# 方法2:使用相对路径(更灵活)
notebook_path = Path().resolve()
# 假设项目根目录在notebook的上两级
project_root = notebook_path.parents[1]
sys.path.insert(0, str(project_root))

# 现在可以导入项目模块
import config
from utils.preprocessing import clean_data

我经常在notebook的开头添加这样一个单元格,确保所有必要的路径都已正确设置。

5. 执行另一个Python文件

有时候,我们不仅需要调用另一个文件中的函数,还需要直接执行整个Python文件。这在脚本编排、任务调度和构建工具链时特别有用。

5.1 使用os.system执行外部命令

最简单的方法是使用os.system(),它会在系统shell中执行命令:

import os

# 执行另一个Python脚本
os.system('python other_script.py')

# 传递参数
os.system('python process_data.py --input data.csv --output results.json')

# 在特定目录中执行
os.system('cd /path/to/project && python main.py')

然而,这种方法有几个缺点:

  1. 依赖于系统shell,在不同操作系统上行为可能不同
  2. 难以捕获输出或错误信息
  3. 安全性问题(如果命令来自不可信来源)

5.2 使用subprocess模块(推荐)

subprocess模块提供了更强大、更安全的执行外部命令的方式:

基本用法:

import subprocess

# 执行脚本并捕获输出
result = subprocess.run(
    ['python', 'other_script.py'],
    capture_output=True,
    text=True
)

print(f"返回码: {result.returncode}")
print(f"标准输出:\n{result.stdout}")
if result.stderr:
    print(f"标准错误:\n{result.stderr}")

传递参数:

import subprocess

# 执行带参数的脚本
args = [
    'python',
    'data_pipeline.py',
    '--input', 'raw_data.csv',
    '--output', 'processed_data.parquet',
    '--verbose'
]

result = subprocess.run(args, capture_output=True, text=True)

if result.returncode == 0:
    print("脚本执行成功")
    print(f"输出: {result.stdout}")
else:
    print(f"脚本执行失败,错误: {result.stderr}")

设置工作目录:

import subprocess
from pathlib import Path

# 指定工作目录
project_root = Path(__file__).resolve().parent

result = subprocess.run(
    ['python', 'scripts/process.py'],
    cwd=project_root,  # 设置工作目录
    capture_output=True,
    text=True
)

5.3 动态导入并执行模块

如果你需要执行另一个Python文件并访问其中的变量或函数,而不仅仅是运行它,可以使用动态导入:

方法一:使用importlib

import importlib.util
import sys

def import_module_from_path(module_name, file_path):
    """
    从指定路径导入模块
    
    参数:
        module_name: 模块名称
        file_path: 模块文件路径
    """
    spec = importlib.util.spec_from_file_location(module_name, file_path)
    module = importlib.util.module_from_spec(spec)
    sys.modules[module_name] = module
    spec.loader.exec_module(module)
    return module

# 使用示例
other_module = import_module_from_path('my_module', '/path/to/my_module.py')

# 现在可以访问模块中的内容
if hasattr(other_module, 'some_function'):
    result = other_module.some_function()
    print(f"函数执行结果: {result}")

if hasattr(other_module, 'SOME_CONSTANT'):
    print(f"常量值: {other_module.SOME_CONSTANT}")

方法二:直接执行文件并获取全局变量

def execute_file_and_get_globals(file_path):
    """
    执行Python文件并返回其全局命名空间
    
    参数:
        file_path: 要执行的Python文件路径
    返回:
        文件的全局命名空间字典
    """
    globals_dict = {}
    
    with open(file_path, 'r', encoding='utf-8') as f:
        code = f.read()
    
    # 执行代码
    exec(code, globals_dict)
    
    return globals_dict

# 使用示例
config_globals = execute_file_and_get_globals('config.py')

# 访问配置变量
db_host = config_globals.get('DATABASE_HOST', 'localhost')
db_port = config_globals.get('DATABASE_PORT', 5432)

print(f"数据库配置: {db_host}:{db_port}")

# 如果文件中有函数,也可以调用
if 'initialize_database' in config_globals:
    config_globals['initialize_database']()

5.4 实际应用场景

场景一:配置管理 在大型项目中,经常需要根据环境加载不同的配置文件:

import importlib.util
from pathlib import Path

def load_config(environment='development'):
    """
    根据环境加载配置文件
    
    参数:
        environment: 环境名称,如'development'、'testing'、'production'
    """
    config_dir = Path(__file__).parent / 'config'
    config_file = config_dir / f'{environment}.py'
    
    if not config_file.exists():
        raise FileNotFoundError(f"配置文件不存在: {config_file}")
    
    # 动态导入配置文件
    spec = importlib.util.spec_from_file_location(f'config_{environment}', config_file)
    config_module = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(config_module)
    
    # 收集所有大写的配置变量
    config = {}
    for key in dir(config_module):
        if key.isupper() and not key.startswith('_'):
            config[key] = getattr(config_module, key)
    
    return config

# 使用示例
dev_config = load_config('development')
prod_config = load_config('production')

print(f"开发环境数据库: {dev_config.get('DATABASE_URL')}")
print(f"生产环境数据库: {prod_config.get('DATABASE_URL')}")

场景二:插件系统 实现一个简单的插件系统,动态加载和执行插件:

# plugin_loader.py
import importlib.util
from pathlib import Path
from typing import List, Dict, Any

class PluginLoader:
    def __init__(self, plugins_dir: str):
        self.plugins_dir = Path(plugins_dir)
        self.plugins = {}
    
    def discover_plugins(self):
        """发现所有插件"""
        plugin_files = self.plugins_dir.glob('*.py')
        
        for plugin_file in plugin_files:
            if plugin_file.name == '__init__.py':
                continue
            
            plugin_name = plugin_file.stem
            self.load_plugin(plugin_name, plugin_file)
    
    def load_plugin(self, plugin_name: str, plugin_file: Path):
        """加载单个插件"""
        spec = importlib.util.spec_from_file_location(
            f'plugins.{plugin_name}',
            plugin_file
        )
        plugin_module = importlib.util.module_from_spec(spec)
        spec.loader.exec_module(plugin_module)
        
        # 检查插件是否有必要的接口
        if hasattr(plugin_module, 'Plugin'):
            plugin_class = plugin_module.Plugin
            self.plugins[plugin_name] = plugin_class()
            print(f"已加载插件: {plugin_name}")
        else:
            print(f"警告: {plugin_name} 没有Plugin类,跳过")
    
    def execute_plugin(self, plugin_name: str, *args, **kwargs) -> Any:
        """执行插件"""
        if plugin_name not in self.plugins:
            raise ValueError(f"插件未找到: {plugin_name}")
        
        plugin = self.plugins[plugin_name]
        return plugin.execute(*args, **kwargs)
    
    def list_plugins(self) -> List[str]:
        """列出所有已加载的插件"""
        return list(self.plugins.keys())

# 使用示例
if __name__ == "__main__":
    loader = PluginLoader('plugins')
    loader.discover_plugins()
    
    print(f"发现的插件: {loader.list_plugins()}")
    
    # 执行插件
    for plugin_name in loader.list_plugins():
        try:
            result = loader.execute_plugin(plugin_name)
            print(f"插件 {plugin_name} 执行结果: {result}")
        except Exception as e:
            print(f"插件 {plugin_name} 执行失败: {e}")

场景三:测试脚本编排 在自动化测试中,经常需要按特定顺序执行多个测试脚本:

# test_runner.py
import subprocess
import time
from datetime import datetime
from pathlib import Path
from typing import List, Dict

class TestRunner:
    def __init__(self, test_dir: str):
        self.test_dir = Path(test_dir)
        self.results = []
    
    def discover_tests(self) -> List[Path]:
        """发现所有测试脚本"""
        test_files = []
        
        # 查找所有test_*.py文件
        for pattern in ['test_*.py', '*_test.py']:
            test_files.extend(self.test_dir.rglob(pattern))
        
        # 按文件名排序,确保执行顺序一致
        test_files.sort()
        
        return test_files
    
    def run_test(self, test_file: Path) -> Dict:
        """运行单个测试脚本"""
        start_time = time.time()
        
        result = subprocess.run(
            ['python', str(test_file)],
            capture_output=True,
            text=True
        )
        
        end_time = time.time()
        duration = end_time - start_time
        
        test_result = {
            'test_file': test_file.name,
            'path': str(test_file),
            'returncode': result.returncode,
            'stdout': result.stdout,
            'stderr': result.stderr,
            'duration': duration,
            'timestamp': datetime.now().isoformat(),
            'success': result.returncode == 0
        }
        
        return test_result
    
    def run_all_tests(self):
        """运行所有测试"""
        test_files = self.discover_tests()
        
        print(f"发现 {len(test_files)} 个测试文件")
        print("=" * 50)
        
        for i, test_file in enumerate(test_files, 1):
            print(f"运行测试 [{i}/{len(test_files)}]: {test_file.name}")
            
            result = self.run_test(test_file)
            self.results.append(result)
            
            status = "✓ 通过" if result['success'] else "✗ 失败"
            print(f"  状态: {status}, 耗时: {result['duration']:.2f}秒")
            
            if result['stderr']:
                print(f"  错误输出: {result['stderr'][:200]}...")
        
        self.generate_report()
    
    def generate_report(self):
        """生成测试报告"""
        total = len(self.results)
        passed = sum(1 for r in self.results if r['success'])
        failed = total - passed
        
        print("\n" + "=" * 50)
        print("测试报告")
        print("=" * 50)
        print(f"总计: {total}, 通过: {passed}, 失败: {failed}")
        
        if failed > 0:
            print("\n失败的测试:")
            for result in self.results:
                if not result['success']:
                    print(f"  - {result['test_file']}")
                    if result['stderr']:
                        print(f"    错误: {result['stderr'][:100]}")

# 使用示例
if __name__ == "__main__":
    runner = TestRunner('tests')
    runner.run_all_tests()

这些方法覆盖了从简单到复杂的各种场景,你可以根据具体需求选择合适的方法。在实际项目中,我通常优先使用subprocess.run()来执行独立脚本,而对于需要交互或共享数据的场景,则使用动态导入。关键是要理解每种方法的优缺点,并根据具体情况做出选择。

更多推荐