diff --git a/.DS_Store b/.DS_Store index 35fa5a5..7beb045 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..dc81c24 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,59 @@ +name: CI Pipeline + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.12" + + - name: Install Poetry + uses: snok/install-poetry@v1 + with: + version: latest + virtualenvs-create: true + virtualenvs-in-project: true + + - name: Install dependencies + run: | + poetry install --no-interaction --no-root + + - name: Run simple test + run: | + echo "Testing basic functionality..." + poetry run python -c "import src.client.base_client; print('Base client imports successfully')" + poetry run python -c "import src.utils.environment; print('Environment utils work')" + echo "Basic imports successful!" + + - name: Run basic pytest + run: | + poetry run pytest --version + echo "pytest is working" + + notify: + needs: [test] + runs-on: ubuntu-latest + if: always() + + steps: + - name: Notify on success + if: ${{ needs.test.result == 'success' }} + run: | + echo "✅ Basic CI checks passed!" + + - name: Notify on failure + if: ${{ needs.test.result == 'failure' }} + run: | + echo "❌ CI checks failed!" + exit 1 diff --git a/.github/workflows/debug.yml b/.github/workflows/debug.yml new file mode 100644 index 0000000..7adf906 --- /dev/null +++ b/.github/workflows/debug.yml @@ -0,0 +1,115 @@ +name: Debug CI Issues + +# 触发条件:推送到debug分支或在commit消息中包含[debug] +on: + push: + branches: [debug, debug/*] + workflow_dispatch: # 手动触发 + +jobs: + debug: + runs-on: ubuntu-latest + + steps: + - name: "🔍 Step 1: Checkout & Environment Info" + uses: actions/checkout@v4 + + - name: "📊 Step 2: System Information" + run: | + echo "=== System Information ===" + uname -a + cat /etc/os-release + echo "" + echo "=== Available Python versions ===" + ls -la /opt/hostedtoolcache/Python/ + echo "" + echo "=== Environment Variables ===" + env | grep -E "(PYTHON|PATH|HOME)" | sort + + - name: "🐍 Step 3: Setup Python" + uses: actions/setup-python@v4 + with: + python-version: "3.12" + + - name: "📋 Step 4: Python Environment Check" + run: | + echo "=== Python Version & Location ===" + which python + python --version + python -c "import sys; print(f'Python path: {sys.executable}')" + echo "" + echo "=== Pip Information ===" + which pip + pip --version + pip list | head -10 + echo "" + echo "=== Python Paths ===" + python -c "import sys; [print(f' {p}') for p in sys.path]" + + - name: "📁 Step 5: Project Structure Check" + run: | + echo "=== Working Directory ===" + pwd + echo "" + echo "=== Project Files ===" + ls -la + echo "" + echo "=== Python Files ===" + find . -name "*.py" | head -10 + echo "" + echo "=== Config Files ===" + ls -la pyproject.toml poetry.lock 2>/dev/null || echo "Config files missing" + + - name: "🎭 Step 6: Install Poetry" + uses: snok/install-poetry@v1 + with: + version: latest + virtualenvs-create: true + virtualenvs-in-project: true + + - name: "🔧 Step 7: Poetry Environment Check" + run: | + echo "=== Poetry Information ===" + poetry --version + poetry config --list + echo "" + echo "=== Poetry Files Check ===" + poetry check + echo "" + echo "=== Virtual Environment ===" + poetry env info + + - name: "📦 Step 8: Install Dependencies (Verbose)" + run: | + echo "=== Installing Dependencies with Verbose Output ===" + poetry install --no-interaction --no-root -vvv + + - name: "✅ Step 9: Test Basic Imports" + run: | + echo "=== Testing Python Imports ===" + poetry run python -c "print('✅ Basic Python works')" + poetry run python -c "import sys; print('✅ sys module works')" + poetry run python -c "import os; print('✅ os module works')" + poetry run python -c "import pathlib; print('✅ pathlib works')" + + echo "" + echo "=== Testing Project Structure ===" + poetry run python -c "import src; print('✅ src package works')" + poetry run python -c "from src.client import base_client; print('✅ base_client works')" + poetry run python -c "from src.utils import environment; print('✅ environment works')" + + - name: "🧪 Step 10: Test Basic Pytest" + run: | + echo "=== Pytest Information ===" + poetry run pytest --version + poetry run pytest --collect-only tests/ | head -20 + + - name: "📤 Step 11: Save Debug Information" + if: always() + uses: actions/upload-artifact@v4 + with: + name: debug-info + path: | + poetry.lock + .venv/ + *.log diff --git a/.gitignore b/.gitignore index 01c914c..3e8b8c1 100644 --- a/.gitignore +++ b/.gitignore @@ -271,3 +271,5 @@ conf/settings.local.yaml **/.idea .idea + +.claude diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6b2acc5 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,417 @@ +# 🤝 贡献指南 + +感谢您对 Pytest Framework 的关注!我们欢迎所有形式的贡献,无论是代码、文档、问题报告还是功能建议。 + +## 📋 贡献方式 + +### 🐛 报告Bug +- 在 [Issues](https://github.com/your-repo/pytest-framework/issues) 页面创建新的问题 +- 使用 Bug 报告模板 +- 提供详细的复现步骤和环境信息 + +### 💡 功能建议 +- 在 [Issues](https://github.com/your-repo/pytest-framework/issues) 页面创建功能请求 +- 使用功能请求模板 +- 详细描述功能需求和使用场景 + +### 📝 改进文档 +- 修复文档中的错误 +- 添加缺失的文档 +- 改进示例和教程 + +### 🔧 代码贡献 +- 修复Bug +- 实现新功能 +- 优化性能 +- 重构代码 + +## 🚀 开发环境设置 + +### 1. Fork 和克隆项目 + +```bash +# Fork 项目到您的GitHub账户 +# 然后克隆您的Fork + +git clone https://github.com/YOUR_USERNAME/pytest-framework.git +cd pytest-framework + +# 添加上游仓库 +git remote add upstream https://github.com/your-repo/pytest-framework.git +``` + +### 2. 安装开发环境 + +```bash +# 安装Poetry(如果还没有安装) +curl -sSL https://install.python-poetry.org | python3 - + +# 安装项目依赖 +poetry install + +# 激活虚拟环境 +poetry shell + +# 安装pre-commit钩子 +pre-commit install +``` + +### 3. 验证环境 + +```bash +# 运行测试 +pytest tests/ -v + +# 运行代码质量检查 +black --check . +isort --check-only . +flake8 . +mypy src/ +``` + +## 📝 开发流程 + +### 1. 创建功能分支 + +```bash +# 确保主分支是最新的 +git checkout main +git pull upstream main + +# 创建新的功能分支 +git checkout -b feature/your-feature-name +``` + +### 2. 开发和测试 + +```bash +# 进行开发... + +# 运行测试 +pytest tests/ -v + +# 运行代码质量检查 +black . +isort . +flake8 . + +# 提交更改 +git add . +git commit -m "feat: add your feature description" +``` + +### 3. 提交Pull Request + +```bash +# 推送到您的Fork +git push origin feature/your-feature-name + +# 在GitHub上创建Pull Request +``` + +## 📏 代码规范 + +### 1. Python代码风格 + +我们使用以下工具确保代码质量: + +- **Black**: 代码格式化 +- **isort**: 导入排序 +- **flake8**: 代码检查 +- **mypy**: 类型检查 + +```bash +# 格式化代码 +black . +isort . + +# 检查代码 +flake8 . +mypy src/ +``` + +### 2. 命名规范 + +```python +# 文件名:使用下划线分隔 +test_user_authentication.py +data_driver.py + +# 类名:使用驼峰命名 +class UserAuthentication: + pass + +class DataDriver: + pass + +# 函数和变量名:使用下划线分隔 +def create_user(): + pass + +user_data = {} + +# 常量:使用大写字母和下划线 +MAX_RETRY_TIMES = 3 +DEFAULT_TIMEOUT = 30 +``` + +### 3. 文档字符串 + +```python +def create_user(user_data: Dict[str, Any]) -> Dict[str, Any]: + """ + 创建新用户 + + Args: + user_data: 用户数据字典,包含name、email等字段 + + Returns: + 创建成功的用户信息字典 + + Raises: + ValueError: 当用户数据无效时 + APIError: 当API调用失败时 + + Example: + >>> user_data = {"name": "张三", "email": "zhangsan@example.com"} + >>> result = create_user(user_data) + >>> print(result["id"]) + 123 + """ + pass +``` + +### 4. 类型注解 + +```python +from typing import Dict, List, Optional, Union + +def process_users(users: List[Dict[str, Any]], + filter_active: bool = True) -> List[Dict[str, Any]]: + """处理用户列表""" + pass + +class APIClient: + def __init__(self, base_url: str, timeout: Optional[int] = None) -> None: + self.base_url = base_url + self.timeout = timeout or 30 +``` + +## 🧪 测试规范 + +### 1. 测试文件组织 + +``` +tests/ +├── unit/ # 单元测试 +├── integration/ # 集成测试 +├── examples/ # 示例测试 +└── conftest.py # pytest配置 +``` + +### 2. 测试命名 + +```python +class TestUserAuthentication: + """用户认证测试类""" + + def test_login_with_valid_credentials_should_return_success(self): + """使用有效凭据登录应该返回成功""" + pass + + def test_login_with_invalid_password_should_return_401(self): + """使用无效密码登录应该返回401错误""" + pass +``` + +### 3. 测试结构 + +```python +def test_create_user_with_valid_data(self, api_client): + """测试用例应该遵循AAA模式""" + + # Arrange - 准备测试数据 + user_data = { + "name": "张三", + "email": "zhangsan@example.com" + } + + # Act - 执行操作 + response = api_client.post("/users", json=user_data) + + # Assert - 验证结果 + assert response.status_code == 201 + assert response.json()["data"]["name"] == "张三" +``` + +### 4. 测试覆盖率 + +- 新功能必须有对应的测试 +- 测试覆盖率应该保持在80%以上 +- 关键功能的测试覆盖率应该达到95%以上 + +```bash +# 运行覆盖率测试 +pytest --cov=src --cov-report=html --cov-fail-under=80 +``` + +## 📚 文档规范 + +### 1. 文档结构 + +```markdown +# 标题 + +简短的功能描述 + +## 目标 + +说明文档的学习目标 + +## 内容 + +详细的功能说明和示例 + +## 示例 + +具体的代码示例 + +## 最佳实践 + +使用建议和注意事项 +``` + +### 2. 代码示例 + +```python +# 好的示例:完整且可运行 +from src.client.base_client import BaseClient + +client = BaseClient("https://api.example.com") +response = client.get("/users") +assert response.status_code == 200 + +# 避免:不完整的示例 +client.get("/users") # 缺少导入和初始化 +``` + +## 🔄 提交规范 + +### 1. 提交消息格式 + +``` +(): + +[optional body] + +[optional footer] +``` + +### 2. 提交类型 + +- `feat`: 新功能 +- `fix`: Bug修复 +- `docs`: 文档更新 +- `style`: 代码格式化 +- `refactor`: 代码重构 +- `test`: 测试相关 +- `chore`: 构建过程或辅助工具的变动 + +### 3. 示例 + +```bash +feat(client): add retry mechanism for HTTP requests + +Add automatic retry functionality with exponential backoff +for handling transient network failures. + +Closes #123 +``` + +## 🎯 Pull Request 规范 + +### 1. PR标题 + +- 使用清晰描述性的标题 +- 遵循提交消息格式 +- 包含相关的Issue编号 + +### 2. PR描述 + +```markdown +## 变更说明 +简要描述这个PR的变更内容 + +## 变更类型 +- [ ] Bug修复 +- [ ] 新功能 +- [ ] 文档更新 +- [ ] 代码重构 +- [ ] 性能优化 + +## 测试 +- [ ] 添加了新的测试 +- [ ] 所有测试通过 +- [ ] 手动测试通过 + +## 检查清单 +- [ ] 代码遵循项目规范 +- [ ] 添加了必要的文档 +- [ ] 更新了CHANGELOG +``` + +### 3. 代码审查 + +- 所有PR都需要至少一个维护者的审查 +- 确保CI/CD检查通过 +- 解决所有审查意见 + +## 🏷️ 发布流程 + +### 1. 版本号规范 + +我们使用 [语义化版本](https://semver.org/lang/zh-CN/): + +- `MAJOR.MINOR.PATCH` +- `1.0.0` -> `1.0.1` (补丁版本) +- `1.0.0` -> `1.1.0` (次版本) +- `1.0.0` -> `2.0.0` (主版本) + +### 2. 发布步骤 + +```bash +# 1. 更新版本号 +poetry version patch # 或 minor, major + +# 2. 更新CHANGELOG +# 编辑 CHANGELOG.md + +# 3. 提交版本更新 +git add . +git commit -m "chore: bump version to x.x.x" + +# 4. 创建标签 +git tag vx.x.x + +# 5. 推送 +git push origin main --tags +``` + +## 🆘 获取帮助 + +如果您在贡献过程中遇到任何问题: + +- 📖 查看 [文档](./docs/) +- 💬 在 [Discussions](https://github.com/your-repo/pytest-framework/discussions) 中提问 +- 📧 发送邮件到 [maintainers@example.com](mailto:maintainers@example.com) +- 🐛 在 [Issues](https://github.com/your-repo/pytest-framework/issues) 中报告问题 + +## 🙏 致谢 + +感谢所有为这个项目做出贡献的开发者! + +您的贡献将被记录在 [CONTRIBUTORS.md](./CONTRIBUTORS.md) 文件中。 + +--- + +**再次感谢您的贡献!** 🎉 diff --git a/README.md b/README.md index e69de29..835caed 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,339 @@ +# 🚀 Pytest Framework - 现代化接口测试自动化框架 + +
+ +![Python](https://img.shields.io/badge/Python-3.8+-blue.svg) +![Pytest](https://img.shields.io/badge/Pytest-8.0+-green.svg) +![License](https://img.shields.io/badge/License-MIT-yellow.svg) +![Build](https://img.shields.io/badge/Build-Passing-brightgreen.svg) +![Coverage](https://img.shields.io/badge/Coverage-90%25-brightgreen.svg) + +**一个功能强大、易于扩展的Python接口测试自动化框架** + +[快速开始](#-快速开始) • [文档](#-文档) • [贡献](#-贡献) + +
+ +--- + +## ✨ 核心特性 + +### 🎯 **开箱即用** +- 🔧 **零配置启动** - 5分钟快速上手,无需复杂配置 +- 📦 **丰富的内置功能** - HTTP客户端、断言、数据驱动、Mock服务器 +- 🎨 **优雅的API设计** - 链式调用,代码简洁易读 + +### 🚀 **高级功能** +- 🔍 **增强断言引擎** - 基于JMESPath的强大查询和Schema验证 +- 📊 **数据驱动测试** - Excel、CSV、JSON、YAML多格式支持 +- 🎭 **内置Mock服务器** - 轻量级Mock服务,支持复杂场景 +- ⚡ **性能测试** - 负载测试、压力测试、性能监控 + +### 🏗️ **企业级架构** +- 🌍 **多环境管理** - 开发、测试、生产环境无缝切换 +- 🔌 **插件化扩展** - 灵活的插件机制,支持自定义扩展 +- 📈 **全面监控** - 详细的日志记录和性能指标 +- 🛡️ **安全可靠** - 认证支持、敏感数据保护 + +## 🎬 快速预览 + +### 简洁的测试用例 + +```python +from src.client.base_client import BaseClient +from src.utils.assertion import assert_success_response +from src.utils.environment import get_base_url + +class TestUserAPI: + def setup_method(self): + self.client = BaseClient(get_base_url()) + + def test_create_user(self): + user_data = {"name": "张三", "email": "zhangsan@example.com"} + response = self.client.post("/users", json=user_data) + + # 基于JMESPath的链式断言,优雅简洁 + (assert_success_response(response, 201) + .assert_jmespath("data.name", "张三") + .assert_jmespath("data.email", "zhangsan@example.com") + .assert_response_time(2.0, response.elapsed.total_seconds())) +``` + +### 数据驱动测试 + +```python +from src.utils.data_driver import data_driver + +# 生成测试数据 +template = { + "name": "faker.name", + "email": "faker.email", + "phone": "faker.phone_number" +} +test_users = data_driver.generate_test_data(template, count=10) + +@pytest.mark.parametrize("user_data", test_users) +def test_batch_create_users(self, user_data): + response = self.client.post("/users", json=user_data) + assert_success_response(response, 201) +``` + +### Mock服务器 + +```python +from src.utils.mock_server import MockServer, create_mock_response + +# 启动Mock服务器 +mock_server = MockServer(port=8888) +mock_server.add_rule( + "GET", "/api/users/123", + create_mock_response(200, {"id": 123, "name": "张三"}) +).start() +``` + +### 性能测试 + +```python +from src.utils.performance import load_test + +def api_request(): + return requests.get("https://api.example.com/users") + +# 负载测试:10个并发用户,100个请求 +metrics = load_test(api_request, concurrent_users=10, total_requests=100) +print(f"平均响应时间: {metrics.avg_response_time}s") +print(f"QPS: {metrics.requests_per_second}") +``` + +## 🚀 快速开始 + +### 1. 安装框架 + +```bash +# 克隆项目 +git clone https://github.com/ljxpython/pytest_framework.git +cd pytest-framework + +# 安装依赖(推荐使用Poetry) +poetry install && poetry shell + +# 或使用pip +pip install -r requirements.txt +``` + +### 2. 配置环境 + +编辑 `conf/settings.yaml`: + +```yaml +boe: # 开发环境 + API: + base_url: "https://httpbin.org" + timeout: 30 + DEBUG: true +``` + +### 3. 运行测试 + +```bash +# 运行示例测试 +pytest tests/examples/ -v + +# 生成Allure报告 +pytest --alluredir=output/allure-result +allure generate output/allure-result -o output/allure-report --clean +``` + +### 4. 查看报告 + +打开 `output/allure-report/index.html` 查看详细测试报告。 + +## 📚 文档 + +| 文档类型 | 链接 | 描述 | +|---------|------|------| +| 📖 **快速开始** | [docs/quick-start/](./docs/quick-start/) | 5分钟快速上手指南 | +| 🏗️ **架构设计** | [docs/architecture/](./docs/architecture/) | 框架架构和设计理念 | +| 📋 **用户指南** | [docs/user-guide/](./docs/user-guide/) | 详细功能使用指南 | +| 🔧 **API参考** | [docs/api-reference/](./docs/api-reference/) | 完整API文档 | +| 💡 **最佳实践** | [docs/best-practices/](./docs/best-practices/) | 测试最佳实践 | +| 🔌 **扩展开发** | [docs/extension/](./docs/extension/) | 插件和扩展开发 | + +## 🎯 示例项目 + +### 基础示例 + +```python +# tests/examples/basic_example.py +class TestBasicAPI: + """基础API测试示例""" + + def test_get_users(self): + """获取用户列表""" + response = self.client.get("/users") + assert_success_response(response) + assert len(response.json()["data"]) > 0 + + def test_create_user(self): + """创建用户""" + user_data = {"name": "测试用户", "email": "test@example.com"} + response = self.client.post("/users", json=user_data) + assert_success_response(response, 201) +``` + +### 高级示例 + +```python +# tests/examples/advanced_example.py +class TestAdvancedFeatures: + """高级功能示例""" + + @pytest.mark.parametrize("user_data", load_test_data("users.json")) + def test_data_driven(self, user_data): + """数据驱动测试""" + response = self.client.post("/users", json=user_data) + (assert_success_response(response, 201) + .assert_json_path("$.data.name", user_data["name"]) + .assert_schema(USER_SCHEMA)) + + @pytest.mark.performance + def test_performance(self): + """性能测试""" + metrics = load_test( + lambda: self.client.get("/users"), + concurrent_users=10, + total_requests=100 + ) + assert metrics.avg_response_time < 1.0 + assert metrics.error_rate < 0.01 +``` + +## 🏗️ 项目结构 + +``` +pytest-framework/ +├── 📁 conf/ # 配置管理 +│ ├── config.py # 配置管理器 +│ ├── constants.py # 常量定义 +│ └── settings.yaml # 环境配置 +├── 📁 src/ # 核心源码 +│ ├── 📁 client/ # HTTP客户端 +│ │ ├── base_client.py # 基础客户端 +│ │ └── base_auth.py # 认证处理 +│ ├── 📁 model/ # 数据模型 +│ │ ├── auto_pytest.py # 测试模型 +│ │ └── modelsbase.py # 基础模型 +│ └── 📁 utils/ # 工具类库 +│ ├── assertion.py # 增强断言 +│ ├── data_driver.py # 数据驱动 +│ ├── environment.py # 环境管理 +│ ├── mock_server.py # Mock服务器 +│ ├── performance.py # 性能测试 +│ └── log_moudle.py # 日志管理 +├── 📁 tests/ # 测试用例 +│ ├── conftest.py # pytest配置 +│ ├── 📁 examples/ # 示例测试 +│ ├── 📁 test_user/ # 用户模块测试 +│ └── 📁 test_goods/ # 商品模块测试 +├── 📁 docs/ # 项目文档 +├── 📁 output/ # 测试输出 +│ ├── 📁 allure-result/ # Allure原始数据 +│ └── 📁 allure-report/ # Allure报告 +├── 📄 main.py # 主入口文件 +├── 📄 pytest.ini # pytest配置 +├── 📄 pyproject.toml # 项目配置 +└── 📄 README.md # 项目说明 +``` + +## 🌟 核心优势 + +### 🎯 **为测试工程师而生** +- **低学习成本** - 基于pytest,测试工程师快速上手 +- **高开发效率** - 丰富的内置功能,减少重复代码 +- **强大的断言** - 支持复杂的数据验证场景 +- **完善的报告** - Allure集成,美观的测试报告 + +### 🏢 **企业级特性** +- **多环境支持** - 开发、测试、生产环境配置管理 +- **团队协作** - 统一的代码规范和最佳实践 +- **CI/CD集成** - 无缝集成Jenkins、GitHub Actions +- **可扩展架构** - 插件机制支持定制化需求 + +### 🚀 **性能卓越** +- **并发执行** - 支持多进程并行测试 +- **连接复用** - HTTP连接池提升性能 +- **内存优化** - 大数据量测试场景优化 +- **性能监控** - 实时监控测试执行性能 + +## 🛠️ 技术栈 + +| 技术 | 版本 | 用途 | +|------|------|------| +| **Python** | 3.8+ | 核心开发语言 | +| **Pytest** | 8.0+ | 测试框架 | +| **JMESPath** | 1.0+ | JSON查询语言(核心技术栈) | +| **Requests** | 2.32+ | HTTP客户端 | +| **Allure** | 2.13+ | 测试报告 | +| **Dynaconf** | 3.2+ | 配置管理 | +| **Faker** | 29.0+ | 测试数据生成 | +| **Loguru** | 0.7+ | 日志管理 | + +## 📊 性能基准 + +| 指标 | 数值 | 说明 | +|------|------|------| +| **启动时间** | < 2s | 框架初始化时间 | +| **并发支持** | 100+ | 最大并发用户数 | +| **内存占用** | < 100MB | 基础运行内存 | +| **测试速度** | 100+/min | 每分钟执行测试数 | + + + +## 🤝 贡献 + +我欢迎所有形式的贡献!无论是: + +- 🐛 **报告Bug** - 发现问题请提交Issue +- 💡 **功能建议** - 有好想法请告诉我们 +- 📝 **文档改进** - 帮助完善文档 +- 🔧 **代码贡献** - 提交Pull Request + +### 贡献步骤 + +1. **Fork** 项目到您的GitHub +2. **创建** 功能分支 (`git checkout -b feature/AmazingFeature`) +3. **提交** 更改 (`git commit -m 'Add some AmazingFeature'`) +4. **推送** 到分支 (`git push origin feature/AmazingFeature`) +5. **创建** Pull Request + + +## 📄 许可证 + +本项目采用 MIT 许可证 + + + +## 🙏 致谢 + +感谢所有为这个项目做出贡献的开发者和用户! + +特别感谢以下开源项目: +- [Pytest](https://pytest.org/) - 优秀的Python测试框架 +- [Requests](https://requests.readthedocs.io/) - 简洁的HTTP库 +- [Allure](https://allurereport.org/) - 美观的测试报告工具 + +--- + +
+**如果这个项目对您有帮助,请给我们一个 ⭐ Star!** + +**让更多的测试工程师受益于这个框架!** + +也欢迎和您进行微信交流: + +Description + + +[⬆ 回到顶部](#-pytest-framework---现代化接口测试自动化框架) + +
diff --git a/data/api_test_cases.yaml b/data/api_test_cases.yaml new file mode 100644 index 0000000..a58de06 --- /dev/null +++ b/data/api_test_cases.yaml @@ -0,0 +1,173 @@ +# API测试用例数据 +user_creation_cases: + - name: "创建有效用户" + description: "使用有效数据创建用户" + data: + name: "测试用户1" + email: "test1@example.com" + age: 25 + phone: "13800138001" + expected: + status_code: 201 + response_contains: ["id", "name", "email"] + + - name: "创建重复邮箱用户" + description: "使用已存在的邮箱创建用户" + data: + name: "测试用户2" + email: "existing@example.com" + age: 30 + expected: + status_code: 409 + error_message: "邮箱已存在" + + - name: "创建无效邮箱用户" + description: "使用无效邮箱格式创建用户" + data: + name: "测试用户3" + email: "invalid-email" + age: 25 + expected: + status_code: 400 + error_message: "邮箱格式无效" + +user_login_cases: + - name: "有效凭据登录" + description: "使用正确的用户名和密码登录" + data: + username: "testuser" + password: "testpass123" + expected: + status_code: 200 + response_contains: ["access_token", "refresh_token", "user_info"] + + - name: "无效密码登录" + description: "使用错误的密码登录" + data: + username: "testuser" + password: "wrongpassword" + expected: + status_code: 401 + error_message: "用户名或密码错误" + + - name: "不存在用户登录" + description: "使用不存在的用户名登录" + data: + username: "nonexistentuser" + password: "testpass123" + expected: + status_code: 404 + error_message: "用户不存在" + +order_creation_cases: + - name: "创建普通订单" + description: "创建包含单个商品的订单" + data: + user_id: 123 + items: + - product_id: 1001 + quantity: 2 + price: 99.99 + shipping_address: + street: "测试街道123号" + city: "北京市" + postal_code: "100000" + expected: + status_code: 201 + response_contains: ["order_id", "total_amount", "status"] + + - name: "创建多商品订单" + description: "创建包含多个商品的订单" + data: + user_id: 123 + items: + - product_id: 1001 + quantity: 1 + price: 99.99 + - product_id: 1002 + quantity: 3 + price: 49.99 + shipping_address: + street: "测试街道456号" + city: "上海市" + postal_code: "200000" + expected: + status_code: 201 + total_amount: 249.96 + +performance_test_scenarios: + - name: "用户列表查询性能" + description: "测试用户列表查询接口的性能" + endpoint: "/api/users" + method: "GET" + concurrent_users: 10 + total_requests: 100 + expected_response_time: 1.0 + expected_error_rate: 0.01 + + - name: "用户创建性能" + description: "测试用户创建接口的性能" + endpoint: "/api/users" + method: "POST" + concurrent_users: 5 + total_requests: 50 + expected_response_time: 2.0 + expected_error_rate: 0.05 + +mock_responses: + user_service: + get_user_success: + status_code: 200 + headers: + Content-Type: "application/json" + body: + code: 200 + message: "success" + data: + id: 123 + name: "张三" + email: "zhangsan@example.com" + created_at: "2024-01-01T00:00:00Z" + + get_user_not_found: + status_code: 404 + headers: + Content-Type: "application/json" + body: + code: 404 + message: "用户不存在" + error: "USER_NOT_FOUND" + + create_user_success: + status_code: 201 + headers: + Content-Type: "application/json" + body: + code: 201 + message: "用户创建成功" + data: + id: 124 + name: "新用户" + email: "newuser@example.com" + +environment_configs: + development: + api_base_url: "https://dev-api.example.com" + database_url: "mysql://dev-db.example.com:3306/testdb" + timeout: 30 + retry_times: 3 + log_level: "DEBUG" + + testing: + api_base_url: "https://test-api.example.com" + database_url: "mysql://test-db.example.com:3306/testdb" + timeout: 60 + retry_times: 2 + log_level: "INFO" + + production: + api_base_url: "https://api.example.com" + database_url: "mysql://prod-db.example.com:3306/proddb" + timeout: 30 + retry_times: 1 + log_level: "WARNING" diff --git a/data/test_users.json b/data/test_users.json new file mode 100644 index 0000000..592dd4f --- /dev/null +++ b/data/test_users.json @@ -0,0 +1,47 @@ +[ + { + "name": "张三", + "email": "zhangsan@example.com", + "age": 25, + "phone": "13800138001", + "address": "北京市朝阳区", + "role": "user", + "status": "active" + }, + { + "name": "李四", + "email": "lisi@example.com", + "age": 30, + "phone": "13800138002", + "address": "上海市浦东新区", + "role": "admin", + "status": "active" + }, + { + "name": "王五", + "email": "wangwu@example.com", + "age": 28, + "phone": "13800138003", + "address": "广州市天河区", + "role": "user", + "status": "inactive" + }, + { + "name": "赵六", + "email": "zhaoliu@example.com", + "age": 35, + "phone": "13800138004", + "address": "深圳市南山区", + "role": "manager", + "status": "active" + }, + { + "name": "钱七", + "email": "qianqi@example.com", + "age": 22, + "phone": "13800138005", + "address": "杭州市西湖区", + "role": "user", + "status": "pending" + } +] diff --git a/docs/.DS_Store b/docs/.DS_Store new file mode 100644 index 0000000..c9929af Binary files /dev/null and b/docs/.DS_Store differ diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..2c8dc50 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,80 @@ +# 📚 Pytest Framework 文档中心 + +欢迎来到 Pytest Framework 的文档中心!这里包含了框架的完整文档,帮助您快速上手并深入了解框架的各个方面。 + +## 📖 文档导航 + +### 🚀 快速开始 +- [**快速开始指南**](./quick-start/README.md) - 5分钟快速上手 +- [**安装指南**](./quick-start/installation.md) - 详细安装步骤 +- [**第一个测试**](./quick-start/first-test.md) - 编写您的第一个测试用例 + +### 🏗️ 架构设计 +- [**整体架构**](./architecture/overview.md) - 框架整体架构设计 +- [**核心组件**](./architecture/components.md) - 各核心组件详解 +- [**设计理念**](./architecture/design-principles.md) - 框架设计理念和原则 +- [**扩展机制**](./architecture/extension.md) - 框架扩展机制 + +### 📋 用户指南 +- [**基础用法**](./user-guide/basic-usage.md) - 基础功能使用指南 +- [**JMESPath指南**](./user-guide/jmespath-guide.md) - JMESPath查询语言详解(核心技术栈) +- [**高级特性**](./user-guide/advanced-features.md) - 高级特性详解 +- [**配置管理**](./user-guide/configuration.md) - 配置文件管理 +- [**数据驱动测试**](./user-guide/data-driven.md) - 数据驱动测试指南 +- [**断言增强**](./user-guide/assertions.md) - 增强断言功能 +- [**Mock服务**](./user-guide/mock-server.md) - Mock服务器使用 +- [**性能测试**](./user-guide/performance.md) - 性能测试功能 + +### 🔧 API参考 +- [**核心API**](./api-reference/core.md) - 核心API参考 + +### 💡 最佳实践 +- [**测试组织**](./best-practices/test-organization.md) - 测试用例组织 + +### ❓ 常见问题 +- [**FAQ**](./faq/README.md) - 常见问题解答 + +## 🎯 推荐阅读路径 + +### 新手用户 +1. [快速开始指南](./quick-start/README.md) +2. [安装指南](./quick-start/installation.md) +3. [第一个测试](./quick-start/first-test.md) +4. [基础用法](./user-guide/basic-usage.md) + +### 进阶用户 +1. [整体架构](./architecture/overview.md) +2. [高级特性](./user-guide/advanced-features.md) +3. [最佳实践](./best-practices/test-organization.md) +4. [API参考](./api-reference/core.md) + +### 开发者 +1. [设计理念](./architecture/design-principles.md) +2. [扩展机制](./architecture/extension.md) +3. [插件开发](./extension/plugin-development.md) +4. [贡献指南](./extension/contributing.md) + +## 🤝 获取帮助 + +如果您在使用过程中遇到问题,可以通过以下方式获取帮助: + +- 📖 查看 [FAQ](./faq/README.md) 和 [故障排除指南](./faq/troubleshooting.md) +- 🐛 在 GitHub 上提交 [Issue](https://github.com/your-repo/pytest-framework/issues) +- 💬 参与 [讨论区](https://github.com/your-repo/pytest-framework/discussions) +- 📧 发送邮件到 [support@example.com](mailto:support@example.com) + +## 📄 文档贡献 + +我欢迎您为文档做出贡献!如果您发现文档中的错误或希望添加新内容,请: + +1. Fork 项目仓库 +2. 创建新的分支 +3. 修改或添加文档 +4. 提交 Pull Request + +详细的贡献指南请参考 [贡献指南](./extension/contributing.md)。 + +--- + +**最后更新时间**: 2025-6-29 +**文档版本**: v1.0.0 diff --git a/docs/api-reference/core.md b/docs/api-reference/core.md new file mode 100644 index 0000000..500bca1 --- /dev/null +++ b/docs/api-reference/core.md @@ -0,0 +1,501 @@ +# 🔧 核心API参考 + +本文档提供 Pytest Framework 核心API的详细参考信息。 + +## 🌐 HTTP客户端API + +### BaseClient + +基础HTTP客户端类,提供所有HTTP操作的核心功能。 + +```python +class BaseClient: + def __init__(self, host: str, timeout: int = 10, **kwargs) +``` + +#### 参数 +- `host` (str): 服务器主机地址,如 "https://api.example.com" +- `timeout` (int): 请求超时时间,默认10秒 +- `**kwargs`: 其他可选参数 + +#### 方法 + +##### get() +```python +def get(self, url: str, params: Dict = None, **kwargs) -> Response +``` +发送GET请求。 + +**参数:** +- `url` (str): 请求路径 +- `params` (Dict, optional): 查询参数 +- `**kwargs`: 传递给requests的其他参数 + +**返回:** `requests.Response` 对象 + +**示例:** +```python +client = BaseClient("https://api.example.com") +response = client.get("/users", params={"page": 1, "size": 10}) +``` + +##### post() +```python +def post(self, url: str, data: Any = None, json: Dict = None, **kwargs) -> Response +``` +发送POST请求。 + +**参数:** +- `url` (str): 请求路径 +- `data` (Any, optional): 请求体数据 +- `json` (Dict, optional): JSON格式的请求体 +- `**kwargs`: 传递给requests的其他参数 + +**示例:** +```python +# 发送JSON数据 +response = client.post("/users", json={"name": "张三", "email": "zhangsan@example.com"}) + +# 发送表单数据 +response = client.post("/upload", data={"file": "content"}) +``` + +##### put() +```python +def put(self, url: str, data: Any = None, json: Dict = None, **kwargs) -> Response +``` +发送PUT请求。 + +##### delete() +```python +def delete(self, url: str, **kwargs) -> Response +``` +发送DELETE请求。 + +##### request() +```python +def request(self, method: str, url: str, **kwargs) -> Response +``` +发送自定义HTTP方法的请求。 + +**示例:** +```python +response = client.request("PATCH", "/users/123", json={"name": "新名称"}) +``` + +## 🔍 断言API + +### EnhancedAssertion + +增强的断言类,提供丰富的断言方法。 + +```python +class EnhancedAssertion: + def __init__(self, response_data: Any = None) +``` + +#### JMESPath断言方法 + +##### assert_jmespath() +```python +def assert_jmespath(self, jmes_path: str, expected_value: Any) -> 'EnhancedAssertion' +``` +使用JMESPath查询并断言值。 + +**参数:** +- `jmes_path` (str): JMESPath查询表达式 +- `expected_value` (Any): 期望的值 + +**示例:** +```python +(assert_response(response_data) + .assert_jmespath("code", 200) + .assert_jmespath("data.user.name", "张三")) +``` + +##### assert_jmespath_exists() +```python +def assert_jmespath_exists(self, jmes_path: str) -> 'EnhancedAssertion' +``` +断言JMESPath路径存在。 + +##### assert_jmespath_length() +```python +def assert_jmespath_length(self, jmes_path: str, expected_length: int) -> 'EnhancedAssertion' +``` +断言JMESPath查询结果的长度。 + +##### assert_jmespath_type() +```python +def assert_jmespath_type(self, jmes_path: str, expected_type: type) -> 'EnhancedAssertion' +``` +断言JMESPath查询结果的类型。 + +#### 基础断言方法 + +##### assert_status_code() +```python +def assert_status_code(self, expected_code: int, actual_code: int) -> 'EnhancedAssertion' +``` +断言HTTP状态码。 + +##### assert_response_time() +```python +def assert_response_time(self, max_time: float, actual_time: float) -> 'EnhancedAssertion' +``` +断言响应时间。 + +##### assert_contains() +```python +def assert_contains(self, expected_value: Any, container: Any = None) -> 'EnhancedAssertion' +``` +断言包含关系。 + +### 便捷断言函数 + +##### assert_response() +```python +def assert_response(response_data: Any = None) -> EnhancedAssertion +``` +创建断言对象的便捷函数。 + +##### assert_success_response() +```python +def assert_success_response(response, expected_code: int = 200) -> EnhancedAssertion +``` +断言成功响应的快捷函数。 + +##### assert_jmes() +```python +def assert_jmes(data: Any, path: str, expected_value: Any = None) -> EnhancedAssertion +``` +JMESPath断言的便捷函数。 + +## 🔍 JMESPath辅助API + +### JMESPathHelper + +JMESPath查询辅助类。 + +```python +class JMESPathHelper: + def __init__(self, data: Any) +``` + +#### 查询方法 + +##### search() +```python +def search(self, path: str) -> Any +``` +执行JMESPath查询。 + +##### get_value() +```python +def get_value(self, path: str, default: Any = None) -> Any +``` +获取值,支持默认值。 + +##### get_list() +```python +def get_list(self, path: str) -> List[Any] +``` +获取列表结果,确保返回列表类型。 + +##### exists() +```python +def exists(self, path: str) -> bool +``` +检查路径是否存在。 + +##### count() +```python +def count(self, path: str) -> int +``` +计算查询结果的数量。 + +#### 高级查询方法 + +##### filter_by() +```python +def filter_by(self, list_path: str, condition: str) -> List[Any] +``` +根据条件过滤列表。 + +**示例:** +```python +helper = jmes(data) +active_users = helper.filter_by("users", "status == 'active'") +``` + +##### sort_by() +```python +def sort_by(self, list_path: str, sort_key: str, reverse: bool = False) -> List[Any] +``` +根据键排序列表。 + +##### group_by() +```python +def group_by(self, list_path: str, group_key: str) -> Dict[str, List[Any]] +``` +根据键分组列表。 + +### 便捷函数 + +##### jmes() +```python +def jmes(data: Any) -> JMESPathHelper +``` +创建JMESPath辅助器的便捷函数。 + +##### quick_search() +```python +def quick_search(data: Any, path: str, default: Any = None) -> Any +``` +快速JMESPath查询。 + +## 📊 数据驱动API + +### DataDriver + +数据驱动测试类。 + +```python +class DataDriver: + def __init__(self, data_dir: str = "data") +``` + +#### 数据加载方法 + +##### load_json() +```python +def load_json(self, file_path: str, encoding: str = 'utf-8') -> Union[List[Dict], Dict] +``` +从JSON文件加载数据。 + +##### load_yaml() +```python +def load_yaml(self, file_path: str, encoding: str = 'utf-8') -> Union[List[Dict], Dict] +``` +从YAML文件加载数据。 + +##### load_excel() +```python +def load_excel(self, file_path: str, sheet_name: str = None) -> List[Dict] +``` +从Excel文件加载数据。 + +##### load_csv() +```python +def load_csv(self, file_path: str, encoding: str = 'utf-8') -> List[Dict] +``` +从CSV文件加载数据。 + +#### 数据生成方法 + +##### generate_test_data() +```python +def generate_test_data(self, template: Dict, count: int = 1) -> List[Dict] +``` +根据模板生成测试数据。 + +**参数:** +- `template` (Dict): 数据模板,支持Faker方法 +- `count` (int): 生成数据条数 + +**示例:** +```python +template = { + "name": "faker.name", + "email": "faker.email", + "age": 25 +} +test_data = data_driver.generate_test_data(template, count=5) +``` + +### 便捷函数 + +##### load_test_data() +```python +def load_test_data(file_path: str, file_type: str = None) -> Union[List[Dict], Dict] +``` +加载测试数据的便捷函数。 + +## 🌍 环境管理API + +### EnvironmentManager + +环境管理器类。 + +```python +class EnvironmentManager: + def __init__(self, config_dir: str = "conf") +``` + +#### 配置方法 + +##### get_config() +```python +def get_config(self, key: str, default: Any = None) -> Any +``` +获取配置值。 + +##### switch_env() +```python +def switch_env(self, env_name: str) -> None +``` +切换环境。 + +##### get_base_url() +```python +def get_base_url(self, service_name: str = "default") -> str +``` +获取服务基础URL。 + +### 便捷函数 + +##### get_config() +```python +def get_config(key: str, default: Any = None) -> Any +``` +获取配置值的便捷函数。 + +##### get_base_url() +```python +def get_base_url(service_name: str = "default") -> str +``` +获取基础URL的便捷函数。 + +##### switch_environment() +```python +def switch_environment(env_name: str) -> None +``` +切换环境的便捷函数。 + +## 🎭 Mock服务API + +### MockServer + +Mock服务器类。 + +```python +class MockServer: + def __init__(self, host: str = "localhost", port: int = 8888) +``` + +#### 服务器控制方法 + +##### start() +```python +def start(self) -> None +``` +启动Mock服务器。 + +##### stop() +```python +def stop(self) -> None +``` +停止Mock服务器。 + +#### 规则管理方法 + +##### add_rule() +```python +def add_rule(self, method: str, path: str, response: MockResponse, + query_params: Dict = None, request_body: Dict = None) -> 'MockServer' +``` +添加Mock规则。 + +##### reset_rules() +```python +def reset_rules(self) -> None +``` +重置所有规则。 + +### MockResponse + +Mock响应类。 + +```python +class MockResponse: + def __init__(self, status_code: int = 200, headers: Dict[str, str] = None, + body: Any = None, delay: float = 0) +``` + +### 便捷函数 + +##### create_mock_response() +```python +def create_mock_response(status_code: int = 200, body: Any = None, + headers: Dict[str, str] = None, delay: float = 0) -> MockResponse +``` +创建Mock响应的便捷函数。 + +## ⚡ 性能测试API + +### PerformanceTester + +性能测试器类。 + +```python +class PerformanceTester: + def __init__(self) +``` + +#### 测试方法 + +##### load_test() +```python +def load_test(self, request_func: Callable, concurrent_users: int = 10, + total_requests: int = 100, *args, **kwargs) -> PerformanceMetrics +``` +负载测试。 + +##### stress_test() +```python +def stress_test(self, request_func: Callable, duration_seconds: int = 60, + concurrent_users: int = 10, *args, **kwargs) -> PerformanceMetrics +``` +压力测试。 + +### PerformanceMetrics + +性能指标数据类。 + +```python +@dataclass +class PerformanceMetrics: + total_requests: int + successful_requests: int + failed_requests: int + total_time: float + min_response_time: float + max_response_time: float + avg_response_time: float + median_response_time: float + p95_response_time: float + p99_response_time: float + requests_per_second: float + error_rate: float +``` + +#### 方法 + +##### to_dict() +```python +def to_dict(self) -> Dict[str, Any] +``` +转换为字典格式。 + +### 便捷函数 + +##### load_test() +```python +def load_test(request_func: Callable, concurrent_users: int = 10, + total_requests: int = 100, *args, **kwargs) -> PerformanceMetrics +``` +负载测试的便捷函数。 + +--- + +**下一步**: [用户指南](../user-guide/basic-usage.md) | [JMESPath指南](../user-guide/jmespath-guide.md) diff --git a/docs/architecture/components.md b/docs/architecture/components.md new file mode 100644 index 0000000..20329ed --- /dev/null +++ b/docs/architecture/components.md @@ -0,0 +1,502 @@ +# 🧩 核心组件 + +本文档详细介绍 Pytest Framework 的各个核心组件,包括其设计理念、功能特性和使用方法。 + +## 📋 组件概览 + +```mermaid +graph TB + A[用户测试代码] --> B[HTTP客户端层] + A --> C[断言引擎层] + A --> D[数据驱动层] + A --> E[环境管理层] + + B --> F[基础HTTP客户端] + B --> G[认证处理器] + B --> H[响应验证器] + + C --> I[JMESPath断言] + C --> J[Schema验证] + C --> K[链式断言] + + D --> L[数据加载器] + D --> M[数据生成器] + D --> N[参数化集成] + + E --> O[配置管理器] + E --> P[环境切换器] + E --> Q[变量解析器] + + F --> R[连接池] + F --> S[重试机制] + F --> T[日志记录] +``` + +## 🌐 HTTP客户端层 + +### 1. 基础HTTP客户端 (`src/client/base_client.py`) + +**设计理念**: 提供简洁易用的HTTP接口,封装requests库的复杂性。 + +**核心特性**: +- 会话管理和连接复用 +- 自动日志记录 +- 自定义User-Agent +- 超时控制 +- 错误处理 + +**使用示例**: +```python +from src.client.base_client import BaseClient + +# 创建客户端 +client = BaseClient("https://api.example.com", timeout=30) + +# 发送请求 +response = client.get("/users") +response = client.post("/users", json={"name": "张三"}) +``` + +**内部实现**: +```python +class BaseClient: + def __init__(self, host, timeout=10): + self.host = host + self.timeout = timeout + self.session = Session() + + # 配置日志钩子 + if settings.get("logger_hook", True): + self.session.hooks["response"].append(logger_hook) + + # 设置自定义User-Agent + headers = self.session.headers + origin_user_agent = headers.get("User-Agent", "") + new_user_agent = CUSTOM_USER_AGENT.format(origin_user_agent) + headers["User-Agent"] = new_user_agent +``` + +### 2. 认证处理器 (`src/client/base_auth.py`) + +**设计理念**: 支持多种认证方式,可扩展的认证架构。 + +**支持的认证类型**: +- Bearer Token认证 +- Basic认证 +- API Key认证 +- 自定义认证 + +**使用示例**: +```python +from src.client.base_auth import BearerAuth, BasicAuth + +# Bearer Token认证 +client.session.auth = BearerAuth("your-access-token") + +# Basic认证 +client.session.auth = BasicAuth("username", "password") +``` + +### 3. 响应验证器 (`src/client/validatable.py`) + +**设计理念**: 提供响应数据的自动验证和转换功能。 + +**功能特性**: +- 响应状态码验证 +- Content-Type检查 +- JSON格式验证 +- 自动数据转换 + +## 🔍 断言引擎层 + +### 1. JMESPath断言引擎 (`src/utils/assertion.py`) + +**设计理念**: 基于JMESPath提供强大的JSON数据查询和断言能力。 + +**核心功能**: +- JMESPath路径查询 +- 链式断言语法 +- 类型检查 +- 存在性验证 + +**使用示例**: +```python +from src.utils.assertion import assert_response + +(assert_response(response_data) + .assert_jmespath("code", 200) + .assert_jmespath("data.user.name", "张三") + .assert_jmespath_exists("data.user.email") + .assert_jmespath_length("data.items", 5)) +``` + +### 2. JMESPath辅助器 (`src/utils/jmespath_helper.py`) + +**设计理念**: 提供JMESPath查询的便捷方法和常用模式。 + +**核心功能**: +- 查询结果缓存 +- 错误处理 +- 类型转换 +- 常用模式 + +**使用示例**: +```python +from src.utils.jmespath_helper import jmes, CommonJMESPatterns + +helper = jmes(response_data) + +# 基础查询 +user_name = helper.get_value("data.user.name") + +# 条件过滤 +active_users = helper.filter_by("data.users", "status == 'active'") + +# 排序 +sorted_users = helper.sort_by("data.users", "created_at") + +# 使用常用模式 +api_code = helper.get_value(CommonJMESPatterns.API_CODE) +``` + +### 3. Schema验证器 + +**设计理念**: 提供JSON Schema验证,确保API响应结构的正确性。 + +**功能特性**: +- JSON Schema验证 +- 自定义验证规则 +- 详细错误报告 +- 性能优化 + +## 📊 数据驱动层 + +### 1. 数据加载器 (`src/utils/data_driver.py`) + +**设计理念**: 支持多种数据源,统一的数据加载接口。 + +**支持的数据格式**: +- JSON文件 +- YAML文件 +- CSV文件 +- Excel文件 + +**使用示例**: +```python +from src.utils.data_driver import data_driver, load_test_data + +# 加载JSON数据 +users = load_test_data("test_users.json") + +# 加载Excel数据 +test_cases = data_driver.load_excel("test_cases.xlsx", "用户测试") + +# 加载YAML配置 +config = data_driver.load_yaml("api_config.yaml") +``` + +### 2. 数据生成器 + +**设计理念**: 基于Faker库提供动态测试数据生成。 + +**功能特性**: +- 模板化数据生成 +- 多语言支持 +- 自定义生成器 +- 数据关联 + +**使用示例**: +```python +# 定义数据模板 +template = { + "name": "faker.name", + "email": "faker.email", + "phone": "faker.phone_number", + "address": "faker.address" +} + +# 生成测试数据 +test_data = data_driver.generate_test_data(template, count=10) +``` + +### 3. 参数化集成 + +**设计理念**: 与pytest参数化无缝集成,支持数据驱动测试。 + +**使用示例**: +```python +@pytest.mark.parametrize("user_data", load_test_data("users.json")) +def test_create_user(self, user_data): + response = self.client.post("/users", json=user_data) + assert response.status_code == 201 +``` + +## 🌍 环境管理层 + +### 1. 配置管理器 (`src/utils/environment.py`) + +**设计理念**: 基于Dynaconf提供灵活的配置管理。 + +**功能特性**: +- 多环境配置 +- 配置继承 +- 环境变量集成 +- 配置验证 + +**配置结构**: +```yaml +# conf/settings.yaml +boe: # 开发环境 + API: + base_url: "https://dev-api.example.com" + timeout: 30 + DB: + host: "dev-db.example.com" + port: 3306 + +test: # 测试环境 + API: + base_url: "https://test-api.example.com" + timeout: 60 +``` + +### 2. 环境切换器 + +**设计理念**: 运行时动态切换环境配置。 + +**使用示例**: +```python +from src.utils.environment import switch_environment, get_config + +# 切换到测试环境 +switch_environment("test") + +# 获取配置 +api_url = get_config("API.base_url") +timeout = get_config("API.timeout", 30) +``` + +## 🎭 Mock服务层 + +### 1. Mock服务器 (`src/utils/mock_server.py`) + +**设计理念**: 轻量级HTTP Mock服务器,支持复杂的模拟场景。 + +**核心功能**: +- 规则匹配引擎 +- 响应模拟 +- 延迟模拟 +- 调用统计 + +**使用示例**: +```python +from src.utils.mock_server import MockServer, create_mock_response + +# 创建Mock服务器 +mock_server = MockServer(port=8888) + +# 添加Mock规则 +mock_server.add_rule( + "GET", "/api/users/123", + create_mock_response(200, {"id": 123, "name": "张三"}) +) + +# 启动服务器 +mock_server.start() +``` + +### 2. 规则匹配引擎 + +**设计理念**: 灵活的请求匹配机制,支持复杂的匹配条件。 + +**匹配条件**: +- HTTP方法 +- URL路径 +- 查询参数 +- 请求头 +- 请求体 + +## ⚡ 性能测试层 + +### 1. 性能测试器 (`src/utils/performance.py`) + +**设计理念**: 提供负载测试和压力测试能力。 + +**功能特性**: +- 并发执行 +- 性能指标收集 +- 结果分析 +- 报告生成 + +**使用示例**: +```python +from src.utils.performance import load_test + +def api_request(): + return requests.get("https://api.example.com/users") + +# 负载测试 +metrics = load_test( + api_request, + concurrent_users=10, + total_requests=100 +) + +print(f"平均响应时间: {metrics.avg_response_time}s") +print(f"QPS: {metrics.requests_per_second}") +``` + +### 2. 指标收集器 + +**设计理念**: 全面的性能指标收集和分析。 + +**收集的指标**: +- 响应时间(平均、最小、最大、百分位) +- 吞吐量(QPS) +- 错误率 +- 并发性能 + +## 📝 日志系统 + +### 1. 日志管理器 (`src/utils/log_moudle.py`) + +**设计理念**: 基于Loguru的结构化日志系统。 + +**功能特性**: +- 结构化日志 +- 多级别日志 +- 文件轮转 +- 性能优化 + +**使用示例**: +```python +from src.utils.log_moudle import logger + +logger.info("API请求开始", extra={ + "method": "POST", + "url": "/api/users", + "request_id": "req_123" +}) +``` + +## 🔧 工具类库 + +### 1. 文件操作工具 (`src/utils/file_operation.py`) + +**功能特性**: +- 文件压缩和解压 +- 目录操作 +- 文件监控 +- 批量处理 + +### 2. 日期操作工具 (`src/utils/date_operation.py`) + +**功能特性**: +- 日期格式化 +- 时区处理 +- 日期计算 +- 相对时间 + +### 3. 字典工具 (`src/utils/dict_tools.py`) + +**功能特性**: +- 深度合并 +- 路径访问 +- 类型转换 +- 数据清洗 + +## 🔌 扩展机制 + +### 1. 插件系统 + +**设计理念**: 基于pytest插件机制的扩展架构。 + +**扩展点**: +- 自定义断言 +- 自定义客户端 +- 自定义数据源 +- 自定义报告 + +### 2. Hook系统 + +**设计理念**: 事件驱动的Hook机制。 + +**可用Hook**: +- 测试前置处理 +- 测试后置处理 +- 请求前置处理 +- 响应后置处理 + +## 📊 组件交互 + +### 1. 数据流 + +```mermaid +sequenceDiagram + participant Test as 测试用例 + participant Client as HTTP客户端 + participant Assert as 断言引擎 + participant Data as 数据驱动 + participant Env as 环境管理 + + Test->>Env: 获取配置 + Env-->>Test: 返回配置 + Test->>Data: 加载测试数据 + Data-->>Test: 返回数据 + Test->>Client: 发送HTTP请求 + Client-->>Test: 返回响应 + Test->>Assert: 执行断言 + Assert-->>Test: 返回结果 +``` + +### 2. 依赖关系 + +```mermaid +graph TD + A[测试用例] --> B[HTTP客户端] + A --> C[断言引擎] + A --> D[数据驱动] + + B --> E[环境管理] + C --> F[JMESPath辅助器] + D --> E + + E --> G[配置文件] + F --> H[JMESPath库] + + B --> I[日志系统] + C --> I + D --> I +``` + +## 🎯 设计原则 + +### 1. 单一职责原则 +每个组件只负责一个特定的功能领域,保持组件的内聚性。 + +### 2. 开放封闭原则 +组件对扩展开放,对修改封闭,通过接口和插件机制支持扩展。 + +### 3. 依赖倒置原则 +高层模块不依赖低层模块,都依赖于抽象接口。 + +### 4. 接口隔离原则 +使用多个专门的接口,而不是单一的总接口。 + +## 🚀 性能优化 + +### 1. 连接复用 +HTTP客户端使用连接池,减少连接建立开销。 + +### 2. 缓存机制 +配置和查询结果缓存,提升性能。 + +### 3. 异步支持 +支持异步HTTP请求,提高并发性能。 + +### 4. 内存优化 +大数据量处理时的内存优化策略。 + +--- + +**下一步**: [设计理念](./design-principles.md) | [扩展机制](./extension.md) diff --git a/docs/architecture/design-principles.md b/docs/architecture/design-principles.md new file mode 100644 index 0000000..a33ba38 --- /dev/null +++ b/docs/architecture/design-principles.md @@ -0,0 +1,417 @@ +# 🎨 设计理念 + +本文档阐述 Pytest Framework 的核心设计理念和架构原则,这些理念指导着框架的设计和演进。 + +## 🎯 核心理念 + +### 1. 简单易用 (Simplicity First) + +**理念**: 复杂的功能应该有简单的接口。 + +**体现**: +- **链式API**: 断言使用链式调用,代码更加流畅 +- **约定优于配置**: 合理的默认值,减少配置工作 +- **一致性**: 相似功能使用相似的API设计 + +```python +# 简单的链式断言 +(assert_response(response_data) + .assert_jmespath("code", 200) + .assert_jmespath("data.user.name", "张三") + .assert_jmespath_exists("data.user.email")) + +# 简单的客户端使用 +client = BaseClient("https://api.example.com") +response = client.get("/users") +``` + +### 2. 数据驱动 (Data-Driven) + +**理念**: 测试逻辑与测试数据分离,提高测试的可维护性和可扩展性。 + +**体现**: +- **多格式支持**: JSON、YAML、CSV、Excel等数据源 +- **动态数据生成**: 基于Faker的测试数据生成 +- **参数化集成**: 与pytest参数化无缝集成 + +```python +# 数据与逻辑分离 +@pytest.mark.parametrize("user_data", load_test_data("users.json")) +def test_create_user(self, user_data): + response = self.client.post("/users", json=user_data) + assert_api_response(response, "code", 201) +``` + +### 3. JMESPath优先 (JMESPath First) + +**理念**: 使用JMESPath作为主要的JSON查询语言,提供强大而简洁的数据查询能力。 + +**优势**: +- **表达力强**: 支持复杂的查询和转换 +- **性能优秀**: 编译型查询,性能优于解释型 +- **生态成熟**: AWS等大型项目的首选 + +```python +# JMESPath查询示例 +helper = jmes(response_data) + +# 基础查询 +user_name = helper.get_value("data.user.name") + +# 条件过滤 +active_users = helper.filter_by("data.users", "status == 'active'") + +# 复杂查询 +tech_python_users = helper.filter_by( + "data.users", + "department == '技术部' && contains(skills, 'Python')" +) +``` + +## 🏗️ 架构原则 + +### 1. 分层架构 (Layered Architecture) + +**原则**: 系统按功能分层,每层只与相邻层交互。 + +``` +┌─────────────────┐ +│ 用户测试层 │ ← 测试用例编写 +├─────────────────┤ +│ 业务逻辑层 │ ← 测试流程编排 +├─────────────────┤ +│ 核心功能层 │ ← HTTP客户端、断言引擎 +├─────────────────┤ +│ 工具支撑层 │ ← 数据驱动、环境管理 +├─────────────────┤ +│ 基础设施层 │ ← 日志、配置、存储 +└─────────────────┘ +``` + +**好处**: +- 职责清晰,易于维护 +- 层间解耦,便于测试 +- 可替换性强,支持扩展 + +### 2. 模块化设计 (Modular Design) + +**原则**: 功能模块化,高内聚低耦合。 + +**模块划分**: +``` +src/ +├── client/ # HTTP客户端模块 +├── utils/ # 工具类模块 +│ ├── assertion.py # 断言模块 +│ ├── data_driver.py # 数据驱动模块 +│ ├── environment.py # 环境管理模块 +│ └── jmespath_helper.py # JMESPath辅助模块 +└── model/ # 数据模型模块 +``` + +**设计原则**: +- 每个模块有明确的职责边界 +- 模块间通过接口交互 +- 支持独立开发和测试 + +### 3. 插件化架构 (Plugin Architecture) + +**原则**: 核心功能稳定,扩展功能通过插件实现。 + +**扩展点**: +- 自定义断言方法 +- 自定义数据源 +- 自定义认证方式 +- 自定义报告格式 + +```python +# 自定义断言插件示例 +class CustomAssertion(EnhancedAssertion): + def assert_business_rule(self, rule_name: str): + """自定义业务规则断言""" + # 实现业务规则验证逻辑 + pass + +# 自定义数据源插件示例 +class DatabaseDataDriver(DataDriver): + def load_from_database(self, query: str): + """从数据库加载测试数据""" + # 实现数据库数据加载逻辑 + pass +``` + +## 🔧 设计模式 + +### 1. 建造者模式 (Builder Pattern) + +**应用**: HTTP请求构建、断言链构建 + +```python +# HTTP请求建造者 +response = (client + .get("/users") + .with_params({"page": 1, "size": 10}) + .with_headers({"Authorization": "Bearer token"}) + .with_timeout(30) + .execute()) + +# 断言建造者 +(assert_response(response_data) + .assert_jmespath("code", 200) + .assert_jmespath_exists("data") + .assert_jmespath_length("data.items", 10)) +``` + +### 2. 策略模式 (Strategy Pattern) + +**应用**: 认证策略、数据加载策略 + +```python +# 认证策略 +class AuthStrategy: + def authenticate(self, client): pass + +class BearerAuthStrategy(AuthStrategy): + def authenticate(self, client): + client.session.auth = BearerAuth(self.token) + +class BasicAuthStrategy(AuthStrategy): + def authenticate(self, client): + client.session.auth = BasicAuth(self.username, self.password) +``` + +### 3. 工厂模式 (Factory Pattern) + +**应用**: 客户端创建、断言创建 + +```python +# 客户端工厂 +class ClientFactory: + @staticmethod + def create_client(service_name: str) -> BaseClient: + config = get_service_config(service_name) + return BaseClient( + host=config.base_url, + timeout=config.timeout + ) + +# 断言工厂 +def assert_response(data: Any) -> EnhancedAssertion: + return EnhancedAssertion(data) +``` + +### 4. 观察者模式 (Observer Pattern) + +**应用**: 测试事件监听、日志记录 + +```python +# 测试事件观察者 +class TestEventObserver: + def on_test_start(self, test_info): pass + def on_test_end(self, test_result): pass + +class LoggingObserver(TestEventObserver): + def on_test_start(self, test_info): + logger.info(f"测试开始: {test_info.name}") + + def on_test_end(self, test_result): + logger.info(f"测试结束: {test_result.status}") +``` + +## 🎨 API设计原则 + +### 1. 一致性 (Consistency) + +**原则**: 相似的功能使用相似的API设计。 + +```python +# 一致的命名规范 +client.get("/users") # HTTP方法对应方法名 +client.post("/users") +client.put("/users/123") +client.delete("/users/123") + +# 一致的参数顺序 +assert_jmespath(path, expected_value) +assert_json_path(path, expected_value) +assert_xpath(path, expected_value) +``` + +### 2. 可发现性 (Discoverability) + +**原则**: API应该容易发现和理解。 + +```python +# 清晰的方法命名 +helper.exists(path) # 检查路径是否存在 +helper.get_value(path) # 获取值 +helper.get_list(path) # 获取列表 +helper.filter_by(path, condition) # 条件过滤 + +# 有意义的常量 +CommonJMESPatterns.API_CODE # "code" +CommonJMESPatterns.USER_NAME # "data.user.name" +CommonJMESPatterns.ACTIVE_USERS # "data[?status == 'active']" +``` + +### 3. 容错性 (Fault Tolerance) + +**原则**: API应该优雅地处理错误情况。 + +```python +# 提供默认值 +value = helper.get_value("data.user.name", "未知用户") + +# 安全的类型转换 +users = helper.get_list("data.users") # 即使不是列表也返回列表 + +# 详细的错误信息 +try: + helper.assert_jmespath("invalid.path", "value") +except AssertionError as e: + # 错误信息包含具体的路径和期望值 + print(f"断言失败: {e}") +``` + +## 🔄 演进策略 + +### 1. 向后兼容 (Backward Compatibility) + +**原则**: 新版本保持对旧版本的兼容性。 + +**策略**: +- 废弃而不是删除旧API +- 提供迁移指南 +- 渐进式升级路径 + +```python +# 废弃警告示例 +@deprecated("使用 assert_jmespath 替代") +def assert_json_path(path, value): + warnings.warn("assert_json_path 已废弃,请使用 assert_jmespath") + return assert_jmespath(path, value) +``` + +### 2. 渐进式增强 (Progressive Enhancement) + +**原则**: 核心功能稳定,高级功能逐步增加。 + +**实现**: +- 核心API保持稳定 +- 新功能通过扩展提供 +- 可选依赖管理 + +### 3. 社区驱动 (Community Driven) + +**原则**: 根据社区反馈持续改进。 + +**机制**: +- 开放的Issue和PR流程 +- 定期的社区调研 +- 透明的决策过程 + +## 🎯 质量保证 + +### 1. 测试驱动开发 (Test-Driven Development) + +**原则**: 先写测试,再写实现。 + +```python +# 测试先行 +def test_jmespath_filter_by(): + data = {"users": [{"name": "张三", "active": True}]} + helper = jmes(data) + + active_users = helper.filter_by("users", "active == `true`") + assert len(active_users) == 1 + assert active_users[0]["name"] == "张三" +``` + +### 2. 代码审查 (Code Review) + +**原则**: 所有代码变更都需要审查。 + +**检查点**: +- 设计是否符合架构原则 +- 代码是否遵循编码规范 +- 测试覆盖率是否充分 +- 文档是否完整 + +### 3. 持续集成 (Continuous Integration) + +**原则**: 自动化测试和质量检查。 + +**流程**: +- 代码提交触发CI +- 运行全量测试套件 +- 代码质量检查 +- 安全扫描 + +## 📊 性能考虑 + +### 1. 延迟加载 (Lazy Loading) + +**原则**: 只在需要时加载资源。 + +```python +class JMESPathHelper: + def __init__(self, data): + self.data = data + self._compiled_expressions = {} # 缓存编译后的表达式 + + def search(self, path): + if path not in self._compiled_expressions: + self._compiled_expressions[path] = jmespath.compile(path) + return self._compiled_expressions[path].search(self.data) +``` + +### 2. 缓存策略 (Caching Strategy) + +**原则**: 合理使用缓存提升性能。 + +**应用**: +- 配置缓存 +- 查询结果缓存 +- 编译表达式缓存 + +### 3. 资源管理 (Resource Management) + +**原则**: 合理管理系统资源。 + +**实现**: +- 连接池管理 +- 内存使用优化 +- 文件句柄管理 + +## 🔮 未来展望 + +### 1. 云原生支持 + +**方向**: 支持容器化部署和云原生架构。 + +**计划**: +- Docker镜像优化 +- Kubernetes集成 +- 服务网格支持 + +### 2. AI辅助测试 + +**方向**: 集成AI能力,提升测试效率。 + +**计划**: +- 智能测试数据生成 +- 自动化测试用例生成 +- 异常检测和分析 + +### 3. 多语言支持 + +**方向**: 扩展到其他编程语言。 + +**计划**: +- Java SDK +- JavaScript SDK +- Go SDK + +--- + +**下一步**: [扩展机制](./extension.md) | [核心组件](./components.md) diff --git a/docs/architecture/extension.md b/docs/architecture/extension.md new file mode 100644 index 0000000..d5bc631 --- /dev/null +++ b/docs/architecture/extension.md @@ -0,0 +1,527 @@ +# 🔌 扩展机制 - 让框架为你所用 + +> "好的框架不是限制你,而是给你无限可能。" + +想象一下,你有一个超级灵活的乐高积木盒,可以搭建任何你想要的东西。这就是我们框架的扩展机制! + +## 🎯 为什么要扩展? + +### 真实场景 +你是不是遇到过这些情况: +- 😤 "这个断言方法不够用,我需要验证特殊的业务规则" +- 🤔 "我们公司有自己的认证方式,框架不支持" +- 😅 "测试数据需要从我们的内部系统获取" +- 🙄 "报告格式不符合老板的要求" + +别担心!我们的扩展机制就是为了解决这些"个性化需求"而生的。 + +## 🛠️ 扩展点大揭秘 + +### 1. 自定义断言 - 让验证更贴心 + +还在为复杂的业务规则验证发愁?来看看这个: + +```python +from src.utils.assertion import EnhancedAssertion + +class BusinessAssertion(EnhancedAssertion): + """业务专用断言 - 老板再也不用担心我的验证了""" + + def assert_user_permission(self, user_data, required_permission): + """验证用户权限 - 比保安还严格""" + permissions = user_data.get("permissions", []) + if required_permission not in permissions: + raise AssertionError(f"用户缺少权限: {required_permission}") + self.logger.info(f"✅ 权限验证通过: {required_permission}") + return self + + def assert_money_format(self, amount): + """验证金额格式 - 财务部门的最爱""" + import re + if not re.match(r'^\d+\.\d{2}$', str(amount)): + raise AssertionError(f"金额格式错误: {amount}") + self.logger.info(f"💰 金额格式正确: {amount}") + return self + + def assert_business_rule(self, data, rule_name): + """验证业务规则 - 你的规则你做主""" + rules = { + "vip_discount": lambda d: d.get("discount", 0) >= 0.1, + "order_limit": lambda d: d.get("amount", 0) <= 10000, + "working_hours": lambda d: 9 <= int(d.get("hour", 0)) <= 18 + } + + if rule_name not in rules: + raise AssertionError(f"未知业务规则: {rule_name}") + + if not rules[rule_name](data): + raise AssertionError(f"业务规则验证失败: {rule_name}") + + self.logger.info(f"🎯 业务规则验证通过: {rule_name}") + return self + +# 使用起来超级爽 +def test_vip_user_order(): + response = client.post("/orders", json=order_data) + + (BusinessAssertion(response.json()) + .assert_jmespath("code", 200) + .assert_user_permission(response.json()["user"], "place_order") + .assert_money_format(response.json()["amount"]) + .assert_business_rule(response.json(), "vip_discount")) +``` + +### 2. 自定义数据源 - 数据哪里都能来 + +公司有特殊的数据存储?没问题! + +```python +from src.utils.data_driver import DataDriver + +class CompanyDataDriver(DataDriver): + """公司专用数据驱动器 - 连接一切数据源""" + + def load_from_crm(self, customer_type="vip"): + """从CRM系统加载客户数据""" + # 这里连接你们的CRM系统 + crm_api = CRMClient(api_key="your-secret-key") + customers = crm_api.get_customers(type=customer_type) + + return [ + { + "name": customer.name, + "email": customer.email, + "level": customer.vip_level, + "credit": customer.credit_score + } + for customer in customers + ] + + def load_from_redis(self, pattern="test:*"): + """从Redis缓存加载数据""" + import redis + r = redis.Redis(host='localhost', port=6379, db=0) + + keys = r.keys(pattern) + return [ + { + "key": key.decode(), + "value": r.get(key).decode(), + "ttl": r.ttl(key) + } + for key in keys + ] + + def generate_realistic_orders(self, count=10): + """生成真实的订单数据 - 比faker更懂业务""" + import random + from datetime import datetime, timedelta + + products = ["iPhone 15", "MacBook Pro", "AirPods", "iPad"] + statuses = ["pending", "paid", "shipped", "delivered"] + + orders = [] + for i in range(count): + order_date = datetime.now() - timedelta(days=random.randint(0, 30)) + orders.append({ + "order_id": f"ORD-{order_date.strftime('%Y%m%d')}-{i:04d}", + "product": random.choice(products), + "quantity": random.randint(1, 5), + "price": round(random.uniform(99, 2999), 2), + "status": random.choice(statuses), + "order_date": order_date.isoformat(), + "customer_id": f"CUST-{random.randint(1000, 9999)}" + }) + + return orders + +# 使用示例 +@pytest.mark.parametrize("customer", CompanyDataDriver().load_from_crm("premium")) +def test_premium_customer_workflow(customer): + """测试高级客户工作流 - 用真实数据测试""" + # 使用真实的客户数据进行测试 + response = client.post("/orders", json={ + "customer_id": customer["id"], + "items": [{"product": "premium_service", "quantity": 1}] + }) + + assert response.status_code == 201 +``` + +### 3. 自定义认证 - 安全你说了算 + +```python +from src.client.base_auth import AuthStrategy + +class WeChatAuth(AuthStrategy): + """微信认证 - 因为我们就是这么潮""" + + def __init__(self, app_id, app_secret): + self.app_id = app_id + self.app_secret = app_secret + self.access_token = None + + def authenticate(self, client): + """微信认证流程""" + # 获取access_token + auth_response = client.post("/wechat/auth", json={ + "app_id": self.app_id, + "app_secret": self.app_secret + }) + + self.access_token = auth_response.json()["access_token"] + + # 设置认证头 + client.session.headers.update({ + "Authorization": f"WeChat {self.access_token}", + "X-App-ID": self.app_id + }) + +class CompanySSO(AuthStrategy): + """公司单点登录 - 一次登录,处处通行""" + + def __init__(self, username, password): + self.username = username + self.password = password + + def authenticate(self, client): + """SSO认证流程""" + # 第一步:获取SSO票据 + sso_response = client.post("/sso/login", json={ + "username": self.username, + "password": self.password + }) + + ticket = sso_response.json()["ticket"] + + # 第二步:用票据换取访问令牌 + token_response = client.post("/sso/exchange", json={ + "ticket": ticket, + "service": "api-testing" + }) + + access_token = token_response.json()["access_token"] + + # 设置认证信息 + client.session.headers.update({ + "Authorization": f"Bearer {access_token}", + "X-SSO-User": self.username + }) + +# 使用起来就是这么简单 +def test_with_wechat_auth(): + client = BaseClient("https://api.example.com") + auth = WeChatAuth("your-app-id", "your-app-secret") + auth.authenticate(client) + + response = client.get("/user/profile") + assert response.status_code == 200 +``` + +## 🎨 插件开发指南 + +### 创建你的第一个插件 + +想要创建一个插件?跟着这个步骤,5分钟搞定: + +```python +# plugins/awesome_plugin.py +""" +超棒插件 - 让测试更有趣 +""" + +import pytest +from src.utils.log_moudle import logger + +class AwesomePlugin: + """超棒的插件类""" + + def __init__(self): + self.test_count = 0 + self.emoji_map = { + "PASSED": "🎉", + "FAILED": "😭", + "SKIPPED": "🙄" + } + + @pytest.hookimpl + def pytest_runtest_setup(self, item): + """测试开始前的准备工作""" + self.test_count += 1 + logger.info(f"🚀 开始第 {self.test_count} 个测试: {item.name}") + + @pytest.hookimpl + def pytest_runtest_teardown(self, item, nextitem): + """测试结束后的清理工作""" + logger.info(f"🧹 测试 {item.name} 清理完成") + + @pytest.hookimpl + def pytest_runtest_logreport(self, report): + """测试结果报告""" + if report.when == "call": + emoji = self.emoji_map.get(report.outcome, "🤔") + logger.info(f"{emoji} 测试结果: {report.outcome}") + + if report.outcome == "FAILED": + logger.error(f"💔 失败原因: {report.longrepr}") + +# conftest.py - 注册插件 +def pytest_configure(config): + """注册我们的超棒插件""" + config.pluginmanager.register(AwesomePlugin(), "awesome") +``` + +### 高级插件示例 + +```python +# plugins/performance_monitor.py +""" +性能监控插件 - 让慢测试无处遁形 +""" + +import time +import pytest +from src.utils.log_moudle import logger + +class PerformanceMonitor: + """性能监控器 - 测试界的体检医生""" + + def __init__(self): + self.slow_tests = [] + self.test_times = {} + self.warning_threshold = 5.0 # 5秒警告线 + self.danger_threshold = 10.0 # 10秒危险线 + + @pytest.hookimpl + def pytest_runtest_setup(self, item): + """记录测试开始时间""" + self.test_times[item.nodeid] = time.time() + + @pytest.hookimpl + def pytest_runtest_teardown(self, item, nextitem): + """计算测试耗时""" + start_time = self.test_times.get(item.nodeid) + if start_time: + duration = time.time() - start_time + + if duration > self.danger_threshold: + emoji = "🐌" + level = "DANGER" + elif duration > self.warning_threshold: + emoji = "⚠️" + level = "WARNING" + else: + emoji = "⚡" + level = "FAST" + + logger.info(f"{emoji} [{level}] {item.name}: {duration:.2f}s") + + if duration > self.warning_threshold: + self.slow_tests.append({ + "name": item.name, + "duration": duration, + "level": level + }) + + @pytest.hookimpl + def pytest_sessionfinish(self, session, exitstatus): + """测试结束后的性能报告""" + if self.slow_tests: + logger.warning("🐌 发现慢测试,需要优化:") + for test in sorted(self.slow_tests, key=lambda x: x["duration"], reverse=True): + logger.warning(f" - {test['name']}: {test['duration']:.2f}s ({test['level']})") + else: + logger.info("🚀 所有测试都很快,棒棒哒!") +``` + +## 🎪 Hook系统 - 事件驱动的魔法 + +我们的Hook系统就像是一个事件派对,每个重要时刻都会发出邀请: + +```python +# hooks/test_lifecycle.py +""" +测试生命周期钩子 - 见证每个测试的一生 +""" + +class TestLifecycleHooks: + """测试生命周期钩子管理器""" + + def __init__(self): + self.hooks = { + "before_test": [], + "after_test": [], + "before_request": [], + "after_response": [], + "on_error": [] + } + + def register_hook(self, event, callback): + """注册钩子函数""" + if event in self.hooks: + self.hooks[event].append(callback) + + def trigger_hook(self, event, *args, **kwargs): + """触发钩子事件""" + for callback in self.hooks.get(event, []): + try: + callback(*args, **kwargs) + except Exception as e: + logger.error(f"钩子执行失败 {event}: {e}") + +# 全局钩子管理器 +lifecycle = TestLifecycleHooks() + +# 注册一些有趣的钩子 +def log_test_start(test_name): + """测试开始日志""" + logger.info(f"🎬 测试开始: {test_name}") + +def log_test_end(test_name, result): + """测试结束日志""" + emoji = "🎉" if result == "PASSED" else "😭" + logger.info(f"{emoji} 测试结束: {test_name} - {result}") + +def log_api_request(method, url, data=None): + """API请求日志""" + logger.info(f"📤 API请求: {method} {url}") + if data: + logger.debug(f"📦 请求数据: {data}") + +def log_api_response(response): + """API响应日志""" + logger.info(f"📥 API响应: {response.status_code}") + +# 注册钩子 +lifecycle.register_hook("before_test", log_test_start) +lifecycle.register_hook("after_test", log_test_end) +lifecycle.register_hook("before_request", log_api_request) +lifecycle.register_hook("after_response", log_api_response) +``` + +## 🚀 实战案例:构建企业级扩展 + +让我们来看一个真实的企业级扩展案例: + +```python +# extensions/enterprise_suite.py +""" +企业级扩展套件 - 一站式解决方案 +""" + +class EnterpriseSuite: + """企业级功能套件""" + + def __init__(self, config): + self.config = config + self.audit_logger = AuditLogger() + self.security_checker = SecurityChecker() + self.performance_monitor = PerformanceMonitor() + + def setup_enterprise_features(self, client): + """设置企业级功能""" + # 1. 安全检查 + self.security_checker.validate_ssl(client) + + # 2. 审计日志 + self.audit_logger.start_session() + + # 3. 性能监控 + self.performance_monitor.start_monitoring() + + # 4. 合规检查 + self.ensure_compliance() + + def ensure_compliance(self): + """确保合规性""" + # 检查是否符合GDPR、SOX等法规要求 + compliance_rules = [ + "data_encryption", + "access_logging", + "retention_policy" + ] + + for rule in compliance_rules: + if not self.check_compliance_rule(rule): + raise ComplianceError(f"违反合规规则: {rule}") + +# 使用企业级扩展 +def test_with_enterprise_features(): + """使用企业级功能的测试""" + enterprise = EnterpriseSuite(config) + client = BaseClient("https://api.company.com") + + # 启用企业级功能 + enterprise.setup_enterprise_features(client) + + # 正常的测试逻辑 + response = client.get("/sensitive-data") + assert response.status_code == 200 + + # 企业级验证 + enterprise.audit_logger.log_data_access(response.json()) + enterprise.security_checker.validate_response(response) +``` + +## 💡 扩展最佳实践 + +### 1. 保持简单 +```python +# ✅ 好的扩展 - 简单明了 +class SimpleValidator: + def validate_email(self, email): + return "@" in email and "." in email + +# ❌ 过度复杂的扩展 +class OverComplexValidator: + def __init__(self, config, logger, cache, db, redis, kafka, elasticsearch): + # 太多依赖了... +``` + +### 2. 文档先行 +```python +class AwesomeExtension: + """ + 超棒的扩展 + + 这个扩展可以让你的测试变得更棒! + + 使用方法: + ext = AwesomeExtension() + ext.make_awesome(your_test) + + 注意事项: + - 需要Python 3.8+ + - 不要在生产环境使用 + - 记得给我点赞 ⭐ + """ +``` + +### 3. 向后兼容 +```python +def new_feature(data, new_param=None): + """新功能,但保持向后兼容""" + if new_param is None: + # 使用旧的行为 + return old_behavior(data) + else: + # 使用新的行为 + return new_behavior(data, new_param) +``` + +## 🎯 总结 + +扩展机制让我们的框架变成了一个"变形金刚": +- 🔧 **自定义断言** - 让验证更贴合业务 +- 📊 **自定义数据源** - 数据从哪里来都不是问题 +- 🔐 **自定义认证** - 安全方案你说了算 +- 🎪 **Hook系统** - 在关键时刻插入你的逻辑 +- 🚀 **插件机制** - 功能无限扩展 + +记住:**好的扩展不是炫技,而是解决实际问题。** + +开始创建你的第一个扩展吧!让框架真正为你所用! + +--- + +**下一步**: 试试创建一个简单的自定义断言,体验扩展的乐趣! diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md new file mode 100644 index 0000000..e6ea023 --- /dev/null +++ b/docs/architecture/overview.md @@ -0,0 +1,325 @@ +# 🏗️ 整体架构 + +Pytest Framework 是一个基于 Python + pytest 的现代化接口测试自动化框架,采用分层架构设计,具有高度的可扩展性和可维护性。 + +## 🎯 设计目标 + +- **易用性**: 简单直观的API,降低学习成本 +- **可扩展性**: 模块化设计,支持插件扩展 +- **可维护性**: 清晰的代码结构,良好的文档 +- **高性能**: 支持并发执行和性能测试 +- **企业级**: 满足企业级测试需求 + +## 🏛️ 架构层次 + +```mermaid +graph TB + A[用户层 User Layer] --> B[接口层 Interface Layer] + B --> C[业务层 Business Layer] + C --> D[核心层 Core Layer] + D --> E[基础设施层 Infrastructure Layer] + + subgraph "用户层" + A1[测试用例 Test Cases] + A2[配置文件 Config Files] + A3[测试数据 Test Data] + end + + subgraph "接口层" + B1[CLI 命令行] + B2[Web API] + B3[IDE 插件] + end + + subgraph "业务层" + C1[测试执行器 Test Runner] + C2[报告生成器 Report Generator] + C3[数据管理器 Data Manager] + end + + subgraph "核心层" + D1[HTTP 客户端 HTTP Client] + D2[断言引擎 Assertion Engine] + D3[Mock 服务器 Mock Server] + D4[性能测试器 Performance Tester] + end + + subgraph "基础设施层" + E1[日志系统 Logging] + E2[配置管理 Configuration] + E3[数据库 Database] + E4[文件系统 File System] + end +``` + +## 📦 核心模块 + +### 1. 配置管理模块 (conf/) + +负责管理框架的配置信息,支持多环境配置。 + +```python +# 配置结构 +conf/ +├── config.py # 配置管理器 +├── constants.py # 常量定义 +├── settings.yaml # 主配置文件 +└── settings.local.yaml # 本地配置文件 +``` + +**核心功能**: +- 多环境配置支持 (dev/test/prod) +- 动态配置加载 +- 环境变量集成 +- 配置验证 + +### 2. 核心组件模块 (src/) + +框架的核心功能实现。 + +```python +src/ +├── client/ # HTTP客户端 +│ ├── base_client.py # 基础HTTP客户端 +│ ├── base_auth.py # 认证处理 +│ └── validatable.py # 响应验证 +├── model/ # 数据模型 +│ ├── auto_pytest.py # 测试相关模型 +│ └── modelsbase.py # 基础模型 +└── utils/ # 工具类 + ├── assertion.py # 增强断言 + ├── data_driver.py # 数据驱动 + ├── environment.py # 环境管理 + ├── mock_server.py # Mock服务器 + ├── performance.py # 性能测试 + └── log_moudle.py # 日志管理 +``` + +### 3. 测试用例模块 (tests/) + +测试用例的组织和管理。 + +```python +tests/ +├── conftest.py # pytest配置 +├── examples/ # 示例测试 +├── test_user/ # 用户模块测试 +└── test_goods/ # 商品模块测试 +``` + +## 🔄 数据流 + +```mermaid +sequenceDiagram + participant User as 用户 + participant CLI as 命令行接口 + participant Runner as 测试执行器 + participant Client as HTTP客户端 + participant Assert as 断言引擎 + participant Report as 报告生成器 + + User->>CLI: 执行测试命令 + CLI->>Runner: 启动测试执行 + Runner->>Client: 发送HTTP请求 + Client->>Assert: 返回响应数据 + Assert->>Runner: 断言结果 + Runner->>Report: 生成测试报告 + Report->>User: 展示测试结果 +``` + +## 🧩 组件交互 + +### 1. 配置驱动 + +所有组件都通过配置管理器获取配置信息: + +```python +from src.utils.environment import get_config, get_base_url + +# 获取API配置 +base_url = get_base_url("user_service") +timeout = get_config("API.timeout", 30) + +# 创建客户端 +client = BaseClient(base_url, timeout=timeout) +``` + +### 2. 插件机制 + +框架支持通过插件扩展功能: + +```python +# pytest插件注册 +def pytest_configure(config): + """注册自定义插件""" + config.pluginmanager.register(CustomPlugin()) + +# 自定义Hook +def pytest_runtest_setup(item): + """测试前置处理""" + setup_test_environment(item) +``` + +### 3. 事件驱动 + +组件间通过事件进行解耦通信: + +```python +from src.utils.events import EventBus + +# 发布事件 +EventBus.publish("test_started", test_info) + +# 订阅事件 +@EventBus.subscribe("test_completed") +def handle_test_completed(test_result): + generate_report(test_result) +``` + +## 🔧 扩展点 + +框架提供多个扩展点,支持自定义功能: + +### 1. 自定义断言 + +```python +from src.utils.assertion import EnhancedAssertion + +class CustomAssertion(EnhancedAssertion): + def assert_business_rule(self, data): + """自定义业务规则断言""" + # 实现自定义断言逻辑 + pass +``` + +### 2. 自定义客户端 + +```python +from src.client.base_client import BaseClient + +class CustomClient(BaseClient): + def __init__(self, host, **kwargs): + super().__init__(host, **kwargs) + # 添加自定义功能 +``` + +### 3. 自定义数据源 + +```python +from src.utils.data_driver import DataDriver + +class CustomDataDriver(DataDriver): + def load_from_database(self, query): + """从数据库加载测试数据""" + # 实现数据库数据加载 + pass +``` + +## 📊 性能特性 + +### 1. 并发执行 + +```python +# pytest-xdist 并发执行 +pytest -n auto # 自动检测CPU核心数 +pytest -n 4 # 指定4个进程 +``` + +### 2. 异步支持 + +```python +import asyncio +from src.client.async_client import AsyncClient + +async def test_async_api(): + client = AsyncClient("https://api.example.com") + response = await client.get("/users") + assert response.status_code == 200 +``` + +### 3. 连接池 + +```python +# HTTP连接池配置 +client = BaseClient( + host="https://api.example.com", + pool_connections=10, + pool_maxsize=20 +) +``` + +## 🛡️ 安全特性 + +### 1. 认证支持 + +```python +from src.client.base_auth import BearerAuth, BasicAuth + +# Bearer Token认证 +client.session.auth = BearerAuth("your-token") + +# Basic认证 +client.session.auth = BasicAuth("username", "password") +``` + +### 2. 敏感数据保护 + +```python +# 配置文件中的敏感数据 +# .secrets.yaml (不提交到版本控制) +production: + DB: + password: "encrypted_password" + API: + secret_key: "encrypted_secret" +``` + +## 📈 监控和观测 + +### 1. 日志系统 + +```python +from src.utils.log_moudle import logger + +# 结构化日志 +logger.info("API请求开始", extra={ + "method": "POST", + "url": "/api/users", + "request_id": "req_123" +}) +``` + +### 2. 指标收集 + +```python +from src.utils.metrics import MetricsCollector + +# 收集性能指标 +metrics = MetricsCollector() +metrics.record_response_time("api_call", 0.5) +metrics.increment_counter("api_success") +``` + +## 🔮 未来规划 + +### 短期目标 (1-3个月) +- [ ] GraphQL支持 +- [ ] WebSocket测试 +- [ ] 更多数据源支持 +- [ ] 可视化测试编辑器 + +### 中期目标 (3-6个月) +- [ ] 分布式测试执行 +- [ ] AI辅助测试生成 +- [ ] 实时监控面板 +- [ ] 云原生部署 + +### 长期目标 (6-12个月) +- [ ] 多语言SDK +- [ ] 企业级权限管理 +- [ ] 测试资产市场 +- [ ] 智能测试推荐 + +--- + +**下一步**: [核心组件](./components.md) | [设计理念](./design-principles.md) | [扩展机制](./extension.md) diff --git a/docs/assets/image-20250531212549739.png b/docs/assets/image-20250531212549739.png new file mode 100644 index 0000000..8d9cdf8 Binary files /dev/null and b/docs/assets/image-20250531212549739.png differ diff --git a/docs/best-practices/test-organization.md b/docs/best-practices/test-organization.md new file mode 100644 index 0000000..7ad6676 --- /dev/null +++ b/docs/best-practices/test-organization.md @@ -0,0 +1,547 @@ +# 💡 测试组织最佳实践 + +良好的测试组织是维护高质量测试套件的关键。本指南将介绍如何有效地组织和管理您的测试用例。 + +## 🎯 组织原则 + +### 1. 按功能模块组织 + +``` +tests/ +├── test_user/ # 用户模块 +│ ├── test_user_auth.py # 用户认证 +│ ├── test_user_profile.py # 用户资料 +│ └── test_user_settings.py # 用户设置 +├── test_order/ # 订单模块 +│ ├── test_order_create.py # 订单创建 +│ ├── test_order_query.py # 订单查询 +│ └── test_order_cancel.py # 订单取消 +└── test_payment/ # 支付模块 + ├── test_payment_process.py # 支付处理 + └── test_payment_refund.py # 退款处理 +``` + +### 2. 按测试类型分层 + +``` +tests/ +├── unit/ # 单元测试 +├── integration/ # 集成测试 +├── e2e/ # 端到端测试 +├── performance/ # 性能测试 +└── smoke/ # 冒烟测试 +``` + +### 3. 按环境和场景组织 + +``` +tests/ +├── scenarios/ # 业务场景测试 +│ ├── user_journey/ # 用户旅程 +│ ├── business_flow/ # 业务流程 +│ └── edge_cases/ # 边界情况 +├── regression/ # 回归测试 +└── compatibility/ # 兼容性测试 +``` + +## 📝 命名规范 + +### 1. 文件命名 + +```python +# 推荐的文件命名 +test_user_authentication.py # 功能明确 +test_order_creation_flow.py # 描述性强 +test_payment_integration.py # 易于理解 + +# 避免的文件命名 +test_user.py # 过于宽泛 +test_api.py # 不够具体 +test_1.py # 无意义 +``` + +### 2. 类命名 + +```python +# 推荐的类命名 +class TestUserAuthentication: + """用户认证相关测试""" + pass + +class TestOrderCreationWorkflow: + """订单创建工作流测试""" + pass + +class TestPaymentGatewayIntegration: + """支付网关集成测试""" + pass + +# 避免的类命名 +class TestUser: # 过于宽泛 +class Test1: # 无意义 +class UserTest: # 不符合pytest约定 +``` + +### 3. 方法命名 + +```python +class TestUserAuthentication: + + # 推荐的方法命名 - 描述性强 + def test_login_with_valid_credentials_should_return_success(self): + """使用有效凭据登录应该返回成功""" + pass + + def test_login_with_invalid_password_should_return_401(self): + """使用无效密码登录应该返回401错误""" + pass + + def test_login_with_expired_token_should_redirect_to_login(self): + """使用过期令牌登录应该重定向到登录页面""" + pass + + # 避免的方法命名 + def test_login(self): # 不够具体 + pass + + def test_user_auth(self): # 过于简略 + pass + + def test_case_1(self): # 无意义 + pass +``` + +## 🏗️ 测试结构模式 + +### 1. AAA模式(Arrange-Act-Assert) + +```python +def test_create_user_with_valid_data(self, api_client): + """创建用户测试 - AAA模式""" + + # Arrange - 准备测试数据 + user_data = { + "name": "张三", + "email": "zhangsan@example.com", + "age": 25 + } + + # Act - 执行操作 + response = api_client.post("/users", json=user_data) + + # Assert - 验证结果 + assert response.status_code == 201 + assert response.json()["data"]["name"] == "张三" + assert response.json()["data"]["email"] == "zhangsan@example.com" +``` + +### 2. Given-When-Then模式 + +```python +def test_user_login_workflow(self, api_client): + """用户登录工作流测试 - GWT模式""" + + # Given - 给定条件 + user_credentials = { + "username": "testuser", + "password": "testpass123" + } + + # When - 当执行操作 + login_response = api_client.post("/auth/login", json=user_credentials) + + # Then - 那么应该 + assert login_response.status_code == 200 + assert "access_token" in login_response.json() + + # And - 并且 + token = login_response.json()["access_token"] + profile_response = api_client.get( + "/user/profile", + headers={"Authorization": f"Bearer {token}"} + ) + assert profile_response.status_code == 200 +``` + +### 3. 页面对象模式(适用于复杂API) + +```python +# api_pages/user_api.py +class UserAPI: + """用户API页面对象""" + + def __init__(self, client): + self.client = client + + def create_user(self, user_data): + """创建用户""" + return self.client.post("/users", json=user_data) + + def get_user(self, user_id): + """获取用户""" + return self.client.get(f"/users/{user_id}") + + def update_user(self, user_id, user_data): + """更新用户""" + return self.client.put(f"/users/{user_id}", json=user_data) + + def delete_user(self, user_id): + """删除用户""" + return self.client.delete(f"/users/{user_id}") + +# tests/test_user_crud.py +class TestUserCRUD: + + @pytest.fixture + def user_api(self, api_client): + return UserAPI(api_client) + + def test_user_lifecycle(self, user_api): + """用户生命周期测试""" + # 创建用户 + user_data = {"name": "张三", "email": "zhangsan@example.com"} + create_response = user_api.create_user(user_data) + assert create_response.status_code == 201 + + user_id = create_response.json()["data"]["id"] + + # 获取用户 + get_response = user_api.get_user(user_id) + assert get_response.status_code == 200 + + # 更新用户 + update_data = {"name": "李四"} + update_response = user_api.update_user(user_id, update_data) + assert update_response.status_code == 200 + + # 删除用户 + delete_response = user_api.delete_user(user_id) + assert delete_response.status_code == 204 +``` + +## 🏷️ 测试标记和分类 + +### 1. 功能标记 + +```python +import pytest + +class TestUserAPI: + + @pytest.mark.smoke + def test_user_login(self): + """冒烟测试:用户登录""" + pass + + @pytest.mark.regression + def test_user_profile_update(self): + """回归测试:用户资料更新""" + pass + + @pytest.mark.integration + def test_user_order_integration(self): + """集成测试:用户订单集成""" + pass + + @pytest.mark.performance + def test_user_list_performance(self): + """性能测试:用户列表性能""" + pass +``` + +### 2. 优先级标记 + +```python +class TestCriticalFeatures: + + @pytest.mark.P0 + def test_critical_user_login(self): + """P0级别:关键用户登录功能""" + pass + + @pytest.mark.P1 + def test_important_user_registration(self): + """P1级别:重要用户注册功能""" + pass + + @pytest.mark.P2 + def test_normal_user_settings(self): + """P2级别:普通用户设置功能""" + pass +``` + +### 3. 环境标记 + +```python +class TestEnvironmentSpecific: + + @pytest.mark.dev_only + def test_debug_endpoint(self): + """仅在开发环境运行""" + pass + + @pytest.mark.prod_safe + def test_read_only_operation(self): + """生产环境安全测试""" + pass + + @pytest.mark.requires_external_service + def test_third_party_integration(self): + """需要外部服务的测试""" + pass +``` + +## 📊 测试数据管理 + +### 1. 测试数据分离 + +```python +# data/test_users.py +TEST_USERS = { + "valid_user": { + "name": "张三", + "email": "zhangsan@example.com", + "age": 25 + }, + "admin_user": { + "name": "管理员", + "email": "admin@example.com", + "role": "admin" + }, + "invalid_user": { + "name": "", + "email": "invalid-email", + "age": -1 + } +} + +# tests/test_user_creation.py +from data.test_users import TEST_USERS + +class TestUserCreation: + + def test_create_valid_user(self, api_client): + user_data = TEST_USERS["valid_user"] + response = api_client.post("/users", json=user_data) + assert response.status_code == 201 + + def test_create_invalid_user(self, api_client): + user_data = TEST_USERS["invalid_user"] + response = api_client.post("/users", json=user_data) + assert response.status_code == 400 +``` + +### 2. 动态测试数据 + +```python +from src.utils.data_driver import data_driver + +class TestUserCreationWithDynamicData: + + @pytest.fixture + def user_template(self): + return { + "name": "faker.name", + "email": "faker.email", + "phone": "faker.phone_number", + "address": "faker.address" + } + + def test_create_multiple_users(self, api_client, user_template): + """创建多个用户测试""" + test_users = data_driver.generate_test_data(user_template, count=5) + + for user_data in test_users: + response = api_client.post("/users", json=user_data) + assert response.status_code == 201 + + # 清理数据 + user_id = response.json()["data"]["id"] + api_client.delete(f"/users/{user_id}") +``` + +## 🔧 Fixture组织 + +### 1. 分层Fixture + +```python +# conftest.py - 根级别 +@pytest.fixture(scope="session") +def api_client(): + """全局API客户端""" + return BaseClient(get_base_url()) + +# tests/test_user/conftest.py - 模块级别 +@pytest.fixture(scope="module") +def user_api(api_client): + """用户API客户端""" + return UserAPI(api_client) + +@pytest.fixture +def test_user(): + """测试用户数据""" + return {"name": "测试用户", "email": "test@example.com"} + +# tests/test_user/test_user_auth.py - 测试级别 +@pytest.fixture +def authenticated_user(user_api, test_user): + """已认证的测试用户""" + # 创建用户 + create_response = user_api.create_user(test_user) + user_id = create_response.json()["data"]["id"] + + # 登录用户 + login_response = user_api.login(test_user["email"], "password") + token = login_response.json()["access_token"] + + yield {"user_id": user_id, "token": token} + + # 清理 + user_api.delete_user(user_id) +``` + +### 2. 参数化Fixture + +```python +@pytest.fixture(params=["admin", "user", "guest"]) +def user_role(request): + """参数化用户角色""" + return request.param + +@pytest.fixture +def user_with_role(user_role): + """根据角色创建用户""" + users = { + "admin": {"name": "管理员", "role": "admin"}, + "user": {"name": "普通用户", "role": "user"}, + "guest": {"name": "访客", "role": "guest"} + } + return users[user_role] + +def test_access_control(api_client, user_with_role): + """访问控制测试""" + # 测试不同角色的访问权限 + pass +``` + +## 📈 测试执行策略 + +### 1. 测试分组执行 + +```bash +# 按标记执行 +pytest -m smoke # 冒烟测试 +pytest -m "smoke or regression" # 冒烟或回归测试 +pytest -m "not slow" # 排除慢速测试 + +# 按目录执行 +pytest tests/test_user/ # 用户模块测试 +pytest tests/integration/ # 集成测试 + +# 按优先级执行 +pytest -m P0 # 最高优先级 +pytest -m "P0 or P1" # 高优先级 +``` + +### 2. 并行执行 + +```bash +# 自动检测CPU核心数 +pytest -n auto + +# 指定进程数 +pytest -n 4 + +# 按测试文件分发 +pytest --dist=loadfile -n 4 + +# 按测试方法分发 +pytest --dist=loadscope -n 4 +``` + +### 3. 失败重试 + +```bash +# 失败重试 +pytest --reruns 3 + +# 重试延迟 +pytest --reruns 3 --reruns-delay 1 + +# 仅重试特定异常 +pytest --reruns 3 --only-rerun AssertionError +``` + +## 🎯 质量保证 + +### 1. 代码覆盖率 + +```bash +# 安装coverage插件 +pip install pytest-cov + +# 运行带覆盖率的测试 +pytest --cov=src --cov-report=html + +# 设置覆盖率阈值 +pytest --cov=src --cov-fail-under=80 +``` + +### 2. 测试质量检查 + +```python +# 测试方法应该有文档字符串 +def test_user_creation(self): + """ + 测试用户创建功能 + + 验证: + 1. 使用有效数据创建用户成功 + 2. 返回正确的用户信息 + 3. 用户ID为正整数 + """ + pass + +# 测试应该有明确的断言 +def test_api_response(self, api_client): + response = api_client.get("/users") + + # 好的断言 - 明确具体 + assert response.status_code == 200 + assert isinstance(response.json()["data"], list) + assert len(response.json()["data"]) > 0 + + # 避免的断言 - 过于宽泛 + assert response # 不够具体 + assert response.json() # 没有验证内容 +``` + +### 3. 测试维护 + +```python +# 定期清理无用测试 +@pytest.mark.skip(reason="功能已废弃") +def test_deprecated_feature(self): + pass + +# 更新过时的测试 +def test_updated_api_endpoint(self, api_client): + """更新后的API端点测试""" + # 使用新的API端点 + response = api_client.get("/v2/users") + assert response.status_code == 200 + +# 重构重复代码 +def _create_test_user(self, api_client, user_data=None): + """创建测试用户的辅助方法""" + if user_data is None: + user_data = {"name": "测试用户", "email": "test@example.com"} + + response = api_client.post("/users", json=user_data) + assert response.status_code == 201 + return response.json()["data"]["id"] +``` + +--- + +**下一步**: [高级特性](../user-guide/advanced-features.md) | [性能测试](../user-guide/performance.md) diff --git a/docs/faq/README.md b/docs/faq/README.md new file mode 100644 index 0000000..80b7286 --- /dev/null +++ b/docs/faq/README.md @@ -0,0 +1,408 @@ +# ❓ 常见问题解答 + +本文档收集了使用 Pytest Framework 过程中的常见问题和解决方案。 + +## 🚀 快速开始问题 + +### Q: 安装后运行测试报错 "ModuleNotFoundError" +**A:** 这通常是Python路径问题。解决方案: + +```bash +# 方法1: 设置PYTHONPATH +export PYTHONPATH=$PYTHONPATH:$(pwd) + +# 方法2: 在项目根目录运行 +python -m pytest tests/ + +# 方法3: 使用相对导入 +# 在测试文件中使用 from src.xxx import xxx +``` + +### Q: 为什么选择JMESPath而不是JSONPath? +**A:** JMESPath有以下优势: +- **语法更简洁**: `users[?active].name` vs `$.users[?(@.active)].name` +- **功能更强大**: 支持函数、排序、投影等高级功能 +- **性能更好**: 编译型查询,比解释型JSONPath快 +- **生态成熟**: AWS CLI等大型项目的首选 +- **可读性强**: 声明式语法,更容易理解和维护 + +### Q: 如何配置不同的测试环境? +**A:** 编辑 `conf/settings.yaml` 文件: + +```yaml +boe: # 开发环境 + API: + base_url: "https://dev-api.example.com" + timeout: 30 + +test: # 测试环境 + API: + base_url: "https://test-api.example.com" + timeout: 60 + +# 切换环境 +ENV=test pytest tests/ +``` + +## 🔍 JMESPath使用问题 + +### Q: JMESPath查询返回None怎么办? +**A:** 使用安全的查询方法: + +```python +# 问题:直接查询可能返回None +result = jmespath.search("data.nonexistent", response) # None + +# 解决:使用默认值 +from src.utils.jmespath_helper import jmes +helper = jmes(response) +result = helper.get_value("data.nonexistent", "默认值") +``` + +### Q: 如何查询数组中的特定元素? +**A:** 使用条件过滤: + +```python +# 查询活跃用户 +active_users = helper.filter_by("users", "status == 'active'") + +# 查询特定ID的用户 +user = helper.find_first("users", "id == `123`") + +# 查询年龄大于25的用户 +young_users = helper.filter_by("users", "age > `25`") +``` + +### Q: JMESPath复杂查询如何调试? +**A:** 使用在线工具和日志: + +```python +# 1. 使用在线JMESPath测试工具 +# https://jmespath.org/ + +# 2. 启用调试日志 +import logging +logging.getLogger('jmespath_helper').setLevel(logging.DEBUG) + +# 3. 分步查询 +data = helper.get_value("data") +users = helper.get_list("data.users") +filtered = helper.filter_by("data.users", "active == `true`") +``` + +## 🌐 HTTP客户端问题 + +### Q: 如何处理HTTPS证书问题? +**A:** 配置SSL验证: + +```python +# 忽略SSL证书验证(仅测试环境) +client = BaseClient("https://api.example.com") +client.session.verify = False + +# 使用自定义证书 +client.session.verify = "/path/to/cert.pem" + +# 配置代理 +client.session.proxies = { + "http": "http://proxy.example.com:8080", + "https": "https://proxy.example.com:8080" +} +``` + +### Q: 如何设置请求超时? +**A:** 多种超时设置方式: + +```python +# 1. 客户端级别超时 +client = BaseClient("https://api.example.com", timeout=30) + +# 2. 请求级别超时 +response = client.get("/users", timeout=60) + +# 3. 分别设置连接和读取超时 +response = client.get("/users", timeout=(5, 30)) # (连接超时, 读取超时) +``` + +### Q: 如何处理认证? +**A:** 框架支持多种认证方式: + +```python +from src.client.base_auth import BearerAuth, BasicAuth + +# Bearer Token +client.session.auth = BearerAuth("your-token") + +# Basic认证 +client.session.auth = BasicAuth("username", "password") + +# API Key +client.session.headers.update({"X-API-Key": "your-key"}) + +# 自定义认证头 +client.session.headers.update({"Authorization": "Custom your-token"}) +``` + +## 📊 数据驱动问题 + +### Q: 如何处理大量测试数据? +**A:** 使用分批处理和优化策略: + +```python +# 1. 分批加载数据 +def load_data_in_batches(file_path, batch_size=100): + all_data = load_test_data(file_path) + for i in range(0, len(all_data), batch_size): + yield all_data[i:i + batch_size] + +# 2. 使用生成器 +@pytest.mark.parametrize("user_data", + (data for batch in load_data_in_batches("large_users.json") for data in batch)) +def test_user_creation(self, user_data): + pass + +# 3. 过滤数据 +filtered_data = [data for data in all_data if data.get("active")] +``` + +### Q: 如何生成关联的测试数据? +**A:** 使用数据依赖和模板: + +```python +# 生成关联数据 +def generate_related_data(): + # 先生成用户 + user = data_driver.generate_test_data({ + "id": "faker.random_int", + "name": "faker.name", + "email": "faker.email" + }, count=1)[0] + + # 再生成该用户的订单 + order = { + "user_id": user["id"], + "order_id": f"ORD-{user['id']}-{random.randint(1000, 9999)}", + "amount": random.uniform(10, 1000) + } + + return {"user": user, "order": order} +``` + +## 🎭 Mock服务问题 + +### Q: Mock服务器端口冲突怎么办? +**A:** 使用动态端口分配: + +```python +import socket + +def get_free_port(): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(('', 0)) + s.listen(1) + port = s.getsockname()[1] + return port + +# 使用动态端口 +mock_server = MockServer(port=get_free_port()) +``` + +### Q: 如何模拟复杂的API行为? +**A:** 使用动态响应和状态管理: + +```python +class StatefulMockServer: + def __init__(self): + self.state = {} + self.call_count = {} + + def dynamic_response(self, request): + path = request.path + self.call_count[path] = self.call_count.get(path, 0) + 1 + + # 根据调用次数返回不同响应 + if self.call_count[path] == 1: + return {"status": "processing"} + elif self.call_count[path] == 2: + return {"status": "completed"} + else: + return {"status": "error", "message": "Too many requests"} +``` + +## ⚡ 性能测试问题 + +### Q: 性能测试结果不稳定怎么办? +**A:** 使用多次测试和统计分析: + +```python +def stable_performance_test(request_func, iterations=5): + results = [] + + for i in range(iterations): + metrics = load_test(request_func, concurrent_users=10, total_requests=100) + results.append(metrics.avg_response_time) + + # 计算统计值 + avg_time = sum(results) / len(results) + std_dev = (sum((x - avg_time) ** 2 for x in results) / len(results)) ** 0.5 + + # 使用平均值和标准差进行判断 + assert avg_time < 1.0 + assert std_dev < 0.2 # 标准差小于0.2秒,说明结果稳定 +``` + +### Q: 如何测试API的并发安全性? +**A:** 使用并发测试模式: + +```python +import threading +from concurrent.futures import ThreadPoolExecutor + +def test_concurrent_safety(): + results = [] + errors = [] + + def concurrent_request(): + try: + response = client.post("/api/counter/increment") + results.append(response.json()["count"]) + except Exception as e: + errors.append(str(e)) + + # 并发执行 + with ThreadPoolExecutor(max_workers=10) as executor: + futures = [executor.submit(concurrent_request) for _ in range(100)] + for future in futures: + future.result() + + # 验证结果 + assert len(errors) == 0 # 没有错误 + assert len(set(results)) == len(results) # 所有结果都不同(如果是计数器) +``` + +## 🔧 配置和环境问题 + +### Q: 如何管理敏感信息? +**A:** 使用环境变量和密钥文件: + +```bash +# 1. 使用环境变量 +export API_SECRET_KEY="your-secret-key" +export DB_PASSWORD="your-password" + +# 2. 使用.env文件(不要提交到版本控制) +echo "API_SECRET_KEY=your-secret-key" > .env +echo "DB_PASSWORD=your-password" >> .env + +# 3. 使用密钥管理服务 +# AWS Secrets Manager, Azure Key Vault, etc. +``` + +```python +# 在代码中使用 +import os +from src.utils.environment import get_config + +# 优先使用环境变量 +secret_key = os.getenv("API_SECRET_KEY") or get_config("API.secret_key") +``` + +### Q: 如何在CI/CD中运行测试? +**A:** 配置环境变量和测试命令: + +```yaml +# GitHub Actions示例 +- name: Run tests + env: + ENV: test + API_BASE_URL: https://test-api.example.com + DB_HOST: localhost + run: | + pytest tests/ -v --junitxml=test-results.xml +``` + +## 🐛 调试和故障排除 + +### Q: 测试失败时如何调试? +**A:** 使用多种调试方法: + +```python +# 1. 增加详细日志 +import logging +logging.basicConfig(level=logging.DEBUG) + +# 2. 使用pytest的调试选项 +# pytest tests/ -v -s --tb=long + +# 3. 在测试中添加调试信息 +def test_api_call(self): + response = self.client.get("/users") + + # 打印调试信息 + print(f"Status Code: {response.status_code}") + print(f"Response Headers: {response.headers}") + print(f"Response Body: {response.text}") + + assert response.status_code == 200 + +# 4. 使用pdb调试器 +import pdb; pdb.set_trace() +``` + +### Q: 如何处理间歇性失败的测试? +**A:** 使用重试机制和更好的等待策略: + +```python +# 1. 使用pytest-rerunfailures插件 +# pytest tests/ --reruns 3 --reruns-delay 1 + +# 2. 在代码中实现重试 +import time +from functools import wraps + +def retry(times=3, delay=1): + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + for i in range(times): + try: + return func(*args, **kwargs) + except Exception as e: + if i == times - 1: + raise + time.sleep(delay) + return wrapper + return decorator + +@retry(times=3, delay=2) +def test_flaky_api(self): + response = self.client.get("/flaky-endpoint") + assert response.status_code == 200 +``` + +## 📚 学习资源 + +### Q: 如何深入学习JMESPath? +**A:** 推荐学习资源: +- [JMESPath官方教程](https://jmespath.org/tutorial.html) +- [JMESPath在线测试工具](https://jmespath.org/) +- [AWS CLI JMESPath指南](https://docs.aws.amazon.com/cli/latest/userguide/cli-usage-output-format.html) + +### Q: 如何学习更多pytest技巧? +**A:** 推荐资源: +- [pytest官方文档](https://docs.pytest.org/) +- [pytest插件列表](https://plugincompat.herokuapp.com/) +- [Python Testing 101](https://python-testing-101.readthedocs.io/) + +## 🆘 获取更多帮助 + +如果您的问题在这里没有找到答案: + +1. 📖 查看 [故障排除指南](./troubleshooting.md) +2. 🔍 搜索 [GitHub Issues](https://github.com/your-repo/pytest-framework/issues) +3. 💬 在 [讨论区](https://github.com/your-repo/pytest-framework/discussions) 提问 +4. 📧 发送邮件到 [support@example.com](mailto:support@example.com) + +--- + +**相关文档**: [用户指南](../user-guide/basic-usage.md) | [性能测试](../user-guide/performance.md) diff --git a/docs/quick-start/README.md b/docs/quick-start/README.md new file mode 100644 index 0000000..38bbdef --- /dev/null +++ b/docs/quick-start/README.md @@ -0,0 +1,231 @@ +# 🚀 快速开始指南 + +欢迎使用 Pytest Framework!这个指南将帮助您在5分钟内快速上手,开始编写您的第一个接口测试。 + +## 📋 前置要求 + +在开始之前,请确保您的系统满足以下要求: + +- **Python**: 3.8 或更高版本 +- **操作系统**: Windows、macOS 或 Linux +- **内存**: 至少 2GB RAM +- **磁盘空间**: 至少 500MB 可用空间 + +## ⚡ 快速安装 + +### 1. 克隆项目 + +```bash +git clone https://github.com/ljxpython/pytest_framework.git +cd pytest_framework +``` + +### 2. 安装依赖 + +使用 Poetry(推荐): +```bash +poetry install +poetry shell +``` + +或使用 pip: +```bash +pip install -r requirements.txt +``` + +### 3. 验证安装 + +```bash +pytest --version +python -m pytest tests/examples/test_enhanced_features.py::TestEnvironmentConfig::test_get_config_values -v +``` + +![image-20250825200947059](./assets/image-20250825200947059.png) + +如果看到测试通过,说明安装成功! + +## 🎯 5分钟快速体验 + +### 步骤1: 配置环境 + +编辑 `conf/settings.yaml` 文件: + +```yaml +boe: # 开发环境 + API: + base_url: "https://httpbin.org" + timeout: 30 + + DEBUG: true + LOG_LEVEL: "INFO" +``` + +### 步骤2: 编写第一个测试 + +创建文件 `tests/my_first_test.py`: + +```python +import pytest +from src.client.base_client import BaseClient +from src.utils.assertion import assert_success_response +from src.utils.environment import get_base_url + +class TestMyFirstAPI: + """我的第一个API测试""" + + def setup_method(self): + """测试前置设置""" + base_url = get_base_url() + self.client = BaseClient(base_url) + + def test_get_request(self): + """测试GET请求""" + response = self.client.get("/get?name=张三&age=25") + + # 使用增强断言验证响应 + (assert_success_response(response) + .assert_json_path("$.args.name", "张三") + .assert_json_path("$.args.age", "25")) + + def test_post_request(self): + """测试POST请求""" + user_data = { + "name": "李四", + "email": "lisi@example.com", + "age": 30 + } + + response = self.client.post("/post", json=user_data) + + # 验证响应 + (assert_success_response(response) + .assert_json_path("$.json.name", "李四") + .assert_json_path("$.json.email", "lisi@example.com") + .assert_contains("application/json", response.headers.get("Content-Type"))) +``` + +### 步骤3: 运行测试 + +```bash +# 运行单个测试文件 +pytest tests/my_first_test.py -v + +# 运行所有测试 +pytest -v + +# 生成Allure报告 +pytest --alluredir=output/allure-result +allure generate output/allure-result -o output/allure-report --clean +``` + +![image-20250826105511480](./assets/image-20250826105511480.png) + + + +### 步骤4: 查看测试报告 + +打开 `output/allure-report/index.html` 查看详细的测试报告。 + +## 🌟 核心特性预览 + +### 1. 增强断言 + +```python +from src.utils.assertion import assert_response + +# 链式断言 +(assert_response(response_data) + .assert_status_code(200, response.status_code) + .assert_json_path("$.data.user.name", "张三") + .assert_jmespath("users[?age > `25`].name | [0]", "李四") + .assert_contains("success", response_data)) +``` + +### 2. 数据驱动测试 + +```python +from src.utils.data_driver import data_driver + +# 生成测试数据 +template = { + "name": "faker.name", + "email": "faker.email", + "phone": "faker.phone_number" +} +test_data = data_driver.generate_test_data(template, count=5) + +# 参数化测试 +@pytest.mark.parametrize("user_data", test_data) +def test_create_user(self, user_data): + response = self.client.post("/users", json=user_data) + assert_success_response(response, 201) +``` + +### 3. Mock服务器 + +```python +from src.utils.mock_server import MockServer, create_mock_response + +# 启动Mock服务器 +mock_server = MockServer(port=8888) +mock_server.add_rule( + "GET", "/api/users/123", + create_mock_response(200, {"id": 123, "name": "张三"}) +) +mock_server.start() +``` + +### 4. 性能测试 + +```python +from src.utils.performance import load_test + +def api_request(): + return requests.get("https://httpbin.org/delay/0.1") + +# 负载测试 +metrics = load_test(api_request, concurrent_users=10, total_requests=100) +print(f"平均响应时间: {metrics.avg_response_time}s") +print(f"QPS: {metrics.requests_per_second}") +``` + +## 📁 项目结构 + +``` +pytest-framework/ +├── conf/ # 配置文件 +│ ├── config.py # 配置管理 +│ └── settings.yaml # 环境配置 +├── src/ # 核心源码 +│ ├── client/ # HTTP客户端 +│ ├── model/ # 数据模型 +│ └── utils/ # 工具类 +├── tests/ # 测试用例 +│ ├── examples/ # 示例测试 +│ └── conftest.py # pytest配置 +├── docs/ # 文档 +├── output/ # 测试输出 +└── main.py # 主入口 +``` + +## 🎯 下一步 + +恭喜!您已经成功运行了第一个测试。接下来您可以: + +1. 📖 阅读 [基础用法指南](../user-guide/basic-usage.md) 了解更多功能 +2. 🏗️ 查看 [架构设计](../architecture/overview.md) 理解框架原理 +3. 💡 学习 [最佳实践](../best-practices/test-organization.md) 编写高质量测试 +4. 🔧 探索 [高级特性](../user-guide/advanced-features.md) 提升测试效率 + +## ❓ 遇到问题? + +如果在快速开始过程中遇到任何问题: + +- 📖 查看 [常见问题](../faq/README.md) +- 🔧 参考 [故障排除指南](../faq/troubleshooting.md) +- 💬 在 GitHub 上提交 Issue +- 📧 发送邮件寻求帮助 + +--- + +**下一步**: [安装指南](./installation.md) | [第一个测试](./first-test.md) diff --git a/docs/quick-start/assets/image-20250825200947059.png b/docs/quick-start/assets/image-20250825200947059.png new file mode 100644 index 0000000..0d450e7 Binary files /dev/null and b/docs/quick-start/assets/image-20250825200947059.png differ diff --git a/docs/quick-start/assets/image-20250826105511480.png b/docs/quick-start/assets/image-20250826105511480.png new file mode 100644 index 0000000..2d4cc3f Binary files /dev/null and b/docs/quick-start/assets/image-20250826105511480.png differ diff --git a/docs/quick-start/first-test.md b/docs/quick-start/first-test.md new file mode 100644 index 0000000..93c5cd6 --- /dev/null +++ b/docs/quick-start/first-test.md @@ -0,0 +1,515 @@ +# 🎯 第一个测试 + +本指南将带您编写第一个接口测试用例,体验框架的核心功能。 + +## 🎯 学习目标 + +通过本指南,您将学会: +- 创建基础的测试文件和测试类 +- 使用HTTP客户端发送请求 +- 使用JMESPath进行响应断言 +- 运行和查看测试结果 + +## 📝 编写第一个测试 + +### 1. 创建测试文件 + +在 `tests/` 目录下创建 `test_my_first_api.py`: + +```python +""" +我的第一个API测试 + +演示框架基础功能的使用 +""" + +import pytest +from src.client.base_client import BaseClient +from src.utils.assertion import assert_api_response, assert_jmes +from src.utils.environment import get_base_url +from src.utils.jmespath_helper import jmes, CommonJMESPatterns + + +class TestMyFirstAPI: + """我的第一个API测试类""" + + def setup_method(self): + """每个测试方法执行前的设置""" + # 获取基础URL(从配置文件读取) + base_url = get_base_url() or "https://httpbin.org" + self.client = BaseClient(base_url) + + def test_simple_get_request(self): + """测试简单的GET请求""" + # 发送GET请求 + response = self.client.get("/get") + + # 使用JMESPath断言验证响应 + response_data = response.json() + + # 基础断言 + assert response.status_code == 200 + assert_jmes(response_data, "url", "https://httpbin.org/get") + + # 验证响应结构 + helper = jmes(response_data) + assert helper.exists("headers") + assert helper.exists("origin") + assert helper.get_value("args", {}) == {} + + def test_get_with_parameters(self): + """测试带参数的GET请求""" + # 发送带查询参数的GET请求 + params = { + "name": "张三", + "age": "25", + "city": "北京" + } + response = self.client.get("/get", params=params) + + # 使用JMESPath验证参数 + response_data = response.json() + helper = jmes(response_data) + + # 验证参数正确传递 + assert helper.get_value("args.name") == "张三" + assert helper.get_value("args.age") == "25" + assert helper.get_value("args.city") == "北京" + + # 验证参数数量 + args_count = helper.count("args") + assert args_count == 3 + + def test_post_with_json_data(self): + """测试POST请求发送JSON数据""" + # 准备用户数据 + user_data = { + "name": "李四", + "email": "lisi@example.com", + "age": 30, + "department": "技术部" + } + + # 发送POST请求 + response = self.client.post("/post", json=user_data) + + # 验证响应 + response_data = response.json() + helper = jmes(response_data) + + # 验证状态码 + assert response.status_code == 200 + + # 验证请求数据被正确接收 + assert helper.get_value("json.name") == "李四" + assert helper.get_value("json.email") == "lisi@example.com" + assert helper.get_value("json.age") == 30 + + # 验证Content-Type + content_type = helper.get_value("headers.Content-Type") + assert "application/json" in content_type + + def test_response_headers(self): + """测试响应头验证""" + response = self.client.get("/get") + response_data = response.json() + helper = jmes(response_data) + + # 验证常见响应头 + headers = helper.get_dict("headers") + + assert "User-Agent" in headers + assert "Accept" in headers + assert "Host" in headers + + # 验证自定义User-Agent + user_agent = headers.get("User-Agent", "") + assert "LiJiaXin/QA/" in user_agent + + def test_error_handling(self): + """测试错误处理""" + # 请求不存在的端点 + response = self.client.get("/status/404") + + # 验证404状态码 + assert response.status_code == 404 + + # httpbin的404响应是HTML,不是JSON + # 所以我们只验证状态码 + print(f"404响应状态码: {response.status_code}") + + def test_different_http_methods(self): + """测试不同的HTTP方法""" + test_data = {"test": "data"} + + # 测试PUT请求 + put_response = self.client.put("/put", json=test_data) + assert put_response.status_code == 200 + + put_data = put_response.json() + assert jmes(put_data).get_value("json.test") == "data" + + # 测试PATCH请求 + patch_response = self.client.request("PATCH", "/patch", json=test_data) + assert patch_response.status_code == 200 + + patch_data = patch_response.json() + assert jmes(patch_data).get_value("json.test") == "data" + + # 测试DELETE请求 + delete_response = self.client.delete("/delete") + assert delete_response.status_code == 200 + + +class TestAdvancedJMESPath: + """高级JMESPath查询测试""" + + def setup_method(self): + """测试前置设置""" + self.client = BaseClient("https://httpbin.org") + + def test_complex_json_response(self): + """测试复杂JSON响应的JMESPath查询""" + # 模拟复杂的API响应数据 + complex_data = { + "code": 200, + "message": "success", + "data": { + "users": [ + { + "id": 1, + "name": "张三", + "email": "zhangsan@example.com", + "age": 25, + "department": "技术部", + "skills": ["Python", "Java", "JavaScript"], + "active": True + }, + { + "id": 2, + "name": "李四", + "email": "lisi@example.com", + "age": 30, + "department": "产品部", + "skills": ["产品设计", "用户研究"], + "active": True + }, + { + "id": 3, + "name": "王五", + "email": "wangwu@example.com", + "age": 28, + "department": "技术部", + "skills": ["Python", "Go"], + "active": False + } + ], + "pagination": { + "total": 3, + "page": 1, + "size": 10 + } + } + } + + helper = jmes(complex_data) + + # 基础查询 + assert helper.get_value("code") == 200 + assert helper.get_value("data.pagination.total") == 3 + + # 数组查询 + first_user = helper.get_value("data.users[0].name") + assert first_user == "张三" + + # 条件过滤 + tech_users = helper.filter_by("data.users", "department == '技术部'") + assert len(tech_users) == 2 + + # 活跃用户 + active_users = helper.filter_by("data.users", "active == `true`") + assert len(active_users) == 2 + + # 技能查询 + python_users = helper.filter_by("data.users", "contains(skills, 'Python')") + assert len(python_users) == 2 + + # 排序 + sorted_by_age = helper.sort_by("data.users", "age") + assert sorted_by_age[0]["age"] == 25 + assert sorted_by_age[-1]["age"] == 30 + + # 字段提取 + user_names = helper.get_list("data.users[].name") + assert "张三" in user_names + assert "李四" in user_names + assert "王五" in user_names + + # 分组 + groups = helper.group_by("data.users", "department") + assert "技术部" in groups + assert "产品部" in groups + assert len(groups["技术部"]) == 2 + assert len(groups["产品部"]) == 1 + + def test_jmespath_patterns(self): + """测试常用JMESPath模式""" + # 模拟API响应 + api_response = { + "code": 200, + "message": "操作成功", + "data": { + "user": { + "id": 123, + "name": "测试用户", + "email": "test@example.com", + "status": "active" + }, + "items": [ + {"id": 1, "name": "商品1", "price": 99.99}, + {"id": 2, "name": "商品2", "price": 199.99} + ] + } + } + + helper = jmes(api_response) + + # 使用常用模式 + assert helper.get_value(CommonJMESPatterns.API_CODE) == 200 + assert helper.get_value(CommonJMESPatterns.API_MESSAGE) == "操作成功" + assert helper.exists(CommonJMESPatterns.API_DATA) + + # 用户相关模式 + assert helper.get_value(CommonJMESPatterns.USER_ID) == 123 + assert helper.get_value(CommonJMESPatterns.USER_NAME) == "测试用户" + assert helper.get_value(CommonJMESPatterns.USER_EMAIL) == "test@example.com" + assert helper.get_value(CommonJMESPatterns.USER_STATUS) == "active" + + # 列表操作模式 + first_item = helper.get_value(CommonJMESPatterns.FIRST_ITEM.replace("data", "data.items")) + assert first_item["name"] == "商品1" + + item_count = helper.count("data.items") + assert item_count == 2 +``` + +### 2. 运行测试 + +```bash +# 运行单个测试文件 +pytest tests/test_my_first_api.py -v + +# 运行特定测试方法 +pytest tests/test_my_first_api.py::TestMyFirstAPI::test_simple_get_request -v + +# 运行并显示详细输出 +pytest tests/test_my_first_api.py -v -s +``` + +### 3. 预期输出 + +``` +====================================================================== test session starts ======================================================================= +platform darwin -- Python 3.12.3, pytest-8.4.1, pluggy-1.5.0 -- /Library/Frameworks/Python.framework/Versions/3.12/bin/python3 +cachedir: .pytest_cache +rootdir: /Users/bytedance/PycharmProjects/my_best/pytest_framework +configfile: pytest.ini +plugins: allure-pytest-2.14.3, Faker-37.4.0, anyio-4.8.0, asyncio-1.0.0 +collected 7 items + +tests/test_my_first_api.py::TestMyFirstAPI::test_simple_get_request PASSED +tests/test_my_first_api.py::TestMyFirstAPI::test_get_with_parameters PASSED +tests/test_my_first_api.py::TestMyFirstAPI::test_post_with_json_data PASSED +tests/test_my_first_api.py::TestMyFirstAPI::test_response_headers PASSED +tests/test_my_first_api.py::TestMyFirstAPI::test_error_handling PASSED +tests/test_my_first_api.py::TestMyFirstAPI::test_different_http_methods PASSED +tests/test_my_first_api.py::TestAdvancedJMESPath::test_complex_json_response PASSED + +======================================================================= 7 passed in 3.45s ======================================================================= +``` + +## 🔍 代码解析 + +### 1. 测试类结构 + +```python +class TestMyFirstAPI: + """测试类应该以Test开头""" + + def setup_method(self): + """每个测试方法执行前都会调用""" + # 初始化HTTP客户端 + self.client = BaseClient(base_url) + + def test_simple_get_request(self): + """测试方法应该以test_开头""" + # 测试逻辑 + pass +``` + +### 2. HTTP客户端使用 + +```python +# 创建客户端 +client = BaseClient("https://httpbin.org") + +# 发送GET请求 +response = client.get("/get") +response = client.get("/get", params={"key": "value"}) + +# 发送POST请求 +response = client.post("/post", json={"data": "value"}) + +# 其他HTTP方法 +response = client.put("/put", json=data) +response = client.delete("/delete") +response = client.request("PATCH", "/patch", json=data) +``` + +### 3. JMESPath断言 + +```python +# 基础JMESPath查询 +assert_jmes(response_data, "code", 200) +assert_jmes(response_data, "data.user.name", "张三") + +# 使用JMESPath辅助器 +helper = jmes(response_data) + +# 检查路径是否存在 +assert helper.exists("data.users") + +# 获取值(支持默认值) +name = helper.get_value("data.user.name", "默认名称") + +# 条件过滤 +active_users = helper.filter_by("data.users", "active == `true`") + +# 排序 +sorted_users = helper.sort_by("data.users", "age") + +# 计数 +user_count = helper.count("data.users") +``` + +## 🎨 测试最佳实践 + +### 1. 测试命名 + +```python +# 好的测试命名 - 描述性强 +def test_create_user_with_valid_data_should_return_201(self): + pass + +def test_get_user_with_invalid_id_should_return_404(self): + pass + +# 避免的命名 - 不够具体 +def test_user(self): + pass + +def test_api(self): + pass +``` + +### 2. 测试结构(AAA模式) + +```python +def test_create_user(self): + # Arrange - 准备测试数据 + user_data = { + "name": "张三", + "email": "zhangsan@example.com" + } + + # Act - 执行操作 + response = self.client.post("/users", json=user_data) + + # Assert - 验证结果 + assert response.status_code == 201 + assert_jmes(response.json(), "data.name", "张三") +``` + +### 3. 断言组织 + +```python +# 推荐:使用JMESPath进行结构化断言 +response_data = response.json() +helper = jmes(response_data) + +assert response.status_code == 200 +assert helper.get_value("code") == 200 +assert helper.get_value("data.user.name") == "张三" +assert helper.exists("data.user.email") + +# 避免:过多的独立断言 +assert response.json()["code"] == 200 +assert response.json()["data"]["user"]["name"] == "张三" +assert "email" in response.json()["data"]["user"] +``` + +## 🚀 进阶功能预览 + +### 1. 数据驱动测试 + +```python +from src.utils.data_driver import load_test_data + +@pytest.mark.parametrize("user_data", load_test_data("test_users.json")) +def test_create_multiple_users(self, user_data): + response = self.client.post("/users", json=user_data) + assert response.status_code == 201 +``` + +### 2. Mock服务器 + +```python +from src.utils.mock_server import MockServer, create_mock_response + +@pytest.fixture +def mock_server(self): + server = MockServer(port=8888) + server.add_rule("GET", "/users/123", + create_mock_response(200, {"id": 123, "name": "张三"})) + server.start() + yield server + server.stop() +``` + +### 3. 性能测试 + +```python +from src.utils.performance import load_test + +def test_api_performance(self): + def api_request(): + return self.client.get("/users") + + metrics = load_test(api_request, concurrent_users=10, total_requests=100) + assert metrics.avg_response_time < 1.0 +``` + +## 🎯 下一步 + +恭喜!您已经成功编写并运行了第一个测试。接下来您可以: + +1. 📋 学习 [基础用法](../user-guide/basic-usage.md) 了解更多功能 +2. 🔍 深入 [JMESPath查询](../user-guide/jmespath-guide.md) 掌握高级查询技巧 +3. 📊 探索 [数据驱动测试](../user-guide/data-driven.md) 提升测试效率 +4. 🎭 使用 [Mock服务器](../user-guide/mock-server.md) 模拟复杂场景 +5. ⚡ 进行 [性能测试](../user-guide/performance.md) 监控API性能 + +## ❓ 常见问题 + +### Q: 为什么选择JMESPath而不是JSONPath? +A: JMESPath语法更简洁,功能更强大,支持复杂的查询和转换操作,是AWS等大型项目的首选JSON查询语言。 + +### Q: 如何处理认证? +A: 框架支持多种认证方式,详见 [认证指南](../user-guide/authentication.md)。 + +### Q: 如何组织大型测试项目? +A: 参考 [测试组织最佳实践](../best-practices/test-organization.md)。 + +--- + +**下一步**: [基础用法](../user-guide/basic-usage.md) | [JMESPath指南](../user-guide/jmespath-guide.md) diff --git a/docs/quick-start/installation.md b/docs/quick-start/installation.md new file mode 100644 index 0000000..ff4d8cf --- /dev/null +++ b/docs/quick-start/installation.md @@ -0,0 +1,376 @@ +# 📦 安装指南 + +本指南将详细介绍如何在不同环境下安装和配置 Pytest Framework。 + +## 📋 系统要求 + +### 最低要求 +- **Python**: 3.8 或更高版本 +- **操作系统**: Windows 10+、macOS 10.14+、Linux (Ubuntu 18.04+) +- **内存**: 2GB RAM +- **磁盘空间**: 500MB 可用空间 + +### 推荐配置 +- **Python**: 3.11 或 3.12 +- **内存**: 4GB RAM 或更多 +- **磁盘空间**: 1GB 可用空间 + +## 🐍 Python环境准备 + +### 1. 检查Python版本 + +```bash +python --version +# 或 +python3 --version +``` + +如果版本低于3.8,请升级Python。 + +### 2. 安装Python(如需要) + +#### Windows +1. 访问 [Python官网](https://www.python.org/downloads/) +2. 下载最新版本的Python安装包 +3. 运行安装程序,**确保勾选"Add Python to PATH"** + +#### macOS +```bash +# 使用Homebrew安装 +brew install python + +# 或使用pyenv管理多版本 +brew install pyenv +pyenv install 3.12.0 +pyenv global 3.12.0 +``` + +#### Linux (Ubuntu/Debian) +```bash +sudo apt update +sudo apt install python3 python3-pip python3-venv +``` + +## 🚀 安装方式 + +### 方式一:从源码安装(推荐) + +```bash +# 1. 克隆项目 +git clone https://github.com/your-repo/pytest-framework.git +cd pytest-framework + +# 2. 创建虚拟环境 +python -m venv venv + +# 3. 激活虚拟环境 +# Windows +venv\Scripts\activate +# macOS/Linux +source venv/bin/activate + +# 4. 升级pip +pip install --upgrade pip + +# 5. 安装依赖 +pip install -r requirements.txt +``` + +### 方式二:使用Poetry(推荐开发者) + +```bash +# 1. 安装Poetry +curl -sSL https://install.python-poetry.org | python3 - + +# 2. 克隆项目 +git clone https://github.com/your-repo/pytest-framework.git +cd pytest-framework + +# 3. 安装依赖 +poetry install + +# 4. 激活虚拟环境 +poetry shell +``` + +### 方式三:使用pip安装(未来支持) + +```bash +# 从PyPI安装(计划中) +pip install pytest-framework +``` + +## 🔧 依赖说明 + +### 核心依赖 +``` +pytest>=8.3.3 # 测试框架 +requests>=2.32.3 # HTTP客户端 +jmespath>=1.0.1 # JSON查询语言 +assertpy>=1.1 # 增强断言 +dynaconf>=3.2.6 # 配置管理 +loguru>=0.7.2 # 日志管理 +``` + +### 数据处理依赖 +``` +pandas>=2.2.3 # 数据处理 +faker>=29.0.0 # 测试数据生成 +pyyaml>=6.0.2 # YAML支持 +jsonpath-ng>=1.6.1 # JSONPath支持 +``` + +### 测试增强依赖 +``` +allure-pytest>=2.13.5 # 测试报告 +pytest-xdist>=3.6.1 # 并行测试 +pytest-rerunfailures>=14.0 # 失败重试 +pytest-ordering>=0.6 # 测试排序 +``` + +### 可选依赖 +``` +peewee>=3.17.6 # 数据库ORM +pymysql>=1.1.1 # MySQL驱动 +adb-shell>=0.4.4 # Android调试 +``` + +## ✅ 验证安装 + +### 1. 基础验证 + +```bash +# 检查pytest版本 +pytest --version + +# 检查Python包 +python -c "import pytest, requests, jmespath; print('所有核心包安装成功')" +``` + +### 2. 运行测试验证 + +```bash +# 运行示例测试 +pytest tests/examples/test_enhanced_features.py::TestEnhancedAssertion::test_jmespath_assertion -v + +# 预期输出应包含: +# ✓ JMESPath断言通过: code = 200 +# ✓ JMESPath断言通过: data.users[0].name = 张三 +# PASSED +``` + +### 3. 功能验证 + +```bash +# 验证配置加载 +python -c "from src.utils.environment import get_config; print('配置系统正常')" + +# 验证断言功能 +python -c "from src.utils.assertion import assert_response; print('断言系统正常')" + +# 验证JMESPath功能 +python -c "from src.utils.jmespath_helper import jmes; print('JMESPath系统正常')" +``` + +## 🛠️ 开发环境配置 + +### 1. 安装开发工具 + +```bash +# 代码格式化工具 +pip install black isort + +# 代码检查工具 +pip install flake8 mypy + +# 预提交钩子 +pip install pre-commit +pre-commit install +``` + +### 2. IDE配置 + +#### VS Code +创建 `.vscode/settings.json`: +```json +{ + "python.defaultInterpreterPath": "./venv/bin/python", + "python.formatting.provider": "black", + "python.linting.enabled": true, + "python.linting.flake8Enabled": true, + "python.testing.pytestEnabled": true, + "python.testing.pytestArgs": ["tests/"] +} +``` + +#### PyCharm +1. 设置Python解释器为虚拟环境中的Python +2. 配置代码格式化工具为Black +3. 启用pytest作为测试运行器 + +### 3. 环境变量配置 + +创建 `.env` 文件(可选): +```bash +# 环境设置 +ENV=boe +DEBUG=true + +# 数据库配置(如需要) +DB_HOST=localhost +DB_PORT=3306 +DB_USER=test_user +DB_PASSWORD=test_password + +# API配置 +API_BASE_URL=https://api.example.com +API_TIMEOUT=30 +``` + +## 🐳 Docker安装(可选) + +### 1. 创建Dockerfile + +```dockerfile +FROM python:3.12-slim + +WORKDIR /app + +# 安装系统依赖 +RUN apt-get update && apt-get install -y \ + git \ + && rm -rf /var/lib/apt/lists/* + +# 复制依赖文件 +COPY requirements.txt . + +# 安装Python依赖 +RUN pip install --no-cache-dir -r requirements.txt + +# 复制项目文件 +COPY . . + +# 设置环境变量 +ENV PYTHONPATH=/app + +# 运行测试 +CMD ["pytest", "tests/", "-v"] +``` + +### 2. 构建和运行 + +```bash +# 构建镜像 +docker build -t pytest-framework . + +# 运行测试 +docker run --rm pytest-framework + +# 交互式运行 +docker run -it --rm -v $(pwd):/app pytest-framework bash +``` + +## 🔧 常见问题解决 + +### 1. Python版本问题 + +**问题**: `python: command not found` +**解决**: +```bash +# 尝试使用python3 +python3 --version + +# 或创建别名 +alias python=python3 +``` + +### 2. 权限问题 + +**问题**: `Permission denied` +**解决**: +```bash +# 使用用户安装 +pip install --user -r requirements.txt + +# 或修改权限 +sudo chown -R $USER:$USER /path/to/project +``` + +### 3. 依赖冲突 + +**问题**: 包版本冲突 +**解决**: +```bash +# 创建新的虚拟环境 +python -m venv fresh_env +source fresh_env/bin/activate +pip install -r requirements.txt +``` + +### 4. 网络问题 + +**问题**: 下载速度慢或失败 +**解决**: +```bash +# 使用国内镜像 +pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple/ + +# 或配置pip +pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple/ +``` + +### 5. 导入错误 + +**问题**: `ModuleNotFoundError` +**解决**: +```bash +# 检查PYTHONPATH +export PYTHONPATH=$PYTHONPATH:$(pwd) + +# 或在项目根目录运行 +python -m pytest tests/ +``` + +## 📊 性能优化 + +### 1. 并行测试配置 + +```bash +# 安装并行测试插件 +pip install pytest-xdist + +# 运行并行测试 +pytest -n auto # 自动检测CPU核心数 +pytest -n 4 # 使用4个进程 +``` + +### 2. 缓存配置 + +```bash +# 启用pytest缓存 +pytest --cache-clear # 清除缓存 +pytest --lf # 只运行上次失败的测试 +pytest --ff # 先运行上次失败的测试 +``` + +## 🎯 下一步 + +安装完成后,您可以: + +1. 📖 阅读 [第一个测试](./first-test.md) 编写您的第一个测试用例 +2. 📋 查看 [基础用法](../user-guide/basic-usage.md) 了解框架基本功能 +3. 🏗️ 学习 [架构设计](../architecture/overview.md) 理解框架原理 +4. 💡 参考 [最佳实践](../best-practices/test-organization.md) 编写高质量测试 + +## 🆘 获取帮助 + +如果安装过程中遇到问题: + +- 📖 查看 [常见问题](../faq/troubleshooting.md) +- 🐛 提交 [Issue](https://github.com/your-repo/pytest-framework/issues) +- 💬 参与 [讨论](https://github.com/your-repo/pytest-framework/discussions) +- 📧 发送邮件到 [support@example.com](mailto:support@example.com) + +--- + +**下一步**: [第一个测试](./first-test.md) | [基础用法](../user-guide/basic-usage.md) diff --git a/docs/user-guide/advanced-features.md b/docs/user-guide/advanced-features.md new file mode 100644 index 0000000..bf033d6 --- /dev/null +++ b/docs/user-guide/advanced-features.md @@ -0,0 +1,577 @@ +# 🚀 高级特性 + +本指南介绍 Pytest Framework 的高级功能,帮助您构建更强大、更灵活的测试解决方案。 + +## 🎭 Mock服务器 + +### 1. 基础Mock服务 + +```python +from src.utils.mock_server import MockServer, create_mock_response + +# 创建Mock服务器 +mock_server = MockServer(host="localhost", port=8888) + +# 添加简单Mock规则 +mock_server.add_rule( + "GET", "/api/users/123", + create_mock_response(200, { + "id": 123, + "name": "张三", + "email": "zhangsan@example.com" + }) +) + +# 启动服务器 +mock_server.start() + +# 在测试中使用 +response = requests.get("http://localhost:8888/api/users/123") +assert response.status_code == 200 +assert response.json()["name"] == "张三" + +# 停止服务器 +mock_server.stop() +``` + +### 2. 高级Mock规则 + +```python +# 带查询参数的Mock +mock_server.add_rule( + "GET", "/api/users", + create_mock_response(200, {"users": []}), + query_params={"page": "1", "size": "10"} +) + +# 带请求体匹配的Mock +mock_server.add_rule( + "POST", "/api/users", + create_mock_response(201, {"id": 124, "message": "创建成功"}), + request_body={"name": "新用户"} +) + +# 模拟延迟响应 +mock_server.add_rule( + "GET", "/api/slow-endpoint", + create_mock_response(200, {"data": "slow response"}, delay=2.0) +) + +# 模拟错误响应 +mock_server.add_rule( + "GET", "/api/error", + create_mock_response(500, {"error": "内部服务器错误"}) +) +``` + +### 3. 动态Mock响应 + +```python +def dynamic_user_response(request): + """动态生成用户响应""" + user_id = request.path.split('/')[-1] + return { + "id": int(user_id), + "name": f"用户{user_id}", + "timestamp": time.time() + } + +# 使用动态响应 +mock_server.add_dynamic_rule( + "GET", "/api/users/{user_id}", + dynamic_user_response +) +``` + +## ⚡ 性能测试 + +### 1. 负载测试 + +```python +from src.utils.performance import load_test, PerformanceMetrics + +def api_request(): + """API请求函数""" + return requests.get("https://api.example.com/users") + +# 执行负载测试 +metrics = load_test( + request_func=api_request, + concurrent_users=10, # 并发用户数 + total_requests=100, # 总请求数 +) + +# 验证性能指标 +assert metrics.avg_response_time < 1.0 # 平均响应时间小于1秒 +assert metrics.error_rate < 0.05 # 错误率小于5% +assert metrics.requests_per_second > 50 # QPS大于50 + +print(f"性能测试结果:") +print(f" 平均响应时间: {metrics.avg_response_time:.3f}s") +print(f" 95%响应时间: {metrics.p95_response_time:.3f}s") +print(f" QPS: {metrics.requests_per_second:.2f}") +print(f" 错误率: {metrics.error_rate:.2%}") +``` + +### 2. 压力测试 + +```python +from src.utils.performance import stress_test + +def create_user_request(): + """创建用户请求""" + user_data = { + "name": f"用户{random.randint(1000, 9999)}", + "email": f"user{random.randint(1000, 9999)}@example.com" + } + return requests.post("https://api.example.com/users", json=user_data) + +# 执行压力测试 +metrics = stress_test( + request_func=create_user_request, + duration_seconds=60, # 持续60秒 + concurrent_users=20, # 20个并发用户 +) + +# 分析压力测试结果 +print(f"压力测试结果:") +print(f" 总请求数: {metrics.total_requests}") +print(f" 成功请求数: {metrics.successful_requests}") +print(f" 失败请求数: {metrics.failed_requests}") +print(f" 平均QPS: {metrics.requests_per_second:.2f}") +``` + +### 3. 性能基准测试 + +```python +class TestAPIPerformance: + """API性能基准测试""" + + @pytest.mark.performance + def test_user_list_performance(self): + """用户列表性能测试""" + def get_users(): + return self.client.get("/api/users?page=1&size=20") + + metrics = load_test(get_users, concurrent_users=5, total_requests=50) + + # 性能基准 + assert metrics.avg_response_time < 0.5 # 平均响应时间 < 500ms + assert metrics.p95_response_time < 1.0 # 95%响应时间 < 1s + assert metrics.error_rate == 0 # 无错误 + + @pytest.mark.performance + def test_user_creation_performance(self): + """用户创建性能测试""" + def create_user(): + user_data = {"name": "性能测试用户", "email": "perf@example.com"} + return self.client.post("/api/users", json=user_data) + + metrics = load_test(create_user, concurrent_users=3, total_requests=30) + + # 写操作性能基准 + assert metrics.avg_response_time < 2.0 # 平均响应时间 < 2s + assert metrics.error_rate < 0.1 # 错误率 < 10% +``` + +## 📊 数据驱动高级用法 + +### 1. 多数据源组合 + +```python +from src.utils.data_driver import data_driver + +class TestDataCombination: + """数据组合测试""" + + def test_user_with_multiple_data_sources(self): + """使用多个数据源的测试""" + # 从JSON加载基础用户数据 + base_users = data_driver.load_json("base_users.json") + + # 从Excel加载测试场景 + test_scenarios = data_driver.load_excel("test_scenarios.xlsx") + + # 生成动态数据 + dynamic_data = data_driver.generate_test_data({ + "timestamp": "faker.date_time", + "session_id": "faker.uuid4" + }, count=len(base_users)) + + # 组合数据 + for i, user in enumerate(base_users): + scenario = test_scenarios[i % len(test_scenarios)] + dynamic = dynamic_data[i] + + # 合并数据进行测试 + test_data = {**user, **scenario, **dynamic} + self._test_user_scenario(test_data) +``` + +### 2. 条件数据生成 + +```python +def generate_conditional_data(): + """根据条件生成测试数据""" + + # 定义数据生成规则 + rules = [ + { + "condition": lambda: random.choice([True, False]), + "template": { + "user_type": "premium", + "features": ["advanced_search", "export", "analytics"] + } + }, + { + "condition": lambda: True, # 默认条件 + "template": { + "user_type": "basic", + "features": ["basic_search"] + } + } + ] + + # 生成数据 + test_data = [] + for _ in range(10): + for rule in rules: + if rule["condition"](): + data = data_driver.generate_test_data(rule["template"], count=1)[0] + test_data.append(data) + break + + return test_data +``` + +### 3. 数据依赖管理 + +```python +class TestDataDependency: + """数据依赖测试""" + + def test_user_order_workflow(self): + """用户订单工作流测试(数据有依赖关系)""" + + # 1. 创建用户 + user_data = data_driver.generate_test_data({ + "name": "faker.name", + "email": "faker.email" + }, count=1)[0] + + user_response = self.client.post("/api/users", json=user_data) + user_id = user_response.json()["data"]["id"] + + # 2. 使用用户ID创建订单 + order_data = { + "user_id": user_id, # 依赖于上一步的结果 + "items": [ + {"product_id": 1001, "quantity": 2} + ] + } + + order_response = self.client.post("/api/orders", json=order_data) + order_id = order_response.json()["data"]["order_id"] + + # 3. 验证订单 + order_detail = self.client.get(f"/api/orders/{order_id}") + assert order_detail.json()["data"]["user_id"] == user_id +``` + +## 🔐 高级认证和安全 + +### 1. 多种认证方式 + +```python +from src.client.base_auth import BearerAuth, BasicAuth, APIKeyAuth + +class TestAuthentication: + """认证测试""" + + def test_bearer_token_auth(self): + """Bearer Token认证测试""" + # 获取访问令牌 + login_response = self.client.post("/auth/login", json={ + "username": "testuser", + "password": "testpass" + }) + token = login_response.json()["access_token"] + + # 使用Bearer认证 + self.client.session.auth = BearerAuth(token) + + # 访问受保护的资源 + response = self.client.get("/api/protected") + assert response.status_code == 200 + + def test_api_key_auth(self): + """API Key认证测试""" + # 设置API Key + self.client.session.headers.update({ + "X-API-Key": "your-api-key" + }) + + response = self.client.get("/api/data") + assert response.status_code == 200 + + def test_oauth2_flow(self): + """OAuth2流程测试""" + # 模拟OAuth2授权码流程 + auth_url = "https://auth.example.com/oauth/authorize" + params = { + "client_id": "your-client-id", + "response_type": "code", + "redirect_uri": "http://localhost:8080/callback" + } + + # 这里通常需要模拟用户授权过程 + # 实际测试中可能需要使用Mock或预设的授权码 +``` + +### 2. 会话管理 + +```python +class TestSessionManagement: + """会话管理测试""" + + def test_session_persistence(self): + """会话持久化测试""" + # 登录获取会话 + login_response = self.client.post("/auth/login", json={ + "username": "testuser", + "password": "testpass" + }) + + # 验证会话Cookie被设置 + assert "session_id" in self.client.session.cookies + + # 使用会话访问其他接口 + profile_response = self.client.get("/api/profile") + assert profile_response.status_code == 200 + + # 登出 + logout_response = self.client.post("/auth/logout") + assert logout_response.status_code == 200 + + # 验证会话失效 + profile_response = self.client.get("/api/profile") + assert profile_response.status_code == 401 +``` + +## 🔄 异步和并发测试 + +### 1. 异步HTTP客户端 + +```python +import asyncio +import aiohttp +from src.client.async_client import AsyncClient + +class TestAsyncAPI: + """异步API测试""" + + async def test_async_requests(self): + """异步请求测试""" + async with AsyncClient("https://api.example.com") as client: + # 并发发送多个请求 + tasks = [ + client.get(f"/api/users/{i}") + for i in range(1, 11) + ] + + responses = await asyncio.gather(*tasks) + + # 验证所有响应 + for response in responses: + assert response.status == 200 + + async def test_async_performance(self): + """异步性能测试""" + start_time = time.time() + + async with AsyncClient("https://api.example.com") as client: + tasks = [client.get("/api/users") for _ in range(100)] + await asyncio.gather(*tasks) + + end_time = time.time() + total_time = end_time - start_time + + # 异步请求应该比同步快很多 + assert total_time < 10 # 100个请求在10秒内完成 +``` + +### 2. 并发测试场景 + +```python +import threading +from concurrent.futures import ThreadPoolExecutor + +class TestConcurrency: + """并发测试""" + + def test_concurrent_user_creation(self): + """并发用户创建测试""" + def create_user(user_id): + user_data = { + "name": f"用户{user_id}", + "email": f"user{user_id}@example.com" + } + response = self.client.post("/api/users", json=user_data) + return response.status_code == 201 + + # 使用线程池并发创建用户 + with ThreadPoolExecutor(max_workers=10) as executor: + futures = [ + executor.submit(create_user, i) + for i in range(1, 21) + ] + + results = [future.result() for future in futures] + + # 验证所有用户都创建成功 + assert all(results) + + def test_race_condition(self): + """竞态条件测试""" + # 测试并发访问同一资源时的行为 + resource_id = "shared-resource-123" + + def access_resource(): + response = self.client.get(f"/api/resources/{resource_id}") + return response.status_code + + # 并发访问 + with ThreadPoolExecutor(max_workers=5) as executor: + futures = [executor.submit(access_resource) for _ in range(10)] + status_codes = [future.result() for future in futures] + + # 验证没有出现意外的错误 + assert all(code in [200, 404] for code in status_codes) +``` + +## 🧪 测试环境隔离 + +### 1. 数据库隔离 + +```python +import pytest +from src.utils.database import DatabaseManager + +class TestDatabaseIsolation: + """数据库隔离测试""" + + @pytest.fixture(autouse=True) + def setup_test_database(self): + """为每个测试设置独立的数据库""" + # 创建测试数据库 + self.db = DatabaseManager.create_test_database() + + # 初始化测试数据 + self.db.execute_script("test_data_setup.sql") + + yield + + # 清理测试数据库 + self.db.drop_test_database() + + def test_user_operations(self): + """用户操作测试(使用隔离的数据库)""" + # 测试逻辑,不会影响其他测试 + pass +``` + +### 2. 服务隔离 + +```python +class TestServiceIsolation: + """服务隔离测试""" + + @pytest.fixture + def isolated_service(self): + """启动隔离的服务实例""" + # 启动测试服务 + service = TestService(port=random.randint(8000, 9000)) + service.start() + + yield service + + # 停止测试服务 + service.stop() + + def test_with_isolated_service(self, isolated_service): + """使用隔离服务的测试""" + client = BaseClient(f"http://localhost:{isolated_service.port}") + response = client.get("/health") + assert response.status_code == 200 +``` + +## 📈 监控和观测 + +### 1. 测试指标收集 + +```python +from src.utils.metrics import MetricsCollector + +class TestMetrics: + """测试指标收集""" + + def setup_method(self): + self.metrics = MetricsCollector() + + def test_api_with_metrics(self): + """带指标收集的API测试""" + start_time = time.time() + + response = self.client.get("/api/users") + + end_time = time.time() + response_time = end_time - start_time + + # 记录指标 + self.metrics.record_response_time("get_users", response_time) + self.metrics.increment_counter("api_calls") + + if response.status_code == 200: + self.metrics.increment_counter("api_success") + else: + self.metrics.increment_counter("api_error") + + assert response.status_code == 200 + + def teardown_method(self): + """输出测试指标""" + print(f"测试指标: {self.metrics.get_summary()}") +``` + +### 2. 分布式追踪 + +```python +from src.utils.tracing import TracingContext + +class TestDistributedTracing: + """分布式追踪测试""" + + def test_with_tracing(self): + """带追踪的测试""" + with TracingContext("user_creation_test") as trace: + # 添加追踪信息 + trace.add_tag("test_type", "integration") + trace.add_tag("user_type", "premium") + + # 执行测试 + response = self.client.post("/api/users", json={ + "name": "追踪测试用户" + }) + + # 记录追踪事件 + trace.log_event("user_created", { + "user_id": response.json().get("id"), + "status_code": response.status_code + }) + + assert response.status_code == 201 +``` + +--- + +**下一步**: [配置管理](./configuration.md) | [性能测试](./performance.md) diff --git a/docs/user-guide/assertions.md b/docs/user-guide/assertions.md new file mode 100644 index 0000000..7320aa6 --- /dev/null +++ b/docs/user-guide/assertions.md @@ -0,0 +1,597 @@ +# ✅ 断言功能 - 让验证变得优雅 + +> "好的断言就像一个严格但友善的老师,既能发现问题,又能给出清晰的指导。" + +还在写一堆 `assert response.json()["data"]["user"]["name"] == "张三"` 这样的断言?累不累?我们的增强断言让你告别繁琐,拥抱优雅! + +## 🎯 为什么需要增强断言? + +### 传统断言 vs 增强断言 + +```python +# 😫 传统断言 - 写到手抽筋 +response = client.get("/api/users/123") +assert response.status_code == 200 +assert response.json()["code"] == 200 +assert response.json()["data"]["user"]["name"] == "张三" +assert response.json()["data"]["user"]["email"] == "zhangsan@example.com" +assert "id" in response.json()["data"]["user"] +assert isinstance(response.json()["data"]["user"]["age"], int) +assert 18 <= response.json()["data"]["user"]["age"] <= 65 + +# 😱 如果断言失败,错误信息还不清楚... + +# 🎉 增强断言 - 优雅到飞起 +from src.utils.assertion import assert_response + +(assert_response(response.json()) + .assert_status_code(200, response.status_code) + .assert_jmespath("code", 200) + .assert_jmespath("data.user.name", "张三") + .assert_jmespath("data.user.email", "zhangsan@example.com") + .assert_jmespath_exists("data.user.id") + .assert_jmespath_type("data.user.age", int) + .assert_value_in_range(18, 65, response.json()["data"]["user"]["age"])) + +# 链式调用,清晰明了,错误信息超详细! +``` + +## 🔍 JMESPath断言 - 核心技术栈 + +### 基础JMESPath断言 + +```python +from src.utils.assertion import assert_response +from src.utils.jmespath_helper import jmes + +# 测试数据 +api_response = { + "code": 200, + "message": "success", + "data": { + "user": { + "id": 123, + "name": "张三", + "email": "zhangsan@example.com", + "age": 25, + "active": True, + "tags": ["VIP", "技术"] + }, + "permissions": ["read", "write", "admin"] + } +} + +# 基础断言 +(assert_response(api_response) + .assert_jmespath("code", 200) # 验证状态码 + .assert_jmespath("message", "success") # 验证消息 + .assert_jmespath("data.user.name", "张三") # 验证用户名 + .assert_jmespath("data.user.age", 25)) # 验证年龄 +``` + +### 高级JMESPath断言 + +```python +# 存在性断言 +(assert_response(api_response) + .assert_jmespath_exists("data.user.id") # 验证ID存在 + .assert_jmespath_exists("data.permissions") # 验证权限存在 + .assert_jmespath_not_exists("data.user.password")) # 验证密码不存在 + +# 类型断言 +(assert_response(api_response) + .assert_jmespath_type("data.user.id", int) # 验证ID是整数 + .assert_jmespath_type("data.user.name", str) # 验证姓名是字符串 + .assert_jmespath_type("data.user.active", bool) # 验证状态是布尔值 + .assert_jmespath_type("data.permissions", list)) # 验证权限是列表 + +# 长度断言 +(assert_response(api_response) + .assert_jmespath_length("data.permissions", 3) # 验证权限数量 + .assert_jmespath_length("data.user.tags", 2)) # 验证标签数量 + +# 包含断言 +(assert_response(api_response) + .assert_jmespath_contains("data.permissions", "admin") # 验证包含admin权限 + .assert_jmespath_contains("data.user.tags", "VIP")) # 验证包含VIP标签 +``` + +### 复杂JMESPath查询断言 + +```python +# 复杂的API响应 +complex_response = { + "code": 200, + "data": { + "users": [ + {"id": 1, "name": "张三", "age": 25, "department": "技术部", "active": True}, + {"id": 2, "name": "李四", "age": 30, "department": "产品部", "active": True}, + {"id": 3, "name": "王五", "age": 28, "department": "技术部", "active": False} + ], + "summary": { + "total": 3, + "active_count": 2, + "departments": ["技术部", "产品部"] + } + } +} + +# 使用JMESPath辅助器进行复杂断言 +helper = jmes(complex_response) + +# 验证活跃用户数量 +active_users = helper.filter_by("data.users", "active == `true`") +assert len(active_users) == 2 + +# 验证技术部用户 +tech_users = helper.filter_by("data.users", "department == '技术部'") +assert len(tech_users) == 2 + +# 验证年龄最大的用户 +oldest_user = helper.sort_by("data.users", "age", reverse=True)[0] +assert oldest_user["name"] == "李四" + +# 验证用户名列表 +user_names = helper.get_list("data.users[].name") +assert "张三" in user_names +assert "李四" in user_names +assert "王五" in user_names + +# 组合断言 +(assert_response(complex_response) + .assert_jmespath("data.summary.total", 3) + .assert_jmespath("data.summary.active_count", len(active_users)) + .assert_jmespath_contains("data.summary.departments", "技术部")) +``` + +## 🎨 链式断言 - 优雅的艺术 + +### 基础链式断言 + +```python +# 一气呵成的断言链 +def test_user_api_complete_validation(self): + """完整的用户API验证""" + response = self.client.get("/api/users/123") + + (assert_response(response.json()) + .assert_status_code(200, response.status_code) # HTTP状态码 + .assert_jmespath("code", 200) # 业务状态码 + .assert_jmespath("message", "success") # 业务消息 + .assert_jmespath_exists("data.user") # 用户数据存在 + .assert_jmespath_type("data.user.id", int) # ID类型 + .assert_jmespath_type("data.user.name", str) # 姓名类型 + .assert_jmespath("data.user.active", True) # 用户状态 + .assert_response_time(2.0, response.elapsed.total_seconds())) # 响应时间 +``` + +### 条件链式断言 + +```python +def test_conditional_assertions(self): + """条件断言示例""" + response = self.client.get("/api/users/123") + response_data = response.json() + + assertion = assert_response(response_data) + + # 基础断言 + assertion.assert_jmespath("code", 200) + + # 条件断言 + user_type = response_data.get("data", {}).get("user", {}).get("type") + + if user_type == "VIP": + # VIP用户特殊验证 + (assertion + .assert_jmespath_exists("data.user.vip_level") + .assert_jmespath_exists("data.user.vip_benefits") + .assert_jmespath_type("data.user.vip_level", int)) + + elif user_type == "admin": + # 管理员用户特殊验证 + (assertion + .assert_jmespath_exists("data.user.permissions") + .assert_jmespath_contains("data.user.permissions", "admin") + .assert_jmespath_type("data.user.permissions", list)) + + else: + # 普通用户验证 + assertion.assert_jmespath("data.user.type", "normal") +``` + +## 🎯 专用断言方法 + +### HTTP响应断言 + +```python +from src.utils.assertion import assert_success_response, assert_error_response + +def test_http_response_assertions(self): + """HTTP响应专用断言""" + + # 成功响应断言 + success_response = self.client.get("/api/users") + assert_success_response(success_response, 200) # 期望200状态码 + + # 创建成功断言 + create_response = self.client.post("/api/users", json={"name": "新用户"}) + assert_success_response(create_response, 201) # 期望201状态码 + + # 错误响应断言 + error_response = self.client.get("/api/users/999999") + assert_error_response(error_response, 404) # 期望404状态码 + + # 验证错误响应 + bad_request = self.client.post("/api/users", json={"invalid": "data"}) + (assert_error_response(bad_request, 400) + .assert_jmespath_exists("error.message") + .assert_jmespath_exists("error.code")) +``` + +### 业务规则断言 + +```python +class BusinessAssertion: + """业务规则断言类""" + + @staticmethod + def assert_user_permission(user_data, required_permission): + """验证用户权限""" + permissions = user_data.get("permissions", []) + if required_permission not in permissions: + raise AssertionError(f"用户缺少权限: {required_permission}") + return True + + @staticmethod + def assert_order_amount(order_data, min_amount=0, max_amount=float('inf')): + """验证订单金额""" + amount = order_data.get("amount", 0) + if not (min_amount <= amount <= max_amount): + raise AssertionError(f"订单金额 {amount} 不在范围 [{min_amount}, {max_amount}] 内") + return True + + @staticmethod + def assert_business_hours(timestamp): + """验证营业时间""" + from datetime import datetime + dt = datetime.fromisoformat(timestamp.replace('Z', '+00:00')) + hour = dt.hour + if not (9 <= hour <= 18): + raise AssertionError(f"时间 {hour}:00 不在营业时间内 (9:00-18:00)") + return True + +def test_business_rules(self): + """业务规则测试""" + response = self.client.get("/api/orders/123") + order_data = response.json()["data"]["order"] + + # 使用业务断言 + BusinessAssertion.assert_order_amount(order_data, min_amount=10, max_amount=10000) + BusinessAssertion.assert_business_hours(order_data["created_at"]) + + # 结合链式断言 + (assert_response(response.json()) + .assert_jmespath("code", 200) + .assert_jmespath_exists("data.order.id") + .assert_jmespath_type("data.order.amount", (int, float))) +``` + +## 🔍 Schema验证 - 结构化验证 + +### JSON Schema断言 + +```python +import jsonschema +from src.utils.assertion import assert_response + +# 定义用户响应的Schema +USER_RESPONSE_SCHEMA = { + "type": "object", + "properties": { + "code": {"type": "integer", "enum": [200]}, + "message": {"type": "string"}, + "data": { + "type": "object", + "properties": { + "user": { + "type": "object", + "properties": { + "id": {"type": "integer", "minimum": 1}, + "name": {"type": "string", "minLength": 1}, + "email": {"type": "string", "format": "email"}, + "age": {"type": "integer", "minimum": 0, "maximum": 150}, + "active": {"type": "boolean"}, + "tags": { + "type": "array", + "items": {"type": "string"} + } + }, + "required": ["id", "name", "email", "active"] + } + }, + "required": ["user"] + } + }, + "required": ["code", "message", "data"] +} + +def test_schema_validation(self): + """Schema验证测试""" + response = self.client.get("/api/users/123") + + # 使用Schema断言 + (assert_response(response.json()) + .assert_schema(USER_RESPONSE_SCHEMA) + .assert_jmespath("data.user.name", "张三")) +``` + +### 自定义Schema验证 + +```python +class CustomSchemaValidator: + """自定义Schema验证器""" + + @staticmethod + def validate_api_response_structure(data): + """验证API响应结构""" + required_fields = ["code", "message", "data"] + for field in required_fields: + if field not in data: + raise AssertionError(f"响应缺少必需字段: {field}") + + # 验证状态码 + if not isinstance(data["code"], int): + raise AssertionError("状态码必须是整数") + + # 验证消息 + if not isinstance(data["message"], str): + raise AssertionError("消息必须是字符串") + + return True + + @staticmethod + def validate_pagination_structure(data): + """验证分页结构""" + pagination_fields = ["total", "page", "size", "items"] + for field in pagination_fields: + if field not in data: + raise AssertionError(f"分页数据缺少字段: {field}") + + # 验证数值字段 + for field in ["total", "page", "size"]: + if not isinstance(data[field], int) or data[field] < 0: + raise AssertionError(f"{field} 必须是非负整数") + + # 验证items是列表 + if not isinstance(data["items"], list): + raise AssertionError("items 必须是列表") + + return True + +def test_custom_schema_validation(self): + """自定义Schema验证测试""" + response = self.client.get("/api/users?page=1&size=10") + response_data = response.json() + + # 使用自定义验证器 + CustomSchemaValidator.validate_api_response_structure(response_data) + CustomSchemaValidator.validate_pagination_structure(response_data["data"]) + + # 结合其他断言 + (assert_response(response_data) + .assert_jmespath("code", 200) + .assert_jmespath_type("data.items", list) + .assert_jmespath_length("data.items", response_data["data"]["size"])) +``` + +## 🎪 高级断言技巧 + +### 模糊匹配断言 + +```python +import re +from src.utils.assertion import assert_response + +def test_fuzzy_matching(self): + """模糊匹配断言""" + response = self.client.get("/api/users/123") + response_data = response.json() + + # 正则表达式断言 + (assert_response(response_data) + .assert_regex_match(r"\d+", str(response_data["data"]["user"]["id"])) # ID是数字 + .assert_regex_match(r"[\w\.-]+@[\w\.-]+\.\w+", response_data["data"]["user"]["email"]) # 邮箱格式 + .assert_regex_match(r"^[\u4e00-\u9fa5]+$", response_data["data"]["user"]["name"])) # 中文姓名 + +def test_partial_matching(self): + """部分匹配断言""" + response = self.client.get("/api/search?q=张") + response_data = response.json() + + # 验证搜索结果都包含"张" + users = response_data["data"]["users"] + for user in users: + assert "张" in user["name"], f"用户 {user['name']} 不包含搜索关键字" + + # 使用JMESPath验证 + helper = jmes(response_data) + user_names = helper.get_list("data.users[].name") + for name in user_names: + assert "张" in name +``` + +### 性能断言 + +```python +import time +from src.utils.assertion import assert_response + +def test_performance_assertions(self): + """性能断言测试""" + start_time = time.time() + response = self.client.get("/api/users") + end_time = time.time() + + response_time = end_time - start_time + + # 响应时间断言 + (assert_response(response.json()) + .assert_jmespath("code", 200) + .assert_response_time(2.0, response_time)) # 响应时间小于2秒 + + # 数据量断言 + users = response.json()["data"]["users"] + assert len(users) <= 100, "单次返回用户数量不应超过100" + + # 内存使用断言(需要额外工具) + import psutil + process = psutil.Process() + memory_usage = process.memory_info().rss / 1024 / 1024 # MB + assert memory_usage < 500, f"内存使用过高: {memory_usage}MB" +``` + +### 批量断言 + +```python +def test_batch_assertions(self): + """批量断言测试""" + # 获取用户列表 + response = self.client.get("/api/users") + users = response.json()["data"]["users"] + + # 批量验证每个用户的数据结构 + for i, user in enumerate(users): + try: + (assert_response({"user": user}) + .assert_jmespath_exists("user.id") + .assert_jmespath_exists("user.name") + .assert_jmespath_exists("user.email") + .assert_jmespath_type("user.id", int) + .assert_jmespath_type("user.name", str) + .assert_regex_match(r"[\w\.-]+@[\w\.-]+\.\w+", user["email"])) + except AssertionError as e: + raise AssertionError(f"第 {i+1} 个用户数据验证失败: {e}") + + print(f"✅ 批量验证通过,共验证 {len(users)} 个用户") +``` + +## 💡 断言最佳实践 + +### 1. 断言粒度控制 + +```python +# ✅ 好的断言 - 粒度适中 +def test_user_creation_good(self): + response = self.client.post("/users", json={"name": "张三"}) + + (assert_success_response(response, 201) + .assert_jmespath("data.user.name", "张三") + .assert_jmespath_exists("data.user.id") + .assert_jmespath_type("data.user.created_at", str)) + +# ❌ 过细的断言 - 太啰嗦 +def test_user_creation_too_detailed(self): + response = self.client.post("/users", json={"name": "张三"}) + + assert response.status_code == 201 + assert response.headers["Content-Type"] == "application/json" + assert "data" in response.json() + assert "user" in response.json()["data"] + assert "id" in response.json()["data"]["user"] + assert "name" in response.json()["data"]["user"] + # ... 太多了! + +# ❌ 过粗的断言 - 不够具体 +def test_user_creation_too_broad(self): + response = self.client.post("/users", json={"name": "张三"}) + assert response.status_code == 201 # 只验证状态码,不够! +``` + +### 2. 错误信息优化 + +```python +class EnhancedAssertion: + """增强断言类 - 提供更好的错误信息""" + + def assert_jmespath_with_context(self, path, expected, actual_data): + """带上下文的JMESPath断言""" + actual = jmespath.search(path, actual_data) + if actual != expected: + context = { + "path": path, + "expected": expected, + "actual": actual, + "data_snippet": self._get_data_snippet(actual_data, path) + } + raise AssertionError(f"JMESPath断言失败:\n{json.dumps(context, indent=2, ensure_ascii=False)}") + + def _get_data_snippet(self, data, path): + """获取相关数据片段""" + # 简化实现,实际可以更智能 + return str(data)[:200] + "..." if len(str(data)) > 200 else str(data) +``` + +### 3. 断言复用 + +```python +class CommonAssertions: + """通用断言集合""" + + @staticmethod + def assert_api_success(response_data): + """标准API成功响应断言""" + return (assert_response(response_data) + .assert_jmespath("code", 200) + .assert_jmespath("message", "success") + .assert_jmespath_exists("data")) + + @staticmethod + def assert_user_data_structure(user_data): + """用户数据结构断言""" + return (assert_response({"user": user_data}) + .assert_jmespath_exists("user.id") + .assert_jmespath_exists("user.name") + .assert_jmespath_exists("user.email") + .assert_jmespath_type("user.id", int)) + + @staticmethod + def assert_pagination_data(pagination_data): + """分页数据断言""" + return (assert_response(pagination_data) + .assert_jmespath_exists("total") + .assert_jmespath_exists("page") + .assert_jmespath_exists("size") + .assert_jmespath_exists("items") + .assert_jmespath_type("items", list)) + +# 使用通用断言 +def test_with_common_assertions(self): + response = self.client.get("/api/users") + response_data = response.json() + + # 使用通用断言 + CommonAssertions.assert_api_success(response_data) + CommonAssertions.assert_pagination_data(response_data["data"]) + + # 验证每个用户数据 + for user in response_data["data"]["items"]: + CommonAssertions.assert_user_data_structure(user) +``` + +## 🎯 总结 + +增强断言让你的测试验证变得: +- 🎨 **优雅** - 链式调用,代码简洁 +- 🔍 **强大** - JMESPath查询,功能丰富 +- 🎯 **精确** - 多种断言类型,验证全面 +- 🛡️ **可靠** - 详细错误信息,问题定位快 +- 🔄 **可复用** - 通用断言,减少重复代码 + +记住:**好的断言不仅能发现问题,还能清楚地告诉你问题在哪里!** + +现在就开始使用增强断言,让你的测试验证更加优雅和强大! + +--- + +**小贴士**: 断言失败时,仔细看错误信息,它会告诉你很多有用的调试信息! diff --git a/docs/user-guide/basic-usage.md b/docs/user-guide/basic-usage.md new file mode 100644 index 0000000..280cea1 --- /dev/null +++ b/docs/user-guide/basic-usage.md @@ -0,0 +1,496 @@ +# 📋 基础用法指南 + +本指南将详细介绍 Pytest Framework 的基础功能和使用方法,帮助您快速掌握框架的核心特性。 + +## 🎯 学习目标 + +通过本指南,您将学会: +- 编写基础的接口测试用例 +- 使用HTTP客户端发送请求 +- 进行响应断言和验证 +- 管理测试配置和环境 +- 组织和运行测试用例 + +## 🚀 第一个测试用例 + +### 1. 创建测试文件 + +在 `tests/` 目录下创建您的测试文件: + +```python +# tests/test_basic_api.py +import pytest +from src.client.base_client import BaseClient +from src.utils.assertion import assert_success_response +from src.utils.environment import get_base_url + +class TestBasicAPI: + """基础API测试类""" + + def setup_method(self): + """每个测试方法执行前的设置""" + base_url = get_base_url() + self.client = BaseClient(base_url) + + def test_get_request(self): + """测试GET请求""" + response = self.client.get("/get") + assert_success_response(response) + + def test_post_request(self): + """测试POST请求""" + data = {"name": "张三", "age": 25} + response = self.client.post("/post", json=data) + assert_success_response(response) +``` + +### 2. 运行测试 + +```bash +# 运行单个测试文件 +pytest tests/test_basic_api.py -v + +# 运行特定测试方法 +pytest tests/test_basic_api.py::TestBasicAPI::test_get_request -v + +# 运行所有测试 +pytest -v +``` + +## 🌐 HTTP客户端使用 + +### 1. 基础客户端 + +```python +from src.client.base_client import BaseClient + +# 创建客户端 +client = BaseClient("https://api.example.com", timeout=30) + +# GET请求 +response = client.get("/users") +response = client.get("/users", params={"page": 1, "size": 10}) + +# POST请求 +response = client.post("/users", json={"name": "张三"}) +response = client.post("/users", data="raw data") + +# PUT请求 +response = client.put("/users/123", json={"name": "李四"}) + +# DELETE请求 +response = client.delete("/users/123") + +# 自定义请求头 +response = client.get("/users", headers={"Authorization": "Bearer token"}) +``` + +### 2. 会话管理 + +```python +# 客户端会自动管理会话和Cookie +client = BaseClient("https://api.example.com") + +# 登录 +login_response = client.post("/login", json={ + "username": "admin", + "password": "password" +}) + +# 后续请求会自动携带登录后的Cookie +user_response = client.get("/profile") +``` + +### 3. 认证处理 + +```python +from src.client.base_auth import BearerAuth, BasicAuth + +# Bearer Token认证 +client.session.auth = BearerAuth("your-access-token") + +# Basic认证 +client.session.auth = BasicAuth("username", "password") + +# 自定义认证头 +client.session.headers.update({ + "Authorization": "Custom your-token", + "X-API-Key": "your-api-key" +}) +``` + +## ✅ 响应断言 + +### 1. 基础断言 + +```python +from src.utils.assertion import assert_response, assert_success_response + +# 快速成功响应断言 +assert_success_response(response) # 默认期望200状态码 +assert_success_response(response, 201) # 期望201状态码 + +# 详细断言 +response_data = response.json() +(assert_response(response_data) + .assert_status_code(200, response.status_code) + .assert_contains("success", response_data.get("message", ""))) +``` + +### 2. JSON数据断言 + +```python +# 响应数据示例 +response_data = { + "code": 200, + "message": "success", + "data": { + "user": { + "id": 123, + "name": "张三", + "email": "zhangsan@example.com" + }, + "permissions": ["read", "write"] + } +} + +# JSONPath断言 +(assert_response(response_data) + .assert_json_path("$.code", 200) + .assert_json_path("$.data.user.name", "张三") + .assert_json_path("$.data.permissions[0]", "read")) + +# JMESPath断言 +(assert_response(response_data) + .assert_jmespath("data.user.id", 123) + .assert_jmespath("data.permissions | length(@)", 2)) +``` + +### 3. 高级断言 + +```python +# 包含断言 +assert_response(response_data).assert_contains("张三") + +# 正则表达式断言 +assert_response(response_data).assert_regex_match(r"\d+", str(response_data["data"]["user"]["id"])) + +# 列表长度断言 +assert_response(response_data).assert_list_length(2, response_data["data"]["permissions"]) + +# 字典键断言 +assert_response(response_data).assert_dict_has_keys(["id", "name", "email"], response_data["data"]["user"]) + +# 数值范围断言 +assert_response(response_data).assert_value_in_range(100, 200, response_data["data"]["user"]["id"]) +``` + +## ⚙️ 配置管理 + +### 1. 环境配置 + +编辑 `conf/settings.yaml`: + +```yaml +# 开发环境 +boe: + API: + base_url: "https://dev-api.example.com" + timeout: 30 + DB: + host: "dev-db.example.com" + port: 3306 + database: "test_db" + DEBUG: true + LOG_LEVEL: "DEBUG" + +# 测试环境 +test: + API: + base_url: "https://test-api.example.com" + timeout: 60 + DB: + host: "test-db.example.com" + port: 3306 + database: "test_db" + DEBUG: false + LOG_LEVEL: "INFO" + +# 生产环境 +prod: + API: + base_url: "https://api.example.com" + timeout: 30 + DB: + host: "prod-db.example.com" + port: 3306 + database: "prod_db" + DEBUG: false + LOG_LEVEL: "WARNING" +``` + +### 2. 使用配置 + +```python +from src.utils.environment import get_config, get_base_url, switch_environment + +# 获取配置值 +base_url = get_base_url() +timeout = get_config("API.timeout", 30) +debug_mode = get_config("DEBUG", False) + +# 切换环境 +switch_environment("test") + +# 获取数据库配置 +db_config = get_config("DB") +host = db_config.get("host") +port = db_config.get("port") +``` + +### 3. 敏感信息管理 + +创建 `conf/.secrets.yaml`(不要提交到版本控制): + +```yaml +boe: + DB: + user: "dev_user" + password: "dev_password" + API: + secret_key: "dev_secret_key" + +test: + DB: + user: "test_user" + password: "test_password" + API: + secret_key: "test_secret_key" +``` + +## 📁 测试组织 + +### 1. 目录结构 + +``` +tests/ +├── conftest.py # pytest配置和fixture +├── test_user/ # 用户模块测试 +│ ├── __init__.py +│ ├── test_user_crud.py # 用户CRUD测试 +│ └── test_user_auth.py # 用户认证测试 +├── test_order/ # 订单模块测试 +│ ├── __init__.py +│ ├── test_order_create.py # 订单创建测试 +│ └── test_order_query.py # 订单查询测试 +└── test_integration/ # 集成测试 + ├── __init__.py + └── test_workflow.py # 业务流程测试 +``` + +### 2. 测试分类 + +使用pytest标记对测试进行分类: + +```python +import pytest + +class TestUserAPI: + + @pytest.mark.smoke + def test_user_login(self): + """冒烟测试:用户登录""" + pass + + @pytest.mark.regression + def test_user_profile_update(self): + """回归测试:用户信息更新""" + pass + + @pytest.mark.slow + def test_user_batch_import(self): + """慢速测试:用户批量导入""" + pass + + @pytest.mark.skip(reason="功能未实现") + def test_user_export(self): + """跳过测试:用户导出""" + pass +``` + +### 3. 运行特定测试 + +```bash +# 运行冒烟测试 +pytest -m smoke -v + +# 运行回归测试 +pytest -m regression -v + +# 排除慢速测试 +pytest -m "not slow" -v + +# 运行特定模块 +pytest tests/test_user/ -v + +# 并行运行测试 +pytest -n auto -v +``` + +## 🔧 Fixture使用 + +### 1. 基础Fixture + +在 `tests/conftest.py` 中定义: + +```python +import pytest +from src.client.base_client import BaseClient +from src.utils.environment import get_base_url + +@pytest.fixture(scope="session") +def api_client(): + """API客户端fixture""" + base_url = get_base_url() + client = BaseClient(base_url) + return client + +@pytest.fixture(scope="function") +def test_user(): + """测试用户fixture""" + return { + "name": "测试用户", + "email": "test@example.com", + "age": 25 + } + +@pytest.fixture(scope="class") +def authenticated_client(api_client): + """已认证的客户端fixture""" + # 执行登录 + login_response = api_client.post("/login", json={ + "username": "testuser", + "password": "testpass" + }) + assert login_response.status_code == 200 + return api_client +``` + +### 2. 使用Fixture + +```python +class TestUserAPI: + + def test_get_users(self, api_client): + """使用API客户端fixture""" + response = api_client.get("/users") + assert response.status_code == 200 + + def test_create_user(self, authenticated_client, test_user): + """使用多个fixture""" + response = authenticated_client.post("/users", json=test_user) + assert response.status_code == 201 +``` + +## 📊 测试报告 + +### 1. 生成Allure报告 + +```bash +# 运行测试并生成Allure数据 +pytest --alluredir=output/allure-result + +# 生成HTML报告 +allure generate output/allure-result -o output/allure-report --clean + +# 启动报告服务器 +allure serve output/allure-result +``` + +### 2. 增强报告信息 + +```python +import allure + +class TestUserAPI: + + @allure.feature("用户管理") + @allure.story("用户创建") + @allure.title("创建新用户") + @allure.description("测试创建新用户的API接口") + @allure.severity(allure.severity_level.CRITICAL) + def test_create_user(self, api_client): + with allure.step("准备用户数据"): + user_data = {"name": "张三", "email": "zhangsan@example.com"} + + with allure.step("发送创建用户请求"): + response = api_client.post("/users", json=user_data) + + with allure.step("验证响应结果"): + assert response.status_code == 201 + + # 添加附件 + allure.attach( + response.text, + name="响应内容", + attachment_type=allure.attachment_type.JSON + ) +``` + +## 🎯 最佳实践 + +### 1. 测试命名 + +```python +# 好的命名 +def test_create_user_with_valid_data_should_return_201(): + pass + +def test_get_user_with_invalid_id_should_return_404(): + pass + +# 避免的命名 +def test_user(): + pass + +def test_api(): + pass +``` + +### 2. 断言组织 + +```python +# 推荐:使用链式断言 +(assert_success_response(response, 201) + .assert_json_path("$.data.id", user_id) + .assert_json_path("$.data.name", expected_name) + .assert_response_time(2.0, response.elapsed.total_seconds())) + +# 避免:多个独立断言 +assert response.status_code == 201 +assert response.json()["data"]["id"] == user_id +assert response.json()["data"]["name"] == expected_name +``` + +### 3. 数据管理 + +```python +# 推荐:使用fixture管理测试数据 +@pytest.fixture +def user_data(): + return {"name": "张三", "email": "zhangsan@example.com"} + +# 推荐:测试后清理数据 +def test_create_user(self, api_client, user_data): + response = api_client.post("/users", json=user_data) + user_id = response.json()["data"]["id"] + + # 测试逻辑... + + # 清理数据 + api_client.delete(f"/users/{user_id}") +``` + +--- + +**下一步**: [高级特性](./advanced-features.md) | [配置管理](./configuration.md) diff --git a/docs/user-guide/configuration.md b/docs/user-guide/configuration.md new file mode 100644 index 0000000..0310650 --- /dev/null +++ b/docs/user-guide/configuration.md @@ -0,0 +1,546 @@ +# ⚙️ 配置管理 - 一套配置,走遍天下 + +> "配置做得好,环境切换没烦恼!" + +还在为不同环境的配置切换而头疼?还在担心把测试数据发到生产环境?别慌!我们的配置管理系统就像一个贴心的管家,帮你打理好一切。 + +## 🎯 为什么配置管理这么重要? + +### 真实痛点 +你是不是遇到过这些尴尬时刻: +- 😱 "卧槽!我把测试数据发到生产环境了!" +- 🤦‍♂️ "这个API地址又变了,要改100个地方..." +- 😤 "开发环境能跑,测试环境就挂了" +- 🙄 "每次换环境都要改代码,烦死了" + +有了我们的配置管理,这些问题统统拜拜! + +## 📁 配置文件结构 - 井井有条 + +``` +conf/ +├── settings.yaml # 主配置文件 - 老大 +├── settings.local.yaml # 本地配置 - 小弟(不提交到git) +├── .secrets.yaml # 敏感信息 - 机密(绝对不提交) +├── config.py # 配置管理器 - 管家 +└── constants.py # 常量定义 - 规矩 +``` + +## 🎨 配置文件编写 - 简单到飞起 + +### 主配置文件 `settings.yaml` + +```yaml +# 这就是传说中的"一套配置走天下" +default: + # 默认配置 - 所有环境的基础 + APP_NAME: "Pytest Framework" + VERSION: "1.0.0" + DEBUG: false + LOG_LEVEL: "INFO" + + # 数据库配置模板 + DB: + driver: "mysql" + charset: "utf8mb4" + pool_size: 10 + timeout: 30 + +# 开发环境 - 程序员的乐园 +boe: + DEBUG: true + LOG_LEVEL: "DEBUG" + + API: + base_url: "https://dev-api.example.com" + timeout: 30 + retry_times: 3 + + DB: + host: "dev-db.example.com" + port: 3306 + database: "test_db" + user: "dev_user" + # 密码放在.secrets.yaml里,安全第一! + + REDIS: + host: "dev-redis.example.com" + port: 6379 + db: 0 + + # 第三方服务 + WECHAT: + app_id: "dev_app_id" + # app_secret 在.secrets.yaml里 + + # 测试专用配置 + TEST: + mock_enabled: true + performance_test: false + slow_test_threshold: 5.0 + +# 测试环境 - 接近真实的演练场 +test: + DEBUG: false + LOG_LEVEL: "INFO" + + API: + base_url: "https://test-api.example.com" + timeout: 60 + retry_times: 2 + + DB: + host: "test-db.example.com" + port: 3306 + database: "test_db" + user: "test_user" + + REDIS: + host: "test-redis.example.com" + port: 6379 + db: 1 + + TEST: + mock_enabled: false + performance_test: true + slow_test_threshold: 3.0 + +# 生产环境 - 神圣不可侵犯 +prod: + DEBUG: false + LOG_LEVEL: "WARNING" + + API: + base_url: "https://api.example.com" + timeout: 30 + retry_times: 1 + + DB: + host: "prod-db.example.com" + port: 3306 + database: "prod_db" + user: "prod_user" + + REDIS: + host: "prod-redis.example.com" + port: 6379 + db: 0 + + TEST: + mock_enabled: false + performance_test: false + slow_test_threshold: 1.0 +``` + +### 敏感信息配置 `.secrets.yaml` + +```yaml +# 这个文件绝对不要提交到git! +# 加到 .gitignore 里:*.secrets.yaml + +boe: + DB: + password: "dev_super_secret_password" + WECHAT: + app_secret: "dev_wechat_secret" + API: + secret_key: "dev_api_secret_key" + +test: + DB: + password: "test_super_secret_password" + WECHAT: + app_secret: "test_wechat_secret" + API: + secret_key: "test_api_secret_key" + +prod: + DB: + password: "prod_ultra_secret_password" + WECHAT: + app_secret: "prod_wechat_secret" + API: + secret_key: "prod_api_secret_key" +``` + +### 本地个人配置 `settings.local.yaml` + +```yaml +# 这是你的个人配置,也不要提交 +# 用来覆盖一些个人偏好 + +boe: + DEBUG: true + LOG_LEVEL: "DEBUG" + + # 个人开发时用的本地数据库 + DB: + host: "localhost" + port: 3306 + user: "root" + + # 个人偏好的API地址 + API: + base_url: "http://localhost:8080" + + # 个人测试配置 + TEST: + mock_enabled: true + auto_cleanup: false # 测试后不自动清理数据,方便调试 +``` + +## 🚀 使用配置 - 简单到不敢相信 + +### 基础使用 + +```python +from src.utils.environment import get_config, get_base_url, switch_environment + +# 获取配置 - 就这么简单 +api_url = get_base_url() # 获取当前环境的API地址 +timeout = get_config("API.timeout", 30) # 获取超时配置,默认30秒 +debug_mode = get_config("DEBUG", False) # 获取调试模式 + +# 获取复杂配置 +db_config = get_config("DB") +redis_config = get_config("REDIS") + +print(f"连接数据库: {db_config['host']}:{db_config['port']}") +print(f"Redis地址: {redis_config['host']}") +``` + +### 环境切换 - 一键搞定 + +```python +# 方法1: 代码中切换 +switch_environment("test") # 切换到测试环境 +api_url = get_base_url() # 现在获取的是测试环境的URL + +# 方法2: 环境变量切换 +# export ENV=test +# python -m pytest tests/ + +# 方法3: 命令行参数 +# pytest tests/ --env=test +``` + +### 高级用法 - 配置也能玩出花 + +```python +class ConfigHelper: + """配置助手 - 让配置使用更优雅""" + + @staticmethod + def get_database_url(): + """获取数据库连接URL""" + db = get_config("DB") + password = get_config("DB.password", "") + + return f"mysql://{db['user']}:{password}@{db['host']}:{db['port']}/{db['database']}" + + @staticmethod + def get_redis_client(): + """获取Redis客户端""" + import redis + config = get_config("REDIS") + + return redis.Redis( + host=config["host"], + port=config["port"], + db=config["db"] + ) + + @staticmethod + def is_mock_enabled(): + """检查是否启用Mock""" + return get_config("TEST.mock_enabled", False) + + @staticmethod + def get_performance_threshold(): + """获取性能测试阈值""" + return get_config("TEST.slow_test_threshold", 5.0) + +# 在测试中使用 +class TestUserAPI: + def setup_method(self): + # 根据配置决定使用真实API还是Mock + if ConfigHelper.is_mock_enabled(): + self.client = MockClient() + else: + self.client = BaseClient(get_base_url()) + + def test_user_creation(self): + response = self.client.post("/users", json={"name": "测试用户"}) + assert response.status_code == 201 + + # 根据配置决定是否进行性能检查 + if get_config("TEST.performance_test", False): + assert response.elapsed.total_seconds() < ConfigHelper.get_performance_threshold() +``` + +## 🎭 环境特定配置 - 因地制宜 + +### 开发环境配置技巧 + +```python +# 开发环境专用配置 +if get_config("DEBUG"): + # 开发环境下的特殊处理 + import logging + logging.basicConfig(level=logging.DEBUG) + + # 启用详细日志 + from src.utils.log_moudle import logger + logger.add("debug.log", level="DEBUG", rotation="1 day") + + # 开发环境下不验证SSL证书 + import urllib3 + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) +``` + +### 测试环境配置技巧 + +```python +# 测试环境专用配置 +if get_config("ENV") == "test": + # 测试环境下自动清理数据 + @pytest.fixture(autouse=True) + def auto_cleanup(): + yield + if get_config("TEST.auto_cleanup", True): + cleanup_test_data() + + # 测试环境下启用性能监控 + if get_config("TEST.performance_test"): + pytest_plugins = ["plugins.performance_monitor"] +``` + +### 生产环境配置技巧 + +```python +# 生产环境安全检查 +if get_config("ENV") == "prod": + # 生产环境下禁止某些危险操作 + def dangerous_operation(): + raise RuntimeError("生产环境禁止执行此操作!") + + # 生产环境下启用审计日志 + from src.utils.audit import AuditLogger + audit_logger = AuditLogger() + audit_logger.start() +``` + +## 🔐 安全配置管理 - 安全第一 + +### 敏感信息处理 + +```python +import os +from src.utils.environment import get_config + +class SecureConfig: + """安全配置管理器""" + + @staticmethod + def get_secret(key, default=None): + """安全地获取敏感信息""" + # 优先级:环境变量 > 配置文件 > 默认值 + env_key = key.replace(".", "_").upper() + + # 1. 先从环境变量获取 + value = os.getenv(env_key) + if value: + return value + + # 2. 再从配置文件获取 + value = get_config(key) + if value: + return value + + # 3. 最后使用默认值 + return default + + @staticmethod + def get_database_password(): + """获取数据库密码""" + return SecureConfig.get_secret("DB.password") + + @staticmethod + def get_api_secret(): + """获取API密钥""" + return SecureConfig.get_secret("API.secret_key") + +# 使用示例 +db_password = SecureConfig.get_database_password() +api_secret = SecureConfig.get_api_secret() +``` + +### 配置验证 + +```python +class ConfigValidator: + """配置验证器 - 防止配置错误""" + + @staticmethod + def validate_required_configs(): + """验证必需的配置项""" + required_configs = [ + "API.base_url", + "DB.host", + "DB.port", + "DB.database" + ] + + missing_configs = [] + for config_key in required_configs: + if not get_config(config_key): + missing_configs.append(config_key) + + if missing_configs: + raise ValueError(f"缺少必需的配置项: {missing_configs}") + + @staticmethod + def validate_config_format(): + """验证配置格式""" + # 验证URL格式 + api_url = get_config("API.base_url") + if api_url and not api_url.startswith(("http://", "https://")): + raise ValueError(f"API URL格式错误: {api_url}") + + # 验证端口号 + db_port = get_config("DB.port") + if db_port and not (1 <= db_port <= 65535): + raise ValueError(f"数据库端口号错误: {db_port}") + + # 验证超时时间 + timeout = get_config("API.timeout") + if timeout and timeout <= 0: + raise ValueError(f"超时时间必须大于0: {timeout}") + +# 在应用启动时验证配置 +def startup_validation(): + """启动时配置验证""" + try: + ConfigValidator.validate_required_configs() + ConfigValidator.validate_config_format() + print("✅ 配置验证通过") + except ValueError as e: + print(f"❌ 配置验证失败: {e}") + exit(1) +``` + +## 🎪 动态配置 - 配置也能热更新 + +```python +import time +import threading +from watchdog.observers import Observer +from watchdog.events import FileSystemEventHandler + +class ConfigWatcher(FileSystemEventHandler): + """配置文件监控器 - 配置变了我就知道""" + + def __init__(self, config_manager): + self.config_manager = config_manager + self.last_reload = 0 + + def on_modified(self, event): + """配置文件被修改时的处理""" + if event.src_path.endswith('.yaml'): + # 防止重复触发 + current_time = time.time() + if current_time - self.last_reload > 1: + print(f"🔄 检测到配置文件变化: {event.src_path}") + self.config_manager.reload() + self.last_reload = current_time + +class DynamicConfigManager: + """动态配置管理器""" + + def __init__(self): + self.observer = Observer() + self.watcher = ConfigWatcher(self) + + def start_watching(self, config_dir="conf"): + """开始监控配置文件""" + self.observer.schedule(self.watcher, config_dir, recursive=True) + self.observer.start() + print(f"🔍 开始监控配置目录: {config_dir}") + + def stop_watching(self): + """停止监控""" + self.observer.stop() + self.observer.join() + print("⏹️ 停止配置监控") + + def reload(self): + """重新加载配置""" + # 这里重新加载配置逻辑 + print("🔄 配置已重新加载") + +# 使用动态配置 +dynamic_config = DynamicConfigManager() +dynamic_config.start_watching() + +# 在程序结束时停止监控 +import atexit +atexit.register(dynamic_config.stop_watching) +``` + +## 💡 配置最佳实践 + +### 1. 配置分层原则 +``` +环境变量 > 本地配置 > 主配置 > 默认值 +``` + +### 2. 敏感信息处理 +```python +# ✅ 正确做法 +password = os.getenv("DB_PASSWORD") or get_config("DB.password") + +# ❌ 错误做法 - 直接写在代码里 +password = "my_secret_password" # 千万别这样! +``` + +### 3. 配置命名规范 +```yaml +# ✅ 好的命名 - 清晰明了 +API: + base_url: "https://api.example.com" + timeout: 30 + retry_times: 3 + +# ❌ 不好的命名 - 容易混淆 +api_url: "https://api.example.com" +t: 30 +retry: 3 +``` + +### 4. 环境隔离 +```python +# ✅ 环境隔离 - 安全可靠 +if get_config("ENV") == "prod": + # 生产环境特殊处理 + pass + +# ❌ 混合环境 - 危险操作 +# 不要在一个配置文件里混合不同环境的敏感信息 +``` + +## 🎯 总结 + +配置管理就像是测试框架的"大脑": +- 🎨 **结构清晰** - 配置文件井井有条 +- 🔐 **安全可靠** - 敏感信息妥善保护 +- 🚀 **使用简单** - 一行代码获取配置 +- 🎭 **环境隔离** - 不同环境互不干扰 +- 🔄 **动态更新** - 配置变化实时感知 + +记住:**好的配置管理让你专注于测试逻辑,而不是环境问题!** + +现在,去配置你的测试环境吧,让测试在任何地方都能完美运行! + +--- + +**小贴士**: 别忘了把 `.secrets.yaml` 和 `settings.local.yaml` 加到 `.gitignore` 里哦! diff --git a/docs/user-guide/data-driven.md b/docs/user-guide/data-driven.md new file mode 100644 index 0000000..4ae403c --- /dev/null +++ b/docs/user-guide/data-driven.md @@ -0,0 +1,594 @@ +# 📊 数据驱动测试 - 让数据为你打工 + +> "数据驱动测试:一套逻辑,千种场景!" + +还在为每个测试场景写重复代码?还在手动构造测试数据?别累坏自己!数据驱动测试让你写一次逻辑,测试无数场景。 + +## 🎯 什么是数据驱动测试? + +### 传统测试 vs 数据驱动测试 + +```python +# 😫 传统方式 - 重复到想哭 +def test_create_user_zhang(): + response = client.post("/users", json={"name": "张三", "age": 25}) + assert response.status_code == 201 + +def test_create_user_li(): + response = client.post("/users", json={"name": "李四", "age": 30}) + assert response.status_code == 201 + +def test_create_user_wang(): + response = client.post("/users", json={"name": "王五", "age": 28}) + assert response.status_code == 201 + +# 😱 如果有100个用户要测试...你会疯掉的! + +# 🎉 数据驱动方式 - 一次编写,处处运行 +@pytest.mark.parametrize("user_data", load_test_data("users.json")) +def test_create_user(self, user_data): + response = self.client.post("/users", json=user_data) + assert response.status_code == 201 + assert response.json()["name"] == user_data["name"] + +# 一个测试方法,测试所有用户数据! +``` + +## 📁 数据源大全 - 想要什么格式都有 + +### 1. JSON数据 - 程序员的最爱 + +```json +// data/users.json +[ + { + "name": "张三", + "email": "zhangsan@example.com", + "age": 25, + "department": "技术部", + "skills": ["Python", "Java"], + "active": true + }, + { + "name": "李四", + "email": "lisi@example.com", + "age": 30, + "department": "产品部", + "skills": ["产品设计", "用户研究"], + "active": true + }, + { + "name": "王五", + "email": "wangwu@example.com", + "age": 28, + "department": "技术部", + "skills": ["Python", "Go"], + "active": false + } +] +``` + +```python +# 使用JSON数据 +from src.utils.data_driver import load_test_data + +@pytest.mark.parametrize("user", load_test_data("users.json")) +def test_user_creation(self, user): + """测试用户创建 - JSON数据驱动""" + response = self.client.post("/users", json=user) + + assert response.status_code == 201 + assert response.json()["name"] == user["name"] + assert response.json()["email"] == user["email"] +``` + +### 2. YAML数据 - 人类友好格式 + +```yaml +# data/test_scenarios.yaml +user_creation_tests: + - name: "正常用户创建" + description: "使用有效数据创建用户" + input: + name: "张三" + email: "zhangsan@example.com" + age: 25 + expected: + status_code: 201 + response_contains: ["id", "name", "email"] + + - name: "邮箱格式错误" + description: "使用无效邮箱格式" + input: + name: "李四" + email: "invalid-email" + age: 30 + expected: + status_code: 400 + error_message: "邮箱格式无效" + +login_tests: + - scenario: "成功登录" + username: "admin" + password: "password123" + expected_result: "success" + + - scenario: "密码错误" + username: "admin" + password: "wrong_password" + expected_result: "failed" +``` + +```python +# 使用YAML数据 +@pytest.mark.parametrize("test_case", load_test_data("test_scenarios.yaml")["user_creation_tests"]) +def test_user_creation_scenarios(self, test_case): + """测试用户创建场景 - YAML数据驱动""" + print(f"🧪 测试场景: {test_case['name']}") + + response = self.client.post("/users", json=test_case["input"]) + + # 验证状态码 + assert response.status_code == test_case["expected"]["status_code"] + + # 验证响应内容 + if "response_contains" in test_case["expected"]: + for field in test_case["expected"]["response_contains"]: + assert field in response.json() +``` + +### 3. Excel数据 - 产品经理的最爱 + +```python +# data/user_test_cases.xlsx +# 表格内容: +# | 姓名 | 邮箱 | 年龄 | 部门 | 期望状态码 | 备注 | +# | 张三 | zhangsan@example.com | 25 | 技术部 | 201 | 正常用户 | +# | 李四 | invalid-email | 30 | 产品部 | 400 | 邮箱格式错误 | + +from src.utils.data_driver import data_driver + +@pytest.mark.parametrize("test_case", data_driver.load_excel("user_test_cases.xlsx", "用户测试")) +def test_user_from_excel(self, test_case): + """测试用户创建 - Excel数据驱动""" + user_data = { + "name": test_case["姓名"], + "email": test_case["邮箱"], + "age": test_case["年龄"], + "department": test_case["部门"] + } + + response = self.client.post("/users", json=user_data) + + # 验证期望的状态码 + assert response.status_code == test_case["期望状态码"] + + print(f"✅ {test_case['备注']}: {test_case['姓名']} - {response.status_code}") +``` + +### 4. CSV数据 - 简单实用 + +```csv +# data/products.csv +name,price,category,stock,active +iPhone 15,999.99,手机,100,true +MacBook Pro,1999.99,电脑,50,true +AirPods,199.99,耳机,200,true +iPad,599.99,平板,80,false +``` + +```python +@pytest.mark.parametrize("product", data_driver.load_csv("products.csv")) +def test_product_creation(self, product): + """测试商品创建 - CSV数据驱动""" + response = self.client.post("/products", json=product) + + if product["active"] == "true": + assert response.status_code == 201 + assert response.json()["name"] == product["name"] + else: + # 非活跃商品应该创建失败 + assert response.status_code == 400 +``` + +## 🎲 动态数据生成 - 让Faker为你打工 + +### 基础数据生成 + +```python +from src.utils.data_driver import data_driver + +# 定义数据模板 +user_template = { + "name": "faker.name", # 随机姓名 + "email": "faker.email", # 随机邮箱 + "phone": "faker.phone_number", # 随机电话 + "address": "faker.address", # 随机地址 + "age": 25, # 固定值 + "department": "技术部" # 固定值 +} + +# 生成测试数据 +test_users = data_driver.generate_test_data(user_template, count=10) + +@pytest.mark.parametrize("user", test_users) +def test_dynamic_user_creation(self, user): + """测试用户创建 - 动态数据生成""" + response = self.client.post("/users", json=user) + assert response.status_code == 201 + + # 验证生成的数据格式 + assert "@" in user["email"] # 邮箱包含@ + assert len(user["name"]) > 0 # 姓名不为空 + assert user["age"] == 25 # 固定值正确 +``` + +### 高级数据生成 + +```python +# 复杂的数据模板 +order_template = { + "order_id": "faker.uuid4", + "customer": { + "name": "faker.name", + "email": "faker.email", + "phone": "faker.phone_number" + }, + "items": [ + { + "product_name": "faker.word", + "quantity": "faker.random_int:1:10", + "price": "faker.pyfloat:2:2:True:10:1000" + } + ], + "shipping_address": { + "street": "faker.street_address", + "city": "faker.city", + "postal_code": "faker.postcode" + }, + "order_date": "faker.date_time_this_year", + "status": "faker.random_element:pending,paid,shipped,delivered" +} + +# 生成复杂订单数据 +test_orders = data_driver.generate_test_data(order_template, count=5) + +@pytest.mark.parametrize("order", test_orders) +def test_order_creation(self, order): + """测试订单创建 - 复杂数据生成""" + response = self.client.post("/orders", json=order) + assert response.status_code == 201 + + # 验证订单数据 + assert len(order["order_id"]) == 36 # UUID长度 + assert order["items"][0]["quantity"] >= 1 + assert order["items"][0]["price"] >= 10 +``` + +### 中文数据生成 + +```python +# 中文数据模板 +chinese_user_template = { + "name": "faker.name:zh_CN", # 中文姓名 + "company": "faker.company:zh_CN", # 中文公司名 + "address": "faker.address:zh_CN", # 中文地址 + "phone": "faker.phone_number:zh_CN", # 中国手机号 + "id_card": "faker.ssn:zh_CN", # 身份证号 + "email": "faker.email", # 邮箱(英文) + "age": "faker.random_int:18:65" # 年龄范围 +} + +chinese_users = data_driver.generate_test_data(chinese_user_template, count=20) + +@pytest.mark.parametrize("user", chinese_users) +def test_chinese_user_creation(self, user): + """测试中文用户创建""" + response = self.client.post("/users", json=user) + assert response.status_code == 201 + + # 验证中文数据 + assert len(user["name"]) >= 2 # 中文姓名至少2个字符 + assert user["phone"].startswith(("13", "14", "15", "16", "17", "18", "19")) +``` + +## 🎪 数据组合和关联 + +### 数据依赖处理 + +```python +class TestUserOrderWorkflow: + """用户订单工作流测试 - 数据有依赖关系""" + + def test_complete_user_journey(self): + """完整的用户旅程测试""" + + # 1. 生成用户数据 + user_template = { + "name": "faker.name", + "email": "faker.email", + "phone": "faker.phone_number" + } + user_data = data_driver.generate_test_data(user_template, count=1)[0] + + # 2. 创建用户 + user_response = self.client.post("/users", json=user_data) + assert user_response.status_code == 201 + user_id = user_response.json()["id"] + + # 3. 生成订单数据(依赖用户ID) + order_data = { + "user_id": user_id, # 使用刚创建的用户ID + "items": [ + { + "product_id": 1001, + "quantity": 2, + "price": 99.99 + } + ], + "total_amount": 199.98 + } + + # 4. 创建订单 + order_response = self.client.post("/orders", json=order_data) + assert order_response.status_code == 201 + + # 5. 验证订单关联 + order_id = order_response.json()["id"] + order_detail = self.client.get(f"/orders/{order_id}") + assert order_detail.json()["user_id"] == user_id +``` + +### 批量数据生成 + +```python +def generate_related_test_data(): + """生成相关联的测试数据""" + + # 生成公司数据 + companies = data_driver.generate_test_data({ + "name": "faker.company", + "industry": "faker.random_element:IT,金融,教育,医疗", + "size": "faker.random_element:小型,中型,大型" + }, count=5) + + # 为每个公司生成员工数据 + all_employees = [] + for company in companies: + employees = data_driver.generate_test_data({ + "name": "faker.name", + "email": "faker.email", + "position": "faker.job", + "company_name": company["name"], # 关联公司 + "salary": "faker.random_int:5000:50000" + }, count=random.randint(3, 8)) + + all_employees.extend(employees) + + return companies, all_employees + +@pytest.mark.parametrize("employee", generate_related_test_data()[1]) +def test_employee_creation(self, employee): + """测试员工创建 - 关联公司数据""" + response = self.client.post("/employees", json=employee) + assert response.status_code == 201 + assert response.json()["company_name"] == employee["company_name"] +``` + +## 🎯 数据过滤和选择 + +### 条件数据过滤 + +```python +# 加载所有用户数据 +all_users = load_test_data("users.json") + +# 过滤活跃用户 +active_users = [user for user in all_users if user.get("active", True)] + +# 过滤技术部员工 +tech_users = [user for user in all_users if user.get("department") == "技术部"] + +# 过滤年龄范围 +young_users = [user for user in all_users if 20 <= user.get("age", 0) <= 30] + +@pytest.mark.parametrize("user", active_users) +def test_active_user_features(self, user): + """测试活跃用户功能""" + response = self.client.get(f"/users/{user['id']}/features") + assert response.status_code == 200 + assert response.json()["active"] == True + +@pytest.mark.parametrize("user", tech_users) +def test_tech_user_permissions(self, user): + """测试技术部用户权限""" + response = self.client.get(f"/users/{user['id']}/permissions") + assert response.status_code == 200 + assert "code_access" in response.json()["permissions"] +``` + +### 数据分组测试 + +```python +from itertools import groupby + +# 按部门分组测试 +all_users = load_test_data("users.json") +users_by_dept = {} +for dept, users in groupby(all_users, key=lambda x: x["department"]): + users_by_dept[dept] = list(users) + +class TestDepartmentFeatures: + """按部门测试功能""" + + @pytest.mark.parametrize("user", users_by_dept.get("技术部", [])) + def test_tech_department_features(self, user): + """测试技术部专属功能""" + response = self.client.get(f"/tech/features", + headers={"User-ID": str(user["id"])}) + assert response.status_code == 200 + + @pytest.mark.parametrize("user", users_by_dept.get("产品部", [])) + def test_product_department_features(self, user): + """测试产品部专属功能""" + response = self.client.get(f"/product/features", + headers={"User-ID": str(user["id"])}) + assert response.status_code == 200 +``` + +## 🎨 数据模板进阶技巧 + +### 自定义数据生成器 + +```python +from faker import Faker +import random + +fake = Faker('zh_CN') + +def custom_data_generators(): + """自定义数据生成器""" + + def generate_chinese_mobile(): + """生成中国手机号""" + prefixes = ['130', '131', '132', '133', '134', '135', '136', '137', '138', '139', + '150', '151', '152', '153', '155', '156', '157', '158', '159', + '180', '181', '182', '183', '184', '185', '186', '187', '188', '189'] + prefix = random.choice(prefixes) + suffix = ''.join([str(random.randint(0, 9)) for _ in range(8)]) + return prefix + suffix + + def generate_realistic_email(name): + """根据姓名生成真实的邮箱""" + domains = ['qq.com', '163.com', '126.com', 'gmail.com', 'hotmail.com'] + # 简单的拼音转换(实际项目中可以用pypinyin) + name_pinyin = name.lower().replace(' ', '') + domain = random.choice(domains) + return f"{name_pinyin}{random.randint(1, 999)}@{domain}" + + def generate_id_card(): + """生成身份证号""" + return fake.ssn() + + return { + "mobile": generate_chinese_mobile, + "realistic_email": generate_realistic_email, + "id_card": generate_id_card + } + +# 使用自定义生成器 +generators = custom_data_generators() + +realistic_user_template = { + "name": "faker.name:zh_CN", + "mobile": generators["mobile"], + "id_card": generators["id_card"], + "age": "faker.random_int:18:65", + "city": "faker.city:zh_CN" +} + +# 生成真实的用户数据 +realistic_users = [] +for i in range(10): + user = {} + for key, value in realistic_user_template.items(): + if callable(value): + user[key] = value() + elif isinstance(value, str) and value.startswith("faker."): + # 处理faker表达式 + user[key] = fake.name() # 简化处理 + else: + user[key] = value + + # 生成基于姓名的邮箱 + user["email"] = generators["realistic_email"](user["name"]) + realistic_users.append(user) + +@pytest.mark.parametrize("user", realistic_users) +def test_realistic_user_data(self, user): + """测试真实用户数据""" + response = self.client.post("/users", json=user) + assert response.status_code == 201 + + # 验证数据真实性 + assert len(user["mobile"]) == 11 + assert user["mobile"].startswith(('13', '15', '18')) + assert '@' in user["email"] +``` + +## 💡 数据驱动最佳实践 + +### 1. 数据文件组织 + +``` +data/ +├── users/ # 用户相关数据 +│ ├── normal_users.json +│ ├── vip_users.json +│ └── invalid_users.json +├── products/ # 商品相关数据 +│ ├── electronics.csv +│ ├── books.yaml +│ └── clothing.xlsx +├── scenarios/ # 测试场景数据 +│ ├── happy_path.yaml +│ ├── edge_cases.json +│ └── error_cases.yaml +└── templates/ # 数据模板 + ├── user_template.py + └── order_template.py +``` + +### 2. 数据版本管理 + +```python +# data/version_info.yaml +version: "1.2.0" +last_updated: "2024-01-15" +changes: + - "添加VIP用户测试数据" + - "更新商品价格信息" + - "修复邮箱格式问题" + +compatibility: + min_framework_version: "1.0.0" + max_framework_version: "2.0.0" +``` + +### 3. 数据清理策略 + +```python +@pytest.fixture(autouse=True) +def data_cleanup(): + """自动数据清理""" + created_resources = [] + + yield created_resources + + # 测试结束后清理创建的数据 + for resource in created_resources: + try: + if resource["type"] == "user": + client.delete(f"/users/{resource['id']}") + elif resource["type"] == "order": + client.delete(f"/orders/{resource['id']}") + except Exception as e: + logger.warning(f"清理资源失败: {e}") +``` + +## 🎯 总结 + +数据驱动测试让你的测试变得: +- 🎯 **高效** - 一套逻辑测试多种场景 +- 🔄 **可维护** - 数据和逻辑分离 +- 🎲 **灵活** - 支持多种数据源和生成方式 +- 🎪 **真实** - 使用真实或接近真实的数据 +- 🧹 **干净** - 自动化数据清理 + +记住:**好的测试数据是测试成功的一半!** + +现在就开始用数据驱动测试,让你的测试覆盖更多场景,发现更多问题! + +--- + +**小贴士**: 数据文件记得加到版本控制里,但敏感数据要小心处理哦! diff --git a/docs/user-guide/jmespath-guide.md b/docs/user-guide/jmespath-guide.md new file mode 100644 index 0000000..dd81a24 --- /dev/null +++ b/docs/user-guide/jmespath-guide.md @@ -0,0 +1,455 @@ +# 🔍 JMESPath查询指南 + +JMESPath是本框架的核心技术栈,提供强大而简洁的JSON数据查询能力。本指南将详细介绍JMESPath的使用方法和最佳实践。 + +## 🎯 为什么选择JMESPath + +### 优势对比 + +| 特性 | JMESPath | JSONPath | 原生Python | +|------|----------|----------|-------------| +| **语法简洁** | ✅ 非常简洁 | ⚠️ 较复杂 | ❌ 冗长 | +| **性能** | ✅ 编译型,高性能 | ⚠️ 解释型 | ✅ 原生性能 | +| **功能强大** | ✅ 支持复杂查询 | ⚠️ 功能有限 | ✅ 功能完整 | +| **可读性** | ✅ 声明式,易读 | ⚠️ 一般 | ❌ 命令式 | +| **生态支持** | ✅ AWS等大厂使用 | ⚠️ 社区支持 | ✅ Python原生 | + +### 实际对比示例 + +```python +# 查询活跃用户的姓名 +data = { + "users": [ + {"name": "张三", "status": "active"}, + {"name": "李四", "status": "inactive"}, + {"name": "王五", "status": "active"} + ] +} + +# JMESPath方式 - 简洁明了 +active_names = jmespath.search("users[?status == 'active'].name", data) + +# JSONPath方式 - 语法复杂 +active_names = jsonpath.jsonpath(data, "$.users[?(@.status=='active')].name") + +# 原生Python方式 - 代码冗长 +active_names = [user["name"] for user in data["users"] if user["status"] == "active"] +``` + +## 📚 JMESPath基础语法 + +### 1. 基础访问 + +```python +data = { + "name": "张三", + "age": 25, + "address": { + "city": "北京", + "district": "朝阳区" + } +} + +# 基础字段访问 +name = jmespath.search("name", data) # "张三" +age = jmespath.search("age", data) # 25 + +# 嵌套字段访问 +city = jmespath.search("address.city", data) # "北京" +``` + +### 2. 数组操作 + +```python +data = { + "users": [ + {"id": 1, "name": "张三", "age": 25}, + {"id": 2, "name": "李四", "age": 30}, + {"id": 3, "name": "王五", "age": 28} + ] +} + +# 数组索引 +first_user = jmespath.search("users[0]", data) +last_user = jmespath.search("users[-1]", data) + +# 数组切片 +first_two = jmespath.search("users[:2]", data) +last_two = jmespath.search("users[-2:]", data) + +# 提取字段 +names = jmespath.search("users[].name", data) # ["张三", "李四", "王五"] +ages = jmespath.search("users[].age", data) # [25, 30, 28] +``` + +### 3. 条件过滤 + +```python +data = { + "users": [ + {"name": "张三", "age": 25, "department": "技术部", "active": True}, + {"name": "李四", "age": 30, "department": "产品部", "active": True}, + {"name": "王五", "age": 28, "department": "技术部", "active": False} + ] +} + +# 简单条件 +active_users = jmespath.search("users[?active]", data) +tech_users = jmespath.search("users[?department == '技术部']", data) + +# 数值比较 +young_users = jmespath.search("users[?age < `30`]", data) +senior_users = jmespath.search("users[?age >= `30`]", data) + +# 复合条件 +active_tech = jmespath.search("users[?active && department == '技术部']", data) +young_or_tech = jmespath.search("users[?age < `30` || department == '技术部']", data) +``` + +### 4. 投影和转换 + +```python +# 对象投影 +user_info = jmespath.search("users[].{name: name, age: age}", data) +# 结果: [{"name": "张三", "age": 25}, {"name": "李四", "age": 30}, ...] + +# 条件投影 +active_info = jmespath.search("users[?active].{姓名: name, 年龄: age}", data) + +# 管道操作 +sorted_names = jmespath.search("users[].name | sort(@)", data) +# 结果: ["李四", "王五", "张三"] +``` + +## 🛠️ 框架中的JMESPath使用 + +### 1. 基础断言 + +```python +from src.utils.assertion import assert_response + +response_data = { + "code": 200, + "message": "success", + "data": { + "user": {"id": 123, "name": "张三"} + } +} + +# 基础JMESPath断言 +(assert_response(response_data) + .assert_jmespath("code", 200) + .assert_jmespath("message", "success") + .assert_jmespath("data.user.name", "张三")) +``` + +### 2. 高级断言 + +```python +# 存在性断言 +assert_response(response_data).assert_jmespath_exists("data.user") + +# 类型断言 +assert_response(response_data).assert_jmespath_type("data.user.id", int) + +# 长度断言 +assert_response(response_data).assert_jmespath_length("data.items", 5) + +# 包含断言 +assert_response(response_data).assert_jmespath_contains("data.tags", "重要") +``` + +### 3. JMESPath辅助器 + +```python +from src.utils.jmespath_helper import jmes + +helper = jmes(response_data) + +# 安全获取值(支持默认值) +user_name = helper.get_value("data.user.name", "未知用户") + +# 获取列表(确保返回列表类型) +items = helper.get_list("data.items") + +# 检查路径是否存在 +has_user = helper.exists("data.user") + +# 计算数量 +item_count = helper.count("data.items") +``` + +## 🎨 高级查询技巧 + +### 1. 复杂条件查询 + +```python +data = { + "products": [ + {"name": "iPhone", "price": 999, "category": "手机", "stock": 50, "rating": 4.5}, + {"name": "iPad", "price": 599, "category": "平板", "stock": 30, "rating": 4.3}, + {"name": "MacBook", "price": 1299, "category": "电脑", "stock": 20, "rating": 4.7}, + {"name": "AirPods", "price": 179, "category": "耳机", "stock": 100, "rating": 4.2} + ] +} + +helper = jmes(data) + +# 价格在500-1000之间的产品 +mid_price = helper.filter_by("products", "price >= `500` && price <= `1000`") + +# 高评分且有库存的产品 +good_products = helper.filter_by("products", "rating > `4.0` && stock > `0`") + +# 手机或电脑类别的产品 +tech_products = helper.filter_by("products", "category == '手机' || category == '电脑'") +``` + +### 2. 数据聚合和统计 + +```python +# 计算总库存 +total_stock = jmespath.search("sum(products[].stock)", data) + +# 平均价格 +avg_price = jmespath.search("avg(products[].price)", data) + +# 最高评分 +max_rating = jmespath.search("max(products[].rating)", data) + +# 最低价格的产品 +cheapest = jmespath.search("products[?price == min(products[].price)] | [0]", data) +``` + +### 3. 排序和分组 + +```python +# 按价格排序 +sorted_by_price = helper.sort_by("products", "price") +sorted_by_price_desc = helper.sort_by("products", "price", reverse=True) + +# 按评分排序 +sorted_by_rating = helper.sort_by("products", "rating", reverse=True) + +# 按类别分组 +grouped = helper.group_by("products", "category") +# 结果: {"手机": [...], "平板": [...], "电脑": [...], "耳机": [...]} +``` + +### 4. 字段提取和重构 + +```python +# 提取特定字段 +product_summary = helper.extract_fields("products", ["name", "price", "rating"]) + +# 自定义字段映射 +custom_format = jmespath.search(""" +products[].{ + 产品名称: name, + 价格: price, + 评分: rating, + 性价比: rating / (price / `100`) +} +""", data) +``` + +## 🔧 常用查询模式 + +### 1. API响应模式 + +```python +from src.utils.jmespath_helper import CommonJMESPatterns + +# 标准API响应结构 +api_response = { + "code": 200, + "message": "success", + "data": {...} +} + +helper = jmes(api_response) + +# 使用预定义模式 +code = helper.get_value(CommonJMESPatterns.API_CODE) +message = helper.get_value(CommonJMESPatterns.API_MESSAGE) +data = helper.get_value(CommonJMESPatterns.API_DATA) +``` + +### 2. 分页数据模式 + +```python +# 分页响应结构 +page_response = { + "code": 200, + "data": { + "items": [...], + "total": 100, + "page": 1, + "size": 10 + } +} + +helper = jmes(page_response) + +# 分页信息查询 +items = helper.get_list(CommonJMESPatterns.PAGE_ITEMS) +total = helper.get_value(CommonJMESPatterns.PAGE_TOTAL) +current_page = helper.get_value(CommonJMESPatterns.PAGE_CURRENT) +``` + +### 3. 用户数据模式 + +```python +# 用户响应结构 +user_response = { + "code": 200, + "data": { + "user": { + "id": 123, + "name": "张三", + "email": "zhangsan@example.com", + "profile": {...} + } + } +} + +helper = jmes(user_response) + +# 用户信息查询 +user_id = helper.get_value(CommonJMESPatterns.USER_ID) +user_name = helper.get_value(CommonJMESPatterns.USER_NAME) +user_email = helper.get_value(CommonJMESPatterns.USER_EMAIL) +``` + +## 🎯 性能优化 + +### 1. 表达式编译 + +```python +# 频繁使用的查询应该预编译 +compiled_expr = jmespath.compile("data.users[?active].name") + +# 重复使用编译后的表达式 +for response in responses: + active_names = compiled_expr.search(response) +``` + +### 2. 查询优化 + +```python +# 优化前:多次查询 +users = jmespath.search("data.users", response) +active_users = [u for u in users if u.get("active")] +names = [u["name"] for u in active_users] + +# 优化后:单次查询 +names = jmespath.search("data.users[?active].name", response) +``` + +### 3. 缓存策略 + +```python +class CachedJMESHelper: + def __init__(self, data): + self.data = data + self._cache = {} + + def search(self, path): + if path not in self._cache: + self._cache[path] = jmespath.search(path, self.data) + return self._cache[path] +``` + +## 🐛 常见问题和解决方案 + +### 1. 路径不存在 + +```python +# 问题:路径不存在时返回None +result = jmespath.search("data.nonexistent", response) # None + +# 解决:使用默认值 +result = helper.get_value("data.nonexistent", "默认值") +``` + +### 2. 类型错误 + +```python +# 问题:期望列表但得到单个值 +items = jmespath.search("data.item", response) # 可能是单个对象 + +# 解决:确保返回列表 +items = helper.get_list("data.item") # 总是返回列表 +``` + +### 3. 复杂条件 + +```python +# 问题:复杂条件难以表达 +# 查找年龄在25-35之间且技能包含Python的技术部员工 + +# 解决:分步查询或使用辅助方法 +tech_users = helper.filter_by("users", "department == '技术部'") +python_users = [u for u in tech_users + if 25 <= u.get("age", 0) <= 35 + and "Python" in u.get("skills", [])] +``` + +## 📝 最佳实践 + +### 1. 查询复用 + +```python +# 定义常用查询 +class APIQueries: + SUCCESS_CODE = "code" + ERROR_MESSAGE = "error.message" + USER_LIST = "data.users" + ACTIVE_USERS = "data.users[?status == 'active']" + + @staticmethod + def user_by_id(user_id): + return f"data.users[?id == `{user_id}`] | [0]" +``` + +### 2. 错误处理 + +```python +def safe_jmespath_search(data, path, default=None): + """安全的JMESPath查询""" + try: + result = jmespath.search(path, data) + return result if result is not None else default + except Exception as e: + logger.warning(f"JMESPath查询失败: {path}, 错误: {e}") + return default +``` + +### 3. 测试验证 + +```python +def test_jmespath_queries(): + """测试JMESPath查询的正确性""" + test_data = { + "users": [ + {"id": 1, "name": "张三", "active": True}, + {"id": 2, "name": "李四", "active": False} + ] + } + + # 验证查询结果 + active_users = jmespath.search("users[?active]", test_data) + assert len(active_users) == 1 + assert active_users[0]["name"] == "张三" +``` + +## 🔗 参考资源 + +- [JMESPath官方文档](https://jmespath.org/) +- [JMESPath教程](https://jmespath.org/tutorial.html) +- [JMESPath在线测试](https://jmespath.org/) +- [AWS CLI中的JMESPath](https://docs.aws.amazon.com/cli/latest/userguide/cli-usage-output-format.html#cli-usage-output-format-json) + +--- + +**下一步**: [数据驱动测试](./data-driven.md) | [Mock服务器](./mock-server.md) diff --git a/docs/user-guide/mock-server.md b/docs/user-guide/mock-server.md new file mode 100644 index 0000000..3f505e8 --- /dev/null +++ b/docs/user-guide/mock-server.md @@ -0,0 +1,765 @@ +# 🎭 Mock服务器 - 你的专属演员 + +> "Mock服务器:当真实API还在开发中,我们已经在测试了!" + +还在等后端API开发完成?还在为第三方服务不稳定而烦恼?Mock服务器让你摆脱依赖,想测什么就测什么! + +## 🎯 为什么需要Mock服务器? + +### 真实场景 +你是不是遇到过这些尴尬: +- 😤 "后端API还没开发完,我的测试怎么写?" +- 🤦‍♂️ "第三方服务又挂了,测试全部失败..." +- 😱 "测试环境数据被污染了,怎么办?" +- 🙄 "想测试错误场景,但真实API不会出错" +- 😅 "网络太慢了,测试跑一次要半天" + +有了Mock服务器,这些问题统统解决! + +## 🚀 快速上手 - 5分钟搭建Mock服务 + +### 最简单的Mock + +```python +from src.utils.mock_server import MockServer, create_mock_response + +# 1. 创建Mock服务器 +mock_server = MockServer(host="localhost", port=8888) + +# 2. 添加Mock规则 +mock_server.add_rule( + "GET", "/api/hello", + create_mock_response(200, {"message": "Hello, Mock World!"}) +) + +# 3. 启动服务器 +mock_server.start() + +# 4. 测试Mock服务 +import requests +response = requests.get("http://localhost:8888/api/hello") +print(response.json()) # {"message": "Hello, Mock World!"} + +# 5. 停止服务器 +mock_server.stop() +``` + +### 在测试中使用Mock + +```python +import pytest +from src.utils.mock_server import MockServer, create_mock_response + +class TestWithMock: + """使用Mock服务器的测试""" + + @pytest.fixture(scope="class") + def mock_server(self): + """Mock服务器fixture""" + server = MockServer(host="localhost", port=9999) + + # 添加用户API的Mock + server.add_rule( + "GET", "/api/users/123", + create_mock_response(200, { + "id": 123, + "name": "张三", + "email": "zhangsan@example.com", + "department": "技术部" + }) + ) + + # 添加用户列表的Mock + server.add_rule( + "GET", "/api/users", + create_mock_response(200, { + "code": 200, + "data": { + "users": [ + {"id": 123, "name": "张三"}, + {"id": 124, "name": "李四"} + ], + "total": 2 + } + }) + ) + + server.start() + yield server + server.stop() + + def test_get_user(self, mock_server): + """测试获取用户""" + import requests + response = requests.get(f"{mock_server.base_url}/api/users/123") + + assert response.status_code == 200 + assert response.json()["name"] == "张三" + + def test_get_user_list(self, mock_server): + """测试获取用户列表""" + import requests + response = requests.get(f"{mock_server.base_url}/api/users") + + assert response.status_code == 200 + assert response.json()["data"]["total"] == 2 +``` + +## 🎨 Mock规则配置 - 想怎么Mock就怎么Mock + +### 基础Mock规则 + +```python +# 不同HTTP方法的Mock +mock_server = MockServer(port=8888) + +# GET请求Mock +mock_server.add_rule( + "GET", "/api/products", + create_mock_response(200, {"products": ["iPhone", "iPad"]}) +) + +# POST请求Mock +mock_server.add_rule( + "POST", "/api/products", + create_mock_response(201, {"id": 1001, "message": "产品创建成功"}) +) + +# PUT请求Mock +mock_server.add_rule( + "PUT", "/api/products/1001", + create_mock_response(200, {"message": "产品更新成功"}) +) + +# DELETE请求Mock +mock_server.add_rule( + "DELETE", "/api/products/1001", + create_mock_response(204, {}) # 无内容响应 +) +``` + +### 带参数的Mock规则 + +```python +# 查询参数Mock +mock_server.add_rule( + "GET", "/api/users", + create_mock_response(200, { + "users": [{"id": 1, "name": "张三"}], + "page": 1, + "size": 10 + }), + query_params={"page": "1", "size": "10"} # 匹配特定查询参数 +) + +# 不同参数返回不同结果 +mock_server.add_rule( + "GET", "/api/users", + create_mock_response(200, { + "users": [{"id": 2, "name": "李四"}], + "page": 2, + "size": 10 + }), + query_params={"page": "2", "size": "10"} +) + +# 请求体匹配Mock +mock_server.add_rule( + "POST", "/api/login", + create_mock_response(200, { + "token": "admin_token_123", + "user": {"id": 1, "name": "管理员", "role": "admin"} + }), + request_body={"username": "admin", "password": "admin123"} +) + +mock_server.add_rule( + "POST", "/api/login", + create_mock_response(401, { + "error": "用户名或密码错误" + }), + request_body={"username": "admin", "password": "wrong_password"} +) +``` + +### 请求头匹配Mock + +```python +# 需要认证的API Mock +mock_server.add_rule( + "GET", "/api/protected", + create_mock_response(200, {"data": "机密信息"}), + headers={"Authorization": "Bearer valid_token"} +) + +mock_server.add_rule( + "GET", "/api/protected", + create_mock_response(401, {"error": "未授权访问"}), + headers={} # 没有Authorization头 +) + +# API版本控制Mock +mock_server.add_rule( + "GET", "/api/users", + create_mock_response(200, {"version": "v1", "users": []}), + headers={"API-Version": "v1"} +) + +mock_server.add_rule( + "GET", "/api/users", + create_mock_response(200, {"version": "v2", "users": [], "meta": {}}), + headers={"API-Version": "v2"} +) +``` + +## 🎪 高级Mock功能 + +### 动态响应Mock + +```python +import time +import random + +def dynamic_user_response(request): + """动态生成用户响应""" + # 从URL中提取用户ID + user_id = int(request.path.split('/')[-1]) + + # 根据ID生成不同的用户数据 + users = { + 123: {"name": "张三", "department": "技术部"}, + 124: {"name": "李四", "department": "产品部"}, + 125: {"name": "王五", "department": "设计部"} + } + + user = users.get(user_id, {"name": f"用户{user_id}", "department": "未知"}) + + return { + "id": user_id, + "name": user["name"], + "department": user["department"], + "timestamp": time.time(), + "random_number": random.randint(1, 1000) + } + +# 使用动态响应 +mock_server.add_dynamic_rule( + "GET", "/api/users/{user_id}", + dynamic_user_response +) + +# 测试动态响应 +def test_dynamic_mock(): + response1 = requests.get("http://localhost:8888/api/users/123") + response2 = requests.get("http://localhost:8888/api/users/124") + + assert response1.json()["name"] == "张三" + assert response2.json()["name"] == "李四" + assert response1.json()["timestamp"] != response2.json()["timestamp"] +``` + +### 延迟和错误模拟 + +```python +# 模拟慢速API +mock_server.add_rule( + "GET", "/api/slow-endpoint", + create_mock_response(200, {"data": "慢速响应"}, delay=3.0) # 3秒延迟 +) + +# 模拟网络超时 +mock_server.add_rule( + "GET", "/api/timeout", + create_mock_response(200, {"data": "超时响应"}, delay=30.0) # 30秒延迟 +) + +# 模拟服务器错误 +mock_server.add_rule( + "GET", "/api/server-error", + create_mock_response(500, { + "error": "内部服务器错误", + "message": "数据库连接失败" + }) +) + +# 模拟随机错误 +def random_error_response(request): + """随机返回成功或错误""" + if random.random() < 0.3: # 30%概率出错 + return create_mock_response(500, {"error": "随机错误"}) + else: + return create_mock_response(200, {"data": "成功响应"}) + +mock_server.add_dynamic_rule("GET", "/api/flaky", random_error_response) +``` + +### 状态管理Mock + +```python +class StatefulMockServer: + """有状态的Mock服务器""" + + def __init__(self): + self.users = {} + self.next_id = 1 + self.call_count = {} + + def create_user(self, request): + """创建用户""" + user_data = request.json + user_id = self.next_id + self.next_id += 1 + + self.users[user_id] = { + "id": user_id, + "name": user_data["name"], + "email": user_data["email"], + "created_at": time.time() + } + + return create_mock_response(201, self.users[user_id]) + + def get_user(self, request): + """获取用户""" + user_id = int(request.path.split('/')[-1]) + + if user_id in self.users: + return create_mock_response(200, self.users[user_id]) + else: + return create_mock_response(404, {"error": "用户不存在"}) + + def get_users(self, request): + """获取用户列表""" + return create_mock_response(200, { + "users": list(self.users.values()), + "total": len(self.users) + }) + + def delete_user(self, request): + """删除用户""" + user_id = int(request.path.split('/')[-1]) + + if user_id in self.users: + del self.users[user_id] + return create_mock_response(204, {}) + else: + return create_mock_response(404, {"error": "用户不存在"}) + +# 使用有状态Mock +stateful_mock = StatefulMockServer() + +mock_server.add_dynamic_rule("POST", "/api/users", stateful_mock.create_user) +mock_server.add_dynamic_rule("GET", "/api/users/{user_id}", stateful_mock.get_user) +mock_server.add_dynamic_rule("GET", "/api/users", stateful_mock.get_users) +mock_server.add_dynamic_rule("DELETE", "/api/users/{user_id}", stateful_mock.delete_user) + +# 测试有状态Mock +def test_stateful_mock(): + # 创建用户 + create_response = requests.post("http://localhost:8888/api/users", json={ + "name": "张三", + "email": "zhangsan@example.com" + }) + assert create_response.status_code == 201 + user_id = create_response.json()["id"] + + # 获取用户 + get_response = requests.get(f"http://localhost:8888/api/users/{user_id}") + assert get_response.status_code == 200 + assert get_response.json()["name"] == "张三" + + # 删除用户 + delete_response = requests.delete(f"http://localhost:8888/api/users/{user_id}") + assert delete_response.status_code == 204 + + # 再次获取应该404 + get_response2 = requests.get(f"http://localhost:8888/api/users/{user_id}") + assert get_response2.status_code == 404 +``` + +## 🎯 Mock场景设计 + +### 正常业务流程Mock + +```python +def setup_normal_business_flow_mock(mock_server): + """设置正常业务流程Mock""" + + # 1. 用户登录 + mock_server.add_rule( + "POST", "/api/auth/login", + create_mock_response(200, { + "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...", + "refresh_token": "refresh_token_123", + "user": { + "id": 123, + "name": "张三", + "role": "user" + } + }), + request_body={"username": "zhangsan", "password": "password123"} + ) + + # 2. 获取用户信息 + mock_server.add_rule( + "GET", "/api/user/profile", + create_mock_response(200, { + "id": 123, + "name": "张三", + "email": "zhangsan@example.com", + "balance": 1000.00 + }), + headers={"Authorization": "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..."} + ) + + # 3. 获取商品列表 + mock_server.add_rule( + "GET", "/api/products", + create_mock_response(200, { + "products": [ + {"id": 1001, "name": "iPhone 15", "price": 999.99, "stock": 50}, + {"id": 1002, "name": "MacBook Pro", "price": 1999.99, "stock": 30} + ] + }) + ) + + # 4. 创建订单 + mock_server.add_rule( + "POST", "/api/orders", + create_mock_response(201, { + "order_id": "ORD-2024-001", + "user_id": 123, + "items": [{"product_id": 1001, "quantity": 1, "price": 999.99}], + "total_amount": 999.99, + "status": "pending" + }) + ) + + # 5. 支付订单 + mock_server.add_rule( + "POST", "/api/orders/ORD-2024-001/pay", + create_mock_response(200, { + "order_id": "ORD-2024-001", + "payment_id": "PAY-2024-001", + "status": "paid", + "paid_at": "2024-01-01T12:00:00Z" + }) + ) + +def test_complete_purchase_flow(mock_server): + """测试完整购买流程""" + setup_normal_business_flow_mock(mock_server) + + # 1. 登录 + login_response = requests.post(f"{mock_server.base_url}/api/auth/login", json={ + "username": "zhangsan", + "password": "password123" + }) + assert login_response.status_code == 200 + token = login_response.json()["access_token"] + + # 2. 获取用户信息 + headers = {"Authorization": f"Bearer {token}"} + profile_response = requests.get(f"{mock_server.base_url}/api/user/profile", headers=headers) + assert profile_response.status_code == 200 + assert profile_response.json()["balance"] >= 999.99 + + # 3. 浏览商品 + products_response = requests.get(f"{mock_server.base_url}/api/products") + assert products_response.status_code == 200 + products = products_response.json()["products"] + assert len(products) > 0 + + # 4. 创建订单 + order_response = requests.post(f"{mock_server.base_url}/api/orders", json={ + "items": [{"product_id": 1001, "quantity": 1}] + }) + assert order_response.status_code == 201 + order_id = order_response.json()["order_id"] + + # 5. 支付订单 + pay_response = requests.post(f"{mock_server.base_url}/api/orders/{order_id}/pay") + assert pay_response.status_code == 200 + assert pay_response.json()["status"] == "paid" +``` + +### 异常场景Mock + +```python +def setup_error_scenarios_mock(mock_server): + """设置异常场景Mock""" + + # 登录失败 + mock_server.add_rule( + "POST", "/api/auth/login", + create_mock_response(401, {"error": "用户名或密码错误"}), + request_body={"username": "zhangsan", "password": "wrong_password"} + ) + + # 余额不足 + mock_server.add_rule( + "GET", "/api/user/profile", + create_mock_response(200, { + "id": 124, + "name": "李四", + "email": "lisi@example.com", + "balance": 10.00 # 余额不足 + }), + headers={"Authorization": "Bearer poor_user_token"} + ) + + # 商品缺货 + mock_server.add_rule( + "GET", "/api/products/1001", + create_mock_response(200, { + "id": 1001, + "name": "iPhone 15", + "price": 999.99, + "stock": 0 # 缺货 + }) + ) + + # 订单创建失败 + mock_server.add_rule( + "POST", "/api/orders", + create_mock_response(400, { + "error": "商品库存不足", + "details": {"product_id": 1001, "requested": 1, "available": 0} + }) + ) + + # 支付失败 + mock_server.add_rule( + "POST", "/api/orders/ORD-2024-002/pay", + create_mock_response(402, { + "error": "余额不足", + "required": 999.99, + "available": 10.00 + }) + ) + +def test_error_scenarios(mock_server): + """测试异常场景""" + setup_error_scenarios_mock(mock_server) + + # 测试登录失败 + login_response = requests.post(f"{mock_server.base_url}/api/auth/login", json={ + "username": "zhangsan", + "password": "wrong_password" + }) + assert login_response.status_code == 401 + assert "错误" in login_response.json()["error"] + + # 测试余额不足 + profile_response = requests.get(f"{mock_server.base_url}/api/user/profile", + headers={"Authorization": "Bearer poor_user_token"}) + assert profile_response.status_code == 200 + assert profile_response.json()["balance"] < 999.99 + + # 测试商品缺货 + product_response = requests.get(f"{mock_server.base_url}/api/products/1001") + assert product_response.status_code == 200 + assert product_response.json()["stock"] == 0 +``` + +## 🔧 Mock服务器管理 + +### 配置文件驱动Mock + +```python +# mock_config.yaml +mock_rules: + - method: "GET" + path: "/api/users" + response: + status_code: 200 + body: + users: [] + total: 0 + + - method: "POST" + path: "/api/users" + request_body: + name: "张三" + response: + status_code: 201 + body: + id: 123 + name: "张三" + message: "用户创建成功" + +def load_mock_from_config(mock_server, config_file): + """从配置文件加载Mock规则""" + import yaml + + with open(config_file, 'r', encoding='utf-8') as f: + config = yaml.safe_load(f) + + for rule in config['mock_rules']: + mock_server.add_rule( + rule['method'], + rule['path'], + create_mock_response( + rule['response']['status_code'], + rule['response']['body'] + ), + request_body=rule.get('request_body'), + query_params=rule.get('query_params'), + headers=rule.get('headers') + ) + +# 使用配置文件 +mock_server = MockServer(port=8888) +load_mock_from_config(mock_server, "mock_config.yaml") +mock_server.start() +``` + +### Mock服务器监控 + +```python +class MockServerMonitor: + """Mock服务器监控器""" + + def __init__(self, mock_server): + self.mock_server = mock_server + self.request_log = [] + self.call_stats = {} + + def log_request(self, request): + """记录请求""" + self.request_log.append({ + "method": request.method, + "path": request.path, + "timestamp": time.time(), + "headers": dict(request.headers), + "body": request.body + }) + + # 统计调用次数 + key = f"{request.method} {request.path}" + self.call_stats[key] = self.call_stats.get(key, 0) + 1 + + def get_call_count(self, method, path): + """获取调用次数""" + key = f"{method} {path}" + return self.call_stats.get(key, 0) + + def get_request_history(self): + """获取请求历史""" + return self.request_log + + def reset_stats(self): + """重置统计""" + self.request_log.clear() + self.call_stats.clear() + +# 使用监控器 +monitor = MockServerMonitor(mock_server) + +def test_with_monitoring(mock_server): + """带监控的测试""" + # 发送一些请求 + requests.get(f"{mock_server.base_url}/api/users") + requests.get(f"{mock_server.base_url}/api/users") + requests.post(f"{mock_server.base_url}/api/users", json={"name": "张三"}) + + # 检查调用统计 + assert monitor.get_call_count("GET", "/api/users") == 2 + assert monitor.get_call_count("POST", "/api/users") == 1 + + # 检查请求历史 + history = monitor.get_request_history() + assert len(history) == 3 +``` + +## 💡 Mock最佳实践 + +### 1. Mock数据真实性 + +```python +# ✅ 好的Mock数据 - 接近真实 +good_mock_data = { + "id": 123, + "name": "张三", + "email": "zhangsan@example.com", + "phone": "13800138000", + "created_at": "2024-01-01T12:00:00Z", + "updated_at": "2024-01-01T12:00:00Z" +} + +# ❌ 不好的Mock数据 - 过于简单 +bad_mock_data = { + "id": 1, + "name": "test", + "email": "test@test.com" +} +``` + +### 2. Mock规则组织 + +```python +class MockRuleManager: + """Mock规则管理器""" + + def __init__(self, mock_server): + self.mock_server = mock_server + + def setup_user_rules(self): + """设置用户相关规则""" + # 用户相关的所有Mock规则 + pass + + def setup_order_rules(self): + """设置订单相关规则""" + # 订单相关的所有Mock规则 + pass + + def setup_payment_rules(self): + """设置支付相关规则""" + # 支付相关的所有Mock规则 + pass + + def setup_all_rules(self): + """设置所有规则""" + self.setup_user_rules() + self.setup_order_rules() + self.setup_payment_rules() +``` + +### 3. Mock环境隔离 + +```python +@pytest.fixture(scope="function") +def isolated_mock_server(): + """每个测试使用独立的Mock服务器""" + import socket + + # 获取可用端口 + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(('', 0)) + port = s.getsockname()[1] + + server = MockServer(port=port) + server.start() + + yield server + + server.stop() +``` + +## 🎯 总结 + +Mock服务器让你的测试变得: +- 🎭 **独立** - 不依赖外部服务 +- 🚀 **快速** - 本地响应,速度飞快 +- 🎪 **灵活** - 想测什么场景都可以 +- 🛡️ **稳定** - 不会因为外部服务问题而失败 +- 🎯 **精确** - 可以模拟各种边界情况 + +记住:**好的Mock不是为了偷懒,而是为了更好地测试!** + +现在就开始使用Mock服务器,让你的测试摆脱外部依赖,想测什么就测什么! + +--- + +**小贴士**: Mock数据要尽量接近真实数据,这样测试才更有意义! diff --git a/docs/user-guide/performance.md b/docs/user-guide/performance.md new file mode 100644 index 0000000..df79e2d --- /dev/null +++ b/docs/user-guide/performance.md @@ -0,0 +1,452 @@ +# ⚡ 性能测试 - 让API跑得飞快 + +> "性能测试:发现瓶颈,优化体验,让用户爱上你的API!" + +还在担心API在高并发下会崩溃?还在猜测系统能承受多少用户?性能测试让你心中有数,让API在任何情况下都稳如泰山! + +## 🎯 为什么需要性能测试? + +### 真实场景 +你是不是遇到过这些噩梦: +- 😱 "双11活动一开始,服务器就挂了..." +- 🤦‍♂️ "平时好好的,用户一多就卡死" +- 😤 "老板问能支持多少用户,我只能说'应该可以吧'" +- 🙄 "新功能上线后,整个系统都变慢了" +- 😅 "用户投诉响应太慢,但我不知道慢在哪里" + +性能测试帮你提前发现问题,避免线上翻车! + +## 🚀 快速开始 - 第一个性能测试 + +### 最简单的负载测试 + +```python +from src.utils.performance import load_test +import requests + +def simple_api_request(): + """简单的API请求函数""" + return requests.get("https://httpbin.org/get") + +# 执行负载测试 +metrics = load_test( + request_func=simple_api_request, + concurrent_users=10, # 10个并发用户 + total_requests=100 # 总共100个请求 +) + +# 查看结果 +print(f"🎯 性能测试结果:") +print(f" 平均响应时间: {metrics.avg_response_time:.3f}s") +print(f" 95%响应时间: {metrics.p95_response_time:.3f}s") +print(f" QPS: {metrics.requests_per_second:.2f}") +print(f" 成功率: {(1-metrics.error_rate)*100:.1f}%") + +# 验证性能指标 +assert metrics.avg_response_time < 2.0 # 平均响应时间小于2秒 +assert metrics.error_rate < 0.05 # 错误率小于5% +assert metrics.requests_per_second > 5 # QPS大于5 +``` + +### 在测试中使用性能测试 + +```python +import pytest +from src.utils.performance import load_test, stress_test + +class TestAPIPerformance: + """API性能测试类""" + + def setup_method(self): + """测试前置设置""" + from src.client.base_client import BaseClient + self.client = BaseClient("https://httpbin.org") + + @pytest.mark.performance + def test_get_users_performance(self): + """用户列表性能测试""" + + def get_users_request(): + return self.client.get("/get?users=list") + + # 负载测试 + metrics = load_test( + get_users_request, + concurrent_users=5, + total_requests=50 + ) + + # 性能断言 + assert metrics.avg_response_time < 1.0 # 平均响应时间 < 1秒 + assert metrics.p95_response_time < 2.0 # 95%响应时间 < 2秒 + assert metrics.error_rate == 0 # 无错误 + assert metrics.requests_per_second > 10 # QPS > 10 + + print(f"✅ 用户列表性能测试通过!") + print(f" 并发用户: {5}") + print(f" 平均响应时间: {metrics.avg_response_time:.3f}s") + print(f" QPS: {metrics.requests_per_second:.2f}") + + @pytest.mark.performance + def test_create_user_performance(self): + """用户创建性能测试""" + + def create_user_request(): + user_data = { + "name": f"性能测试用户_{time.time()}", + "email": f"perf_{int(time.time()*1000)}@example.com" + } + return self.client.post("/post", json=user_data) + + # 负载测试 + metrics = load_test( + create_user_request, + concurrent_users=3, + total_requests=30 + ) + + # 写操作性能要求相对宽松 + assert metrics.avg_response_time < 3.0 # 平均响应时间 < 3秒 + assert metrics.error_rate < 0.1 # 错误率 < 10% + assert metrics.requests_per_second > 2 # QPS > 2 + + print(f"✅ 用户创建性能测试通过!") +``` + +## 📊 性能测试类型 + +### 1. 负载测试 - 正常压力下的表现 + +```python +def test_normal_load(): + """正常负载测试""" + + def api_request(): + return requests.get("https://api.example.com/users") + + # 模拟正常业务负载 + metrics = load_test( + api_request, + concurrent_users=20, # 20个并发用户 + total_requests=200 # 总共200个请求 + ) + + # 正常负载下的性能要求 + assert metrics.avg_response_time < 1.0 + assert metrics.p95_response_time < 2.0 + assert metrics.error_rate < 0.01 # 错误率小于1% + + print(f"📊 正常负载测试结果:") + print(f" 平均响应时间: {metrics.avg_response_time:.3f}s") + print(f" 最大响应时间: {metrics.max_response_time:.3f}s") + print(f" QPS: {metrics.requests_per_second:.2f}") +``` + +### 2. 压力测试 - 找到系统极限 + +```python +def test_stress_load(): + """压力测试""" + + def api_request(): + return requests.get("https://api.example.com/users") + + # 持续压力测试 + metrics = stress_test( + api_request, + duration_seconds=60, # 持续60秒 + concurrent_users=50 # 50个并发用户 + ) + + # 压力测试下的基本要求 + assert metrics.error_rate < 0.05 # 错误率小于5% + assert metrics.avg_response_time < 5.0 # 平均响应时间小于5秒 + + print(f"🔥 压力测试结果:") + print(f" 总请求数: {metrics.total_requests}") + print(f" 成功请求数: {metrics.successful_requests}") + print(f" 平均QPS: {metrics.requests_per_second:.2f}") + print(f" 错误率: {metrics.error_rate:.2%}") +``` + +### 3. 峰值测试 - 突发流量处理 + +```python +def test_spike_load(): + """峰值测试""" + import threading + import time + + def api_request(): + return requests.get("https://api.example.com/users") + + # 模拟突发流量 + def spike_test(): + # 先进行正常负载 + normal_metrics = load_test(api_request, concurrent_users=10, total_requests=50) + + # 突然增加到高并发 + spike_metrics = load_test(api_request, concurrent_users=100, total_requests=200) + + # 再回到正常负载 + recovery_metrics = load_test(api_request, concurrent_users=10, total_requests=50) + + return normal_metrics, spike_metrics, recovery_metrics + + normal, spike, recovery = spike_test() + + # 验证系统能够处理突发流量 + assert spike.error_rate < 0.1 # 突发流量下错误率小于10% + assert recovery.avg_response_time <= normal.avg_response_time * 1.2 # 恢复后性能接近正常 + + print(f"⚡ 峰值测试结果:") + print(f" 正常负载QPS: {normal.requests_per_second:.2f}") + print(f" 峰值负载QPS: {spike.requests_per_second:.2f}") + print(f" 恢复后QPS: {recovery.requests_per_second:.2f}") +``` + +## 🎯 性能指标详解 + +### 核心性能指标 + +```python +from src.utils.performance import PerformanceMetrics + +def analyze_performance_metrics(metrics: PerformanceMetrics): + """分析性能指标""" + + print(f"📈 详细性能分析:") + print(f" 总请求数: {metrics.total_requests}") + print(f" 成功请求数: {metrics.successful_requests}") + print(f" 失败请求数: {metrics.failed_requests}") + print(f" 总耗时: {metrics.total_time:.2f}s") + print() + + print(f"⏱️ 响应时间分析:") + print(f" 最小响应时间: {metrics.min_response_time:.3f}s") + print(f" 最大响应时间: {metrics.max_response_time:.3f}s") + print(f" 平均响应时间: {metrics.avg_response_time:.3f}s") + print(f" 中位数响应时间: {metrics.median_response_time:.3f}s") + print(f" 95%响应时间: {metrics.p95_response_time:.3f}s") + print(f" 99%响应时间: {metrics.p99_response_time:.3f}s") + print() + + print(f"🚀 吞吐量分析:") + print(f" QPS (每秒请求数): {metrics.requests_per_second:.2f}") + print(f" 错误率: {metrics.error_rate:.2%}") + print() + + # 性能等级评估 + if metrics.avg_response_time < 0.1: + performance_level = "🚀 极快" + elif metrics.avg_response_time < 0.5: + performance_level = "⚡ 很快" + elif metrics.avg_response_time < 1.0: + performance_level = "✅ 良好" + elif metrics.avg_response_time < 2.0: + performance_level = "⚠️ 一般" + else: + performance_level = "🐌 较慢" + + print(f"🎯 性能等级: {performance_level}") + + # 给出优化建议 + if metrics.error_rate > 0.05: + print("💡 建议: 错误率较高,需要检查服务稳定性") + + if metrics.p95_response_time > metrics.avg_response_time * 3: + print("💡 建议: 响应时间波动较大,可能存在性能瓶颈") + + if metrics.requests_per_second < 10: + print("💡 建议: QPS较低,考虑优化服务器性能或增加缓存") + +# 使用示例 +def test_with_detailed_analysis(): + def api_request(): + return requests.get("https://httpbin.org/delay/0.1") # 模拟100ms延迟 + + metrics = load_test(api_request, concurrent_users=10, total_requests=100) + analyze_performance_metrics(metrics) + + +## 🎨 高级性能测试场景 + +### 数据库性能测试 + +```python +def test_database_performance(): + """数据库操作性能测试""" + + def create_user_with_db(): + """创建用户(涉及数据库操作)""" + user_data = { + "name": f"用户_{int(time.time()*1000)}", + "email": f"user_{int(time.time()*1000)}@example.com", + "department": "技术部" + } + return requests.post("https://api.example.com/users", json=user_data) + + # 数据库写操作性能测试 + write_metrics = load_test( + create_user_with_db, + concurrent_users=5, # 数据库写操作并发不宜过高 + total_requests=50 + ) + + # 数据库写操作性能要求 + assert write_metrics.avg_response_time < 2.0 + assert write_metrics.error_rate < 0.02 + + def query_users(): + """查询用户(数据库读操作)""" + return requests.get("https://api.example.com/users?page=1&size=20") + + # 数据库读操作性能测试 + read_metrics = load_test( + query_users, + concurrent_users=20, # 读操作可以有更高并发 + total_requests=200 + ) + + # 数据库读操作性能要求 + assert read_metrics.avg_response_time < 0.5 + assert read_metrics.error_rate < 0.01 + + print(f"💾 数据库性能测试结果:") + print(f" 写操作平均响应时间: {write_metrics.avg_response_time:.3f}s") + print(f" 读操作平均响应时间: {read_metrics.avg_response_time:.3f}s") + print(f" 写操作QPS: {write_metrics.requests_per_second:.2f}") + print(f" 读操作QPS: {read_metrics.requests_per_second:.2f}") + + +### 缓存性能测试 + +```python +def test_cache_performance(): + """缓存性能测试""" + + # 第一次请求(缓存未命中) + def first_request(): + return requests.get("https://api.example.com/users/123") + + first_metrics = load_test(first_request, concurrent_users=1, total_requests=1) + + # 后续请求(缓存命中) + def cached_request(): + return requests.get("https://api.example.com/users/123") + + cached_metrics = load_test(cached_request, concurrent_users=10, total_requests=100) + + # 缓存应该显著提升性能 + cache_improvement = first_metrics.avg_response_time / cached_metrics.avg_response_time + + assert cache_improvement > 2 # 缓存至少提升2倍性能 + assert cached_metrics.avg_response_time < 0.1 # 缓存响应时间小于100ms + + print(f"🚀 缓存性能测试结果:") + print(f" 首次请求响应时间: {first_metrics.avg_response_time:.3f}s") + print(f" 缓存请求响应时间: {cached_metrics.avg_response_time:.3f}s") + print(f" 性能提升倍数: {cache_improvement:.1f}x") + + +## 💡 性能测试最佳实践 + +### 1. 测试环境准备 + +```python +class PerformanceTestSetup: + """性能测试环境准备""" + + @staticmethod + def prepare_test_data(): + """准备测试数据""" + # 创建足够的测试数据 + test_users = [] + for i in range(1000): + test_users.append({ + "id": i + 1, + "name": f"测试用户{i+1}", + "email": f"user{i+1}@example.com" + }) + return test_users + + @staticmethod + def cleanup_test_data(): + """清理测试数据""" + # 测试后清理数据,避免影响后续测试 + pass + + @staticmethod + def warm_up_system(): + """系统预热""" + # 发送一些预热请求,让系统进入稳定状态 + for _ in range(10): + requests.get("https://api.example.com/health") + time.sleep(0.1) + + +### 2. 性能基准管理 + +```python +class PerformanceBenchmark: + """性能基准管理""" + + def __init__(self): + self.benchmarks = { + "user_list": {"avg_response_time": 0.5, "qps": 100}, + "user_create": {"avg_response_time": 1.0, "qps": 50}, + "user_update": {"avg_response_time": 0.8, "qps": 60}, + "user_delete": {"avg_response_time": 0.3, "qps": 80} + } + + def check_performance(self, test_name, metrics): + """检查性能是否达标""" + if test_name not in self.benchmarks: + print(f"⚠️ 未找到 {test_name} 的性能基准") + return True + + benchmark = self.benchmarks[test_name] + + # 检查响应时间 + if metrics.avg_response_time > benchmark["avg_response_time"]: + print(f"❌ {test_name} 响应时间超标: {metrics.avg_response_time:.3f}s > {benchmark['avg_response_time']}s") + return False + + # 检查QPS + if metrics.requests_per_second < benchmark["qps"]: + print(f"❌ {test_name} QPS不达标: {metrics.requests_per_second:.2f} < {benchmark['qps']}") + return False + + print(f"✅ {test_name} 性能达标") + return True + +# 使用性能基准 +benchmark = PerformanceBenchmark() + +def test_with_benchmark(): + def user_list_request(): + return requests.get("https://api.example.com/users") + + metrics = load_test(user_list_request, concurrent_users=10, total_requests=100) + + # 检查是否达到性能基准 + assert benchmark.check_performance("user_list", metrics) + + +## 🎯 总结 + +性能测试让你的API变得: +- ⚡ **快速** - 发现性能瓶颈,优化响应时间 +- 🛡️ **稳定** - 验证高并发下的系统稳定性 +- 📊 **可预测** - 了解系统容量和极限 +- 🎯 **可靠** - 确保用户体验始终良好 +- 🚀 **可扩展** - 为系统扩容提供数据支持 + +记住:**性能测试不是一次性的,而是持续的过程!** + +现在就开始性能测试,让你的API在任何情况下都能稳定快速地服务用户! + +--- + +**小贴士**: 性能测试要在接近生产环境的条件下进行,结果才有参考价值! +``` diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..b1aa4c0 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,87 @@ +site_name: Pytest Framework Documentation +site_description: A comprehensive Python testing framework based on pytest +site_author: pytest-framework team +repo_url: https://github.com/ljxpython/pytest_framework +repo_name: pytest_framework + +theme: + name: material + language: zh + features: + - navigation.tabs + - navigation.sections + - navigation.expand + - navigation.top + - search.highlight + - search.share + palette: + - media: "(prefers-color-scheme: light)" + scheme: default + primary: blue + accent: blue + toggle: + icon: material/weather-sunny + name: 切换到深色模式 + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: blue + accent: blue + toggle: + icon: material/weather-night + name: 切换到浅色模式 + +nav: + - 首页: README.md + - 快速开始: + - 安装指南: docs/quick-start/installation.md + - 快速入门: docs/quick-start/README.md + - 第一个测试: docs/quick-start/first-test.md + - 用户指南: + - 基础使用: docs/user-guide/basic-usage.md + - 配置管理: docs/user-guide/configuration.md + - 数据驱动测试: docs/user-guide/data-driven.md + - 断言工具: docs/user-guide/assertions.md + - JMESPath指南: docs/user-guide/jmespath-guide.md + - Mock服务器: docs/user-guide/mock-server.md + - 性能测试: docs/user-guide/performance.md + - 高级功能: docs/user-guide/advanced-features.md + - 架构设计: + - 总体架构: docs/architecture/overview.md + - 核心组件: docs/architecture/components.md + - 设计原则: docs/architecture/design-principles.md + - 扩展机制: docs/architecture/extension.md + - API参考: + - 核心API: docs/api-reference/core.md + - 最佳实践: + - 测试组织: docs/best-practices/test-organization.md + - FAQ: + - 常见问题: docs/faq/README.md + +markdown_extensions: + - admonition + - codehilite: + guess_lang: false + - toc: + permalink: true + - pymdownx.superfences + - pymdownx.tabbed: + alternate_style: true + - pymdownx.details + - pymdownx.highlight: + anchor_linenums: true + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.emoji + +plugins: + - search: + lang: + - zh + - en + +extra: + social: + - icon: fontawesome/brands/github + link: https://github.com/ljxpython/pytest_framework + +copyright: Copyright © 2024 pytest-framework team diff --git a/poetry.lock b/poetry.lock index af2397a..bbbcac9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -52,6 +52,38 @@ files = [ attrs = ">=16.0.0" pluggy = ">=0.4.0" +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] + +[[package]] +name = "anyio" +version = "4.10.0" +description = "High-level concurrency and networking framework on top of asyncio or Trio" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1"}, + {file = "anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6"}, +] + +[package.dependencies] +idna = ">=2.8" +sniffio = ">=1.1" +typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} + +[package.extras] +trio = ["trio (>=0.26.1)"] + [[package]] name = "assertpy" version = "1.1" @@ -83,6 +115,46 @@ docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphi tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\""] +[[package]] +name = "authlib" +version = "1.6.2" +description = "The ultimate Python library in building OAuth and OpenID Connect servers and clients." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "authlib-1.6.2-py2.py3-none-any.whl", hash = "sha256:2dd5571013cacf6b15f7addce03ed057ffdf629e9e81bacd9c08455a190e9b57"}, + {file = "authlib-1.6.2.tar.gz", hash = "sha256:3bde83ac0392683eeef589cd5ab97e63cbe859e552dd75dca010548e79202cb1"}, +] + +[package.dependencies] +cryptography = "*" + +[[package]] +name = "bandit" +version = "1.8.6" +description = "Security oriented static analyser for python code." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "bandit-1.8.6-py3-none-any.whl", hash = "sha256:3348e934d736fcdb68b6aa4030487097e23a501adf3e7827b63658df464dddd0"}, + {file = "bandit-1.8.6.tar.gz", hash = "sha256:dbfe9c25fc6961c2078593de55fd19f2559f9e45b99f1272341f5b95dea4e56b"}, +] + +[package.dependencies] +colorama = {version = ">=0.3.9", markers = "platform_system == \"Windows\""} +PyYAML = ">=5.3.1" +rich = "*" +stevedore = ">=1.20.0" + +[package.extras] +baseline = ["GitPython (>=3.1.30)"] +sarif = ["jschema-to-python (>=1.2.3)", "sarif-om (>=1.0.4)"] +test = ["beautifulsoup4 (>=4.8.0)", "coverage (>=4.5.4)", "fixtures (>=3.0.0)", "flake8 (>=4.0.0)", "pylint (==1.9.4)", "stestr (>=2.5.0)", "testscenarios (>=0.5.0)", "testtools (>=2.3.0)"] +toml = ["tomli (>=1.1.0) ; python_version < \"3.11\""] +yaml = ["PyYAML"] + [[package]] name = "black" version = "24.8.0" @@ -361,6 +433,107 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "coverage" +version = "7.10.5" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "coverage-7.10.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c6a5c3414bfc7451b879141ce772c546985163cf553f08e0f135f0699a911801"}, + {file = "coverage-7.10.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bc8e4d99ce82f1710cc3c125adc30fd1487d3cf6c2cd4994d78d68a47b16989a"}, + {file = "coverage-7.10.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:02252dc1216e512a9311f596b3169fad54abcb13827a8d76d5630c798a50a754"}, + {file = "coverage-7.10.5-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:73269df37883e02d460bee0cc16be90509faea1e3bd105d77360b512d5bb9c33"}, + {file = "coverage-7.10.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f8a81b0614642f91c9effd53eec284f965577591f51f547a1cbeb32035b4c2f"}, + {file = "coverage-7.10.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6a29f8e0adb7f8c2b95fa2d4566a1d6e6722e0a637634c6563cb1ab844427dd9"}, + {file = "coverage-7.10.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fcf6ab569436b4a647d4e91accba12509ad9f2554bc93d3aee23cc596e7f99c3"}, + {file = "coverage-7.10.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:90dc3d6fb222b194a5de60af8d190bedeeddcbc7add317e4a3cd333ee6b7c879"}, + {file = "coverage-7.10.5-cp310-cp310-win32.whl", hash = "sha256:414a568cd545f9dc75f0686a0049393de8098414b58ea071e03395505b73d7a8"}, + {file = "coverage-7.10.5-cp310-cp310-win_amd64.whl", hash = "sha256:e551f9d03347196271935fd3c0c165f0e8c049220280c1120de0084d65e9c7ff"}, + {file = "coverage-7.10.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c177e6ffe2ebc7c410785307758ee21258aa8e8092b44d09a2da767834f075f2"}, + {file = "coverage-7.10.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:14d6071c51ad0f703d6440827eaa46386169b5fdced42631d5a5ac419616046f"}, + {file = "coverage-7.10.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:61f78c7c3bc272a410c5ae3fde7792b4ffb4acc03d35a7df73ca8978826bb7ab"}, + {file = "coverage-7.10.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f39071caa126f69d63f99b324fb08c7b1da2ec28cbb1fe7b5b1799926492f65c"}, + {file = "coverage-7.10.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:343a023193f04d46edc46b2616cdbee68c94dd10208ecd3adc56fcc54ef2baa1"}, + {file = "coverage-7.10.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:585ffe93ae5894d1ebdee69fc0b0d4b7c75d8007983692fb300ac98eed146f78"}, + {file = "coverage-7.10.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b0ef4e66f006ed181df29b59921bd8fc7ed7cd6a9289295cd8b2824b49b570df"}, + {file = "coverage-7.10.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:eb7b0bbf7cc1d0453b843eca7b5fa017874735bef9bfdfa4121373d2cc885ed6"}, + {file = "coverage-7.10.5-cp311-cp311-win32.whl", hash = "sha256:1d043a8a06987cc0c98516e57c4d3fc2c1591364831e9deb59c9e1b4937e8caf"}, + {file = "coverage-7.10.5-cp311-cp311-win_amd64.whl", hash = "sha256:fefafcca09c3ac56372ef64a40f5fe17c5592fab906e0fdffd09543f3012ba50"}, + {file = "coverage-7.10.5-cp311-cp311-win_arm64.whl", hash = "sha256:7e78b767da8b5fc5b2faa69bb001edafcd6f3995b42a331c53ef9572c55ceb82"}, + {file = "coverage-7.10.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c2d05c7e73c60a4cecc7d9b60dbfd603b4ebc0adafaef371445b47d0f805c8a9"}, + {file = "coverage-7.10.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:32ddaa3b2c509778ed5373b177eb2bf5662405493baeff52278a0b4f9415188b"}, + {file = "coverage-7.10.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:dd382410039fe062097aa0292ab6335a3f1e7af7bba2ef8d27dcda484918f20c"}, + {file = "coverage-7.10.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7fa22800f3908df31cea6fb230f20ac49e343515d968cc3a42b30d5c3ebf9b5a"}, + {file = "coverage-7.10.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f366a57ac81f5e12797136552f5b7502fa053c861a009b91b80ed51f2ce651c6"}, + {file = "coverage-7.10.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5f1dc8f1980a272ad4a6c84cba7981792344dad33bf5869361576b7aef42733a"}, + {file = "coverage-7.10.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:2285c04ee8676f7938b02b4936d9b9b672064daab3187c20f73a55f3d70e6b4a"}, + {file = "coverage-7.10.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c2492e4dd9daab63f5f56286f8a04c51323d237631eb98505d87e4c4ff19ec34"}, + {file = "coverage-7.10.5-cp312-cp312-win32.whl", hash = "sha256:38a9109c4ee8135d5df5505384fc2f20287a47ccbe0b3f04c53c9a1989c2bbaf"}, + {file = "coverage-7.10.5-cp312-cp312-win_amd64.whl", hash = "sha256:6b87f1ad60b30bc3c43c66afa7db6b22a3109902e28c5094957626a0143a001f"}, + {file = "coverage-7.10.5-cp312-cp312-win_arm64.whl", hash = "sha256:672a6c1da5aea6c629819a0e1461e89d244f78d7b60c424ecf4f1f2556c041d8"}, + {file = "coverage-7.10.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ef3b83594d933020f54cf65ea1f4405d1f4e41a009c46df629dd964fcb6e907c"}, + {file = "coverage-7.10.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2b96bfdf7c0ea9faebce088a3ecb2382819da4fbc05c7b80040dbc428df6af44"}, + {file = "coverage-7.10.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:63df1fdaffa42d914d5c4d293e838937638bf75c794cf20bee12978fc8c4e3bc"}, + {file = "coverage-7.10.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8002dc6a049aac0e81ecec97abfb08c01ef0c1fbf962d0c98da3950ace89b869"}, + {file = "coverage-7.10.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:63d4bb2966d6f5f705a6b0c6784c8969c468dbc4bcf9d9ded8bff1c7e092451f"}, + {file = "coverage-7.10.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1f672efc0731a6846b157389b6e6d5d5e9e59d1d1a23a5c66a99fd58339914d5"}, + {file = "coverage-7.10.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3f39cef43d08049e8afc1fde4a5da8510fc6be843f8dea350ee46e2a26b2f54c"}, + {file = "coverage-7.10.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2968647e3ed5a6c019a419264386b013979ff1fb67dd11f5c9886c43d6a31fc2"}, + {file = "coverage-7.10.5-cp313-cp313-win32.whl", hash = "sha256:0d511dda38595b2b6934c2b730a1fd57a3635c6aa2a04cb74714cdfdd53846f4"}, + {file = "coverage-7.10.5-cp313-cp313-win_amd64.whl", hash = "sha256:9a86281794a393513cf117177fd39c796b3f8e3759bb2764259a2abba5cce54b"}, + {file = "coverage-7.10.5-cp313-cp313-win_arm64.whl", hash = "sha256:cebd8e906eb98bb09c10d1feed16096700b1198d482267f8bf0474e63a7b8d84"}, + {file = "coverage-7.10.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0520dff502da5e09d0d20781df74d8189ab334a1e40d5bafe2efaa4158e2d9e7"}, + {file = "coverage-7.10.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d9cd64aca68f503ed3f1f18c7c9174cbb797baba02ca8ab5112f9d1c0328cd4b"}, + {file = "coverage-7.10.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0913dd1613a33b13c4f84aa6e3f4198c1a21ee28ccb4f674985c1f22109f0aae"}, + {file = "coverage-7.10.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1b7181c0feeb06ed8a02da02792f42f829a7b29990fef52eff257fef0885d760"}, + {file = "coverage-7.10.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36d42b7396b605f774d4372dd9c49bed71cbabce4ae1ccd074d155709dd8f235"}, + {file = "coverage-7.10.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b4fdc777e05c4940b297bf47bf7eedd56a39a61dc23ba798e4b830d585486ca5"}, + {file = "coverage-7.10.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:42144e8e346de44a6f1dbd0a56575dd8ab8dfa7e9007da02ea5b1c30ab33a7db"}, + {file = "coverage-7.10.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:66c644cbd7aed8fe266d5917e2c9f65458a51cfe5eeff9c05f15b335f697066e"}, + {file = "coverage-7.10.5-cp313-cp313t-win32.whl", hash = "sha256:2d1b73023854068c44b0c554578a4e1ef1b050ed07cf8b431549e624a29a66ee"}, + {file = "coverage-7.10.5-cp313-cp313t-win_amd64.whl", hash = "sha256:54a1532c8a642d8cc0bd5a9a51f5a9dcc440294fd06e9dda55e743c5ec1a8f14"}, + {file = "coverage-7.10.5-cp313-cp313t-win_arm64.whl", hash = "sha256:74d5b63fe3f5f5d372253a4ef92492c11a4305f3550631beaa432fc9df16fcff"}, + {file = "coverage-7.10.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:68c5e0bc5f44f68053369fa0d94459c84548a77660a5f2561c5e5f1e3bed7031"}, + {file = "coverage-7.10.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cf33134ffae93865e32e1e37df043bef15a5e857d8caebc0099d225c579b0fa3"}, + {file = "coverage-7.10.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ad8fa9d5193bafcf668231294241302b5e683a0518bf1e33a9a0dfb142ec3031"}, + {file = "coverage-7.10.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:146fa1531973d38ab4b689bc764592fe6c2f913e7e80a39e7eeafd11f0ef6db2"}, + {file = "coverage-7.10.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6013a37b8a4854c478d3219ee8bc2392dea51602dd0803a12d6f6182a0061762"}, + {file = "coverage-7.10.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:eb90fe20db9c3d930fa2ad7a308207ab5b86bf6a76f54ab6a40be4012d88fcae"}, + {file = "coverage-7.10.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:384b34482272e960c438703cafe63316dfbea124ac62006a455c8410bf2a2262"}, + {file = "coverage-7.10.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:467dc74bd0a1a7de2bedf8deaf6811f43602cb532bd34d81ffd6038d6d8abe99"}, + {file = "coverage-7.10.5-cp314-cp314-win32.whl", hash = "sha256:556d23d4e6393ca898b2e63a5bca91e9ac2d5fb13299ec286cd69a09a7187fde"}, + {file = "coverage-7.10.5-cp314-cp314-win_amd64.whl", hash = "sha256:f4446a9547681533c8fa3e3c6cf62121eeee616e6a92bd9201c6edd91beffe13"}, + {file = "coverage-7.10.5-cp314-cp314-win_arm64.whl", hash = "sha256:5e78bd9cf65da4c303bf663de0d73bf69f81e878bf72a94e9af67137c69b9fe9"}, + {file = "coverage-7.10.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:5661bf987d91ec756a47c7e5df4fbcb949f39e32f9334ccd3f43233bbb65e508"}, + {file = "coverage-7.10.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a46473129244db42a720439a26984f8c6f834762fc4573616c1f37f13994b357"}, + {file = "coverage-7.10.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1f64b8d3415d60f24b058b58d859e9512624bdfa57a2d1f8aff93c1ec45c429b"}, + {file = "coverage-7.10.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:44d43de99a9d90b20e0163f9770542357f58860a26e24dc1d924643bd6aa7cb4"}, + {file = "coverage-7.10.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a931a87e5ddb6b6404e65443b742cb1c14959622777f2a4efd81fba84f5d91ba"}, + {file = "coverage-7.10.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f9559b906a100029274448f4c8b8b0a127daa4dade5661dfd821b8c188058842"}, + {file = "coverage-7.10.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b08801e25e3b4526ef9ced1aa29344131a8f5213c60c03c18fe4c6170ffa2874"}, + {file = "coverage-7.10.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ed9749bb8eda35f8b636fb7632f1c62f735a236a5d4edadd8bbcc5ea0542e732"}, + {file = "coverage-7.10.5-cp314-cp314t-win32.whl", hash = "sha256:609b60d123fc2cc63ccee6d17e4676699075db72d14ac3c107cc4976d516f2df"}, + {file = "coverage-7.10.5-cp314-cp314t-win_amd64.whl", hash = "sha256:0666cf3d2c1626b5a3463fd5b05f5e21f99e6aec40a3192eee4d07a15970b07f"}, + {file = "coverage-7.10.5-cp314-cp314t-win_arm64.whl", hash = "sha256:bc85eb2d35e760120540afddd3044a5bf69118a91a296a8b3940dfc4fdcfe1e2"}, + {file = "coverage-7.10.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:62835c1b00c4a4ace24c1a88561a5a59b612fbb83a525d1c70ff5720c97c0610"}, + {file = "coverage-7.10.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5255b3bbcc1d32a4069d6403820ac8e6dbcc1d68cb28a60a1ebf17e47028e898"}, + {file = "coverage-7.10.5-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3876385722e335d6e991c430302c24251ef9c2a9701b2b390f5473199b1b8ebf"}, + {file = "coverage-7.10.5-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8048ce4b149c93447a55d279078c8ae98b08a6951a3c4d2d7e87f4efc7bfe100"}, + {file = "coverage-7.10.5-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4028e7558e268dd8bcf4d9484aad393cafa654c24b4885f6f9474bf53183a82a"}, + {file = "coverage-7.10.5-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:03f47dc870eec0367fcdd603ca6a01517d2504e83dc18dbfafae37faec66129a"}, + {file = "coverage-7.10.5-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2d488d7d42b6ded7ea0704884f89dcabd2619505457de8fc9a6011c62106f6e5"}, + {file = "coverage-7.10.5-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b3dcf2ead47fa8be14224ee817dfc1df98043af568fe120a22f81c0eb3c34ad2"}, + {file = "coverage-7.10.5-cp39-cp39-win32.whl", hash = "sha256:02650a11324b80057b8c9c29487020073d5e98a498f1857f37e3f9b6ea1b2426"}, + {file = "coverage-7.10.5-cp39-cp39-win_amd64.whl", hash = "sha256:b45264dd450a10f9e03237b41a9a24e85cbb1e278e5a32adb1a303f58f0017f3"}, + {file = "coverage-7.10.5-py3-none-any.whl", hash = "sha256:0be24d35e4db1d23d0db5c0f6a74a962e2ec83c426b5cac09f4234aadef38e4a"}, + {file = "coverage-7.10.5.tar.gz", hash = "sha256:f2e57716a78bc3ae80b2207be0709a3b2b63b9f2dcf9740ee6ac03588a2015b6"}, +] + +[package.extras] +toml = ["tomli ; python_full_version <= \"3.11.0a6\""] + [[package]] name = "cryptography" version = "43.0.1" @@ -435,6 +608,27 @@ files = [ {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, ] +[[package]] +name = "dparse" +version = "0.6.4" +description = "A parser for Python dependency files" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "dparse-0.6.4-py3-none-any.whl", hash = "sha256:fbab4d50d54d0e739fbb4dedfc3d92771003a5b9aa8545ca7a7045e3b174af57"}, + {file = "dparse-0.6.4.tar.gz", hash = "sha256:90b29c39e3edc36c6284c82c4132648eaf28a01863eb3c231c2512196132201a"}, +] + +[package.dependencies] +packaging = "*" + +[package.extras] +all = ["pipenv", "poetry", "pyyaml"] +conda = ["pyyaml"] +pipenv = ["pipenv"] +poetry = ["poetry"] + [[package]] name = "dynaconf" version = "3.2.6" @@ -504,6 +698,82 @@ docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2. testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "diff-cover (>=9.2)", "pytest (>=8.3.3)", "pytest-asyncio (>=0.24)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.26.4)"] typing = ["typing-extensions (>=4.12.2) ; python_version < \"3.11\""] +[[package]] +name = "flake8" +version = "7.3.0" +description = "the modular source code checker: pep8 pyflakes and co" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e"}, + {file = "flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872"}, +] + +[package.dependencies] +mccabe = ">=0.7.0,<0.8.0" +pycodestyle = ">=2.14.0,<2.15.0" +pyflakes = ">=3.4.0,<3.5.0" + +[[package]] +name = "h11" +version = "0.16.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.16" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<1.0)"] + +[[package]] +name = "httpx" +version = "0.28.1" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" + +[package.extras] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] + [[package]] name = "humps" version = "0.2.2" @@ -573,6 +843,24 @@ files = [ [package.extras] colors = ["colorama (>=0.4.6)"] +[[package]] +name = "jinja2" +version = "3.1.6" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, + {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + [[package]] name = "jmespath" version = "1.0.1" @@ -585,6 +873,18 @@ files = [ {file = "jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe"}, ] +[[package]] +name = "joblib" +version = "1.5.1" +description = "Lightweight pipelining with Python functions" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "joblib-1.5.1-py3-none-any.whl", hash = "sha256:4719a31f054c7d766948dcd83e9613686b27114f190f717cec7eaa2084f8a74a"}, + {file = "joblib-1.5.1.tar.gz", hash = "sha256:f4f86e351f39fe3d0d32a9f2c3d8af1ee4cec285aafcb27003dda5205576b444"}, +] + [[package]] name = "jsonpath-ng" version = "1.6.1" @@ -619,6 +919,202 @@ win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""} [package.extras] dev = ["Sphinx (==7.2.5) ; python_version >= \"3.9\"", "colorama (==0.4.5) ; python_version < \"3.8\"", "colorama (==0.4.6) ; python_version >= \"3.8\"", "exceptiongroup (==1.1.3) ; python_version >= \"3.7\" and python_version < \"3.11\"", "freezegun (==1.1.0) ; python_version < \"3.8\"", "freezegun (==1.2.2) ; python_version >= \"3.8\"", "mypy (==v0.910) ; python_version < \"3.6\"", "mypy (==v0.971) ; python_version == \"3.6\"", "mypy (==v1.4.1) ; python_version == \"3.7\"", "mypy (==v1.5.1) ; python_version >= \"3.8\"", "pre-commit (==3.4.0) ; python_version >= \"3.8\"", "pytest (==6.1.2) ; python_version < \"3.8\"", "pytest (==7.4.0) ; python_version >= \"3.8\"", "pytest-cov (==2.12.1) ; python_version < \"3.8\"", "pytest-cov (==4.1.0) ; python_version >= \"3.8\"", "pytest-mypy-plugins (==1.9.3) ; python_version >= \"3.6\" and python_version < \"3.8\"", "pytest-mypy-plugins (==3.0.0) ; python_version >= \"3.8\"", "sphinx-autobuild (==2021.3.14) ; python_version >= \"3.9\"", "sphinx-rtd-theme (==1.3.0) ; python_version >= \"3.9\"", "tox (==3.27.1) ; python_version < \"3.8\"", "tox (==4.11.0) ; python_version >= \"3.8\""] +[[package]] +name = "markdown-it-py" +version = "4.0.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"}, + {file = "markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"}, +] + +[package.dependencies] +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "markdown-it-pyrs", "mistletoe (>=1.0,<2.0)", "mistune (>=3.0,<4.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins (>=0.5.0)"] +profiling = ["gprof2dot"] +rtd = ["ipykernel", "jupyter_sphinx", "mdit-py-plugins (>=0.5.0)", "myst-parser", "pyyaml", "sphinx", "sphinx-book-theme (>=1.0,<2.0)", "sphinx-copybutton", "sphinx-design"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "requests"] + +[[package]] +name = "markupsafe" +version = "3.0.2" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a"}, + {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, +] + +[[package]] +name = "marshmallow" +version = "4.0.0" +description = "A lightweight library for converting complex datatypes to and from native Python datatypes." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "marshmallow-4.0.0-py3-none-any.whl", hash = "sha256:e7b0528337e9990fd64950f8a6b3a1baabed09ad17a0dfb844d701151f92d203"}, + {file = "marshmallow-4.0.0.tar.gz", hash = "sha256:3b6e80aac299a7935cfb97ed01d1854fb90b5079430969af92118ea1b12a8d55"}, +] + +[package.extras] +dev = ["marshmallow[tests]", "pre-commit (>=3.5,<5.0)", "tox"] +docs = ["autodocsumm (==0.2.14)", "furo (==2024.8.6)", "sphinx (==8.2.3)", "sphinx-copybutton (==0.5.2)", "sphinx-issues (==5.0.1)", "sphinxext-opengraph (==0.10.0)"] +tests = ["pytest", "simplejson"] + +[[package]] +name = "mccabe" +version = "0.7.0" +description = "McCabe checker, plugin for flake8" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, + {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + +[[package]] +name = "mypy" +version = "1.17.1" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "mypy-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3fbe6d5555bf608c47203baa3e72dbc6ec9965b3d7c318aa9a4ca76f465bd972"}, + {file = "mypy-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:80ef5c058b7bce08c83cac668158cb7edea692e458d21098c7d3bce35a5d43e7"}, + {file = "mypy-1.17.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a580f8a70c69e4a75587bd925d298434057fe2a428faaf927ffe6e4b9a98df"}, + {file = "mypy-1.17.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd86bb649299f09d987a2eebb4d52d10603224500792e1bee18303bbcc1ce390"}, + {file = "mypy-1.17.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a76906f26bd8d51ea9504966a9c25419f2e668f012e0bdf3da4ea1526c534d94"}, + {file = "mypy-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:e79311f2d904ccb59787477b7bd5d26f3347789c06fcd7656fa500875290264b"}, + {file = "mypy-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ad37544be07c5d7fba814eb370e006df58fed8ad1ef33ed1649cb1889ba6ff58"}, + {file = "mypy-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:064e2ff508e5464b4bd807a7c1625bc5047c5022b85c70f030680e18f37273a5"}, + {file = "mypy-1.17.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70401bbabd2fa1aa7c43bb358f54037baf0586f41e83b0ae67dd0534fc64edfd"}, + {file = "mypy-1.17.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e92bdc656b7757c438660f775f872a669b8ff374edc4d18277d86b63edba6b8b"}, + {file = "mypy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c1fdf4abb29ed1cb091cf432979e162c208a5ac676ce35010373ff29247bcad5"}, + {file = "mypy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:ff2933428516ab63f961644bc49bc4cbe42bbffb2cd3b71cc7277c07d16b1a8b"}, + {file = "mypy-1.17.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:69e83ea6553a3ba79c08c6e15dbd9bfa912ec1e493bf75489ef93beb65209aeb"}, + {file = "mypy-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b16708a66d38abb1e6b5702f5c2c87e133289da36f6a1d15f6a5221085c6403"}, + {file = "mypy-1.17.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:89e972c0035e9e05823907ad5398c5a73b9f47a002b22359b177d40bdaee7056"}, + {file = "mypy-1.17.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03b6d0ed2b188e35ee6d5c36b5580cffd6da23319991c49ab5556c023ccf1341"}, + {file = "mypy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c837b896b37cd103570d776bda106eabb8737aa6dd4f248451aecf53030cdbeb"}, + {file = "mypy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:665afab0963a4b39dff7c1fa563cc8b11ecff7910206db4b2e64dd1ba25aed19"}, + {file = "mypy-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93378d3203a5c0800c6b6d850ad2f19f7a3cdf1a3701d3416dbf128805c6a6a7"}, + {file = "mypy-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:15d54056f7fe7a826d897789f53dd6377ec2ea8ba6f776dc83c2902b899fee81"}, + {file = "mypy-1.17.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:209a58fed9987eccc20f2ca94afe7257a8f46eb5df1fb69958650973230f91e6"}, + {file = "mypy-1.17.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:099b9a5da47de9e2cb5165e581f158e854d9e19d2e96b6698c0d64de911dd849"}, + {file = "mypy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa6ffadfbe6994d724c5a1bb6123a7d27dd68fc9c059561cd33b664a79578e14"}, + {file = "mypy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:9a2b7d9180aed171f033c9f2fc6c204c1245cf60b0cb61cf2e7acc24eea78e0a"}, + {file = "mypy-1.17.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:15a83369400454c41ed3a118e0cc58bd8123921a602f385cb6d6ea5df050c733"}, + {file = "mypy-1.17.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:55b918670f692fc9fba55c3298d8a3beae295c5cded0a55dccdc5bbead814acd"}, + {file = "mypy-1.17.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:62761474061feef6f720149d7ba876122007ddc64adff5ba6f374fda35a018a0"}, + {file = "mypy-1.17.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c49562d3d908fd49ed0938e5423daed8d407774a479b595b143a3d7f87cdae6a"}, + {file = "mypy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:397fba5d7616a5bc60b45c7ed204717eaddc38f826e3645402c426057ead9a91"}, + {file = "mypy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:9d6b20b97d373f41617bd0708fd46aa656059af57f2ef72aa8c7d6a2b73b74ed"}, + {file = "mypy-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5d1092694f166a7e56c805caaf794e0585cabdbf1df36911c414e4e9abb62ae9"}, + {file = "mypy-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:79d44f9bfb004941ebb0abe8eff6504223a9c1ac51ef967d1263c6572bbebc99"}, + {file = "mypy-1.17.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b01586eed696ec905e61bd2568f48740f7ac4a45b3a468e6423a03d3788a51a8"}, + {file = "mypy-1.17.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43808d9476c36b927fbcd0b0255ce75efe1b68a080154a38ae68a7e62de8f0f8"}, + {file = "mypy-1.17.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:feb8cc32d319edd5859da2cc084493b3e2ce5e49a946377663cc90f6c15fb259"}, + {file = "mypy-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d7598cf74c3e16539d4e2f0b8d8c318e00041553d83d4861f87c7a72e95ac24d"}, + {file = "mypy-1.17.1-py3-none-any.whl", hash = "sha256:a9f52c0351c21fe24c21d8c0eb1f62967b262d6729393397b6f443c3b773c3b9"}, + {file = "mypy-1.17.1.tar.gz", hash = "sha256:25e01ec741ab5bb3eec8ba9cdb0f769230368a22c959c4937360efb89b7e9f01"}, +] + +[package.dependencies] +mypy_extensions = ">=1.0.0" +pathspec = ">=0.9.0" +typing_extensions = ">=4.6.0" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +faster-cache = ["orjson"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +reports = ["lxml"] + [[package]] name = "mypy-extensions" version = "1.0.0" @@ -631,6 +1127,32 @@ files = [ {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, ] +[[package]] +name = "nltk" +version = "3.9.1" +description = "Natural Language Toolkit" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "nltk-3.9.1-py3-none-any.whl", hash = "sha256:4fa26829c5b00715afe3061398a8989dc643b92ce7dd93fb4585a70930d168a1"}, + {file = "nltk-3.9.1.tar.gz", hash = "sha256:87d127bd3de4bd89a4f81265e5fa59cb1b199b27440175370f7417d2bc7ae868"}, +] + +[package.dependencies] +click = "*" +joblib = "*" +regex = ">=2021.8.3" +tqdm = "*" + +[package.extras] +all = ["matplotlib", "numpy", "pyparsing", "python-crfsuite", "requests", "scikit-learn", "scipy", "twython"] +corenlp = ["requests"] +machine-learning = ["numpy", "python-crfsuite", "scikit-learn", "scipy"] +plot = ["matplotlib"] +tgrep = ["pyparsing"] +twitter = ["twython"] + [[package]] name = "nodeenv" version = "1.9.1" @@ -841,18 +1363,6 @@ docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-a test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"] type = ["mypy (>=1.11.2)"] -[[package]] -name = "playhouse" -version = "0.1.0" -description = "This package is an example of a Dependency Hallucination. There is no code in this package, it is just a placeholder to prevent exploitation." -optional = false -python-versions = ">=3.0" -groups = ["main"] -files = [ - {file = "playhouse-0.1.0-py3-none-any.whl", hash = "sha256:ba6b851f8bba7cf083b0289f64729b717f4e8bd552d2ba3dfc6fc1f76fdd40a1"}, - {file = "playhouse-0.1.0.tar.gz", hash = "sha256:98e375b2ec649ad454e3af91c6cb243a37709250062a53559de3ec54044c8a28"}, -] - [[package]] name = "pluggy" version = "1.5.0" @@ -912,6 +1422,37 @@ nodeenv = ">=0.11.1" pyyaml = ">=5.1" virtualenv = ">=20.10.0" +[[package]] +name = "psutil" +version = "6.1.1" +description = "Cross-platform lib for process and system monitoring in Python." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" +groups = ["main"] +files = [ + {file = "psutil-6.1.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:9ccc4316f24409159897799b83004cb1e24f9819b0dcf9c0b68bdcb6cefee6a8"}, + {file = "psutil-6.1.1-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:ca9609c77ea3b8481ab005da74ed894035936223422dc591d6772b147421f777"}, + {file = "psutil-6.1.1-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:8df0178ba8a9e5bc84fed9cfa61d54601b371fbec5c8eebad27575f1e105c0d4"}, + {file = "psutil-6.1.1-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:1924e659d6c19c647e763e78670a05dbb7feaf44a0e9c94bf9e14dfc6ba50468"}, + {file = "psutil-6.1.1-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:018aeae2af92d943fdf1da6b58665124897cfc94faa2ca92098838f83e1b1bca"}, + {file = "psutil-6.1.1-cp27-none-win32.whl", hash = "sha256:6d4281f5bbca041e2292be3380ec56a9413b790579b8e593b1784499d0005dac"}, + {file = "psutil-6.1.1-cp27-none-win_amd64.whl", hash = "sha256:c777eb75bb33c47377c9af68f30e9f11bc78e0f07fbf907be4a5d70b2fe5f030"}, + {file = "psutil-6.1.1-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:fc0ed7fe2231a444fc219b9c42d0376e0a9a1a72f16c5cfa0f68d19f1a0663e8"}, + {file = "psutil-6.1.1-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:0bdd4eab935276290ad3cb718e9809412895ca6b5b334f5a9111ee6d9aff9377"}, + {file = "psutil-6.1.1-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b6e06c20c05fe95a3d7302d74e7097756d4ba1247975ad6905441ae1b5b66003"}, + {file = "psutil-6.1.1-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97f7cb9921fbec4904f522d972f0c0e1f4fabbdd4e0287813b21215074a0f160"}, + {file = "psutil-6.1.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:33431e84fee02bc84ea36d9e2c4a6d395d479c9dd9bba2376c1f6ee8f3a4e0b3"}, + {file = "psutil-6.1.1-cp36-cp36m-win32.whl", hash = "sha256:384636b1a64b47814437d1173be1427a7c83681b17a450bfc309a1953e329603"}, + {file = "psutil-6.1.1-cp36-cp36m-win_amd64.whl", hash = "sha256:8be07491f6ebe1a693f17d4f11e69d0dc1811fa082736500f649f79df7735303"}, + {file = "psutil-6.1.1-cp37-abi3-win32.whl", hash = "sha256:eaa912e0b11848c4d9279a93d7e2783df352b082f40111e078388701fd479e53"}, + {file = "psutil-6.1.1-cp37-abi3-win_amd64.whl", hash = "sha256:f35cfccb065fff93529d2afb4a2e89e363fe63ca1e4a5da22b603a85833c2649"}, + {file = "psutil-6.1.1.tar.gz", hash = "sha256:cf8496728c18f2d0b45198f06895be52f36611711746b7f30c464b422b50e2f5"}, +] + +[package.extras] +dev = ["abi3audit", "black", "check-manifest", "coverage", "packaging", "pylint", "pyperf", "pypinfo", "pytest-cov", "requests", "rstcheck", "ruff", "sphinx", "sphinx_rtd_theme", "toml-sort", "twine", "virtualenv", "vulture", "wheel"] +test = ["pytest", "pytest-xdist", "setuptools"] + [[package]] name = "py" version = "1.11.0" @@ -936,6 +1477,18 @@ files = [ {file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"}, ] +[[package]] +name = "pycodestyle" +version = "2.14.0" +description = "Python style guide checker" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d"}, + {file = "pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783"}, +] + [[package]] name = "pycparser" version = "2.22" @@ -949,6 +1502,159 @@ files = [ {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, ] +[[package]] +name = "pydantic" +version = "2.9.2" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pydantic-2.9.2-py3-none-any.whl", hash = "sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12"}, + {file = "pydantic-2.9.2.tar.gz", hash = "sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f"}, +] + +[package.dependencies] +annotated-types = ">=0.6.0" +pydantic-core = "2.23.4" +typing-extensions = [ + {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, + {version = ">=4.6.1", markers = "python_version < \"3.13\""}, +] + +[package.extras] +email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata ; python_version >= \"3.9\" and sys_platform == \"win32\""] + +[[package]] +name = "pydantic-core" +version = "2.23.4" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pydantic_core-2.23.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:b10bd51f823d891193d4717448fab065733958bdb6a6b351967bd349d48d5c9b"}, + {file = "pydantic_core-2.23.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4fc714bdbfb534f94034efaa6eadd74e5b93c8fa6315565a222f7b6f42ca1166"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63e46b3169866bd62849936de036f901a9356e36376079b05efa83caeaa02ceb"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed1a53de42fbe34853ba90513cea21673481cd81ed1be739f7f2efb931b24916"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cfdd16ab5e59fc31b5e906d1a3f666571abc367598e3e02c83403acabc092e07"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255a8ef062cbf6674450e668482456abac99a5583bbafb73f9ad469540a3a232"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a7cd62e831afe623fbb7aabbb4fe583212115b3ef38a9f6b71869ba644624a2"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f09e2ff1f17c2b51f2bc76d1cc33da96298f0a036a137f5440ab3ec5360b624f"}, + {file = "pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e38e63e6f3d1cec5a27e0afe90a085af8b6806ee208b33030e65b6516353f1a3"}, + {file = "pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0dbd8dbed2085ed23b5c04afa29d8fd2771674223135dc9bc937f3c09284d071"}, + {file = "pydantic_core-2.23.4-cp310-none-win32.whl", hash = "sha256:6531b7ca5f951d663c339002e91aaebda765ec7d61b7d1e3991051906ddde119"}, + {file = "pydantic_core-2.23.4-cp310-none-win_amd64.whl", hash = "sha256:7c9129eb40958b3d4500fa2467e6a83356b3b61bfff1b414c7361d9220f9ae8f"}, + {file = "pydantic_core-2.23.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:77733e3892bb0a7fa797826361ce8a9184d25c8dffaec60b7ffe928153680ba8"}, + {file = "pydantic_core-2.23.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b84d168f6c48fabd1f2027a3d1bdfe62f92cade1fb273a5d68e621da0e44e6d"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df49e7a0861a8c36d089c1ed57d308623d60416dab2647a4a17fe050ba85de0e"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ff02b6d461a6de369f07ec15e465a88895f3223eb75073ffea56b84d9331f607"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:996a38a83508c54c78a5f41456b0103c30508fed9abcad0a59b876d7398f25fd"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d97683ddee4723ae8c95d1eddac7c192e8c552da0c73a925a89fa8649bf13eea"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:216f9b2d7713eb98cb83c80b9c794de1f6b7e3145eef40400c62e86cee5f4e1e"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6f783e0ec4803c787bcea93e13e9932edab72068f68ecffdf86a99fd5918878b"}, + {file = "pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d0776dea117cf5272382634bd2a5c1b6eb16767c223c6a5317cd3e2a757c61a0"}, + {file = "pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d5f7a395a8cf1621939692dba2a6b6a830efa6b3cee787d82c7de1ad2930de64"}, + {file = "pydantic_core-2.23.4-cp311-none-win32.whl", hash = "sha256:74b9127ffea03643e998e0c5ad9bd3811d3dac8c676e47db17b0ee7c3c3bf35f"}, + {file = "pydantic_core-2.23.4-cp311-none-win_amd64.whl", hash = "sha256:98d134c954828488b153d88ba1f34e14259284f256180ce659e8d83e9c05eaa3"}, + {file = "pydantic_core-2.23.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f3e0da4ebaef65158d4dfd7d3678aad692f7666877df0002b8a522cdf088f231"}, + {file = "pydantic_core-2.23.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f69a8e0b033b747bb3e36a44e7732f0c99f7edd5cea723d45bc0d6e95377ffee"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:723314c1d51722ab28bfcd5240d858512ffd3116449c557a1336cbe3919beb87"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb2802e667b7051a1bebbfe93684841cc9351004e2badbd6411bf357ab8d5ac8"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d18ca8148bebe1b0a382a27a8ee60350091a6ddaf475fa05ef50dc35b5df6327"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33e3d65a85a2a4a0dc3b092b938a4062b1a05f3a9abde65ea93b233bca0e03f2"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:128585782e5bfa515c590ccee4b727fb76925dd04a98864182b22e89a4e6ed36"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:68665f4c17edcceecc112dfed5dbe6f92261fb9d6054b47d01bf6371a6196126"}, + {file = "pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:20152074317d9bed6b7a95ade3b7d6054845d70584216160860425f4fbd5ee9e"}, + {file = "pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9261d3ce84fa1d38ed649c3638feefeae23d32ba9182963e465d58d62203bd24"}, + {file = "pydantic_core-2.23.4-cp312-none-win32.whl", hash = "sha256:4ba762ed58e8d68657fc1281e9bb72e1c3e79cc5d464be146e260c541ec12d84"}, + {file = "pydantic_core-2.23.4-cp312-none-win_amd64.whl", hash = "sha256:97df63000f4fea395b2824da80e169731088656d1818a11b95f3b173747b6cd9"}, + {file = "pydantic_core-2.23.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7530e201d10d7d14abce4fb54cfe5b94a0aefc87da539d0346a484ead376c3cc"}, + {file = "pydantic_core-2.23.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df933278128ea1cd77772673c73954e53a1c95a4fdf41eef97c2b779271bd0bd"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cb3da3fd1b6a5d0279a01877713dbda118a2a4fc6f0d821a57da2e464793f05"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c6dcb030aefb668a2b7009c85b27f90e51e6a3b4d5c9bc4c57631292015b0d"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:696dd8d674d6ce621ab9d45b205df149399e4bb9aa34102c970b721554828510"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2971bb5ffe72cc0f555c13e19b23c85b654dd2a8f7ab493c262071377bfce9f6"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8394d940e5d400d04cad4f75c0598665cbb81aecefaca82ca85bd28264af7f9b"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0dff76e0602ca7d4cdaacc1ac4c005e0ce0dcfe095d5b5259163a80d3a10d327"}, + {file = "pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7d32706badfe136888bdea71c0def994644e09fff0bfe47441deaed8e96fdbc6"}, + {file = "pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ed541d70698978a20eb63d8c5d72f2cc6d7079d9d90f6b50bad07826f1320f5f"}, + {file = "pydantic_core-2.23.4-cp313-none-win32.whl", hash = "sha256:3d5639516376dce1940ea36edf408c554475369f5da2abd45d44621cb616f769"}, + {file = "pydantic_core-2.23.4-cp313-none-win_amd64.whl", hash = "sha256:5a1504ad17ba4210df3a045132a7baeeba5a200e930f57512ee02909fc5c4cb5"}, + {file = "pydantic_core-2.23.4-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d4488a93b071c04dc20f5cecc3631fc78b9789dd72483ba15d423b5b3689b555"}, + {file = "pydantic_core-2.23.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:81965a16b675b35e1d09dd14df53f190f9129c0202356ed44ab2728b1c905658"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ffa2ebd4c8530079140dd2d7f794a9d9a73cbb8e9d59ffe24c63436efa8f271"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:61817945f2fe7d166e75fbfb28004034b48e44878177fc54d81688e7b85a3665"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:29d2c342c4bc01b88402d60189f3df065fb0dda3654744d5a165a5288a657368"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5e11661ce0fd30a6790e8bcdf263b9ec5988e95e63cf901972107efc49218b13"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d18368b137c6295db49ce7218b1a9ba15c5bc254c96d7c9f9e924a9bc7825ad"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec4e55f79b1c4ffb2eecd8a0cfba9955a2588497d96851f4c8f99aa4a1d39b12"}, + {file = "pydantic_core-2.23.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:374a5e5049eda9e0a44c696c7ade3ff355f06b1fe0bb945ea3cac2bc336478a2"}, + {file = "pydantic_core-2.23.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5c364564d17da23db1106787675fc7af45f2f7b58b4173bfdd105564e132e6fb"}, + {file = "pydantic_core-2.23.4-cp38-none-win32.whl", hash = "sha256:d7a80d21d613eec45e3d41eb22f8f94ddc758a6c4720842dc74c0581f54993d6"}, + {file = "pydantic_core-2.23.4-cp38-none-win_amd64.whl", hash = "sha256:5f5ff8d839f4566a474a969508fe1c5e59c31c80d9e140566f9a37bba7b8d556"}, + {file = "pydantic_core-2.23.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a4fa4fc04dff799089689f4fd502ce7d59de529fc2f40a2c8836886c03e0175a"}, + {file = "pydantic_core-2.23.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0a7df63886be5e270da67e0966cf4afbae86069501d35c8c1b3b6c168f42cb36"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dcedcd19a557e182628afa1d553c3895a9f825b936415d0dbd3cd0bbcfd29b4b"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f54b118ce5de9ac21c363d9b3caa6c800341e8c47a508787e5868c6b79c9323"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86d2f57d3e1379a9525c5ab067b27dbb8a0642fb5d454e17a9ac434f9ce523e3"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:de6d1d1b9e5101508cb37ab0d972357cac5235f5c6533d1071964c47139257df"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1278e0d324f6908e872730c9102b0112477a7f7cf88b308e4fc36ce1bdb6d58c"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9a6b5099eeec78827553827f4c6b8615978bb4b6a88e5d9b93eddf8bb6790f55"}, + {file = "pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e55541f756f9b3ee346b840103f32779c695a19826a4c442b7954550a0972040"}, + {file = "pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a5c7ba8ffb6d6f8f2ab08743be203654bb1aaa8c9dcb09f82ddd34eadb695605"}, + {file = "pydantic_core-2.23.4-cp39-none-win32.whl", hash = "sha256:37b0fe330e4a58d3c58b24d91d1eb102aeec675a3db4c292ec3928ecd892a9a6"}, + {file = "pydantic_core-2.23.4-cp39-none-win_amd64.whl", hash = "sha256:1498bec4c05c9c787bde9125cfdcc63a41004ff167f495063191b863399b1a29"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f455ee30a9d61d3e1a15abd5068827773d6e4dc513e795f380cdd59932c782d5"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1e90d2e3bd2c3863d48525d297cd143fe541be8bbf6f579504b9712cb6b643ec"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e203fdf807ac7e12ab59ca2bfcabb38c7cf0b33c41efeb00f8e5da1d86af480"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e08277a400de01bc72436a0ccd02bdf596631411f592ad985dcee21445bd0068"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f220b0eea5965dec25480b6333c788fb72ce5f9129e8759ef876a1d805d00801"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d06b0c8da4f16d1d1e352134427cb194a0a6e19ad5db9161bf32b2113409e728"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:ba1a0996f6c2773bd83e63f18914c1de3c9dd26d55f4ac302a7efe93fb8e7433"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:9a5bce9d23aac8f0cf0836ecfc033896aa8443b501c58d0602dbfd5bd5b37753"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:78ddaaa81421a29574a682b3179d4cf9e6d405a09b99d93ddcf7e5239c742e21"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:883a91b5dd7d26492ff2f04f40fbb652de40fcc0afe07e8129e8ae779c2110eb"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88ad334a15b32a791ea935af224b9de1bf99bcd62fabf745d5f3442199d86d59"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:233710f069d251feb12a56da21e14cca67994eab08362207785cf8c598e74577"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:19442362866a753485ba5e4be408964644dd6a09123d9416c54cd49171f50744"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:624e278a7d29b6445e4e813af92af37820fafb6dcc55c012c834f9e26f9aaaef"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f5ef8f42bec47f21d07668a043f077d507e5bf4e668d5c6dfe6aaba89de1a5b8"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:aea443fffa9fbe3af1a9ba721a87f926fe548d32cab71d188a6ede77d0ff244e"}, + {file = "pydantic_core-2.23.4.tar.gz", hash = "sha256:2584f7cf844ac4d970fba483a717dbe10c1c1c96a969bf65d61ffe94df1b2863"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" + +[[package]] +name = "pyflakes" +version = "3.4.0" +description = "passive checker of Python programs" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f"}, + {file = "pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58"}, +] + +[[package]] +name = "pygments" +version = "2.19.2" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, + {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + [[package]] name = "pymysql" version = "1.1.1" @@ -986,6 +1692,25 @@ pluggy = ">=1.5,<2" [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +[[package]] +name = "pytest-cov" +version = "5.0.0" +description = "Pytest plugin for measuring coverage." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857"}, + {file = "pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652"}, +] + +[package.dependencies] +coverage = {version = ">=5.2.1", extras = ["toml"]} +pytest = ">=4.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] + [[package]] name = "pytest-ordering" version = "0.6" @@ -1144,6 +1869,103 @@ files = [ {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, ] +[[package]] +name = "regex" +version = "2025.7.34" +description = "Alternative regular expression module, to replace re." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "regex-2025.7.34-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d856164d25e2b3b07b779bfed813eb4b6b6ce73c2fd818d46f47c1eb5cd79bd6"}, + {file = "regex-2025.7.34-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2d15a9da5fad793e35fb7be74eec450d968e05d2e294f3e0e77ab03fa7234a83"}, + {file = "regex-2025.7.34-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:95b4639c77d414efa93c8de14ce3f7965a94d007e068a94f9d4997bb9bd9c81f"}, + {file = "regex-2025.7.34-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d7de1ceed5a5f84f342ba4a9f4ae589524adf9744b2ee61b5da884b5b659834"}, + {file = "regex-2025.7.34-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02e5860a250cd350c4933cf376c3bc9cb28948e2c96a8bc042aee7b985cfa26f"}, + {file = "regex-2025.7.34-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0a5966220b9a1a88691282b7e4350e9599cf65780ca60d914a798cb791aa1177"}, + {file = "regex-2025.7.34-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48fb045bbd4aab2418dc1ba2088a5e32de4bfe64e1457b948bb328a8dc2f1c2e"}, + {file = "regex-2025.7.34-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:20ff8433fa45e131f7316594efe24d4679c5449c0ca69d91c2f9d21846fdf064"}, + {file = "regex-2025.7.34-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c436fd1e95c04c19039668cfb548450a37c13f051e8659f40aed426e36b3765f"}, + {file = "regex-2025.7.34-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:0b85241d3cfb9f8a13cefdfbd58a2843f208f2ed2c88181bf84e22e0c7fc066d"}, + {file = "regex-2025.7.34-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:075641c94126b064c65ab86e7e71fc3d63e7ff1bea1fb794f0773c97cdad3a03"}, + {file = "regex-2025.7.34-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:70645cad3407d103d1dbcb4841839d2946f7d36cf38acbd40120fee1682151e5"}, + {file = "regex-2025.7.34-cp310-cp310-win32.whl", hash = "sha256:3b836eb4a95526b263c2a3359308600bd95ce7848ebd3c29af0c37c4f9627cd3"}, + {file = "regex-2025.7.34-cp310-cp310-win_amd64.whl", hash = "sha256:cbfaa401d77334613cf434f723c7e8ba585df162be76474bccc53ae4e5520b3a"}, + {file = "regex-2025.7.34-cp310-cp310-win_arm64.whl", hash = "sha256:bca11d3c38a47c621769433c47f364b44e8043e0de8e482c5968b20ab90a3986"}, + {file = "regex-2025.7.34-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:da304313761b8500b8e175eb2040c4394a875837d5635f6256d6fa0377ad32c8"}, + {file = "regex-2025.7.34-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:35e43ebf5b18cd751ea81455b19acfdec402e82fe0dc6143edfae4c5c4b3909a"}, + {file = "regex-2025.7.34-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96bbae4c616726f4661fe7bcad5952e10d25d3c51ddc388189d8864fbc1b3c68"}, + {file = "regex-2025.7.34-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9feab78a1ffa4f2b1e27b1bcdaad36f48c2fed4870264ce32f52a393db093c78"}, + {file = "regex-2025.7.34-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f14b36e6d4d07f1a5060f28ef3b3561c5d95eb0651741474ce4c0a4c56ba8719"}, + {file = "regex-2025.7.34-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:85c3a958ef8b3d5079c763477e1f09e89d13ad22198a37e9d7b26b4b17438b33"}, + {file = "regex-2025.7.34-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37555e4ae0b93358fa7c2d240a4291d4a4227cc7c607d8f85596cdb08ec0a083"}, + {file = "regex-2025.7.34-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ee38926f31f1aa61b0232a3a11b83461f7807661c062df9eb88769d86e6195c3"}, + {file = "regex-2025.7.34-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a664291c31cae9c4a30589bd8bc2ebb56ef880c9c6264cb7643633831e606a4d"}, + {file = "regex-2025.7.34-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f3e5c1e0925e77ec46ddc736b756a6da50d4df4ee3f69536ffb2373460e2dafd"}, + {file = "regex-2025.7.34-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d428fc7731dcbb4e2ffe43aeb8f90775ad155e7db4347a639768bc6cd2df881a"}, + {file = "regex-2025.7.34-cp311-cp311-win32.whl", hash = "sha256:e154a7ee7fa18333ad90b20e16ef84daaeac61877c8ef942ec8dfa50dc38b7a1"}, + {file = "regex-2025.7.34-cp311-cp311-win_amd64.whl", hash = "sha256:24257953d5c1d6d3c129ab03414c07fc1a47833c9165d49b954190b2b7f21a1a"}, + {file = "regex-2025.7.34-cp311-cp311-win_arm64.whl", hash = "sha256:3157aa512b9e606586900888cd469a444f9b898ecb7f8931996cb715f77477f0"}, + {file = "regex-2025.7.34-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7f7211a746aced993bef487de69307a38c5ddd79257d7be83f7b202cb59ddb50"}, + {file = "regex-2025.7.34-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fb31080f2bd0681484b275461b202b5ad182f52c9ec606052020fe13eb13a72f"}, + {file = "regex-2025.7.34-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0200a5150c4cf61e407038f4b4d5cdad13e86345dac29ff9dab3d75d905cf130"}, + {file = "regex-2025.7.34-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:739a74970e736df0773788377969c9fea3876c2fc13d0563f98e5503e5185f46"}, + {file = "regex-2025.7.34-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4fef81b2f7ea6a2029161ed6dea9ae13834c28eb5a95b8771828194a026621e4"}, + {file = "regex-2025.7.34-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea74cf81fe61a7e9d77989050d0089a927ab758c29dac4e8e1b6c06fccf3ebf0"}, + {file = "regex-2025.7.34-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e4636a7f3b65a5f340ed9ddf53585c42e3ff37101d383ed321bfe5660481744b"}, + {file = "regex-2025.7.34-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6cef962d7834437fe8d3da6f9bfc6f93f20f218266dcefec0560ed7765f5fe01"}, + {file = "regex-2025.7.34-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:cbe1698e5b80298dbce8df4d8d1182279fbdaf1044e864cbc9d53c20e4a2be77"}, + {file = "regex-2025.7.34-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:32b9f9bcf0f605eb094b08e8da72e44badabb63dde6b83bd530580b488d1c6da"}, + {file = "regex-2025.7.34-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:524c868ba527eab4e8744a9287809579f54ae8c62fbf07d62aacd89f6026b282"}, + {file = "regex-2025.7.34-cp312-cp312-win32.whl", hash = "sha256:d600e58ee6d036081c89696d2bdd55d507498a7180df2e19945c6642fac59588"}, + {file = "regex-2025.7.34-cp312-cp312-win_amd64.whl", hash = "sha256:9a9ab52a466a9b4b91564437b36417b76033e8778e5af8f36be835d8cb370d62"}, + {file = "regex-2025.7.34-cp312-cp312-win_arm64.whl", hash = "sha256:c83aec91af9c6fbf7c743274fd952272403ad9a9db05fe9bfc9df8d12b45f176"}, + {file = "regex-2025.7.34-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c3c9740a77aeef3f5e3aaab92403946a8d34437db930a0280e7e81ddcada61f5"}, + {file = "regex-2025.7.34-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:69ed3bc611540f2ea70a4080f853741ec698be556b1df404599f8724690edbcd"}, + {file = "regex-2025.7.34-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d03c6f9dcd562c56527c42b8530aad93193e0b3254a588be1f2ed378cdfdea1b"}, + {file = "regex-2025.7.34-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6164b1d99dee1dfad33f301f174d8139d4368a9fb50bf0a3603b2eaf579963ad"}, + {file = "regex-2025.7.34-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1e4f4f62599b8142362f164ce776f19d79bdd21273e86920a7b604a4275b4f59"}, + {file = "regex-2025.7.34-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:72a26dcc6a59c057b292f39d41465d8233a10fd69121fa24f8f43ec6294e5415"}, + {file = "regex-2025.7.34-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5273fddf7a3e602695c92716c420c377599ed3c853ea669c1fe26218867002f"}, + {file = "regex-2025.7.34-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c1844be23cd40135b3a5a4dd298e1e0c0cb36757364dd6cdc6025770363e06c1"}, + {file = "regex-2025.7.34-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dde35e2afbbe2272f8abee3b9fe6772d9b5a07d82607b5788e8508974059925c"}, + {file = "regex-2025.7.34-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f3f6e8e7af516a7549412ce57613e859c3be27d55341a894aacaa11703a4c31a"}, + {file = "regex-2025.7.34-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:469142fb94a869beb25b5f18ea87646d21def10fbacb0bcb749224f3509476f0"}, + {file = "regex-2025.7.34-cp313-cp313-win32.whl", hash = "sha256:da7507d083ee33ccea1310447410c27ca11fb9ef18c95899ca57ff60a7e4d8f1"}, + {file = "regex-2025.7.34-cp313-cp313-win_amd64.whl", hash = "sha256:9d644de5520441e5f7e2db63aec2748948cc39ed4d7a87fd5db578ea4043d997"}, + {file = "regex-2025.7.34-cp313-cp313-win_arm64.whl", hash = "sha256:7bf1c5503a9f2cbd2f52d7e260acb3131b07b6273c470abb78568174fe6bde3f"}, + {file = "regex-2025.7.34-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:8283afe7042d8270cecf27cca558873168e771183d4d593e3c5fe5f12402212a"}, + {file = "regex-2025.7.34-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6c053f9647e3421dd2f5dff8172eb7b4eec129df9d1d2f7133a4386319b47435"}, + {file = "regex-2025.7.34-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a16dd56bbcb7d10e62861c3cd000290ddff28ea142ffb5eb3470f183628011ac"}, + {file = "regex-2025.7.34-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69c593ff5a24c0d5c1112b0df9b09eae42b33c014bdca7022d6523b210b69f72"}, + {file = "regex-2025.7.34-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98d0ce170fcde1a03b5df19c5650db22ab58af375aaa6ff07978a85c9f250f0e"}, + {file = "regex-2025.7.34-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d72765a4bff8c43711d5b0f5b452991a9947853dfa471972169b3cc0ba1d0751"}, + {file = "regex-2025.7.34-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4494f8fd95a77eb434039ad8460e64d57baa0434f1395b7da44015bef650d0e4"}, + {file = "regex-2025.7.34-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4f42b522259c66e918a0121a12429b2abcf696c6f967fa37bdc7b72e61469f98"}, + {file = "regex-2025.7.34-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:aaef1f056d96a0a5d53ad47d019d5b4c66fe4be2da87016e0d43b7242599ffc7"}, + {file = "regex-2025.7.34-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:656433e5b7dccc9bc0da6312da8eb897b81f5e560321ec413500e5367fcd5d47"}, + {file = "regex-2025.7.34-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e91eb2c62c39705e17b4d42d4b86c4e86c884c0d15d9c5a47d0835f8387add8e"}, + {file = "regex-2025.7.34-cp314-cp314-win32.whl", hash = "sha256:f978ddfb6216028c8f1d6b0f7ef779949498b64117fc35a939022f67f810bdcb"}, + {file = "regex-2025.7.34-cp314-cp314-win_amd64.whl", hash = "sha256:4b7dc33b9b48fb37ead12ffc7bdb846ac72f99a80373c4da48f64b373a7abeae"}, + {file = "regex-2025.7.34-cp314-cp314-win_arm64.whl", hash = "sha256:4b8c4d39f451e64809912c82392933d80fe2e4a87eeef8859fcc5380d0173c64"}, + {file = "regex-2025.7.34-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:fd5edc3f453de727af267c7909d083e19f6426fc9dd149e332b6034f2a5611e6"}, + {file = "regex-2025.7.34-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fa1cdfb8db96ef20137de5587954c812821966c3e8b48ffc871e22d7ec0a4938"}, + {file = "regex-2025.7.34-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:89c9504fc96268e8e74b0283e548f53a80c421182a2007e3365805b74ceef936"}, + {file = "regex-2025.7.34-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33be70d75fa05a904ee0dc43b650844e067d14c849df7e82ad673541cd465b5f"}, + {file = "regex-2025.7.34-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:57d25b6732ea93eeb1d090e8399b6235ca84a651b52d52d272ed37d3d2efa0f1"}, + {file = "regex-2025.7.34-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:baf2fe122a3db1c0b9f161aa44463d8f7e33eeeda47bb0309923deb743a18276"}, + {file = "regex-2025.7.34-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a764a83128af9c1a54be81485b34dca488cbcacefe1e1d543ef11fbace191e1"}, + {file = "regex-2025.7.34-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c7f663ccc4093877f55b51477522abd7299a14c5bb7626c5238599db6a0cb95d"}, + {file = "regex-2025.7.34-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4913f52fbc7a744aaebf53acd8d3dc1b519e46ba481d4d7596de3c862e011ada"}, + {file = "regex-2025.7.34-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:efac4db9e044d47fd3b6b0d40b6708f4dfa2d8131a5ac1d604064147c0f552fd"}, + {file = "regex-2025.7.34-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:7373afae7cfb716e3b8e15d0184510d518f9d21471f2d62918dbece85f2c588f"}, + {file = "regex-2025.7.34-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9960d162f3fecf6af252534a1ae337e9c2e20d74469fed782903b24e2cc9d3d7"}, + {file = "regex-2025.7.34-cp39-cp39-win32.whl", hash = "sha256:95d538b10eb4621350a54bf14600cc80b514211d91a019dc74b8e23d2159ace5"}, + {file = "regex-2025.7.34-cp39-cp39-win_amd64.whl", hash = "sha256:f7f3071b5faa605b0ea51ec4bb3ea7257277446b053f4fd3ad02b1dcb4e64353"}, + {file = "regex-2025.7.34-cp39-cp39-win_arm64.whl", hash = "sha256:716a47515ba1d03f8e8a61c5013041c8c90f2e21f055203498105d7571b44531"}, + {file = "regex-2025.7.34.tar.gz", hash = "sha256:9ead9765217afd04a86822dfcd4ed2747dfe426e887da413b15ff0ac2457e21a"}, +] + [[package]] name = "requests" version = "2.32.3" @@ -1182,6 +2004,25 @@ files = [ decorator = ">=3.4.2" py = ">=1.4.26,<2.0.0" +[[package]] +name = "rich" +version = "14.1.0" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +optional = false +python-versions = ">=3.8.0" +groups = ["main"] +files = [ + {file = "rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f"}, + {file = "rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8"}, +] + +[package.dependencies] +markdown-it-py = ">=2.2.0" +pygments = ">=2.13.0,<3.0.0" + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + [[package]] name = "rsa" version = "4.9" @@ -1197,6 +2038,172 @@ files = [ [package.dependencies] pyasn1 = ">=0.1.3" +[[package]] +name = "ruamel-yaml" +version = "0.18.15" +description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "ruamel.yaml-0.18.15-py3-none-any.whl", hash = "sha256:148f6488d698b7a5eded5ea793a025308b25eca97208181b6a026037f391f701"}, + {file = "ruamel.yaml-0.18.15.tar.gz", hash = "sha256:dbfca74b018c4c3fba0b9cc9ee33e53c371194a9000e694995e620490fd40700"}, +] + +[package.dependencies] +"ruamel.yaml.clib" = {version = ">=0.2.7", markers = "platform_python_implementation == \"CPython\" and python_version < \"3.14\""} + +[package.extras] +docs = ["mercurial (>5.7)", "ryd"] +jinja2 = ["ruamel.yaml.jinja2 (>=0.2)"] + +[[package]] +name = "ruamel-yaml-clib" +version = "0.2.12" +description = "C version of reader, parser and emitter for ruamel.yaml derived from libyaml" +optional = false +python-versions = ">=3.9" +groups = ["main"] +markers = "platform_python_implementation == \"CPython\" and python_version < \"3.14\"" +files = [ + {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:11f891336688faf5156a36293a9c362bdc7c88f03a8a027c2c1d8e0bcde998e5"}, + {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:a606ef75a60ecf3d924613892cc603b154178ee25abb3055db5062da811fd969"}, + {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd5415dded15c3822597455bc02bcd66e81ef8b7a48cb71a33628fc9fdde39df"}, + {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f66efbc1caa63c088dead1c4170d148eabc9b80d95fb75b6c92ac0aad2437d76"}, + {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:22353049ba4181685023b25b5b51a574bce33e7f51c759371a7422dcae5402a6"}, + {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:932205970b9f9991b34f55136be327501903f7c66830e9760a8ffb15b07f05cd"}, + {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a52d48f4e7bf9005e8f0a89209bf9a73f7190ddf0489eee5eb51377385f59f2a"}, + {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-win32.whl", hash = "sha256:3eac5a91891ceb88138c113f9db04f3cebdae277f5d44eaa3651a4f573e6a5da"}, + {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-win_amd64.whl", hash = "sha256:ab007f2f5a87bd08ab1499bdf96f3d5c6ad4dcfa364884cb4549aa0154b13a28"}, + {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:4a6679521a58256a90b0d89e03992c15144c5f3858f40d7c18886023d7943db6"}, + {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:d84318609196d6bd6da0edfa25cedfbabd8dbde5140a0a23af29ad4b8f91fb1e"}, + {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb43a269eb827806502c7c8efb7ae7e9e9d0573257a46e8e952f4d4caba4f31e"}, + {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:811ea1594b8a0fb466172c384267a4e5e367298af6b228931f273b111f17ef52"}, + {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:cf12567a7b565cbf65d438dec6cfbe2917d3c1bdddfce84a9930b7d35ea59642"}, + {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7dd5adc8b930b12c8fc5b99e2d535a09889941aa0d0bd06f4749e9a9397c71d2"}, + {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1492a6051dab8d912fc2adeef0e8c72216b24d57bd896ea607cb90bb0c4981d3"}, + {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-win32.whl", hash = "sha256:bd0a08f0bab19093c54e18a14a10b4322e1eacc5217056f3c063bd2f59853ce4"}, + {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-win_amd64.whl", hash = "sha256:a274fb2cb086c7a3dea4322ec27f4cb5cc4b6298adb583ab0e211a4682f241eb"}, + {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:20b0f8dc160ba83b6dcc0e256846e1a02d044e13f7ea74a3d1d56ede4e48c632"}, + {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:943f32bc9dedb3abff9879edc134901df92cfce2c3d5c9348f172f62eb2d771d"}, + {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95c3829bb364fdb8e0332c9931ecf57d9be3519241323c5274bd82f709cebc0c"}, + {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:749c16fcc4a2b09f28843cda5a193e0283e47454b63ec4b81eaa2242f50e4ccd"}, + {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bf165fef1f223beae7333275156ab2022cffe255dcc51c27f066b4370da81e31"}, + {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:32621c177bbf782ca5a18ba4d7af0f1082a3f6e517ac2a18b3974d4edf349680"}, + {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b82a7c94a498853aa0b272fd5bc67f29008da798d4f93a2f9f289feb8426a58d"}, + {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-win32.whl", hash = "sha256:e8c4ebfcfd57177b572e2040777b8abc537cdef58a2120e830124946aa9b42c5"}, + {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-win_amd64.whl", hash = "sha256:0467c5965282c62203273b838ae77c0d29d7638c8a4e3a1c8bdd3602c10904e4"}, + {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4c8c5d82f50bb53986a5e02d1b3092b03622c02c2eb78e29bec33fd9593bae1a"}, + {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:e7e3736715fbf53e9be2a79eb4db68e4ed857017344d697e8b9749444ae57475"}, + {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b7e75b4965e1d4690e93021adfcecccbca7d61c7bddd8e22406ef2ff20d74ef"}, + {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96777d473c05ee3e5e3c3e999f5d23c6f4ec5b0c38c098b3a5229085f74236c6"}, + {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:3bc2a80e6420ca8b7d3590791e2dfc709c88ab9152c00eeb511c9875ce5778bf"}, + {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e188d2699864c11c36cdfdada94d781fd5d6b0071cd9c427bceb08ad3d7c70e1"}, + {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f6f3eac23941b32afccc23081e1f50612bdbe4e982012ef4f5797986828cd01"}, + {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-win32.whl", hash = "sha256:6442cb36270b3afb1b4951f060eccca1ce49f3d087ca1ca4563a6eb479cb3de6"}, + {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-win_amd64.whl", hash = "sha256:e5b8daf27af0b90da7bb903a876477a9e6d7270be6146906b276605997c7e9a3"}, + {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:fc4b630cd3fa2cf7fce38afa91d7cfe844a9f75d7f0f36393fa98815e911d987"}, + {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:bc5f1e1c28e966d61d2519f2a3d451ba989f9ea0f2307de7bc45baa526de9e45"}, + {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a0e060aace4c24dcaf71023bbd7d42674e3b230f7e7b97317baf1e953e5b519"}, + {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e2f1c3765db32be59d18ab3953f43ab62a761327aafc1594a2a1fbe038b8b8a7"}, + {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d85252669dc32f98ebcd5d36768f5d4faeaeaa2d655ac0473be490ecdae3c285"}, + {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e143ada795c341b56de9418c58d028989093ee611aa27ffb9b7f609c00d813ed"}, + {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2c59aa6170b990d8d2719323e628aaf36f3bfbc1c26279c0eeeb24d05d2d11c7"}, + {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-win32.whl", hash = "sha256:beffaed67936fbbeffd10966a4eb53c402fafd3d6833770516bf7314bc6ffa12"}, + {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-win_amd64.whl", hash = "sha256:040ae85536960525ea62868b642bdb0c2cc6021c9f9d507810c0c604e66f5a7b"}, + {file = "ruamel.yaml.clib-0.2.12.tar.gz", hash = "sha256:6c8fbb13ec503f99a91901ab46e0b07ae7941cd527393187039aec586fdfd36f"}, +] + +[[package]] +name = "safety" +version = "3.6.0" +description = "Scan dependencies for known vulnerabilities and licenses." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "safety-3.6.0-py3-none-any.whl", hash = "sha256:9cddbfd7a578b35e4d29df4fb6e3808425ed792a09d81400a5b7f4b4535ef666"}, + {file = "safety-3.6.0.tar.gz", hash = "sha256:a820f827699f83d3d5c2faab24c0ac4d094911931503180847d97942c0a6f7e3"}, +] + +[package.dependencies] +authlib = ">=1.2.0" +click = ">=8.0.2" +dparse = ">=0.6.4" +filelock = ">=3.16.1,<3.17.0" +httpx = "*" +jinja2 = ">=3.1.0" +marshmallow = ">=3.15.0" +nltk = ">=3.9" +packaging = ">=21.0" +psutil = ">=6.1.0,<6.2.0" +pydantic = ">=2.6.0,<2.10.0" +requests = "*" +ruamel-yaml = ">=0.17.21" +safety-schemas = "0.0.14" +setuptools = ">=65.5.1" +tenacity = "*" +tomlkit = "*" +typer = ">=0.16.0" +typing-extensions = ">=4.7.1" + +[package.extras] +github = ["pygithub (>=1.43.3)"] +gitlab = ["python-gitlab (>=1.3.0)"] +spdx = ["spdx-tools (>=0.8.2)"] + +[[package]] +name = "safety-schemas" +version = "0.0.14" +description = "Schemas for Safety tools" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "safety_schemas-0.0.14-py3-none-any.whl", hash = "sha256:0bf6fc4aa5e474651b714cc9e427c862792946bf052b61d5c7bec4eac4c0f254"}, + {file = "safety_schemas-0.0.14.tar.gz", hash = "sha256:49953f7a59e919572be25595a8946f9cbbcd2066fe3e160c9467d9d1d6d7af6a"}, +] + +[package.dependencies] +dparse = ">=0.6.4" +packaging = ">=21.0" +pydantic = ">=2.6.0,<2.10.0" +ruamel-yaml = ">=0.17.21" +typing-extensions = ">=4.7.1" + +[[package]] +name = "setuptools" +version = "80.9.0" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922"}, + {file = "setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""] +core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"] + +[[package]] +name = "shellingham" +version = "1.5.4" +description = "Tool to Detect Surrounding Shell" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, + {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, +] + [[package]] name = "six" version = "1.16.0" @@ -1209,6 +2216,110 @@ files = [ {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] +[[package]] +name = "sniffio" +version = "1.3.1" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, +] + +[[package]] +name = "stevedore" +version = "5.5.0" +description = "Manage dynamic plugins for Python applications" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "stevedore-5.5.0-py3-none-any.whl", hash = "sha256:18363d4d268181e8e8452e71a38cd77630f345b2ef6b4a8d5614dac5ee0d18cf"}, + {file = "stevedore-5.5.0.tar.gz", hash = "sha256:d31496a4f4df9825e1a1e4f1f74d19abb0154aff311c3b376fcc89dae8fccd73"}, +] + +[[package]] +name = "tenacity" +version = "9.1.2" +description = "Retry code until it succeeds" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138"}, + {file = "tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb"}, +] + +[package.extras] +doc = ["reno", "sphinx"] +test = ["pytest", "tornado (>=4.5)", "typeguard"] + +[[package]] +name = "tomlkit" +version = "0.13.3" +description = "Style preserving TOML library" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0"}, + {file = "tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1"}, +] + +[[package]] +name = "tqdm" +version = "4.67.1" +description = "Fast, Extensible Progress Meter" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2"}, + {file = "tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[package.extras] +dev = ["nbval", "pytest (>=6)", "pytest-asyncio (>=0.24)", "pytest-cov", "pytest-timeout"] +discord = ["requests"] +notebook = ["ipywidgets (>=6)"] +slack = ["slack-sdk"] +telegram = ["requests"] + +[[package]] +name = "typer" +version = "0.16.1" +description = "Typer, build great CLIs. Easy to code. Based on Python type hints." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "typer-0.16.1-py3-none-any.whl", hash = "sha256:90ee01cb02d9b8395ae21ee3368421faf21fa138cb2a541ed369c08cec5237c9"}, + {file = "typer-0.16.1.tar.gz", hash = "sha256:d358c65a464a7a90f338e3bb7ff0c74ac081449e53884b12ba658cbd72990614"}, +] + +[package.dependencies] +click = ">=8.0.0" +rich = ">=10.11.0" +shellingham = ">=1.3.0" +typing-extensions = ">=3.7.4.3" + +[[package]] +name = "typing-extensions" +version = "4.15.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, +] + [[package]] name = "tzdata" version = "2024.2" @@ -1279,4 +2390,4 @@ dev = ["black (>=19.3b0) ; python_version >= \"3.6\"", "pytest (>=4.6.2)"] [metadata] lock-version = "2.1" python-versions = "^3.12" -content-hash = "5c396431a767f3ec5afa9dc6132a61a4b6e3648021a61ccd20304877be817328" +content-hash = "7dca814a5da2e698160e45dfb24b63bc4d3621a08dd7426ccb4f4460ca0c6c82" diff --git a/practice/thread_demo.py b/practice/thread_demo.py deleted file mode 100644 index a2af003..0000000 --- a/practice/thread_demo.py +++ /dev/null @@ -1,46 +0,0 @@ -import time - -# 线程池 -from concurrent.futures import ThreadPoolExecutor - -# 多进程 -from multiprocessing import Pipe, Process -from threading import Thread -from time import sleep - -from loguru import logger - - -def test_function(key_1: str): - logger.info(f"test_function : {key_1} start") - sleep(2) - logger.info(f"test_function : {key_1} end") - - -if __name__ == "__main__": - # 时间 - start_time = time.time() - # logger.info("main start") - # t = Thread(target=test_function,kwargs={"key_1":"value"}) - # t.start() - # logger.info("main end") - # t.join() - # logger.info(f"耗时{time.time() - start_time}") - # pool = ThreadPoolExecutor(max_workers=3) - # pool.map(test_function,["key_1","key_2","key_3"]) - # pool.map(lambda x: test_function(x),["key_1","key_2","key_3"]) - # for i in range(10): - # pool.submit(test_function, f"key_{i}") - # for i in range(10): - # pool.submit(test_function, {"key_1":f"value_{i}"}) - # pool.shutdown(wait=True) - c1, c2 = Pipe() - c1.send("hello") - logger.info(c2.recv()) - c1.send("world") - logger.info(c2.recv()) - c2.send("hello") - logger.info(c1.recv()) - c2.close() - logger.info(c1.recv()) # 因为c2已经close,所以会抛出异常raise EOFError - logger.info(f"耗时{time.time() - start_time}") diff --git a/pyproject.toml b/pyproject.toml index dbd4fbe..8a5c9d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,11 @@ [tool.poetry] name = "pytest-framework" -version = "0.1.0" -description = "搭建接口测测试框架,可以在这个基础上进行二次封装,提高测试效率" +version = "1.0.0" +description = "现代化接口测试自动化框架 - 功能强大、易于扩展的Python接口测试框架" authors = ["ljxpython <1030470148@qq.com>"] license = "MIT" readme = "README.md" +package-mode = false [tool.poetry.dependencies] python = "^3.12" @@ -34,7 +35,11 @@ humps = "^0.2.2" adb-shell = "^0.4.4" peewee = "^3.17.6" pymysql = "^1.1.1" -playhouse = "^0.1.0" +flake8 = "^7.0.0" +mypy = "^1.11.0" +pytest-cov = "^5.0.0" +safety = "^3.0.0" +bandit = "^1.7.0" [build-system] diff --git a/pytest.ini b/pytest.ini index dfa2c2e..4457b64 100644 --- a/pytest.ini +++ b/pytest.ini @@ -9,9 +9,17 @@ addopts = --alluredir=output/allure-result --color=no markers = - P0: P0及case - P1: - P2: + P0: P0级别测试用例 + P1: P1级别测试用例 + P2: P2级别测试用例 + smoke: 冒烟测试 + regression: 回归测试 + integration: 集成测试 + performance: 性能测试 + slow: 慢速测试 + dev_only: 仅在开发环境运行 + prod_safe: 生产环境安全测试 + requires_external_service: 需要外部服务的测试 ; core: 冒烟测试-主路径case ; full: 全量case ; test: 临时测试case diff --git a/requirements.txt b/requirements.txt index 2430ea0..4fe07d7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,11 +6,23 @@ allure-pytest==2.13.5 ; python_version >= "3.12" and python_version < "4.0" \ allure-python-commons==2.13.5 ; python_version >= "3.12" and python_version < "4.0" \ --hash=sha256:8b0e837b6e32d810adec563f49e1d04127a5b6770e0232065b7cb09b9953980d \ --hash=sha256:a232e7955811f988e49a4c1dd6c16cce7e9b81d0ea0422b1e5654d3254e2caf3 +annotated-types==0.7.0 ; python_version >= "3.12" and python_version < "4.0" \ + --hash=sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53 \ + --hash=sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89 +anyio==4.10.0 ; python_version >= "3.12" and python_version < "4.0" \ + --hash=sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6 \ + --hash=sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1 assertpy==1.1 ; python_version >= "3.12" and python_version < "4.0" \ --hash=sha256:acc64329934ad71a3221de185517a43af33e373bb44dc05b5a9b174394ef4833 attrs==24.2.0 ; python_version >= "3.12" and python_version < "4.0" \ --hash=sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346 \ --hash=sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2 +authlib==1.6.2 ; python_version >= "3.12" and python_version < "4.0" \ + --hash=sha256:2dd5571013cacf6b15f7addce03ed057ffdf629e9e81bacd9c08455a190e9b57 \ + --hash=sha256:3bde83ac0392683eeef589cd5ab97e63cbe859e552dd75dca010548e79202cb1 +bandit==1.8.6 ; python_version >= "3.12" and python_version < "4.0" \ + --hash=sha256:3348e934d736fcdb68b6aa4030487097e23a501adf3e7827b63658df464dddd0 \ + --hash=sha256:dbfe9c25fc6961c2078593de55fd19f2559f9e45b99f1272341f5b95dea4e56b black==24.8.0 ; python_version >= "3.12" and python_version < "4.0" \ --hash=sha256:09cdeb74d494ec023ded657f7092ba518e8cf78fa8386155e4a03fdcc44679e6 \ --hash=sha256:1f13f7f386f86f8121d76599114bb8c17b69d962137fc70efe56137727c7047e \ @@ -205,6 +217,95 @@ click==8.1.7 ; python_version >= "3.12" and python_version < "4.0" \ colorama==0.4.6 ; python_version >= "3.12" and python_version < "4.0" and (sys_platform == "win32" or platform_system == "Windows") \ --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 +coverage[toml]==7.10.5 ; python_version >= "3.12" and python_version < "4.0" \ + --hash=sha256:02252dc1216e512a9311f596b3169fad54abcb13827a8d76d5630c798a50a754 \ + --hash=sha256:02650a11324b80057b8c9c29487020073d5e98a498f1857f37e3f9b6ea1b2426 \ + --hash=sha256:03f47dc870eec0367fcdd603ca6a01517d2504e83dc18dbfafae37faec66129a \ + --hash=sha256:0520dff502da5e09d0d20781df74d8189ab334a1e40d5bafe2efaa4158e2d9e7 \ + --hash=sha256:0666cf3d2c1626b5a3463fd5b05f5e21f99e6aec40a3192eee4d07a15970b07f \ + --hash=sha256:0913dd1613a33b13c4f84aa6e3f4198c1a21ee28ccb4f674985c1f22109f0aae \ + --hash=sha256:0be24d35e4db1d23d0db5c0f6a74a962e2ec83c426b5cac09f4234aadef38e4a \ + --hash=sha256:0d511dda38595b2b6934c2b730a1fd57a3635c6aa2a04cb74714cdfdd53846f4 \ + --hash=sha256:146fa1531973d38ab4b689bc764592fe6c2f913e7e80a39e7eeafd11f0ef6db2 \ + --hash=sha256:14d6071c51ad0f703d6440827eaa46386169b5fdced42631d5a5ac419616046f \ + --hash=sha256:1b7181c0feeb06ed8a02da02792f42f829a7b29990fef52eff257fef0885d760 \ + --hash=sha256:1d043a8a06987cc0c98516e57c4d3fc2c1591364831e9deb59c9e1b4937e8caf \ + --hash=sha256:1f64b8d3415d60f24b058b58d859e9512624bdfa57a2d1f8aff93c1ec45c429b \ + --hash=sha256:1f672efc0731a6846b157389b6e6d5d5e9e59d1d1a23a5c66a99fd58339914d5 \ + --hash=sha256:1f8a81b0614642f91c9effd53eec284f965577591f51f547a1cbeb32035b4c2f \ + --hash=sha256:2285c04ee8676f7938b02b4936d9b9b672064daab3187c20f73a55f3d70e6b4a \ + --hash=sha256:2968647e3ed5a6c019a419264386b013979ff1fb67dd11f5c9886c43d6a31fc2 \ + --hash=sha256:2b96bfdf7c0ea9faebce088a3ecb2382819da4fbc05c7b80040dbc428df6af44 \ + --hash=sha256:2d1b73023854068c44b0c554578a4e1ef1b050ed07cf8b431549e624a29a66ee \ + --hash=sha256:2d488d7d42b6ded7ea0704884f89dcabd2619505457de8fc9a6011c62106f6e5 \ + --hash=sha256:32ddaa3b2c509778ed5373b177eb2bf5662405493baeff52278a0b4f9415188b \ + --hash=sha256:343a023193f04d46edc46b2616cdbee68c94dd10208ecd3adc56fcc54ef2baa1 \ + --hash=sha256:36d42b7396b605f774d4372dd9c49bed71cbabce4ae1ccd074d155709dd8f235 \ + --hash=sha256:384b34482272e960c438703cafe63316dfbea124ac62006a455c8410bf2a2262 \ + --hash=sha256:3876385722e335d6e991c430302c24251ef9c2a9701b2b390f5473199b1b8ebf \ + --hash=sha256:38a9109c4ee8135d5df5505384fc2f20287a47ccbe0b3f04c53c9a1989c2bbaf \ + --hash=sha256:3f39cef43d08049e8afc1fde4a5da8510fc6be843f8dea350ee46e2a26b2f54c \ + --hash=sha256:4028e7558e268dd8bcf4d9484aad393cafa654c24b4885f6f9474bf53183a82a \ + --hash=sha256:414a568cd545f9dc75f0686a0049393de8098414b58ea071e03395505b73d7a8 \ + --hash=sha256:42144e8e346de44a6f1dbd0a56575dd8ab8dfa7e9007da02ea5b1c30ab33a7db \ + --hash=sha256:44d43de99a9d90b20e0163f9770542357f58860a26e24dc1d924643bd6aa7cb4 \ + --hash=sha256:467dc74bd0a1a7de2bedf8deaf6811f43602cb532bd34d81ffd6038d6d8abe99 \ + --hash=sha256:5255b3bbcc1d32a4069d6403820ac8e6dbcc1d68cb28a60a1ebf17e47028e898 \ + --hash=sha256:54a1532c8a642d8cc0bd5a9a51f5a9dcc440294fd06e9dda55e743c5ec1a8f14 \ + --hash=sha256:556d23d4e6393ca898b2e63a5bca91e9ac2d5fb13299ec286cd69a09a7187fde \ + --hash=sha256:5661bf987d91ec756a47c7e5df4fbcb949f39e32f9334ccd3f43233bbb65e508 \ + --hash=sha256:585ffe93ae5894d1ebdee69fc0b0d4b7c75d8007983692fb300ac98eed146f78 \ + --hash=sha256:5e78bd9cf65da4c303bf663de0d73bf69f81e878bf72a94e9af67137c69b9fe9 \ + --hash=sha256:5f1dc8f1980a272ad4a6c84cba7981792344dad33bf5869361576b7aef42733a \ + --hash=sha256:6013a37b8a4854c478d3219ee8bc2392dea51602dd0803a12d6f6182a0061762 \ + --hash=sha256:609b60d123fc2cc63ccee6d17e4676699075db72d14ac3c107cc4976d516f2df \ + --hash=sha256:61f78c7c3bc272a410c5ae3fde7792b4ffb4acc03d35a7df73ca8978826bb7ab \ + --hash=sha256:62835c1b00c4a4ace24c1a88561a5a59b612fbb83a525d1c70ff5720c97c0610 \ + --hash=sha256:63d4bb2966d6f5f705a6b0c6784c8969c468dbc4bcf9d9ded8bff1c7e092451f \ + --hash=sha256:63df1fdaffa42d914d5c4d293e838937638bf75c794cf20bee12978fc8c4e3bc \ + --hash=sha256:66c644cbd7aed8fe266d5917e2c9f65458a51cfe5eeff9c05f15b335f697066e \ + --hash=sha256:672a6c1da5aea6c629819a0e1461e89d244f78d7b60c424ecf4f1f2556c041d8 \ + --hash=sha256:68c5e0bc5f44f68053369fa0d94459c84548a77660a5f2561c5e5f1e3bed7031 \ + --hash=sha256:6a29f8e0adb7f8c2b95fa2d4566a1d6e6722e0a637634c6563cb1ab844427dd9 \ + --hash=sha256:6b87f1ad60b30bc3c43c66afa7db6b22a3109902e28c5094957626a0143a001f \ + --hash=sha256:73269df37883e02d460bee0cc16be90509faea1e3bd105d77360b512d5bb9c33 \ + --hash=sha256:74d5b63fe3f5f5d372253a4ef92492c11a4305f3550631beaa432fc9df16fcff \ + --hash=sha256:7e78b767da8b5fc5b2faa69bb001edafcd6f3995b42a331c53ef9572c55ceb82 \ + --hash=sha256:7fa22800f3908df31cea6fb230f20ac49e343515d968cc3a42b30d5c3ebf9b5a \ + --hash=sha256:8002dc6a049aac0e81ecec97abfb08c01ef0c1fbf962d0c98da3950ace89b869 \ + --hash=sha256:8048ce4b149c93447a55d279078c8ae98b08a6951a3c4d2d7e87f4efc7bfe100 \ + --hash=sha256:90dc3d6fb222b194a5de60af8d190bedeeddcbc7add317e4a3cd333ee6b7c879 \ + --hash=sha256:9a86281794a393513cf117177fd39c796b3f8e3759bb2764259a2abba5cce54b \ + --hash=sha256:a46473129244db42a720439a26984f8c6f834762fc4573616c1f37f13994b357 \ + --hash=sha256:a931a87e5ddb6b6404e65443b742cb1c14959622777f2a4efd81fba84f5d91ba \ + --hash=sha256:ad8fa9d5193bafcf668231294241302b5e683a0518bf1e33a9a0dfb142ec3031 \ + --hash=sha256:b08801e25e3b4526ef9ced1aa29344131a8f5213c60c03c18fe4c6170ffa2874 \ + --hash=sha256:b0ef4e66f006ed181df29b59921bd8fc7ed7cd6a9289295cd8b2824b49b570df \ + --hash=sha256:b3dcf2ead47fa8be14224ee817dfc1df98043af568fe120a22f81c0eb3c34ad2 \ + --hash=sha256:b45264dd450a10f9e03237b41a9a24e85cbb1e278e5a32adb1a303f58f0017f3 \ + --hash=sha256:b4fdc777e05c4940b297bf47bf7eedd56a39a61dc23ba798e4b830d585486ca5 \ + --hash=sha256:bc85eb2d35e760120540afddd3044a5bf69118a91a296a8b3940dfc4fdcfe1e2 \ + --hash=sha256:bc8e4d99ce82f1710cc3c125adc30fd1487d3cf6c2cd4994d78d68a47b16989a \ + --hash=sha256:c177e6ffe2ebc7c410785307758ee21258aa8e8092b44d09a2da767834f075f2 \ + --hash=sha256:c2492e4dd9daab63f5f56286f8a04c51323d237631eb98505d87e4c4ff19ec34 \ + --hash=sha256:c2d05c7e73c60a4cecc7d9b60dbfd603b4ebc0adafaef371445b47d0f805c8a9 \ + --hash=sha256:c6a5c3414bfc7451b879141ce772c546985163cf553f08e0f135f0699a911801 \ + --hash=sha256:cebd8e906eb98bb09c10d1feed16096700b1198d482267f8bf0474e63a7b8d84 \ + --hash=sha256:cf33134ffae93865e32e1e37df043bef15a5e857d8caebc0099d225c579b0fa3 \ + --hash=sha256:d9cd64aca68f503ed3f1f18c7c9174cbb797baba02ca8ab5112f9d1c0328cd4b \ + --hash=sha256:dd382410039fe062097aa0292ab6335a3f1e7af7bba2ef8d27dcda484918f20c \ + --hash=sha256:e551f9d03347196271935fd3c0c165f0e8c049220280c1120de0084d65e9c7ff \ + --hash=sha256:eb7b0bbf7cc1d0453b843eca7b5fa017874735bef9bfdfa4121373d2cc885ed6 \ + --hash=sha256:eb90fe20db9c3d930fa2ad7a308207ab5b86bf6a76f54ab6a40be4012d88fcae \ + --hash=sha256:ed9749bb8eda35f8b636fb7632f1c62f735a236a5d4edadd8bbcc5ea0542e732 \ + --hash=sha256:ef3b83594d933020f54cf65ea1f4405d1f4e41a009c46df629dd964fcb6e907c \ + --hash=sha256:f2e57716a78bc3ae80b2207be0709a3b2b63b9f2dcf9740ee6ac03588a2015b6 \ + --hash=sha256:f366a57ac81f5e12797136552f5b7502fa053c861a009b91b80ed51f2ce651c6 \ + --hash=sha256:f39071caa126f69d63f99b324fb08c7b1da2ec28cbb1fe7b5b1799926492f65c \ + --hash=sha256:f4446a9547681533c8fa3e3c6cf62121eeee616e6a92bd9201c6edd91beffe13 \ + --hash=sha256:f9559b906a100029274448f4c8b8b0a127daa4dade5661dfd821b8c188058842 \ + --hash=sha256:fcf6ab569436b4a647d4e91accba12509ad9f2554bc93d3aee23cc596e7f99c3 \ + --hash=sha256:fefafcca09c3ac56372ef64a40f5fe17c5592fab906e0fdffd09543f3012ba50 cryptography==43.0.1 ; python_version >= "3.12" and python_version < "4.0" \ --hash=sha256:014f58110f53237ace6a408b5beb6c427b64e084eb451ef25a28308270086494 \ --hash=sha256:1bbcce1a551e262dfbafb6e6252f1ae36a248e615ca44ba302df077a846a8806 \ @@ -239,6 +340,9 @@ decorator==5.1.1 ; python_version >= "3.12" and python_version < "4.0" \ distlib==0.3.8 ; python_version >= "3.12" and python_version < "4.0" \ --hash=sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784 \ --hash=sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64 +dparse==0.6.4 ; python_version >= "3.12" and python_version < "4.0" \ + --hash=sha256:90b29c39e3edc36c6284c82c4132648eaf28a01863eb3c231c2512196132201a \ + --hash=sha256:fbab4d50d54d0e739fbb4dedfc3d92771003a5b9aa8545ca7a7045e3b174af57 dynaconf==3.2.6 ; python_version >= "3.12" and python_version < "4.0" \ --hash=sha256:3911c740d717df4576ed55f616c7cbad6e06bc8ef23ffca444b6e2a12fb1c34c \ --hash=sha256:74cc1897396380bb957730eb341cc0976ee9c38bbcb53d3307c50caed0aedfb8 @@ -251,6 +355,18 @@ faker==29.0.0 ; python_version >= "3.12" and python_version < "4.0" \ filelock==3.16.1 ; python_version >= "3.12" and python_version < "4.0" \ --hash=sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0 \ --hash=sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435 +flake8==7.3.0 ; python_version >= "3.12" and python_version < "4.0" \ + --hash=sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e \ + --hash=sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872 +h11==0.16.0 ; python_version >= "3.12" and python_version < "4.0" \ + --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \ + --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 +httpcore==1.0.9 ; python_version >= "3.12" and python_version < "4.0" \ + --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \ + --hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8 +httpx==0.28.1 ; python_version >= "3.12" and python_version < "4.0" \ + --hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \ + --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad humps==0.2.2 ; python_version >= "3.12" and python_version < "4.0" \ --hash=sha256:401de60fd73673b697086ba7c865966751c57b11cce57a37c9eabb3e75938417 \ --hash=sha256:777f7a17f6b20aa9cc078d63b56ebd66532c6b2c88b370f1d596d5205410f67c @@ -266,18 +382,140 @@ iniconfig==2.0.0 ; python_version >= "3.12" and python_version < "4.0" \ isort==5.13.2 ; python_version >= "3.12" and python_version < "4.0" \ --hash=sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109 \ --hash=sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6 +jinja2==3.1.6 ; python_version >= "3.12" and python_version < "4.0" \ + --hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \ + --hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67 jmespath==1.0.1 ; python_version >= "3.12" and python_version < "4.0" \ --hash=sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980 \ --hash=sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe +joblib==1.5.1 ; python_version >= "3.12" and python_version < "4.0" \ + --hash=sha256:4719a31f054c7d766948dcd83e9613686b27114f190f717cec7eaa2084f8a74a \ + --hash=sha256:f4f86e351f39fe3d0d32a9f2c3d8af1ee4cec285aafcb27003dda5205576b444 jsonpath-ng==1.6.1 ; python_version >= "3.12" and python_version < "4.0" \ --hash=sha256:086c37ba4917304850bd837aeab806670224d3f038fe2833ff593a672ef0a5fa \ --hash=sha256:8f22cd8273d7772eea9aaa84d922e0841aa36fdb8a2c6b7f6c3791a16a9bc0be loguru==0.7.2 ; python_version >= "3.12" and python_version < "4.0" \ --hash=sha256:003d71e3d3ed35f0f8984898359d65b79e5b21943f78af86aa5491210429b8eb \ --hash=sha256:e671a53522515f34fd406340ee968cb9ecafbc4b36c679da03c18fd8d0bd51ac +markdown-it-py==4.0.0 ; python_version >= "3.12" and python_version < "4.0" \ + --hash=sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147 \ + --hash=sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3 +markupsafe==3.0.2 ; python_version >= "3.12" and python_version < "4.0" \ + --hash=sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4 \ + --hash=sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30 \ + --hash=sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0 \ + --hash=sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9 \ + --hash=sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396 \ + --hash=sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13 \ + --hash=sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028 \ + --hash=sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca \ + --hash=sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557 \ + --hash=sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832 \ + --hash=sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0 \ + --hash=sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b \ + --hash=sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579 \ + --hash=sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a \ + --hash=sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c \ + --hash=sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff \ + --hash=sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c \ + --hash=sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22 \ + --hash=sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094 \ + --hash=sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb \ + --hash=sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e \ + --hash=sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5 \ + --hash=sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a \ + --hash=sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d \ + --hash=sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a \ + --hash=sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b \ + --hash=sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8 \ + --hash=sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225 \ + --hash=sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c \ + --hash=sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144 \ + --hash=sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f \ + --hash=sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87 \ + --hash=sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d \ + --hash=sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93 \ + --hash=sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf \ + --hash=sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158 \ + --hash=sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84 \ + --hash=sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb \ + --hash=sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48 \ + --hash=sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171 \ + --hash=sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c \ + --hash=sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6 \ + --hash=sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd \ + --hash=sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d \ + --hash=sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1 \ + --hash=sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d \ + --hash=sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca \ + --hash=sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a \ + --hash=sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29 \ + --hash=sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe \ + --hash=sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798 \ + --hash=sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c \ + --hash=sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8 \ + --hash=sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f \ + --hash=sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f \ + --hash=sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a \ + --hash=sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178 \ + --hash=sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0 \ + --hash=sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79 \ + --hash=sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430 \ + --hash=sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50 +marshmallow==4.0.0 ; python_version >= "3.12" and python_version < "4.0" \ + --hash=sha256:3b6e80aac299a7935cfb97ed01d1854fb90b5079430969af92118ea1b12a8d55 \ + --hash=sha256:e7b0528337e9990fd64950f8a6b3a1baabed09ad17a0dfb844d701151f92d203 +mccabe==0.7.0 ; python_version >= "3.12" and python_version < "4.0" \ + --hash=sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325 \ + --hash=sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e +mdurl==0.1.2 ; python_version >= "3.12" and python_version < "4.0" \ + --hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \ + --hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba mypy-extensions==1.0.0 ; python_version >= "3.12" and python_version < "4.0" \ --hash=sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d \ --hash=sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782 +mypy==1.17.1 ; python_version >= "3.12" and python_version < "4.0" \ + --hash=sha256:03b6d0ed2b188e35ee6d5c36b5580cffd6da23319991c49ab5556c023ccf1341 \ + --hash=sha256:064e2ff508e5464b4bd807a7c1625bc5047c5022b85c70f030680e18f37273a5 \ + --hash=sha256:099b9a5da47de9e2cb5165e581f158e854d9e19d2e96b6698c0d64de911dd849 \ + --hash=sha256:15a83369400454c41ed3a118e0cc58bd8123921a602f385cb6d6ea5df050c733 \ + --hash=sha256:15d54056f7fe7a826d897789f53dd6377ec2ea8ba6f776dc83c2902b899fee81 \ + --hash=sha256:1b16708a66d38abb1e6b5702f5c2c87e133289da36f6a1d15f6a5221085c6403 \ + --hash=sha256:209a58fed9987eccc20f2ca94afe7257a8f46eb5df1fb69958650973230f91e6 \ + --hash=sha256:25e01ec741ab5bb3eec8ba9cdb0f769230368a22c959c4937360efb89b7e9f01 \ + --hash=sha256:397fba5d7616a5bc60b45c7ed204717eaddc38f826e3645402c426057ead9a91 \ + --hash=sha256:3fbe6d5555bf608c47203baa3e72dbc6ec9965b3d7c318aa9a4ca76f465bd972 \ + --hash=sha256:43808d9476c36b927fbcd0b0255ce75efe1b68a080154a38ae68a7e62de8f0f8 \ + --hash=sha256:55b918670f692fc9fba55c3298d8a3beae295c5cded0a55dccdc5bbead814acd \ + --hash=sha256:5d1092694f166a7e56c805caaf794e0585cabdbf1df36911c414e4e9abb62ae9 \ + --hash=sha256:62761474061feef6f720149d7ba876122007ddc64adff5ba6f374fda35a018a0 \ + --hash=sha256:665afab0963a4b39dff7c1fa563cc8b11ecff7910206db4b2e64dd1ba25aed19 \ + --hash=sha256:69e83ea6553a3ba79c08c6e15dbd9bfa912ec1e493bf75489ef93beb65209aeb \ + --hash=sha256:70401bbabd2fa1aa7c43bb358f54037baf0586f41e83b0ae67dd0534fc64edfd \ + --hash=sha256:79d44f9bfb004941ebb0abe8eff6504223a9c1ac51ef967d1263c6572bbebc99 \ + --hash=sha256:80ef5c058b7bce08c83cac668158cb7edea692e458d21098c7d3bce35a5d43e7 \ + --hash=sha256:89e972c0035e9e05823907ad5398c5a73b9f47a002b22359b177d40bdaee7056 \ + --hash=sha256:93378d3203a5c0800c6b6d850ad2f19f7a3cdf1a3701d3416dbf128805c6a6a7 \ + --hash=sha256:9a2b7d9180aed171f033c9f2fc6c204c1245cf60b0cb61cf2e7acc24eea78e0a \ + --hash=sha256:9d6b20b97d373f41617bd0708fd46aa656059af57f2ef72aa8c7d6a2b73b74ed \ + --hash=sha256:a76906f26bd8d51ea9504966a9c25419f2e668f012e0bdf3da4ea1526c534d94 \ + --hash=sha256:a9f52c0351c21fe24c21d8c0eb1f62967b262d6729393397b6f443c3b773c3b9 \ + --hash=sha256:ad37544be07c5d7fba814eb370e006df58fed8ad1ef33ed1649cb1889ba6ff58 \ + --hash=sha256:b01586eed696ec905e61bd2568f48740f7ac4a45b3a468e6423a03d3788a51a8 \ + --hash=sha256:c1fdf4abb29ed1cb091cf432979e162c208a5ac676ce35010373ff29247bcad5 \ + --hash=sha256:c49562d3d908fd49ed0938e5423daed8d407774a479b595b143a3d7f87cdae6a \ + --hash=sha256:c4a580f8a70c69e4a75587bd925d298434057fe2a428faaf927ffe6e4b9a98df \ + --hash=sha256:c837b896b37cd103570d776bda106eabb8737aa6dd4f248451aecf53030cdbeb \ + --hash=sha256:d7598cf74c3e16539d4e2f0b8d8c318e00041553d83d4861f87c7a72e95ac24d \ + --hash=sha256:dd86bb649299f09d987a2eebb4d52d10603224500792e1bee18303bbcc1ce390 \ + --hash=sha256:e79311f2d904ccb59787477b7bd5d26f3347789c06fcd7656fa500875290264b \ + --hash=sha256:e92bdc656b7757c438660f775f872a669b8ff374edc4d18277d86b63edba6b8b \ + --hash=sha256:fa6ffadfbe6994d724c5a1bb6123a7d27dd68fc9c059561cd33b664a79578e14 \ + --hash=sha256:feb8cc32d319edd5859da2cc084493b3e2ce5e49a946377663cc90f6c15fb259 \ + --hash=sha256:ff2933428516ab63f961644bc49bc4cbe42bbffb2cd3b71cc7277c07d16b1a8b +nltk==3.9.1 ; python_version >= "3.12" and python_version < "4.0" \ + --hash=sha256:4fa26829c5b00715afe3061398a8989dc643b92ce7dd93fb4585a70930d168a1 \ + --hash=sha256:87d127bd3de4bd89a4f81265e5fa59cb1b199b27440175370f7417d2bc7ae868 nodeenv==1.9.1 ; python_version >= "3.12" and python_version < "4.0" \ --hash=sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f \ --hash=sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9 @@ -389,9 +627,6 @@ peewee==3.17.6 ; python_version >= "3.12" and python_version < "4.0" \ platformdirs==4.3.6 ; python_version >= "3.12" and python_version < "4.0" \ --hash=sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907 \ --hash=sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb -playhouse==0.1.0 ; python_version >= "3.12" and python_version < "4.0" \ - --hash=sha256:98e375b2ec649ad454e3af91c6cb243a37709250062a53559de3ec54044c8a28 \ - --hash=sha256:ba6b851f8bba7cf083b0289f64729b717f4e8bd552d2ba3dfc6fc1f76fdd40a1 pluggy==1.5.0 ; python_version >= "3.12" and python_version < "4.0" \ --hash=sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1 \ --hash=sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669 @@ -404,18 +639,141 @@ polling2==0.5.0 ; python_version >= "3.12" and python_version < "4.0" \ pre-commit==3.8.0 ; python_version >= "3.12" and python_version < "4.0" \ --hash=sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af \ --hash=sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f +psutil==6.1.1 ; python_version >= "3.12" and python_version < "4.0" \ + --hash=sha256:018aeae2af92d943fdf1da6b58665124897cfc94faa2ca92098838f83e1b1bca \ + --hash=sha256:0bdd4eab935276290ad3cb718e9809412895ca6b5b334f5a9111ee6d9aff9377 \ + --hash=sha256:1924e659d6c19c647e763e78670a05dbb7feaf44a0e9c94bf9e14dfc6ba50468 \ + --hash=sha256:33431e84fee02bc84ea36d9e2c4a6d395d479c9dd9bba2376c1f6ee8f3a4e0b3 \ + --hash=sha256:384636b1a64b47814437d1173be1427a7c83681b17a450bfc309a1953e329603 \ + --hash=sha256:6d4281f5bbca041e2292be3380ec56a9413b790579b8e593b1784499d0005dac \ + --hash=sha256:8be07491f6ebe1a693f17d4f11e69d0dc1811fa082736500f649f79df7735303 \ + --hash=sha256:8df0178ba8a9e5bc84fed9cfa61d54601b371fbec5c8eebad27575f1e105c0d4 \ + --hash=sha256:97f7cb9921fbec4904f522d972f0c0e1f4fabbdd4e0287813b21215074a0f160 \ + --hash=sha256:9ccc4316f24409159897799b83004cb1e24f9819b0dcf9c0b68bdcb6cefee6a8 \ + --hash=sha256:b6e06c20c05fe95a3d7302d74e7097756d4ba1247975ad6905441ae1b5b66003 \ + --hash=sha256:c777eb75bb33c47377c9af68f30e9f11bc78e0f07fbf907be4a5d70b2fe5f030 \ + --hash=sha256:ca9609c77ea3b8481ab005da74ed894035936223422dc591d6772b147421f777 \ + --hash=sha256:cf8496728c18f2d0b45198f06895be52f36611711746b7f30c464b422b50e2f5 \ + --hash=sha256:eaa912e0b11848c4d9279a93d7e2783df352b082f40111e078388701fd479e53 \ + --hash=sha256:f35cfccb065fff93529d2afb4a2e89e363fe63ca1e4a5da22b603a85833c2649 \ + --hash=sha256:fc0ed7fe2231a444fc219b9c42d0376e0a9a1a72f16c5cfa0f68d19f1a0663e8 py==1.11.0 ; python_version >= "3.12" and python_version < "4.0" \ --hash=sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719 \ --hash=sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378 pyasn1==0.6.1 ; python_version >= "3.12" and python_version < "4.0" \ --hash=sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629 \ --hash=sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034 +pycodestyle==2.14.0 ; python_version >= "3.12" and python_version < "4.0" \ + --hash=sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783 \ + --hash=sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d pycparser==2.22 ; python_version >= "3.12" and python_version < "4.0" and platform_python_implementation != "PyPy" \ --hash=sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6 \ --hash=sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc +pydantic-core==2.23.4 ; python_version >= "3.12" and python_version < "4.0" \ + --hash=sha256:0a7df63886be5e270da67e0966cf4afbae86069501d35c8c1b3b6c168f42cb36 \ + --hash=sha256:0cb3da3fd1b6a5d0279a01877713dbda118a2a4fc6f0d821a57da2e464793f05 \ + --hash=sha256:0dbd8dbed2085ed23b5c04afa29d8fd2771674223135dc9bc937f3c09284d071 \ + --hash=sha256:0dff76e0602ca7d4cdaacc1ac4c005e0ce0dcfe095d5b5259163a80d3a10d327 \ + --hash=sha256:1278e0d324f6908e872730c9102b0112477a7f7cf88b308e4fc36ce1bdb6d58c \ + --hash=sha256:128585782e5bfa515c590ccee4b727fb76925dd04a98864182b22e89a4e6ed36 \ + --hash=sha256:1498bec4c05c9c787bde9125cfdcc63a41004ff167f495063191b863399b1a29 \ + --hash=sha256:19442362866a753485ba5e4be408964644dd6a09123d9416c54cd49171f50744 \ + --hash=sha256:1b84d168f6c48fabd1f2027a3d1bdfe62f92cade1fb273a5d68e621da0e44e6d \ + --hash=sha256:1e90d2e3bd2c3863d48525d297cd143fe541be8bbf6f579504b9712cb6b643ec \ + --hash=sha256:20152074317d9bed6b7a95ade3b7d6054845d70584216160860425f4fbd5ee9e \ + --hash=sha256:216f9b2d7713eb98cb83c80b9c794de1f6b7e3145eef40400c62e86cee5f4e1e \ + --hash=sha256:233710f069d251feb12a56da21e14cca67994eab08362207785cf8c598e74577 \ + --hash=sha256:255a8ef062cbf6674450e668482456abac99a5583bbafb73f9ad469540a3a232 \ + --hash=sha256:2584f7cf844ac4d970fba483a717dbe10c1c1c96a969bf65d61ffe94df1b2863 \ + --hash=sha256:2971bb5ffe72cc0f555c13e19b23c85b654dd2a8f7ab493c262071377bfce9f6 \ + --hash=sha256:29d2c342c4bc01b88402d60189f3df065fb0dda3654744d5a165a5288a657368 \ + --hash=sha256:2e203fdf807ac7e12ab59ca2bfcabb38c7cf0b33c41efeb00f8e5da1d86af480 \ + --hash=sha256:33e3d65a85a2a4a0dc3b092b938a4062b1a05f3a9abde65ea93b233bca0e03f2 \ + --hash=sha256:374a5e5049eda9e0a44c696c7ade3ff355f06b1fe0bb945ea3cac2bc336478a2 \ + --hash=sha256:37b0fe330e4a58d3c58b24d91d1eb102aeec675a3db4c292ec3928ecd892a9a6 \ + --hash=sha256:3d5639516376dce1940ea36edf408c554475369f5da2abd45d44621cb616f769 \ + --hash=sha256:42c6dcb030aefb668a2b7009c85b27f90e51e6a3b4d5c9bc4c57631292015b0d \ + --hash=sha256:4a7cd62e831afe623fbb7aabbb4fe583212115b3ef38a9f6b71869ba644624a2 \ + --hash=sha256:4ba762ed58e8d68657fc1281e9bb72e1c3e79cc5d464be146e260c541ec12d84 \ + --hash=sha256:4fc714bdbfb534f94034efaa6eadd74e5b93c8fa6315565a222f7b6f42ca1166 \ + --hash=sha256:4ffa2ebd4c8530079140dd2d7f794a9d9a73cbb8e9d59ffe24c63436efa8f271 \ + --hash=sha256:5a1504ad17ba4210df3a045132a7baeeba5a200e930f57512ee02909fc5c4cb5 \ + --hash=sha256:5c364564d17da23db1106787675fc7af45f2f7b58b4173bfdd105564e132e6fb \ + --hash=sha256:5e11661ce0fd30a6790e8bcdf263b9ec5988e95e63cf901972107efc49218b13 \ + --hash=sha256:5f54b118ce5de9ac21c363d9b3caa6c800341e8c47a508787e5868c6b79c9323 \ + --hash=sha256:5f5ff8d839f4566a474a969508fe1c5e59c31c80d9e140566f9a37bba7b8d556 \ + --hash=sha256:61817945f2fe7d166e75fbfb28004034b48e44878177fc54d81688e7b85a3665 \ + --hash=sha256:624e278a7d29b6445e4e813af92af37820fafb6dcc55c012c834f9e26f9aaaef \ + --hash=sha256:63e46b3169866bd62849936de036f901a9356e36376079b05efa83caeaa02ceb \ + --hash=sha256:6531b7ca5f951d663c339002e91aaebda765ec7d61b7d1e3991051906ddde119 \ + --hash=sha256:68665f4c17edcceecc112dfed5dbe6f92261fb9d6054b47d01bf6371a6196126 \ + --hash=sha256:696dd8d674d6ce621ab9d45b205df149399e4bb9aa34102c970b721554828510 \ + --hash=sha256:6f783e0ec4803c787bcea93e13e9932edab72068f68ecffdf86a99fd5918878b \ + --hash=sha256:723314c1d51722ab28bfcd5240d858512ffd3116449c557a1336cbe3919beb87 \ + --hash=sha256:74b9127ffea03643e998e0c5ad9bd3811d3dac8c676e47db17b0ee7c3c3bf35f \ + --hash=sha256:7530e201d10d7d14abce4fb54cfe5b94a0aefc87da539d0346a484ead376c3cc \ + --hash=sha256:77733e3892bb0a7fa797826361ce8a9184d25c8dffaec60b7ffe928153680ba8 \ + --hash=sha256:78ddaaa81421a29574a682b3179d4cf9e6d405a09b99d93ddcf7e5239c742e21 \ + --hash=sha256:7c9129eb40958b3d4500fa2467e6a83356b3b61bfff1b414c7361d9220f9ae8f \ + --hash=sha256:7d32706badfe136888bdea71c0def994644e09fff0bfe47441deaed8e96fdbc6 \ + --hash=sha256:81965a16b675b35e1d09dd14df53f190f9129c0202356ed44ab2728b1c905658 \ + --hash=sha256:8394d940e5d400d04cad4f75c0598665cbb81aecefaca82ca85bd28264af7f9b \ + --hash=sha256:86d2f57d3e1379a9525c5ab067b27dbb8a0642fb5d454e17a9ac434f9ce523e3 \ + --hash=sha256:883a91b5dd7d26492ff2f04f40fbb652de40fcc0afe07e8129e8ae779c2110eb \ + --hash=sha256:88ad334a15b32a791ea935af224b9de1bf99bcd62fabf745d5f3442199d86d59 \ + --hash=sha256:9261d3ce84fa1d38ed649c3638feefeae23d32ba9182963e465d58d62203bd24 \ + --hash=sha256:97df63000f4fea395b2824da80e169731088656d1818a11b95f3b173747b6cd9 \ + --hash=sha256:98d134c954828488b153d88ba1f34e14259284f256180ce659e8d83e9c05eaa3 \ + --hash=sha256:996a38a83508c54c78a5f41456b0103c30508fed9abcad0a59b876d7398f25fd \ + --hash=sha256:9a5bce9d23aac8f0cf0836ecfc033896aa8443b501c58d0602dbfd5bd5b37753 \ + --hash=sha256:9a6b5099eeec78827553827f4c6b8615978bb4b6a88e5d9b93eddf8bb6790f55 \ + --hash=sha256:9d18368b137c6295db49ce7218b1a9ba15c5bc254c96d7c9f9e924a9bc7825ad \ + --hash=sha256:a4fa4fc04dff799089689f4fd502ce7d59de529fc2f40a2c8836886c03e0175a \ + --hash=sha256:a5c7ba8ffb6d6f8f2ab08743be203654bb1aaa8c9dcb09f82ddd34eadb695605 \ + --hash=sha256:aea443fffa9fbe3af1a9ba721a87f926fe548d32cab71d188a6ede77d0ff244e \ + --hash=sha256:b10bd51f823d891193d4717448fab065733958bdb6a6b351967bd349d48d5c9b \ + --hash=sha256:ba1a0996f6c2773bd83e63f18914c1de3c9dd26d55f4ac302a7efe93fb8e7433 \ + --hash=sha256:bb2802e667b7051a1bebbfe93684841cc9351004e2badbd6411bf357ab8d5ac8 \ + --hash=sha256:cfdd16ab5e59fc31b5e906d1a3f666571abc367598e3e02c83403acabc092e07 \ + --hash=sha256:d06b0c8da4f16d1d1e352134427cb194a0a6e19ad5db9161bf32b2113409e728 \ + --hash=sha256:d0776dea117cf5272382634bd2a5c1b6eb16767c223c6a5317cd3e2a757c61a0 \ + --hash=sha256:d18ca8148bebe1b0a382a27a8ee60350091a6ddaf475fa05ef50dc35b5df6327 \ + --hash=sha256:d4488a93b071c04dc20f5cecc3631fc78b9789dd72483ba15d423b5b3689b555 \ + --hash=sha256:d5f7a395a8cf1621939692dba2a6b6a830efa6b3cee787d82c7de1ad2930de64 \ + --hash=sha256:d7a80d21d613eec45e3d41eb22f8f94ddc758a6c4720842dc74c0581f54993d6 \ + --hash=sha256:d97683ddee4723ae8c95d1eddac7c192e8c552da0c73a925a89fa8649bf13eea \ + --hash=sha256:dcedcd19a557e182628afa1d553c3895a9f825b936415d0dbd3cd0bbcfd29b4b \ + --hash=sha256:de6d1d1b9e5101508cb37ab0d972357cac5235f5c6533d1071964c47139257df \ + --hash=sha256:df49e7a0861a8c36d089c1ed57d308623d60416dab2647a4a17fe050ba85de0e \ + --hash=sha256:df933278128ea1cd77772673c73954e53a1c95a4fdf41eef97c2b779271bd0bd \ + --hash=sha256:e08277a400de01bc72436a0ccd02bdf596631411f592ad985dcee21445bd0068 \ + --hash=sha256:e38e63e6f3d1cec5a27e0afe90a085af8b6806ee208b33030e65b6516353f1a3 \ + --hash=sha256:e55541f756f9b3ee346b840103f32779c695a19826a4c442b7954550a0972040 \ + --hash=sha256:ec4e55f79b1c4ffb2eecd8a0cfba9955a2588497d96851f4c8f99aa4a1d39b12 \ + --hash=sha256:ed1a53de42fbe34853ba90513cea21673481cd81ed1be739f7f2efb931b24916 \ + --hash=sha256:ed541d70698978a20eb63d8c5d72f2cc6d7079d9d90f6b50bad07826f1320f5f \ + --hash=sha256:f09e2ff1f17c2b51f2bc76d1cc33da96298f0a036a137f5440ab3ec5360b624f \ + --hash=sha256:f220b0eea5965dec25480b6333c788fb72ce5f9129e8759ef876a1d805d00801 \ + --hash=sha256:f3e0da4ebaef65158d4dfd7d3678aad692f7666877df0002b8a522cdf088f231 \ + --hash=sha256:f455ee30a9d61d3e1a15abd5068827773d6e4dc513e795f380cdd59932c782d5 \ + --hash=sha256:f5ef8f42bec47f21d07668a043f077d507e5bf4e668d5c6dfe6aaba89de1a5b8 \ + --hash=sha256:f69a8e0b033b747bb3e36a44e7732f0c99f7edd5cea723d45bc0d6e95377ffee \ + --hash=sha256:ff02b6d461a6de369f07ec15e465a88895f3223eb75073ffea56b84d9331f607 +pydantic==2.9.2 ; python_version >= "3.12" and python_version < "4.0" \ + --hash=sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f \ + --hash=sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12 +pyflakes==3.4.0 ; python_version >= "3.12" and python_version < "4.0" \ + --hash=sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58 \ + --hash=sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f +pygments==2.19.2 ; python_version >= "3.12" and python_version < "4.0" \ + --hash=sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887 \ + --hash=sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b pymysql==1.1.1 ; python_version >= "3.12" and python_version < "4.0" \ --hash=sha256:4de15da4c61dc132f4fb9ab763063e693d521a80fd0e87943b9a453dd4c19d6c \ --hash=sha256:e127611aaf2b417403c60bf4dc570124aeb4a57f5f37b8e95ae399a42f904cd0 +pytest-cov==5.0.0 ; python_version >= "3.12" and python_version < "4.0" \ + --hash=sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652 \ + --hash=sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857 pytest-ordering==0.6 ; python_version >= "3.12" and python_version < "4.0" \ --hash=sha256:27fba3fc265f5d0f8597e7557885662c1bdc1969497cd58aff6ed21c3b617de2 \ --hash=sha256:3f314a178dbeb6777509548727dc69edf22d6d9a2867bf2d310ab85c403380b6 \ @@ -492,18 +850,192 @@ pyyaml==6.0.2 ; python_version >= "3.12" and python_version < "4.0" \ --hash=sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba \ --hash=sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12 \ --hash=sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4 +regex==2025.7.34 ; python_version >= "3.12" and python_version < "4.0" \ + --hash=sha256:0200a5150c4cf61e407038f4b4d5cdad13e86345dac29ff9dab3d75d905cf130 \ + --hash=sha256:02e5860a250cd350c4933cf376c3bc9cb28948e2c96a8bc042aee7b985cfa26f \ + --hash=sha256:075641c94126b064c65ab86e7e71fc3d63e7ff1bea1fb794f0773c97cdad3a03 \ + --hash=sha256:0a5966220b9a1a88691282b7e4350e9599cf65780ca60d914a798cb791aa1177 \ + --hash=sha256:0b85241d3cfb9f8a13cefdfbd58a2843f208f2ed2c88181bf84e22e0c7fc066d \ + --hash=sha256:1a764a83128af9c1a54be81485b34dca488cbcacefe1e1d543ef11fbace191e1 \ + --hash=sha256:1e4f4f62599b8142362f164ce776f19d79bdd21273e86920a7b604a4275b4f59 \ + --hash=sha256:20ff8433fa45e131f7316594efe24d4679c5449c0ca69d91c2f9d21846fdf064 \ + --hash=sha256:24257953d5c1d6d3c129ab03414c07fc1a47833c9165d49b954190b2b7f21a1a \ + --hash=sha256:2d15a9da5fad793e35fb7be74eec450d968e05d2e294f3e0e77ab03fa7234a83 \ + --hash=sha256:3157aa512b9e606586900888cd469a444f9b898ecb7f8931996cb715f77477f0 \ + --hash=sha256:32b9f9bcf0f605eb094b08e8da72e44badabb63dde6b83bd530580b488d1c6da \ + --hash=sha256:33be70d75fa05a904ee0dc43b650844e067d14c849df7e82ad673541cd465b5f \ + --hash=sha256:35e43ebf5b18cd751ea81455b19acfdec402e82fe0dc6143edfae4c5c4b3909a \ + --hash=sha256:37555e4ae0b93358fa7c2d240a4291d4a4227cc7c607d8f85596cdb08ec0a083 \ + --hash=sha256:3b836eb4a95526b263c2a3359308600bd95ce7848ebd3c29af0c37c4f9627cd3 \ + --hash=sha256:4494f8fd95a77eb434039ad8460e64d57baa0434f1395b7da44015bef650d0e4 \ + --hash=sha256:469142fb94a869beb25b5f18ea87646d21def10fbacb0bcb749224f3509476f0 \ + --hash=sha256:48fb045bbd4aab2418dc1ba2088a5e32de4bfe64e1457b948bb328a8dc2f1c2e \ + --hash=sha256:4913f52fbc7a744aaebf53acd8d3dc1b519e46ba481d4d7596de3c862e011ada \ + --hash=sha256:4b7dc33b9b48fb37ead12ffc7bdb846ac72f99a80373c4da48f64b373a7abeae \ + --hash=sha256:4b8c4d39f451e64809912c82392933d80fe2e4a87eeef8859fcc5380d0173c64 \ + --hash=sha256:4f42b522259c66e918a0121a12429b2abcf696c6f967fa37bdc7b72e61469f98 \ + --hash=sha256:4fef81b2f7ea6a2029161ed6dea9ae13834c28eb5a95b8771828194a026621e4 \ + --hash=sha256:524c868ba527eab4e8744a9287809579f54ae8c62fbf07d62aacd89f6026b282 \ + --hash=sha256:57d25b6732ea93eeb1d090e8399b6235ca84a651b52d52d272ed37d3d2efa0f1 \ + --hash=sha256:5d7de1ceed5a5f84f342ba4a9f4ae589524adf9744b2ee61b5da884b5b659834 \ + --hash=sha256:6164b1d99dee1dfad33f301f174d8139d4368a9fb50bf0a3603b2eaf579963ad \ + --hash=sha256:656433e5b7dccc9bc0da6312da8eb897b81f5e560321ec413500e5367fcd5d47 \ + --hash=sha256:69c593ff5a24c0d5c1112b0df9b09eae42b33c014bdca7022d6523b210b69f72 \ + --hash=sha256:69ed3bc611540f2ea70a4080f853741ec698be556b1df404599f8724690edbcd \ + --hash=sha256:6c053f9647e3421dd2f5dff8172eb7b4eec129df9d1d2f7133a4386319b47435 \ + --hash=sha256:6cef962d7834437fe8d3da6f9bfc6f93f20f218266dcefec0560ed7765f5fe01 \ + --hash=sha256:70645cad3407d103d1dbcb4841839d2946f7d36cf38acbd40120fee1682151e5 \ + --hash=sha256:716a47515ba1d03f8e8a61c5013041c8c90f2e21f055203498105d7571b44531 \ + --hash=sha256:72a26dcc6a59c057b292f39d41465d8233a10fd69121fa24f8f43ec6294e5415 \ + --hash=sha256:7373afae7cfb716e3b8e15d0184510d518f9d21471f2d62918dbece85f2c588f \ + --hash=sha256:739a74970e736df0773788377969c9fea3876c2fc13d0563f98e5503e5185f46 \ + --hash=sha256:7bf1c5503a9f2cbd2f52d7e260acb3131b07b6273c470abb78568174fe6bde3f \ + --hash=sha256:7f7211a746aced993bef487de69307a38c5ddd79257d7be83f7b202cb59ddb50 \ + --hash=sha256:8283afe7042d8270cecf27cca558873168e771183d4d593e3c5fe5f12402212a \ + --hash=sha256:85c3a958ef8b3d5079c763477e1f09e89d13ad22198a37e9d7b26b4b17438b33 \ + --hash=sha256:89c9504fc96268e8e74b0283e548f53a80c421182a2007e3365805b74ceef936 \ + --hash=sha256:95b4639c77d414efa93c8de14ce3f7965a94d007e068a94f9d4997bb9bd9c81f \ + --hash=sha256:95d538b10eb4621350a54bf14600cc80b514211d91a019dc74b8e23d2159ace5 \ + --hash=sha256:96bbae4c616726f4661fe7bcad5952e10d25d3c51ddc388189d8864fbc1b3c68 \ + --hash=sha256:98d0ce170fcde1a03b5df19c5650db22ab58af375aaa6ff07978a85c9f250f0e \ + --hash=sha256:9960d162f3fecf6af252534a1ae337e9c2e20d74469fed782903b24e2cc9d3d7 \ + --hash=sha256:9a9ab52a466a9b4b91564437b36417b76033e8778e5af8f36be835d8cb370d62 \ + --hash=sha256:9d644de5520441e5f7e2db63aec2748948cc39ed4d7a87fd5db578ea4043d997 \ + --hash=sha256:9ead9765217afd04a86822dfcd4ed2747dfe426e887da413b15ff0ac2457e21a \ + --hash=sha256:9feab78a1ffa4f2b1e27b1bcdaad36f48c2fed4870264ce32f52a393db093c78 \ + --hash=sha256:a16dd56bbcb7d10e62861c3cd000290ddff28ea142ffb5eb3470f183628011ac \ + --hash=sha256:a664291c31cae9c4a30589bd8bc2ebb56ef880c9c6264cb7643633831e606a4d \ + --hash=sha256:aaef1f056d96a0a5d53ad47d019d5b4c66fe4be2da87016e0d43b7242599ffc7 \ + --hash=sha256:baf2fe122a3db1c0b9f161aa44463d8f7e33eeeda47bb0309923deb743a18276 \ + --hash=sha256:bca11d3c38a47c621769433c47f364b44e8043e0de8e482c5968b20ab90a3986 \ + --hash=sha256:c1844be23cd40135b3a5a4dd298e1e0c0cb36757364dd6cdc6025770363e06c1 \ + --hash=sha256:c3c9740a77aeef3f5e3aaab92403946a8d34437db930a0280e7e81ddcada61f5 \ + --hash=sha256:c436fd1e95c04c19039668cfb548450a37c13f051e8659f40aed426e36b3765f \ + --hash=sha256:c7f663ccc4093877f55b51477522abd7299a14c5bb7626c5238599db6a0cb95d \ + --hash=sha256:c83aec91af9c6fbf7c743274fd952272403ad9a9db05fe9bfc9df8d12b45f176 \ + --hash=sha256:cbe1698e5b80298dbce8df4d8d1182279fbdaf1044e864cbc9d53c20e4a2be77 \ + --hash=sha256:cbfaa401d77334613cf434f723c7e8ba585df162be76474bccc53ae4e5520b3a \ + --hash=sha256:d03c6f9dcd562c56527c42b8530aad93193e0b3254a588be1f2ed378cdfdea1b \ + --hash=sha256:d428fc7731dcbb4e2ffe43aeb8f90775ad155e7db4347a639768bc6cd2df881a \ + --hash=sha256:d5273fddf7a3e602695c92716c420c377599ed3c853ea669c1fe26218867002f \ + --hash=sha256:d600e58ee6d036081c89696d2bdd55d507498a7180df2e19945c6642fac59588 \ + --hash=sha256:d72765a4bff8c43711d5b0f5b452991a9947853dfa471972169b3cc0ba1d0751 \ + --hash=sha256:d856164d25e2b3b07b779bfed813eb4b6b6ce73c2fd818d46f47c1eb5cd79bd6 \ + --hash=sha256:da304313761b8500b8e175eb2040c4394a875837d5635f6256d6fa0377ad32c8 \ + --hash=sha256:da7507d083ee33ccea1310447410c27ca11fb9ef18c95899ca57ff60a7e4d8f1 \ + --hash=sha256:dde35e2afbbe2272f8abee3b9fe6772d9b5a07d82607b5788e8508974059925c \ + --hash=sha256:e154a7ee7fa18333ad90b20e16ef84daaeac61877c8ef942ec8dfa50dc38b7a1 \ + --hash=sha256:e4636a7f3b65a5f340ed9ddf53585c42e3ff37101d383ed321bfe5660481744b \ + --hash=sha256:e91eb2c62c39705e17b4d42d4b86c4e86c884c0d15d9c5a47d0835f8387add8e \ + --hash=sha256:ea74cf81fe61a7e9d77989050d0089a927ab758c29dac4e8e1b6c06fccf3ebf0 \ + --hash=sha256:ee38926f31f1aa61b0232a3a11b83461f7807661c062df9eb88769d86e6195c3 \ + --hash=sha256:efac4db9e044d47fd3b6b0d40b6708f4dfa2d8131a5ac1d604064147c0f552fd \ + --hash=sha256:f14b36e6d4d07f1a5060f28ef3b3561c5d95eb0651741474ce4c0a4c56ba8719 \ + --hash=sha256:f3e5c1e0925e77ec46ddc736b756a6da50d4df4ee3f69536ffb2373460e2dafd \ + --hash=sha256:f3f6e8e7af516a7549412ce57613e859c3be27d55341a894aacaa11703a4c31a \ + --hash=sha256:f7f3071b5faa605b0ea51ec4bb3ea7257277446b053f4fd3ad02b1dcb4e64353 \ + --hash=sha256:f978ddfb6216028c8f1d6b0f7ef779949498b64117fc35a939022f67f810bdcb \ + --hash=sha256:fa1cdfb8db96ef20137de5587954c812821966c3e8b48ffc871e22d7ec0a4938 \ + --hash=sha256:fb31080f2bd0681484b275461b202b5ad182f52c9ec606052020fe13eb13a72f \ + --hash=sha256:fd5edc3f453de727af267c7909d083e19f6426fc9dd149e332b6034f2a5611e6 requests==2.32.3 ; python_version >= "3.12" and python_version < "4.0" \ --hash=sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760 \ --hash=sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6 retry==0.9.2 ; python_version >= "3.12" and python_version < "4.0" \ --hash=sha256:ccddf89761fa2c726ab29391837d4327f819ea14d244c232a1d24c67a2f98606 \ --hash=sha256:f8bfa8b99b69c4506d6f5bd3b0aabf77f98cdb17f3c9fc3f5ca820033336fba4 +rich==14.1.0 ; python_version >= "3.12" and python_version < "4.0" \ + --hash=sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f \ + --hash=sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8 rsa==4.9 ; python_version >= "3.12" and python_version < "4" \ --hash=sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7 \ --hash=sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21 +ruamel-yaml-clib==0.2.12 ; platform_python_implementation == "CPython" and python_version < "3.14" and python_version >= "3.12" \ + --hash=sha256:040ae85536960525ea62868b642bdb0c2cc6021c9f9d507810c0c604e66f5a7b \ + --hash=sha256:0467c5965282c62203273b838ae77c0d29d7638c8a4e3a1c8bdd3602c10904e4 \ + --hash=sha256:0b7e75b4965e1d4690e93021adfcecccbca7d61c7bddd8e22406ef2ff20d74ef \ + --hash=sha256:11f891336688faf5156a36293a9c362bdc7c88f03a8a027c2c1d8e0bcde998e5 \ + --hash=sha256:1492a6051dab8d912fc2adeef0e8c72216b24d57bd896ea607cb90bb0c4981d3 \ + --hash=sha256:20b0f8dc160ba83b6dcc0e256846e1a02d044e13f7ea74a3d1d56ede4e48c632 \ + --hash=sha256:22353049ba4181685023b25b5b51a574bce33e7f51c759371a7422dcae5402a6 \ + --hash=sha256:2c59aa6170b990d8d2719323e628aaf36f3bfbc1c26279c0eeeb24d05d2d11c7 \ + --hash=sha256:32621c177bbf782ca5a18ba4d7af0f1082a3f6e517ac2a18b3974d4edf349680 \ + --hash=sha256:3bc2a80e6420ca8b7d3590791e2dfc709c88ab9152c00eeb511c9875ce5778bf \ + --hash=sha256:3eac5a91891ceb88138c113f9db04f3cebdae277f5d44eaa3651a4f573e6a5da \ + --hash=sha256:4a6679521a58256a90b0d89e03992c15144c5f3858f40d7c18886023d7943db6 \ + --hash=sha256:4c8c5d82f50bb53986a5e02d1b3092b03622c02c2eb78e29bec33fd9593bae1a \ + --hash=sha256:4f6f3eac23941b32afccc23081e1f50612bdbe4e982012ef4f5797986828cd01 \ + --hash=sha256:5a0e060aace4c24dcaf71023bbd7d42674e3b230f7e7b97317baf1e953e5b519 \ + --hash=sha256:6442cb36270b3afb1b4951f060eccca1ce49f3d087ca1ca4563a6eb479cb3de6 \ + --hash=sha256:6c8fbb13ec503f99a91901ab46e0b07ae7941cd527393187039aec586fdfd36f \ + --hash=sha256:749c16fcc4a2b09f28843cda5a193e0283e47454b63ec4b81eaa2242f50e4ccd \ + --hash=sha256:7dd5adc8b930b12c8fc5b99e2d535a09889941aa0d0bd06f4749e9a9397c71d2 \ + --hash=sha256:811ea1594b8a0fb466172c384267a4e5e367298af6b228931f273b111f17ef52 \ + --hash=sha256:932205970b9f9991b34f55136be327501903f7c66830e9760a8ffb15b07f05cd \ + --hash=sha256:943f32bc9dedb3abff9879edc134901df92cfce2c3d5c9348f172f62eb2d771d \ + --hash=sha256:95c3829bb364fdb8e0332c9931ecf57d9be3519241323c5274bd82f709cebc0c \ + --hash=sha256:96777d473c05ee3e5e3c3e999f5d23c6f4ec5b0c38c098b3a5229085f74236c6 \ + --hash=sha256:a274fb2cb086c7a3dea4322ec27f4cb5cc4b6298adb583ab0e211a4682f241eb \ + --hash=sha256:a52d48f4e7bf9005e8f0a89209bf9a73f7190ddf0489eee5eb51377385f59f2a \ + --hash=sha256:a606ef75a60ecf3d924613892cc603b154178ee25abb3055db5062da811fd969 \ + --hash=sha256:ab007f2f5a87bd08ab1499bdf96f3d5c6ad4dcfa364884cb4549aa0154b13a28 \ + --hash=sha256:b82a7c94a498853aa0b272fd5bc67f29008da798d4f93a2f9f289feb8426a58d \ + --hash=sha256:bb43a269eb827806502c7c8efb7ae7e9e9d0573257a46e8e952f4d4caba4f31e \ + --hash=sha256:bc5f1e1c28e966d61d2519f2a3d451ba989f9ea0f2307de7bc45baa526de9e45 \ + --hash=sha256:bd0a08f0bab19093c54e18a14a10b4322e1eacc5217056f3c063bd2f59853ce4 \ + --hash=sha256:beffaed67936fbbeffd10966a4eb53c402fafd3d6833770516bf7314bc6ffa12 \ + --hash=sha256:bf165fef1f223beae7333275156ab2022cffe255dcc51c27f066b4370da81e31 \ + --hash=sha256:cf12567a7b565cbf65d438dec6cfbe2917d3c1bdddfce84a9930b7d35ea59642 \ + --hash=sha256:d84318609196d6bd6da0edfa25cedfbabd8dbde5140a0a23af29ad4b8f91fb1e \ + --hash=sha256:d85252669dc32f98ebcd5d36768f5d4faeaeaa2d655ac0473be490ecdae3c285 \ + --hash=sha256:e143ada795c341b56de9418c58d028989093ee611aa27ffb9b7f609c00d813ed \ + --hash=sha256:e188d2699864c11c36cdfdada94d781fd5d6b0071cd9c427bceb08ad3d7c70e1 \ + --hash=sha256:e2f1c3765db32be59d18ab3953f43ab62a761327aafc1594a2a1fbe038b8b8a7 \ + --hash=sha256:e5b8daf27af0b90da7bb903a876477a9e6d7270be6146906b276605997c7e9a3 \ + --hash=sha256:e7e3736715fbf53e9be2a79eb4db68e4ed857017344d697e8b9749444ae57475 \ + --hash=sha256:e8c4ebfcfd57177b572e2040777b8abc537cdef58a2120e830124946aa9b42c5 \ + --hash=sha256:f66efbc1caa63c088dead1c4170d148eabc9b80d95fb75b6c92ac0aad2437d76 \ + --hash=sha256:fc4b630cd3fa2cf7fce38afa91d7cfe844a9f75d7f0f36393fa98815e911d987 \ + --hash=sha256:fd5415dded15c3822597455bc02bcd66e81ef8b7a48cb71a33628fc9fdde39df +ruamel-yaml==0.18.15 ; python_version >= "3.12" and python_version < "4.0" \ + --hash=sha256:148f6488d698b7a5eded5ea793a025308b25eca97208181b6a026037f391f701 \ + --hash=sha256:dbfca74b018c4c3fba0b9cc9ee33e53c371194a9000e694995e620490fd40700 +safety-schemas==0.0.14 ; python_version >= "3.12" and python_version < "4.0" \ + --hash=sha256:0bf6fc4aa5e474651b714cc9e427c862792946bf052b61d5c7bec4eac4c0f254 \ + --hash=sha256:49953f7a59e919572be25595a8946f9cbbcd2066fe3e160c9467d9d1d6d7af6a +safety==3.6.0 ; python_version >= "3.12" and python_version < "4.0" \ + --hash=sha256:9cddbfd7a578b35e4d29df4fb6e3808425ed792a09d81400a5b7f4b4535ef666 \ + --hash=sha256:a820f827699f83d3d5c2faab24c0ac4d094911931503180847d97942c0a6f7e3 +setuptools==80.9.0 ; python_version >= "3.12" and python_version < "4.0" \ + --hash=sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922 \ + --hash=sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c +shellingham==1.5.4 ; python_version >= "3.12" and python_version < "4.0" \ + --hash=sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686 \ + --hash=sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de six==1.16.0 ; python_version >= "3.12" and python_version < "4.0" \ --hash=sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926 \ --hash=sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254 +sniffio==1.3.1 ; python_version >= "3.12" and python_version < "4.0" \ + --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \ + --hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc +stevedore==5.5.0 ; python_version >= "3.12" and python_version < "4.0" \ + --hash=sha256:18363d4d268181e8e8452e71a38cd77630f345b2ef6b4a8d5614dac5ee0d18cf \ + --hash=sha256:d31496a4f4df9825e1a1e4f1f74d19abb0154aff311c3b376fcc89dae8fccd73 +tenacity==9.1.2 ; python_version >= "3.12" and python_version < "4.0" \ + --hash=sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb \ + --hash=sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138 +tomlkit==0.13.3 ; python_version >= "3.12" and python_version < "4.0" \ + --hash=sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1 \ + --hash=sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0 +tqdm==4.67.1 ; python_version >= "3.12" and python_version < "4.0" \ + --hash=sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2 \ + --hash=sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2 +typer==0.16.1 ; python_version >= "3.12" and python_version < "4.0" \ + --hash=sha256:90ee01cb02d9b8395ae21ee3368421faf21fa138cb2a541ed369c08cec5237c9 \ + --hash=sha256:d358c65a464a7a90f338e3bb7ff0c74ac081449e53884b12ba658cbd72990614 +typing-extensions==4.15.0 ; python_version >= "3.12" and python_version < "4.0" \ + --hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \ + --hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548 tzdata==2024.2 ; python_version >= "3.12" and python_version < "4.0" \ --hash=sha256:7d85cc416e9382e69095b7bdf4afd9e3880418a2413feec7069d533d6b4e31cc \ --hash=sha256:a48093786cdcde33cad18c2555e8532f34422074448fbc874186f0abd79565cd diff --git a/src/.DS_Store b/src/.DS_Store new file mode 100644 index 0000000..dc686d1 Binary files /dev/null and b/src/.DS_Store differ diff --git a/src/client/.DS_Store b/src/client/.DS_Store new file mode 100644 index 0000000..a41ac92 Binary files /dev/null and b/src/client/.DS_Store differ diff --git a/src/client/base_client.py b/src/client/base_client.py index c61850f..9137e34 100644 --- a/src/client/base_client.py +++ b/src/client/base_client.py @@ -128,3 +128,17 @@ def put( *args, **kwargs, ) + + def delete( + self, path, params=None, data=None, json=None, headers=None, *args, **kwargs + ): + return self.request( + "DELETE", + path, + params=params, + data=data, + json=json, + headers=headers, + *args, + **kwargs, + ) diff --git a/src/utils/assertion.py b/src/utils/assertion.py new file mode 100644 index 0000000..7aa514d --- /dev/null +++ b/src/utils/assertion.py @@ -0,0 +1,239 @@ +""" +增强的断言工具模块 + +提供丰富的断言方法,支持接口测试中的各种验证场景 +主要基于JMESPath进行JSON数据查询和验证 +""" + +import json +import re +from typing import Any, Dict, List, Optional, Union + +import jmespath +from assertpy import assert_that +from jsonpath_ng import parse + +from src.utils.log_moudle import logger + + +class EnhancedAssertion: + """增强的断言类,提供丰富的断言方法""" + + def __init__(self, response_data: Any = None): + """ + 初始化断言对象 + + Args: + response_data: 响应数据,可以是字典、列表或字符串 + """ + self.response_data = response_data + self.logger = logger + + def assert_status_code( + self, expected_code: int, actual_code: int + ) -> "EnhancedAssertion": + """断言HTTP状态码""" + assert_that(actual_code).is_equal_to(expected_code) + self.logger.info(f"✓ 状态码断言通过: {actual_code}") + return self + + def assert_response_time( + self, max_time: float, actual_time: float + ) -> "EnhancedAssertion": + """断言响应时间""" + assert_that(actual_time).is_less_than_or_equal_to(max_time) + self.logger.info(f"✓ 响应时间断言通过: {actual_time}s <= {max_time}s") + return self + + def assert_json_path( + self, json_path: str, expected_value: Any + ) -> "EnhancedAssertion": + """使用JSONPath断言""" + jsonpath_expr = parse(json_path) + matches = jsonpath_expr.find(self.response_data) + + if not matches: + raise AssertionError(f"JSONPath '{json_path}' 未找到匹配项") + + actual_value = matches[0].value + assert_that(actual_value).is_equal_to(expected_value) + self.logger.info(f"✓ JSONPath断言通过: {json_path} = {actual_value}") + return self + + def assert_jmespath( + self, jmes_path: str, expected_value: Any + ) -> "EnhancedAssertion": + """使用JMESPath断言 - 推荐的主要断言方法""" + actual_value = jmespath.search(jmes_path, self.response_data) + assert_that(actual_value).is_equal_to(expected_value) + self.logger.info(f"✓ JMESPath断言通过: {jmes_path} = {actual_value}") + return self + + def assert_jmespath_exists(self, jmes_path: str) -> "EnhancedAssertion": + """断言JMESPath路径存在""" + actual_value = jmespath.search(jmes_path, self.response_data) + assert_that(actual_value).is_not_none() + self.logger.info(f"✓ JMESPath路径存在: {jmes_path}") + return self + + def assert_jmespath_not_exists(self, jmes_path: str) -> "EnhancedAssertion": + """断言JMESPath路径不存在""" + actual_value = jmespath.search(jmes_path, self.response_data) + assert_that(actual_value).is_none() + self.logger.info(f"✓ JMESPath路径不存在: {jmes_path}") + return self + + def assert_jmespath_contains( + self, jmes_path: str, expected_value: Any + ) -> "EnhancedAssertion": + """断言JMESPath查询结果包含指定值""" + actual_value = jmespath.search(jmes_path, self.response_data) + assert_that(actual_value).contains(expected_value) + self.logger.info( + f"✓ JMESPath包含断言通过: {jmes_path} contains {expected_value}" + ) + return self + + def assert_jmespath_length( + self, jmes_path: str, expected_length: int + ) -> "EnhancedAssertion": + """断言JMESPath查询结果的长度""" + actual_value = jmespath.search(jmes_path, self.response_data) + if actual_value is None: + raise AssertionError(f"JMESPath '{jmes_path}' 返回None,无法检查长度") + assert_that(actual_value).is_length(expected_length) + self.logger.info( + f"✓ JMESPath长度断言通过: {jmes_path} length = {expected_length}" + ) + return self + + def assert_jmespath_type( + self, jmes_path: str, expected_type: type + ) -> "EnhancedAssertion": + """断言JMESPath查询结果的类型""" + actual_value = jmespath.search(jmes_path, self.response_data) + assert_that(actual_value).is_instance_of(expected_type) + self.logger.info( + f"✓ JMESPath类型断言通过: {jmes_path} is {expected_type.__name__}" + ) + return self + + def assert_contains( + self, expected_value: Any, container: Any = None + ) -> "EnhancedAssertion": + """断言包含关系""" + target = container if container is not None else self.response_data + assert_that(target).contains(expected_value) + self.logger.info(f"✓ 包含断言通过: {expected_value} in {type(target).__name__}") + return self + + def assert_not_contains( + self, unexpected_value: Any, container: Any = None + ) -> "EnhancedAssertion": + """断言不包含关系""" + target = container if container is not None else self.response_data + assert_that(target).does_not_contain(unexpected_value) + self.logger.info( + f"✓ 不包含断言通过: {unexpected_value} not in {type(target).__name__}" + ) + return self + + def assert_regex_match(self, pattern: str, text: str = None) -> "EnhancedAssertion": + """断言正则表达式匹配""" + target = text if text is not None else str(self.response_data) + assert_that(re.search(pattern, target)).is_not_none() + self.logger.info(f"✓ 正则断言通过: '{pattern}' 匹配成功") + return self + + def assert_schema(self, expected_schema: Dict) -> "EnhancedAssertion": + """断言JSON Schema""" + try: + import jsonschema + + jsonschema.validate(self.response_data, expected_schema) + self.logger.info("✓ JSON Schema断言通过") + except ImportError: + self.logger.warning("jsonschema库未安装,跳过schema验证") + except jsonschema.ValidationError as e: + raise AssertionError(f"JSON Schema验证失败: {e.message}") + return self + + def assert_list_length( + self, expected_length: int, target_list: List = None + ) -> "EnhancedAssertion": + """断言列表长度""" + target = target_list if target_list is not None else self.response_data + assert_that(target).is_length(expected_length) + self.logger.info(f"✓ 列表长度断言通过: {len(target)} = {expected_length}") + return self + + def assert_dict_has_keys( + self, expected_keys: List[str], target_dict: Dict = None + ) -> "EnhancedAssertion": + """断言字典包含指定键""" + target = target_dict if target_dict is not None else self.response_data + for key in expected_keys: + assert_that(target).contains_key(key) + self.logger.info(f"✓ 字典键断言通过: {expected_keys}") + return self + + def assert_value_in_range( + self, + min_val: Union[int, float], + max_val: Union[int, float], + actual_val: Union[int, float] = None, + ) -> "EnhancedAssertion": + """断言数值在指定范围内""" + target = actual_val if actual_val is not None else self.response_data + assert_that(target).is_between(min_val, max_val) + self.logger.info(f"✓ 数值范围断言通过: {min_val} <= {target} <= {max_val}") + return self + + +def assert_response(response_data: Any = None) -> EnhancedAssertion: + """创建断言对象的便捷函数""" + return EnhancedAssertion(response_data) + + +# 常用断言的快捷函数 +def assert_success_response(response, expected_code: int = 200): + """断言成功响应的快捷函数""" + return assert_response( + response.json() if hasattr(response, "json") else response + ).assert_status_code( + expected_code, + response.status_code if hasattr(response, "status_code") else expected_code, + ) + + +def assert_error_response(response, expected_code: int = 400): + """断言错误响应的快捷函数""" + return assert_response( + response.json() if hasattr(response, "json") else response + ).assert_status_code( + expected_code, + response.status_code if hasattr(response, "status_code") else expected_code, + ) + + +# JMESPath专用断言函数 +def assert_jmes(data: Any, path: str, expected_value: Any = None): + """JMESPath断言的便捷函数""" + assertion = assert_response(data) + if expected_value is not None: + return assertion.assert_jmespath(path, expected_value) + else: + return assertion.assert_jmespath_exists(path) + + +def assert_api_response(response, success_path: str = "code", success_value: Any = 200): + """API响应断言的便捷函数,基于JMESPath""" + response_data = response.json() if hasattr(response, "json") else response + return ( + assert_response(response_data) + .assert_status_code( + success_value if hasattr(response, "status_code") else 200, + response.status_code if hasattr(response, "status_code") else 200, + ) + .assert_jmespath(success_path, success_value) + ) diff --git a/src/utils/data_driver.py b/src/utils/data_driver.py new file mode 100644 index 0000000..a104c48 --- /dev/null +++ b/src/utils/data_driver.py @@ -0,0 +1,298 @@ +""" +数据驱动测试工具模块 + +支持从多种数据源读取测试数据,包括Excel、CSV、JSON、YAML等 +""" + +import csv +import json +import os +from pathlib import Path +from typing import Any, Dict, Generator, List, Optional, Union + +import pandas as pd +import yaml +from faker import Faker + +from src.utils.log_moudle import logger + + +class DataDriver: + """数据驱动测试类""" + + def __init__(self, data_dir: str = "data"): + """ + 初始化数据驱动器 + + Args: + data_dir: 数据文件目录 + """ + # 确保data_dir是绝对路径 + if not os.path.isabs(data_dir): + # 获取项目根目录 + project_root = self._find_project_root() + self.data_dir = Path(project_root) / data_dir + else: + self.data_dir = Path(data_dir) + + self.faker = Faker("zh_CN") # 中文数据生成器 + self.logger = logger + + def _find_project_root(self) -> str: + """查找项目根目录""" + current_dir = os.path.dirname(os.path.abspath(__file__)) + + # 向上查找项目标识文件 + while current_dir != os.path.dirname(current_dir): + # 检查常见的项目标识文件 + for marker in [ + "pyproject.toml", + "setup.py", + "requirements.txt", + "pytest.ini", + ".git", + ]: + if os.path.exists(os.path.join(current_dir, marker)): + return current_dir + current_dir = os.path.dirname(current_dir) + + # 如果找不到,返回当前文件所在目录的上上级目录(假设是 src/utils/) + return os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ) + + def load_excel(self, file_path: str, sheet_name: str = None) -> List[Dict]: + """ + 从Excel文件加载测试数据 + + Args: + file_path: Excel文件路径 + sheet_name: 工作表名称,默认为第一个工作表 + + Returns: + 测试数据列表 + """ + full_path = self.data_dir / file_path + try: + if sheet_name: + df = pd.read_excel(full_path, sheet_name=sheet_name) + else: + df = pd.read_excel(full_path) + + # 将NaN值替换为None + df = df.where(pd.notnull(df), None) + data = df.to_dict("records") + + self.logger.info(f"从Excel文件加载了 {len(data)} 条测试数据: {full_path}") + return data + + except Exception as e: + self.logger.error(f"加载Excel文件失败: {full_path}, 错误: {e}") + raise + + def load_csv(self, file_path: str, encoding: str = "utf-8") -> List[Dict]: + """ + 从CSV文件加载测试数据 + + Args: + file_path: CSV文件路径 + encoding: 文件编码 + + Returns: + 测试数据列表 + """ + full_path = self.data_dir / file_path + try: + data = [] + with open(full_path, "r", encoding=encoding) as file: + reader = csv.DictReader(file) + data = list(reader) + + self.logger.info(f"从CSV文件加载了 {len(data)} 条测试数据: {full_path}") + return data + + except Exception as e: + self.logger.error(f"加载CSV文件失败: {full_path}, 错误: {e}") + raise + + def load_json( + self, file_path: str, encoding: str = "utf-8" + ) -> Union[List[Dict], Dict]: + """ + 从JSON文件加载测试数据 + + Args: + file_path: JSON文件路径 + encoding: 文件编码 + + Returns: + 测试数据 + """ + full_path = self.data_dir / file_path + try: + with open(full_path, "r", encoding=encoding) as file: + data = json.load(file) + + count = len(data) if isinstance(data, list) else 1 + self.logger.info(f"从JSON文件加载了 {count} 条测试数据: {full_path}") + return data + + except Exception as e: + self.logger.error(f"加载JSON文件失败: {full_path}, 错误: {e}") + raise + + def load_yaml( + self, file_path: str, encoding: str = "utf-8" + ) -> Union[List[Dict], Dict]: + """ + 从YAML文件加载测试数据 + + Args: + file_path: YAML文件路径 + encoding: 文件编码 + + Returns: + 测试数据 + """ + full_path = self.data_dir / file_path + try: + with open(full_path, "r", encoding=encoding) as file: + data = yaml.safe_load(file) + + count = len(data) if isinstance(data, list) else 1 + self.logger.info(f"从YAML文件加载了 {count} 条测试数据: {full_path}") + return data + + except Exception as e: + self.logger.error(f"加载YAML文件失败: {full_path}, 错误: {e}") + raise + + def generate_test_data(self, template: Dict, count: int = 1) -> List[Dict]: + """ + 根据模板生成测试数据 + + Args: + template: 数据模板,支持Faker方法 + count: 生成数据条数 + + Returns: + 生成的测试数据列表 + """ + data_list = [] + + for _ in range(count): + data = {} + for key, value in template.items(): + if isinstance(value, str) and value.startswith("faker."): + # 执行Faker方法 + faker_method = value.replace("faker.", "") + try: + data[key] = getattr(self.faker, faker_method)() + except AttributeError: + self.logger.warning(f"Faker方法不存在: {faker_method}") + data[key] = value + else: + data[key] = value + data_list.append(data) + + self.logger.info(f"生成了 {count} 条测试数据") + return data_list + + def save_data( + self, + data: Union[List[Dict], Dict], + file_path: str, + file_type: str = "json", + encoding: str = "utf-8", + ): + """ + 保存数据到文件 + + Args: + data: 要保存的数据 + file_path: 文件路径 + file_type: 文件类型 (json, yaml, csv, excel) + encoding: 文件编码 + """ + full_path = self.data_dir / file_path + full_path.parent.mkdir(parents=True, exist_ok=True) + + try: + if file_type.lower() == "json": + with open(full_path, "w", encoding=encoding) as file: + json.dump(data, file, ensure_ascii=False, indent=2) + + elif file_type.lower() == "yaml": + with open(full_path, "w", encoding=encoding) as file: + yaml.dump(data, file, allow_unicode=True, default_flow_style=False) + + elif file_type.lower() == "csv": + if isinstance(data, list) and data: + df = pd.DataFrame(data) + df.to_csv(full_path, index=False, encoding=encoding) + else: + raise ValueError("CSV格式需要列表数据") + + elif file_type.lower() == "excel": + if isinstance(data, list) and data: + df = pd.DataFrame(data) + df.to_excel(full_path, index=False) + else: + raise ValueError("Excel格式需要列表数据") + + else: + raise ValueError(f"不支持的文件类型: {file_type}") + + self.logger.info(f"数据已保存到: {full_path}") + + except Exception as e: + self.logger.error(f"保存数据失败: {full_path}, 错误: {e}") + raise + + +# 全局数据驱动器实例 +data_driver = DataDriver() + + +def load_test_data(file_path: str, file_type: str = None) -> Union[List[Dict], Dict]: + """ + 加载测试数据的便捷函数 + + Args: + file_path: 文件路径 + file_type: 文件类型,如果不指定则根据文件扩展名判断 + + Returns: + 测试数据 + """ + if file_type is None: + file_type = Path(file_path).suffix.lower().lstrip(".") + + if file_type in ["xlsx", "xls"]: + return data_driver.load_excel(file_path) + elif file_type == "csv": + return data_driver.load_csv(file_path) + elif file_type == "json": + return data_driver.load_json(file_path) + elif file_type in ["yaml", "yml"]: + return data_driver.load_yaml(file_path) + else: + raise ValueError(f"不支持的文件类型: {file_type}") + + +def parametrize_from_file(file_path: str, file_type: str = None): + """ + 从文件生成pytest参数化装饰器的数据 + + Args: + file_path: 文件路径 + file_type: 文件类型 + + Returns: + 参数化数据 + """ + data = load_test_data(file_path, file_type) + if isinstance(data, list): + return data + else: + return [data] diff --git a/src/utils/environment.py b/src/utils/environment.py new file mode 100644 index 0000000..841f174 --- /dev/null +++ b/src/utils/environment.py @@ -0,0 +1,264 @@ +""" +环境管理工具模块 + +支持多环境配置管理和动态切换 +""" + +import os +from typing import Any, Dict, Optional + +from dynaconf import Dynaconf + +from src.utils.log_moudle import logger + + +class EnvironmentManager: + """环境管理器""" + + def __init__(self, config_dir: str = "conf"): + """ + 初始化环境管理器 + + Args: + config_dir: 配置文件目录 + """ + # 确保config_dir是绝对路径 + if not os.path.isabs(config_dir): + # 获取项目根目录 + project_root = self._find_project_root() + self.config_dir = os.path.join(project_root, config_dir) + else: + self.config_dir = config_dir + + self.logger = logger + self._current_env = None + self._settings = None + self._load_settings() + + def _find_project_root(self) -> str: + """查找项目根目录""" + current_dir = os.path.dirname(os.path.abspath(__file__)) + + # 向上查找项目标识文件 + while current_dir != os.path.dirname(current_dir): + # 检查常见的项目标识文件 + for marker in [ + "pyproject.toml", + "setup.py", + "requirements.txt", + "pytest.ini", + ".git", + ]: + if os.path.exists(os.path.join(current_dir, marker)): + return current_dir + current_dir = os.path.dirname(current_dir) + + # 如果找不到,返回当前文件所在目录的上上级目录(假设是 src/utils/) + return os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ) + + def _load_settings(self): + """加载配置设置""" + try: + # 检查配置目录是否存在 + if not os.path.exists(self.config_dir): + self.logger.warning(f"配置目录不存在: {self.config_dir},使用默认配置") + # 创建默认配置 + self._settings = Dynaconf( + envvar_prefix="PYTEST_FRAMEWORK", + environments=True, + load_dotenv=False, # 禁用dotenv以避免路径查找问题 + env=os.getenv("ENV", "boe"), + ) + else: + self._settings = Dynaconf( + root_path=self.config_dir, + envvar_prefix="PYTEST_FRAMEWORK", + settings_files=[ + "settings.yaml", + "settings.local.yaml", + ".secrets.yaml", + ], + environments=True, + load_dotenv=False, # 禁用dotenv以避免路径查找问题 + env=os.getenv("ENV", "boe"), # 默认环境 + ) + + self._current_env = self._settings.current_env + self.logger.info(f"配置加载成功,当前环境: {self._current_env}") + except Exception as e: + self.logger.error(f"配置加载失败: {e}") + # 提供兜底配置 + self._settings = Dynaconf( + envvar_prefix="PYTEST_FRAMEWORK", + environments=True, + load_dotenv=False, + env=os.getenv("ENV", "boe"), + ) + self._current_env = self._settings.current_env + + @property + def current_env(self) -> str: + """获取当前环境""" + return self._current_env + + @property + def settings(self) -> Dynaconf: + """获取配置对象""" + return self._settings + + def switch_env(self, env_name: str): + """ + 切换环境 + + Args: + env_name: 环境名称 + """ + try: + self._settings.setenv(env_name) + self._current_env = env_name + self.logger.info(f"环境已切换到: {env_name}") + except Exception as e: + self.logger.error(f"环境切换失败: {e}") + raise + + def get_config(self, key: str, default: Any = None) -> Any: + """ + 获取配置值 + + Args: + key: 配置键,支持点号分隔的嵌套键 + default: 默认值 + + Returns: + 配置值 + """ + try: + return self._settings.get(key, default) + except Exception as e: + self.logger.warning(f"获取配置失败: {key}, 使用默认值: {default}") + return default + + def get_db_config(self) -> Dict[str, Any]: + """获取数据库配置""" + return self.get_config("DB", {}) + + def get_api_config(self) -> Dict[str, Any]: + """获取API配置""" + return self.get_config("API", {}) + + def get_test_config(self) -> Dict[str, Any]: + """获取测试配置""" + return self.get_config("TEST", {}) + + def get_base_url(self, service_name: str = "default") -> str: + """ + 获取服务基础URL + + Args: + service_name: 服务名称 + + Returns: + 基础URL + """ + api_config = self.get_api_config() + if service_name in api_config: + return api_config[service_name].get("base_url", "") + return api_config.get("base_url", "") + + def get_timeout(self, service_name: str = "default") -> int: + """ + 获取超时配置 + + Args: + service_name: 服务名称 + + Returns: + 超时时间(秒) + """ + api_config = self.get_api_config() + if service_name in api_config: + return api_config[service_name].get("timeout", 30) + return api_config.get("timeout", 30) + + def is_debug_mode(self) -> bool: + """是否为调试模式""" + return self.get_config("DEBUG", False) + + def get_log_level(self) -> str: + """获取日志级别""" + return self.get_config("LOG_LEVEL", "INFO") + + def get_parallel_workers(self) -> int: + """获取并行执行的工作进程数""" + return self.get_config("PARALLEL_WORKERS", 1) + + def get_retry_times(self) -> int: + """获取重试次数""" + return self.get_config("RETRY_TIMES", 0) + + def get_allure_dir(self) -> str: + """获取Allure报告目录""" + return self.get_config("ALLURE_DIR", "output/allure-result") + + def list_environments(self) -> list: + """列出所有可用环境""" + try: + # 从配置文件中获取环境列表 + environments = [] + if hasattr(self._settings, "_environments"): + environments = list(self._settings._environments.keys()) + return environments + except Exception as e: + self.logger.error(f"获取环境列表失败: {e}") + return [] + + def validate_environment(self, env_name: str) -> bool: + """ + 验证环境是否有效 + + Args: + env_name: 环境名称 + + Returns: + 是否有效 + """ + available_envs = self.list_environments() + return env_name in available_envs + + def export_env_vars(self): + """导出环境变量""" + try: + # 导出当前环境的配置为环境变量 + for key, value in self._settings.as_dict().items(): + if isinstance(value, (str, int, float, bool)): + os.environ[f"PYTEST_FRAMEWORK_{key.upper()}"] = str(value) + + self.logger.info("环境变量导出成功") + except Exception as e: + self.logger.error(f"环境变量导出失败: {e}") + + +# 全局环境管理器实例 +env_manager = EnvironmentManager() + + +def get_current_env() -> str: + """获取当前环境""" + return env_manager.current_env + + +def get_config(key: str, default: Any = None) -> Any: + """获取配置值的便捷函数""" + return env_manager.get_config(key, default) + + +def get_base_url(service_name: str = "default") -> str: + """获取基础URL的便捷函数""" + return env_manager.get_base_url(service_name) + + +def switch_environment(env_name: str): + """切换环境的便捷函数""" + env_manager.switch_env(env_name) diff --git a/src/utils/jmespath_helper.py b/src/utils/jmespath_helper.py new file mode 100644 index 0000000..32fd38d --- /dev/null +++ b/src/utils/jmespath_helper.py @@ -0,0 +1,298 @@ +""" +JMESPath辅助工具模块 + +提供JMESPath查询的便捷方法和常用查询模式 +""" + +from typing import Any, Dict, List, Optional, Union + +import jmespath + +from src.utils.log_moudle import logger + + +class JMESPathHelper: + """JMESPath查询辅助类""" + + def __init__(self, data: Any): + """ + 初始化JMESPath辅助器 + + Args: + data: 要查询的数据 + """ + self.data = data + self.logger = logger + + def search(self, path: str) -> Any: + """ + 执行JMESPath查询 + + Args: + path: JMESPath查询路径 + + Returns: + 查询结果 + """ + try: + result = jmespath.search(path, self.data) + self.logger.debug(f"JMESPath查询: {path} -> {result}") + return result + except Exception as e: + self.logger.error(f"JMESPath查询失败: {path}, 错误: {e}") + raise + + def exists(self, path: str) -> bool: + """ + 检查路径是否存在 + + Args: + path: JMESPath查询路径 + + Returns: + 是否存在 + """ + result = self.search(path) + return result is not None + + def get_value(self, path: str, default: Any = None) -> Any: + """ + 获取值,支持默认值 + + Args: + path: JMESPath查询路径 + default: 默认值 + + Returns: + 查询结果或默认值 + """ + result = self.search(path) + return result if result is not None else default + + def get_list(self, path: str) -> List[Any]: + """ + 获取列表结果 + + Args: + path: JMESPath查询路径 + + Returns: + 列表结果 + """ + result = self.search(path) + if result is None: + return [] + if not isinstance(result, list): + return [result] + return result + + def get_dict(self, path: str) -> Dict[str, Any]: + """ + 获取字典结果 + + Args: + path: JMESPath查询路径 + + Returns: + 字典结果 + """ + result = self.search(path) + if result is None: + return {} + if not isinstance(result, dict): + raise ValueError(f"JMESPath查询结果不是字典类型: {type(result)}") + return result + + def count(self, path: str) -> int: + """ + 计算查询结果的数量 + + Args: + path: JMESPath查询路径 + + Returns: + 数量 + """ + result = self.search(path) + if result is None: + return 0 + if isinstance(result, (list, dict, str)): + return len(result) + return 1 + + def filter_by(self, list_path: str, condition: str) -> List[Any]: + """ + 根据条件过滤列表 + + Args: + list_path: 列表的JMESPath路径 + condition: 过滤条件 + + Returns: + 过滤后的列表 + """ + filter_path = f"{list_path}[?{condition}]" + return self.get_list(filter_path) + + def sort_by( + self, list_path: str, sort_key: str, reverse: bool = False + ) -> List[Any]: + """ + 根据键排序列表 + + Args: + list_path: 列表的JMESPath路径 + sort_key: 排序键 + reverse: 是否逆序 + + Returns: + 排序后的列表 + """ + if reverse: + sort_path = f"reverse(sort_by({list_path}, &{sort_key}))" + else: + sort_path = f"sort_by({list_path}, &{sort_key})" + return self.get_list(sort_path) + + def group_by(self, list_path: str, group_key: str) -> Dict[str, List[Any]]: + """ + 根据键分组列表 + + Args: + list_path: 列表的JMESPath路径 + group_key: 分组键 + + Returns: + 分组后的字典 + """ + items = self.get_list(list_path) + groups = {} + + for item in items: + if isinstance(item, dict) and group_key in item: + key = str(item[group_key]) + if key not in groups: + groups[key] = [] + groups[key].append(item) + + return groups + + def extract_fields(self, list_path: str, fields: List[str]) -> List[Dict[str, Any]]: + """ + 从列表中提取指定字段 + + Args: + list_path: 列表的JMESPath路径 + fields: 要提取的字段列表 + + Returns: + 提取字段后的列表 + """ + field_expr = "{" + ", ".join([f"{field}: {field}" for field in fields]) + "}" + extract_path = f"{list_path}[].{field_expr}" + return self.get_list(extract_path) + + def find_first(self, list_path: str, condition: str) -> Optional[Any]: + """ + 查找第一个匹配条件的元素 + + Args: + list_path: 列表的JMESPath路径 + condition: 查找条件 + + Returns: + 第一个匹配的元素或None + """ + find_path = f"{list_path}[?{condition}] | [0]" + return self.search(find_path) + + def validate_structure(self, required_paths: List[str]) -> Dict[str, bool]: + """ + 验证数据结构是否包含必需的路径 + + Args: + required_paths: 必需的路径列表 + + Returns: + 验证结果字典 + """ + results = {} + for path in required_paths: + results[path] = self.exists(path) + return results + + +# 常用JMESPath查询模式 +class CommonJMESPatterns: + """常用JMESPath查询模式""" + + # API响应相关 + API_CODE = "code" + API_MESSAGE = "message" + API_DATA = "data" + API_SUCCESS = "code == `200`" + API_ERROR = "code != `200`" + + # 分页相关 + PAGE_TOTAL = "data.total" + PAGE_SIZE = "data.size" + PAGE_CURRENT = "data.current" + PAGE_ITEMS = "data.items" + + # 用户相关 + USER_ID = "data.user.id" + USER_NAME = "data.user.name" + USER_EMAIL = "data.user.email" + USER_STATUS = "data.user.status" + + # 列表操作 + FIRST_ITEM = "data[0]" + LAST_ITEM = "data[-1]" + ITEM_COUNT = "length(data)" + + # 条件查询 + ACTIVE_USERS = "data[?status == 'active']" + ADMIN_USERS = "data[?role == 'admin']" + RECENT_ITEMS = "data[?created_at > '2024-01-01']" + + @staticmethod + def get_user_by_id(user_id: int) -> str: + """根据ID查找用户""" + return f"data[?id == `{user_id}`] | [0]" + + @staticmethod + def get_items_by_status(status: str) -> str: + """根据状态查找项目""" + return f"data[?status == '{status}']" + + @staticmethod + def get_field_values(field: str) -> str: + """获取所有项目的指定字段值""" + return f"data[].{field}" + + @staticmethod + def sort_by_field(field: str, reverse: bool = False) -> str: + """根据字段排序""" + if reverse: + return f"reverse(sort_by(data, &{field}))" + return f"sort_by(data, &{field})" + + +def jmes(data: Any) -> JMESPathHelper: + """创建JMESPath辅助器的便捷函数""" + return JMESPathHelper(data) + + +def quick_search(data: Any, path: str, default: Any = None) -> Any: + """快速JMESPath查询的便捷函数""" + return JMESPathHelper(data).get_value(path, default) + + +def quick_exists(data: Any, path: str) -> bool: + """快速检查路径是否存在的便捷函数""" + return JMESPathHelper(data).exists(path) + + +def quick_count(data: Any, path: str) -> int: + """快速计算数量的便捷函数""" + return JMESPathHelper(data).count(path) diff --git a/src/utils/mock_server.py b/src/utils/mock_server.py new file mode 100644 index 0000000..c6d0994 --- /dev/null +++ b/src/utils/mock_server.py @@ -0,0 +1,348 @@ +""" +Mock服务器工具模块 + +提供轻量级的Mock服务器,用于模拟API响应 +""" + +import json +import threading +import time +from http.server import BaseHTTPRequestHandler, HTTPServer +from typing import Any, Dict, List, Optional +from urllib.parse import parse_qs, urlparse + +from src.utils.log_moudle import logger + + +class MockResponse: + """Mock响应类""" + + def __init__( + self, + status_code: int = 200, + headers: Dict[str, str] = None, + body: Any = None, + delay: float = 0, + ): + """ + 初始化Mock响应 + + Args: + status_code: HTTP状态码 + headers: 响应头 + body: 响应体 + delay: 响应延迟(秒) + """ + self.status_code = status_code + self.headers = headers or {"Content-Type": "application/json"} + self.body = body or {} + self.delay = delay + + +class MockRule: + """Mock规则类""" + + def __init__( + self, + method: str, + path: str, + response: MockResponse, + query_params: Dict = None, + request_body: Dict = None, + ): + """ + 初始化Mock规则 + + Args: + method: HTTP方法 + path: 请求路径 + response: Mock响应 + query_params: 查询参数匹配条件 + request_body: 请求体匹配条件 + """ + self.method = method.upper() + self.path = path + self.response = response + self.query_params = query_params or {} + self.request_body = request_body or {} + self.call_count = 0 + + def matches( + self, + method: str, + path: str, + query_params: Dict = None, + request_body: Dict = None, + ) -> bool: + """ + 检查请求是否匹配规则 + + Args: + method: HTTP方法 + path: 请求路径 + query_params: 查询参数 + request_body: 请求体 + + Returns: + 是否匹配 + """ + # 检查方法和路径 + if self.method != method.upper() or self.path != path: + return False + + # 检查查询参数 + if self.query_params: + query_params = query_params or {} + for key, value in self.query_params.items(): + if key not in query_params or query_params[key] != value: + return False + + # 检查请求体 + if self.request_body: + request_body = request_body or {} + for key, value in self.request_body.items(): + if key not in request_body or request_body[key] != value: + return False + + return True + + +class MockRequestHandler(BaseHTTPRequestHandler): + """Mock请求处理器""" + + def log_message(self, format, *args): + """重写日志方法,使用自定义logger""" + logger.info(f"Mock Server: {format % args}") + + def do_GET(self): + """处理GET请求""" + self._handle_request("GET") + + def do_POST(self): + """处理POST请求""" + self._handle_request("POST") + + def do_PUT(self): + """处理PUT请求""" + self._handle_request("PUT") + + def do_DELETE(self): + """处理DELETE请求""" + self._handle_request("DELETE") + + def do_PATCH(self): + """处理PATCH请求""" + self._handle_request("PATCH") + + def _handle_request(self, method: str): + """处理请求的通用方法""" + try: + # 解析请求 + parsed_url = urlparse(self.path) + path = parsed_url.path + query_params = parse_qs(parsed_url.query) + + # 读取请求体 + request_body = {} + if method in ["POST", "PUT", "PATCH"]: + content_length = int(self.headers.get("Content-Length", 0)) + if content_length > 0: + body_data = self.rfile.read(content_length) + try: + request_body = json.loads(body_data.decode("utf-8")) + except json.JSONDecodeError: + request_body = {"raw": body_data.decode("utf-8")} + + # 查找匹配的规则 + mock_server = getattr(self.server, "mock_server", None) + if mock_server: + response = mock_server.find_response( + method, path, query_params, request_body + ) + if response: + # 模拟延迟 + if response.delay > 0: + time.sleep(response.delay) + + # 发送响应 + self.send_response(response.status_code) + for key, value in response.headers.items(): + self.send_header(key, value) + self.end_headers() + + # 发送响应体 + if isinstance(response.body, (dict, list)): + response_data = json.dumps(response.body, ensure_ascii=False) + else: + response_data = str(response.body) + + self.wfile.write(response_data.encode("utf-8")) + return + + # 没有找到匹配的规则,返回404 + self.send_response(404) + self.send_header("Content-Type", "application/json") + self.end_headers() + error_response = { + "error": "Not Found", + "message": f"No mock rule found for {method} {path}", + } + self.wfile.write(json.dumps(error_response).encode("utf-8")) + + except Exception as e: + logger.error(f"Mock服务器处理请求失败: {e}") + self.send_response(500) + self.send_header("Content-Type", "application/json") + self.end_headers() + error_response = {"error": "Internal Server Error", "message": str(e)} + self.wfile.write(json.dumps(error_response).encode("utf-8")) + + +class MockServer: + """Mock服务器类""" + + def __init__(self, host: str = "localhost", port: int = 8888): + """ + 初始化Mock服务器 + + Args: + host: 服务器主机 + port: 服务器端口 + """ + self.host = host + self.port = port + self.rules: List[MockRule] = [] + self.server = None + self.server_thread = None + self.logger = logger + + def add_rule( + self, + method: str, + path: str, + response: MockResponse, + query_params: Dict = None, + request_body: Dict = None, + ) -> "MockServer": + """ + 添加Mock规则 + + Args: + method: HTTP方法 + path: 请求路径 + response: Mock响应 + query_params: 查询参数匹配条件 + request_body: 请求体匹配条件 + + Returns: + Mock服务器实例(支持链式调用) + """ + rule = MockRule(method, path, response, query_params, request_body) + self.rules.append(rule) + self.logger.info(f"添加Mock规则: {method} {path}") + return self + + def find_response( + self, + method: str, + path: str, + query_params: Dict = None, + request_body: Dict = None, + ) -> Optional[MockResponse]: + """ + 查找匹配的响应 + + Args: + method: HTTP方法 + path: 请求路径 + query_params: 查询参数 + request_body: 请求体 + + Returns: + 匹配的响应或None + """ + for rule in self.rules: + if rule.matches(method, path, query_params, request_body): + rule.call_count += 1 + self.logger.info( + f"匹配Mock规则: {method} {path} (调用次数: {rule.call_count})" + ) + return rule.response + + self.logger.warning(f"未找到匹配的Mock规则: {method} {path}") + return None + + def start(self): + """启动Mock服务器""" + try: + self.server = HTTPServer((self.host, self.port), MockRequestHandler) + self.server.mock_server = self # 将Mock服务器实例传递给请求处理器 + + self.server_thread = threading.Thread(target=self.server.serve_forever) + self.server_thread.daemon = True + self.server_thread.start() + + self.logger.info(f"Mock服务器已启动: http://{self.host}:{self.port}") + + except Exception as e: + self.logger.error(f"Mock服务器启动失败: {e}") + raise + + def stop(self): + """停止Mock服务器""" + if self.server: + self.server.shutdown() + self.server.server_close() + self.logger.info("Mock服务器已停止") + + def reset_rules(self): + """重置所有规则""" + self.rules.clear() + self.logger.info("Mock规则已重置") + + def get_call_count(self, method: str, path: str) -> int: + """ + 获取指定规则的调用次数 + + Args: + method: HTTP方法 + path: 请求路径 + + Returns: + 调用次数 + """ + for rule in self.rules: + if rule.method == method.upper() and rule.path == path: + return rule.call_count + return 0 + + @property + def base_url(self) -> str: + """获取Mock服务器的基础URL""" + return f"http://{self.host}:{self.port}" + + +# 全局Mock服务器实例 +mock_server = MockServer() + + +def create_mock_response( + status_code: int = 200, + body: Any = None, + headers: Dict[str, str] = None, + delay: float = 0, +) -> MockResponse: + """创建Mock响应的便捷函数""" + return MockResponse(status_code, headers, body, delay) + + +def start_mock_server(host: str = "localhost", port: int = 8888) -> MockServer: + """启动Mock服务器的便捷函数""" + global mock_server + mock_server = MockServer(host, port) + mock_server.start() + return mock_server + + +if __name__ == "__main__": + start_mock_server() diff --git a/src/utils/performance.py b/src/utils/performance.py new file mode 100644 index 0000000..8847560 --- /dev/null +++ b/src/utils/performance.py @@ -0,0 +1,336 @@ +""" +性能测试工具模块 + +提供接口性能监控和分析功能 +""" + +import statistics +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass +from typing import Any, Callable, Dict, List, Optional + +import requests + +from src.utils.log_moudle import logger + + +@dataclass +class PerformanceMetrics: + """性能指标数据类""" + + total_requests: int + successful_requests: int + failed_requests: int + total_time: float + min_response_time: float + max_response_time: float + avg_response_time: float + median_response_time: float + p95_response_time: float + p99_response_time: float + requests_per_second: float + error_rate: float + + def to_dict(self) -> Dict[str, Any]: + """转换为字典""" + return { + "total_requests": self.total_requests, + "successful_requests": self.successful_requests, + "failed_requests": self.failed_requests, + "total_time": round(self.total_time, 3), + "min_response_time": round(self.min_response_time, 3), + "max_response_time": round(self.max_response_time, 3), + "avg_response_time": round(self.avg_response_time, 3), + "median_response_time": round(self.median_response_time, 3), + "p95_response_time": round(self.p95_response_time, 3), + "p99_response_time": round(self.p99_response_time, 3), + "requests_per_second": round(self.requests_per_second, 2), + "error_rate": round(self.error_rate * 100, 2), + } + + +@dataclass +class RequestResult: + """单次请求结果""" + + success: bool + response_time: float + status_code: Optional[int] = None + error_message: Optional[str] = None + + +class PerformanceTester: + """性能测试器""" + + def __init__(self): + """初始化性能测试器""" + self.logger = logger + + def _execute_request( + self, request_func: Callable, *args, **kwargs + ) -> RequestResult: + """ + 执行单次请求 + + Args: + request_func: 请求函数 + *args: 位置参数 + **kwargs: 关键字参数 + + Returns: + 请求结果 + """ + start_time = time.time() + try: + response = request_func(*args, **kwargs) + end_time = time.time() + + response_time = end_time - start_time + + # 检查响应状态 + if hasattr(response, "status_code"): + success = 200 <= response.status_code < 400 + status_code = response.status_code + else: + success = True + status_code = None + + return RequestResult( + success=success, response_time=response_time, status_code=status_code + ) + + except Exception as e: + end_time = time.time() + response_time = end_time - start_time + + return RequestResult( + success=False, response_time=response_time, error_message=str(e) + ) + + def load_test( + self, + request_func: Callable, + concurrent_users: int = 10, + total_requests: int = 100, + *args, + **kwargs, + ) -> PerformanceMetrics: + """ + 负载测试 + + Args: + request_func: 请求函数 + concurrent_users: 并发用户数 + total_requests: 总请求数 + *args: 传递给请求函数的位置参数 + **kwargs: 传递给请求函数的关键字参数 + + Returns: + 性能指标 + """ + self.logger.info( + f"开始负载测试: {concurrent_users} 并发用户, {total_requests} 总请求" + ) + + results: List[RequestResult] = [] + start_time = time.time() + + with ThreadPoolExecutor(max_workers=concurrent_users) as executor: + # 提交所有任务 + futures = [ + executor.submit(self._execute_request, request_func, *args, **kwargs) + for _ in range(total_requests) + ] + + # 收集结果 + for future in as_completed(futures): + try: + result = future.result() + results.append(result) + except Exception as e: + self.logger.error(f"请求执行失败: {e}") + results.append( + RequestResult( + success=False, response_time=0, error_message=str(e) + ) + ) + + end_time = time.time() + total_time = end_time - start_time + + # 计算性能指标 + return self._calculate_metrics(results, total_time) + + def stress_test( + self, + request_func: Callable, + duration_seconds: int = 60, + concurrent_users: int = 10, + *args, + **kwargs, + ) -> PerformanceMetrics: + """ + 压力测试 + + Args: + request_func: 请求函数 + duration_seconds: 测试持续时间(秒) + concurrent_users: 并发用户数 + *args: 传递给请求函数的位置参数 + **kwargs: 传递给请求函数的关键字参数 + + Returns: + 性能指标 + """ + self.logger.info( + f"开始压力测试: {concurrent_users} 并发用户, 持续 {duration_seconds} 秒" + ) + + results: List[RequestResult] = [] + start_time = time.time() + end_time = start_time + duration_seconds + + with ThreadPoolExecutor(max_workers=concurrent_users) as executor: + futures = [] + + # 在指定时间内持续提交任务 + while time.time() < end_time: + future = executor.submit( + self._execute_request, request_func, *args, **kwargs + ) + futures.append(future) + + # 避免提交过多任务导致内存问题 + if len(futures) >= concurrent_users * 10: + # 等待一些任务完成 + completed_futures = [] + for f in futures: + if f.done(): + completed_futures.append(f) + + for f in completed_futures: + try: + result = f.result() + results.append(result) + except Exception as e: + self.logger.error(f"请求执行失败: {e}") + results.append( + RequestResult( + success=False, response_time=0, error_message=str(e) + ) + ) + futures.remove(f) + + # 等待剩余任务完成 + for future in as_completed(futures): + try: + result = future.result() + results.append(result) + except Exception as e: + self.logger.error(f"请求执行失败: {e}") + results.append( + RequestResult( + success=False, response_time=0, error_message=str(e) + ) + ) + + actual_duration = time.time() - start_time + + # 计算性能指标 + return self._calculate_metrics(results, actual_duration) + + def _calculate_metrics( + self, results: List[RequestResult], total_time: float + ) -> PerformanceMetrics: + """ + 计算性能指标 + + Args: + results: 请求结果列表 + total_time: 总耗时 + + Returns: + 性能指标 + """ + if not results: + raise ValueError("没有请求结果数据") + + # 基本统计 + total_requests = len(results) + successful_requests = sum(1 for r in results if r.success) + failed_requests = total_requests - successful_requests + + # 响应时间统计 + response_times = [r.response_time for r in results] + min_response_time = min(response_times) + max_response_time = max(response_times) + avg_response_time = statistics.mean(response_times) + median_response_time = statistics.median(response_times) + + # 百分位数 + sorted_times = sorted(response_times) + p95_index = int(len(sorted_times) * 0.95) + p99_index = int(len(sorted_times) * 0.99) + p95_response_time = ( + sorted_times[p95_index] + if p95_index < len(sorted_times) + else max_response_time + ) + p99_response_time = ( + sorted_times[p99_index] + if p99_index < len(sorted_times) + else max_response_time + ) + + # 吞吐量和错误率 + requests_per_second = total_requests / total_time if total_time > 0 else 0 + error_rate = failed_requests / total_requests if total_requests > 0 else 0 + + metrics = PerformanceMetrics( + total_requests=total_requests, + successful_requests=successful_requests, + failed_requests=failed_requests, + total_time=total_time, + min_response_time=min_response_time, + max_response_time=max_response_time, + avg_response_time=avg_response_time, + median_response_time=median_response_time, + p95_response_time=p95_response_time, + p99_response_time=p99_response_time, + requests_per_second=requests_per_second, + error_rate=error_rate, + ) + + self.logger.info(f"性能测试完成: {metrics.to_dict()}") + return metrics + + +# 全局性能测试器实例 +performance_tester = PerformanceTester() + + +def load_test( + request_func: Callable, + concurrent_users: int = 10, + total_requests: int = 100, + *args, + **kwargs, +) -> PerformanceMetrics: + """负载测试的便捷函数""" + return performance_tester.load_test( + request_func, concurrent_users, total_requests, *args, **kwargs + ) + + +def stress_test( + request_func: Callable, + duration_seconds: int = 60, + concurrent_users: int = 10, + *args, + **kwargs, +) -> PerformanceMetrics: + """压力测试的便捷函数""" + return performance_tester.stress_test( + request_func, duration_seconds, concurrent_users, *args, **kwargs + ) diff --git a/tests/.DS_Store b/tests/.DS_Store new file mode 100644 index 0000000..229fa0c Binary files /dev/null and b/tests/.DS_Store differ diff --git a/tests/examples/test_complete_workflow.py b/tests/examples/test_complete_workflow.py new file mode 100644 index 0000000..db69a78 --- /dev/null +++ b/tests/examples/test_complete_workflow.py @@ -0,0 +1,419 @@ +""" +完整工作流示例测试 + +展示框架所有功能的综合使用示例 +""" + +import time + +import pytest +import requests + +from src.client.base_client import BaseClient +from src.utils.assertion import assert_response, assert_success_response +from src.utils.data_driver import data_driver, load_test_data +from src.utils.environment import get_base_url, get_config +from src.utils.mock_server import MockServer, create_mock_response +from src.utils.performance import load_test, stress_test + + +class TestCompleteWorkflow: + """完整工作流测试类""" + + @pytest.fixture(scope="class") + def mock_server(self): + """Mock服务器fixture""" + server = MockServer(host="localhost", port=9998) + + # 用户相关Mock规则 + server.add_rule( + "POST", + "/api/users", + create_mock_response( + 201, + { + "code": 201, + "message": "用户创建成功", + "data": { + "id": 1001, + "name": "张三", + "email": "zhangsan@example.com", + "created_at": "2024-01-01T00:00:00Z", + }, + }, + ), + ) + + server.add_rule( + "GET", + "/api/users/1001", + create_mock_response( + 200, + { + "code": 200, + "message": "success", + "data": { + "id": 1001, + "name": "张三", + "email": "zhangsan@example.com", + "status": "active", + "created_at": "2024-01-01T00:00:00Z", + }, + }, + ), + ) + + server.add_rule( + "PUT", + "/api/users/1001", + create_mock_response( + 200, + { + "code": 200, + "message": "用户更新成功", + "data": { + "id": 1001, + "name": "张三更新", + "email": "zhangsan_updated@example.com", + "status": "active", + "updated_at": "2024-01-01T01:00:00Z", + }, + }, + ), + ) + + server.add_rule("DELETE", "/api/users/1001", create_mock_response(204, {})) + + # 订单相关Mock规则 + server.add_rule( + "POST", + "/api/orders", + create_mock_response( + 201, + { + "code": 201, + "message": "订单创建成功", + "data": { + "order_id": "ORD-2024-001", + "user_id": 1001, + "total_amount": 299.99, + "status": "pending", + "items": [{"product_id": 2001, "quantity": 2, "price": 149.99}], + }, + }, + ), + ) + + server.start() + yield server + server.stop() + + @pytest.fixture + def api_client(self, mock_server): + """API客户端fixture""" + return BaseClient(mock_server.base_url) + + @pytest.fixture + def test_users_data(self): + """测试用户数据fixture""" + return load_test_data("test_users.json") + + def test_user_lifecycle_workflow(self, api_client): + """用户生命周期工作流测试""" + + # 1. 创建用户 + user_data = {"name": "张三", "email": "zhangsan@example.com", "age": 25} + + create_response = api_client.post("/api/users", json=user_data) + + # 使用增强断言验证创建响应 + ( + assert_success_response(create_response, 201) + .assert_json_path("$.code", 201) + .assert_json_path("$.data.name", "张三") + .assert_json_path("$.data.email", "zhangsan@example.com") + .assert_dict_has_keys( + ["id", "name", "email"], create_response.json()["data"] + ) + ) + + user_id = create_response.json()["data"]["id"] + + # 2. 获取用户信息 + get_response = api_client.get(f"/api/users/{user_id}") + + ( + assert_success_response(get_response) + .assert_json_path("$.data.id", user_id) + .assert_json_path("$.data.status", "active") + .assert_contains("created_at", get_response.json()["data"]) + ) + + # 3. 更新用户信息 + update_data = {"name": "张三更新", "email": "zhangsan_updated@example.com"} + + update_response = api_client.put(f"/api/users/{user_id}", json=update_data) + + ( + assert_success_response(update_response) + .assert_json_path("$.data.name", "张三更新") + .assert_json_path("$.data.email", "zhangsan_updated@example.com") + .assert_contains("updated_at", update_response.json()["data"]) + ) + + # 4. 删除用户 + delete_response = api_client.delete(f"/api/users/{user_id}") + assert delete_response.status_code == 204 + + @pytest.mark.parametrize("user_data", load_test_data("test_users.json")[:3]) + def test_data_driven_user_creation(self, api_client, user_data): + """数据驱动的用户创建测试""" + + # 使用测试数据创建用户 + response = api_client.post("/api/users", json=user_data) + + # 验证响应 + ( + assert_success_response(response, 201) + .assert_json_path("$.code", 201) + .assert_json_path("$.message", "用户创建成功") + .assert_dict_has_keys(["id", "name", "email"], response.json()["data"]) + ) + + def test_generated_test_data(self, api_client): + """使用生成的测试数据""" + + # 定义数据模板 + user_template = { + "name": "faker.name", + "email": "faker.email", + "phone": "faker.phone_number", + "age": 25, + "city": "北京", + } + + # 生成测试数据 + test_users = data_driver.generate_test_data(user_template, count=3) + + for user_data in test_users: + response = api_client.post("/api/users", json=user_data) + + ( + assert_success_response(response, 201) + .assert_json_path("$.code", 201) + .assert_contains("用户创建成功", response.json()["message"]) + ) + + def test_business_workflow_integration(self, api_client): + """业务工作流集成测试""" + + # 1. 创建用户 + user_data = {"name": "业务用户", "email": "business@example.com"} + user_response = api_client.post("/api/users", json=user_data) + user_id = user_response.json()["data"]["id"] + + # 2. 创建订单 + order_data = { + "user_id": user_id, + "items": [{"product_id": 2001, "quantity": 2, "price": 149.99}], + "shipping_address": {"street": "测试街道123号", "city": "北京市"}, + } + + order_response = api_client.post("/api/orders", json=order_data) + + # 验证订单创建 + ( + assert_success_response(order_response, 201) + .assert_json_path("$.data.user_id", user_id) + .assert_json_path("$.data.total_amount", 299.99) + .assert_json_path("$.data.status", "pending") + .assert_list_length(1, order_response.json()["data"]["items"]) + ) + + order_id = order_response.json()["data"]["order_id"] + + # 3. 验证订单信息 + assert order_id.startswith("ORD-") + assert len(order_response.json()["data"]["items"]) == 1 + + @pytest.mark.performance + def test_api_performance(self, api_client): + """API性能测试""" + + def create_user_request(): + """创建用户请求函数""" + user_data = { + "name": "性能测试用户", + "email": f"perf_test_{int(time.time() * 1000)}@example.com", + } + return api_client.post("/api/users", json=user_data) + + # 负载测试 + metrics = load_test(create_user_request, concurrent_users=5, total_requests=20) + + # 验证性能指标 + assert metrics.total_requests == 20 + assert metrics.successful_requests >= 18 # 允许少量失败 + assert metrics.error_rate <= 0.1 # 错误率不超过10% + assert metrics.avg_response_time <= 3.0 # 平均响应时间不超过3秒 + + print(f"性能测试结果:") + print(f" 总请求数: {metrics.total_requests}") + print(f" 成功请求数: {metrics.successful_requests}") + print(f" 平均响应时间: {metrics.avg_response_time:.3f}s") + print(f" QPS: {metrics.requests_per_second:.2f}") + print(f" 错误率: {metrics.error_rate:.2%}") + + def test_complex_json_assertion(self, api_client): + """复杂JSON断言测试""" + + # 创建用户 + response = api_client.post( + "/api/users", json={"name": "复杂断言测试", "email": "complex@example.com"} + ) + + response_data = response.json() + + # 复杂断言组合 + ( + assert_response(response_data) + .assert_json_path("$.code", 201) + .assert_jmespath("data.id", 1001) + .assert_regex_match( + r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z", + response_data["data"]["created_at"], + ) + .assert_value_in_range(1000, 2000, response_data["data"]["id"]) + .assert_dict_has_keys(["code", "message", "data"], response_data) + .assert_contains("成功", response_data["message"]) + ) + + def test_error_handling(self, api_client): + """错误处理测试""" + + # 测试不存在的用户 + response = api_client.get("/api/users/99999") + + # 这里Mock服务器会返回404,但我们的Mock规则没有定义这个路径 + # 所以会返回默认的404响应 + assert response.status_code == 404 + + error_data = response.json() + assert "error" in error_data + assert error_data["error"] == "Not Found" + + def test_environment_configuration(self): + """环境配置测试""" + + # 测试配置获取 + base_url = get_base_url() + assert isinstance(base_url, str) + + # 测试默认值 + timeout = get_config("API.timeout", 30) + assert isinstance(timeout, int) + assert timeout > 0 + + # 测试调试模式 + debug_mode = get_config("DEBUG", False) + assert isinstance(debug_mode, bool) + + @pytest.mark.integration + def test_full_integration_scenario(self, api_client): + """完整集成场景测试""" + + # 模拟完整的用户注册到下单流程 + + # 1. 用户注册 + registration_data = { + "name": "集成测试用户", + "email": "integration@example.com", + "password": "test123456", + } + + register_response = api_client.post("/api/users", json=registration_data) + assert_success_response(register_response, 201) + + user_id = register_response.json()["data"]["id"] + + # 2. 用户登录(模拟) + # 在实际场景中,这里会有登录逻辑 + + # 3. 浏览商品(模拟) + # 在实际场景中,这里会有商品查询逻辑 + + # 4. 创建订单 + order_data = { + "user_id": user_id, + "items": [{"product_id": 2001, "quantity": 1, "price": 149.99}], + } + + order_response = api_client.post("/api/orders", json=order_data) + assert_success_response(order_response, 201) + + # 5. 验证整个流程 + order_data = order_response.json()["data"] + assert order_data["user_id"] == user_id + assert order_data["status"] == "pending" + assert len(order_data["items"]) == 1 + + print("✅ 完整集成场景测试通过!") + print(f" 用户ID: {user_id}") + print(f" 订单ID: {order_data['order_id']}") + print(f" 订单金额: {order_data['total_amount']}") + + +@pytest.mark.slow +class TestAdvancedFeatures: + """高级功能测试类""" + + def test_schema_validation(self, api_client): + """JSON Schema验证测试""" + + # 定义用户响应的Schema + user_schema = { + "type": "object", + "properties": { + "code": {"type": "integer"}, + "message": {"type": "string"}, + "data": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + "email": {"type": "string", "format": "email"}, + "created_at": {"type": "string"}, + }, + "required": ["id", "name", "email"], + }, + }, + "required": ["code", "message", "data"], + } + + # 创建用户并验证Schema + user_data = {"name": "Schema测试", "email": "schema@example.com"} + response = api_client.post("/api/users", json=user_data) + + # 使用Schema断言 + assert_response(response.json()).assert_schema(user_schema) + + def test_response_time_monitoring(self, api_client): + """响应时间监控测试""" + + start_time = time.time() + response = api_client.post( + "/api/users", + json={"name": "响应时间测试", "email": "response_time@example.com"}, + ) + end_time = time.time() + + response_time = end_time - start_time + + # 验证响应时间 + ( + assert_success_response(response, 201).assert_response_time( + 5.0, response_time + ) + ) # 响应时间应小于5秒 + + print(f"API响应时间: {response_time:.3f}秒") diff --git a/tests/examples/test_enhanced_features.py b/tests/examples/test_enhanced_features.py new file mode 100644 index 0000000..65f370d --- /dev/null +++ b/tests/examples/test_enhanced_features.py @@ -0,0 +1,331 @@ +""" +增强功能示例测试 + +展示框架新增功能的使用方法 +""" + +import pytest +import requests + +from src.client.base_client import BaseClient +from src.utils.assertion import ( + assert_api_response, + assert_jmes, + assert_response, + assert_success_response, +) +from src.utils.data_driver import data_driver, load_test_data +from src.utils.environment import get_base_url, get_config +from src.utils.jmespath_helper import CommonJMESPatterns, jmes, quick_search +from src.utils.mock_server import MockServer, create_mock_response +from src.utils.performance import load_test, stress_test + + +class TestEnhancedAssertion: + """增强断言功能测试""" + + def test_json_path_assertion(self): + """测试JSONPath断言""" + response_data = { + "code": 200, + "message": "success", + "data": { + "user": {"id": 123, "name": "张三", "email": "zhangsan@example.com"}, + "items": [{"id": 1, "name": "商品1"}, {"id": 2, "name": "商品2"}], + }, + } + + # 使用JSONPath断言 + ( + assert_response(response_data) + .assert_json_path("$.code", 200) + .assert_json_path("$.data.user.name", "张三") + .assert_json_path("$.data.items[0].id", 1) + ) + + def test_jmespath_assertion(self): + """测试JMESPath断言 - 主推技术栈""" + response_data = { + "code": 200, + "message": "success", + "data": { + "users": [ + { + "id": 1, + "name": "张三", + "age": 25, + "city": "北京", + "status": "active", + }, + { + "id": 2, + "name": "李四", + "age": 30, + "city": "上海", + "status": "active", + }, + { + "id": 3, + "name": "王五", + "age": 22, + "city": "广州", + "status": "inactive", + }, + ], + "total": 3, + "page": 1, + }, + } + + # 使用JMESPath断言 - 基础查询 + ( + assert_response(response_data) + .assert_jmespath("code", 200) + .assert_jmespath("data.users[0].name", "张三") + .assert_jmespath("data.users[?age > `28`].name | [0]", "李四") + .assert_jmespath("data.total", 3) + ) + + # 使用JMESPath断言 - 高级查询 + ( + assert_response(response_data) + .assert_jmespath_exists("data.users") + .assert_jmespath_length("data.users", 3) + .assert_jmespath_type("data.users", list) + .assert_jmespath_contains("data.users[].status", "active") + ) + + # 使用JMESPath辅助器 + helper = jmes(response_data) + + # 查询活跃用户 + active_users = helper.filter_by("data.users", "status == 'active'") + assert len(active_users) == 2 + + # 按年龄排序 + sorted_users = helper.sort_by("data.users", "age") + assert sorted_users[0]["age"] == 22 + + # 提取用户名 + user_names = helper.get_list("data.users[].name") + assert "张三" in user_names + + # 查找特定用户 + user = helper.find_first("data.users", "name == '张三'") + assert user["id"] == 1 + + def test_schema_assertion(self): + """测试JSON Schema断言""" + response_data = { + "id": 123, + "name": "测试用户", + "email": "test@example.com", + "age": 25, + } + + schema = { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + "email": {"type": "string", "format": "email"}, + "age": {"type": "integer", "minimum": 0}, + }, + "required": ["id", "name", "email"], + } + + # 使用Schema断言 + assert_response(response_data).assert_schema(schema) + + +class TestDataDriver: + """数据驱动测试""" + + def test_generate_test_data(self): + """测试数据生成""" + template = { + "name": "faker.name", + "email": "faker.email", + "phone": "faker.phone_number", + "address": "faker.address", + "age": 25, + "city": "北京", + } + + # 生成测试数据 + test_data = data_driver.generate_test_data(template, count=3) + + assert len(test_data) == 3 + for data in test_data: + assert "name" in data + assert "email" in data + assert data["age"] == 25 + assert data["city"] == "北京" + + @pytest.mark.parametrize( + "user_data", + [ + {"name": "张三", "age": 25, "email": "zhangsan@example.com"}, + {"name": "李四", "age": 30, "email": "lisi@example.com"}, + {"name": "王五", "age": 28, "email": "wangwu@example.com"}, + ], + ) + def test_parametrized_user_creation(self, user_data): + """参数化用户创建测试""" + # 模拟用户创建API调用 + assert user_data["name"] is not None + assert user_data["age"] > 0 + assert "@" in user_data["email"] + + +class TestMockServer: + """Mock服务器测试""" + + @pytest.fixture(scope="class") + def mock_server(self): + """Mock服务器fixture""" + server = MockServer(host="localhost", port=9999) + + # 添加Mock规则 + server.add_rule( + "GET", + "/api/users/123", + create_mock_response( + 200, {"id": 123, "name": "张三", "email": "zhangsan@example.com"} + ), + ) + + server.add_rule( + "POST", + "/api/users", + create_mock_response(201, {"id": 124, "message": "用户创建成功"}), + ) + + server.add_rule( + "GET", + "/api/error", + create_mock_response(500, {"error": "内部服务器错误"}, delay=0.5), + ) + + server.start() + yield server + server.stop() + + def test_mock_get_user(self, mock_server): + """测试Mock GET请求""" + response = requests.get(f"{mock_server.base_url}/api/users/123") + + assert_success_response(response, 200) + data = response.json() + assert data["id"] == 123 + assert data["name"] == "张三" + + def test_mock_create_user(self, mock_server): + """测试Mock POST请求""" + user_data = {"name": "新用户", "email": "newuser@example.com"} + response = requests.post(f"{mock_server.base_url}/api/users", json=user_data) + + assert_success_response(response, 201) + data = response.json() + assert data["message"] == "用户创建成功" + + def test_mock_error_response(self, mock_server): + """测试Mock错误响应""" + response = requests.get(f"{mock_server.base_url}/api/error") + + assert response.status_code == 500 + data = response.json() + assert "error" in data + + +class TestPerformance: + """性能测试""" + + def simple_request(self): + """简单的HTTP请求函数""" + response = requests.get("https://httpbin.org/delay/0.1") + return response + + @pytest.mark.performance + def test_load_testing(self): + """负载测试示例""" + # 执行负载测试:10个并发用户,总共50个请求 + metrics = load_test(self.simple_request, concurrent_users=5, total_requests=20) + + # 验证性能指标 + assert metrics.total_requests == 20 + assert metrics.error_rate < 0.1 # 错误率小于10% + assert metrics.avg_response_time < 2.0 # 平均响应时间小于2秒 + + print(f"性能测试结果: {metrics.to_dict()}") + + @pytest.mark.performance + @pytest.mark.slow + def test_stress_testing(self): + """压力测试示例""" + # 执行压力测试:5个并发用户,持续10秒 + metrics = stress_test( + self.simple_request, duration_seconds=10, concurrent_users=3 + ) + + # 验证性能指标 + assert metrics.requests_per_second > 0 + assert metrics.error_rate < 0.2 # 错误率小于20% + + print(f"压力测试结果: {metrics.to_dict()}") + + +class TestEnvironmentConfig: + """环境配置测试""" + + def test_get_config_values(self): + """测试获取配置值""" + # 获取基础URL + base_url = get_base_url() + assert isinstance(base_url, str) + + # 获取自定义配置 + debug_mode = get_config("DEBUG", False) + assert isinstance(debug_mode, bool) + + # 获取数据库配置 + db_config = get_config("DB", {}) + assert isinstance(db_config, dict) + + def test_api_client_with_config(self): + """测试使用配置的API客户端""" + base_url = get_base_url() or "https://httpbin.org" + client = BaseClient(base_url) + + # 发送测试请求 + response = client.get("/get") + assert response.status_code == 200 + + +@pytest.mark.integration +class TestIntegratedWorkflow: + """集成工作流测试""" + + def test_complete_api_testing_workflow(self): + """完整的API测试工作流""" + # 1. 生成测试数据 + user_template = {"name": "faker.name", "email": "faker.email", "age": 25} + test_users = data_driver.generate_test_data(user_template, count=2) + + # 2. 使用环境配置 + base_url = get_base_url() or "https://httpbin.org" + client = BaseClient(base_url) + + # 3. 执行API测试 + for user in test_users: + response = client.post("/post", json=user) + + # 4. 使用增强断言验证响应 + ( + assert_success_response(response, 200) + .assert_json_path("$.json.name", user["name"]) + .assert_json_path("$.json.email", user["email"]) + .assert_json_path("$.json.age", user["age"]) + ) + + print("集成测试工作流执行成功!") diff --git a/tests/test_goods/test_good_add_del.py b/tests/test_goods/test_good_add_del.py index f34a6e8..0bf86d3 100644 --- a/tests/test_goods/test_good_add_del.py +++ b/tests/test_goods/test_good_add_del.py @@ -12,6 +12,8 @@ from src.client.flask_client.flask_client import flask_clinet +# 该case需要后端启用服务,否则运行会报client连接失败 +@allure.title("商品管理-添加、删除商品") class TestGoodAddDel(object): @allure.feature("商品管理") diff --git a/tests/test_goods/test_update_good.py b/tests/test_goods/test_update_good.py index 692f111..7f18ed3 100644 --- a/tests/test_goods/test_update_good.py +++ b/tests/test_goods/test_update_good.py @@ -1,25 +1,30 @@ -''' +""" 测试模块:商品管理 测试场景: 更新商品 测试重要性: P0 -''' +""" + +import allure import pytest from assertpy import assert_that -import allure # from src.utils.log_moudle import logger from loguru import logger + from src.client.flask_client.flask_client import flask_clinet +# 该case需要后端启用服务,否则运行会报client连接失败 + +@allure.title("商品管理-更新商品") class TestGoodUpdate(object): @allure.feature("商品管理") @allure.story("更新商品") @pytest.mark.P0 def test_good_update(self): - ''' + """ 测试用例: 更新商品 测试步骤: 1. 获取商品列表 @@ -27,7 +32,7 @@ def test_good_update(self): 3. 更新商品信息 4. 验证商品信息是否更新成功 - ''' + """ with allure.step("获取商品列表"): logger.info("调用接口,查询商品列表") pass diff --git a/tests/test_my_first_api.py b/tests/test_my_first_api.py new file mode 100644 index 0000000..b08190c --- /dev/null +++ b/tests/test_my_first_api.py @@ -0,0 +1,235 @@ +""" +我的第一个API测试 + +演示框架基础功能的使用,特别是JMESPath功能 +""" + +import pytest + +from src.client.base_client import BaseClient +from src.utils.assertion import assert_api_response, assert_jmes +from src.utils.environment import get_base_url +from src.utils.jmespath_helper import CommonJMESPatterns, jmes + + +class TestMyFirstAPI: + """我的第一个API测试类""" + + def setup_method(self): + """每个测试方法执行前的设置""" + # 获取基础URL(从配置文件读取) + base_url = get_base_url() or "https://httpbin.org" + self.client = BaseClient(base_url) + + def test_simple_get_request(self): + """测试简单的GET请求""" + # 发送GET请求 + response = self.client.get("/get") + + # 使用JMESPath断言验证响应 + response_data = response.json() + + # 基础断言 + assert response.status_code == 200 + assert_jmes(response_data, "url", "https://httpbin.org/get") + + # 验证响应结构 + helper = jmes(response_data) + assert helper.exists("headers") + assert helper.exists("origin") + assert helper.get_value("args", {}) == {} + + def test_get_with_parameters(self): + """测试带参数的GET请求""" + # 发送带查询参数的GET请求 + params = {"name": "张三", "age": "25", "city": "北京"} + response = self.client.get("/get", params=params) + + # 使用JMESPath验证参数 + response_data = response.json() + helper = jmes(response_data) + + # 验证参数正确传递 + assert helper.get_value("args.name") == "张三" + assert helper.get_value("args.age") == "25" + assert helper.get_value("args.city") == "北京" + + # 验证参数数量 + args_count = helper.count("args") + assert args_count == 3 + + def test_post_with_json_data(self): + """测试POST请求发送JSON数据""" + # 准备用户数据 + user_data = { + "name": "李四", + "email": "lisi@example.com", + "age": 30, + "department": "技术部", + } + + # 发送POST请求 + response = self.client.post("/post", json=user_data) + + # 验证响应 + response_data = response.json() + helper = jmes(response_data) + + # 验证状态码 + assert response.status_code == 200 + + # 验证请求数据被正确接收 + assert helper.get_value("json.name") == "李四" + assert helper.get_value("json.email") == "lisi@example.com" + assert helper.get_value("json.age") == 30 + + # 验证Content-Type + content_type = helper.get_value('headers."Content-Type"') + assert "application/json" in content_type + + def test_response_headers(self): + """测试响应头验证""" + response = self.client.get("/get") + response_data = response.json() + helper = jmes(response_data) + + # 验证常见响应头 + headers = helper.get_dict("headers") + + assert "User-Agent" in headers + assert "Accept" in headers + assert "Host" in headers + + # 验证自定义User-Agent + user_agent = headers.get("User-Agent", "") + assert "LiJiaXin/QA/" in user_agent + + +class TestAdvancedJMESPath: + """高级JMESPath查询测试""" + + def setup_method(self): + """测试前置设置""" + self.client = BaseClient("https://httpbin.org") + + def test_complex_json_response(self): + """测试复杂JSON响应的JMESPath查询""" + # 模拟复杂的API响应数据 + complex_data = { + "code": 200, + "message": "success", + "data": { + "users": [ + { + "id": 1, + "name": "张三", + "email": "zhangsan@example.com", + "age": 25, + "department": "技术部", + "skills": ["Python", "Java", "JavaScript"], + "active": True, + }, + { + "id": 2, + "name": "李四", + "email": "lisi@example.com", + "age": 30, + "department": "产品部", + "skills": ["产品设计", "用户研究"], + "active": True, + }, + { + "id": 3, + "name": "王五", + "email": "wangwu@example.com", + "age": 28, + "department": "技术部", + "skills": ["Python", "Go"], + "active": False, + }, + ], + "pagination": {"total": 3, "page": 1, "size": 10}, + }, + } + + helper = jmes(complex_data) + + # 基础查询 + assert helper.get_value("code") == 200 + assert helper.get_value("data.pagination.total") == 3 + + # 数组查询 + first_user = helper.get_value("data.users[0].name") + assert first_user == "张三" + + # 条件过滤 + tech_users = helper.filter_by("data.users", "department == '技术部'") + assert len(tech_users) == 2 + + # 活跃用户 + active_users = helper.filter_by("data.users", "active == `true`") + assert len(active_users) == 2 + + # 技能查询 + python_users = helper.filter_by("data.users", "contains(skills, 'Python')") + assert len(python_users) == 2 + + # 排序 + sorted_by_age = helper.sort_by("data.users", "age") + assert sorted_by_age[0]["age"] == 25 + assert sorted_by_age[-1]["age"] == 30 + + # 字段提取 + user_names = helper.get_list("data.users[].name") + assert "张三" in user_names + assert "李四" in user_names + assert "王五" in user_names + + # 分组 + groups = helper.group_by("data.users", "department") + assert "技术部" in groups + assert "产品部" in groups + assert len(groups["技术部"]) == 2 + assert len(groups["产品部"]) == 1 + + def test_jmespath_patterns(self): + """测试常用JMESPath模式""" + # 模拟API响应 + api_response = { + "code": 200, + "message": "操作成功", + "data": { + "user": { + "id": 123, + "name": "测试用户", + "email": "test@example.com", + "status": "active", + }, + "items": [ + {"id": 1, "name": "商品1", "price": 99.99}, + {"id": 2, "name": "商品2", "price": 199.99}, + ], + }, + } + + helper = jmes(api_response) + + # 使用常用模式 + assert helper.get_value(CommonJMESPatterns.API_CODE) == 200 + assert helper.get_value(CommonJMESPatterns.API_MESSAGE) == "操作成功" + assert helper.exists(CommonJMESPatterns.API_DATA) + + # 用户相关模式 + assert helper.get_value(CommonJMESPatterns.USER_ID) == 123 + assert helper.get_value(CommonJMESPatterns.USER_NAME) == "测试用户" + assert helper.get_value(CommonJMESPatterns.USER_EMAIL) == "test@example.com" + assert helper.get_value(CommonJMESPatterns.USER_STATUS) == "active" + + # 列表操作模式 + first_item = helper.get_value( + CommonJMESPatterns.FIRST_ITEM.replace("data", "data.items") + ) + assert first_item["name"] == "商品1" + + item_count = helper.count("data.items") + assert item_count == 2 diff --git a/tests/test_user/.DS_Store b/tests/test_user/.DS_Store new file mode 100644 index 0000000..3dddb71 Binary files /dev/null and b/tests/test_user/.DS_Store differ diff --git a/tests/test_user/test_login_logout.py b/tests/test_user/test_login_logout.py index 2039589..85a9543 100644 --- a/tests/test_user/test_login_logout.py +++ b/tests/test_user/test_login_logout.py @@ -10,6 +10,8 @@ from src.client.flask_client.flask_client import flask_clinet from src.utils.log_moudle import logger +# 该case需要后端启用服务,否则运行会报client连接失败 + class TestLoginLogout(object): @allure.feature("用户管理") diff --git a/tests/test_user/test_user_add_del.py b/tests/test_user/test_user_add_del.py index 3d5f51d..f953b36 100644 --- a/tests/test_user/test_user_add_del.py +++ b/tests/test_user/test_user_add_del.py @@ -14,6 +14,8 @@ from src.client.flask_client.flask_client import flask_clinet from src.utils.log_moudle import logger +# 该case需要后端启用服务,否则运行会报client连接失败 + class TestUserAddDel(object): @allure.feature("用户管理")