api-test-toolkit 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. api_test_toolkit-0.2.0/PKG-INFO +21 -0
  2. api_test_toolkit-0.2.0/README.md +113 -0
  3. api_test_toolkit-0.2.0/pyproject.toml +36 -0
  4. api_test_toolkit-0.2.0/setup.cfg +4 -0
  5. api_test_toolkit-0.2.0/src/api_test_kit/__init__.py +18 -0
  6. api_test_toolkit-0.2.0/src/api_test_kit/assertions.py +128 -0
  7. api_test_toolkit-0.2.0/src/api_test_kit/cli.py +45 -0
  8. api_test_toolkit-0.2.0/src/api_test_kit/client.py +208 -0
  9. api_test_toolkit-0.2.0/src/api_test_kit/config.py +82 -0
  10. api_test_toolkit-0.2.0/src/api_test_kit/e2e/__init__.py +15 -0
  11. api_test_toolkit-0.2.0/src/api_test_kit/e2e/context.py +63 -0
  12. api_test_toolkit-0.2.0/src/api_test_kit/e2e/helpers.py +26 -0
  13. api_test_toolkit-0.2.0/src/api_test_kit/e2e/scenario.py +113 -0
  14. api_test_toolkit-0.2.0/src/api_test_kit/e2e/step.py +99 -0
  15. api_test_toolkit-0.2.0/src/api_test_kit/endpoints/__init__.py +44 -0
  16. api_test_toolkit-0.2.0/src/api_test_kit/endpoints/_base.py +44 -0
  17. api_test_toolkit-0.2.0/src/api_test_kit/logger.py +25 -0
  18. api_test_toolkit-0.2.0/src/api_test_kit/scaffold.py +186 -0
  19. api_test_toolkit-0.2.0/src/api_test_kit/templates/AGENTS.md +45 -0
  20. api_test_toolkit-0.2.0/src/api_test_kit/templates/README.md +31 -0
  21. api_test_toolkit-0.2.0/src/api_test_kit/templates/pyproject.toml +21 -0
  22. api_test_toolkit-0.2.0/src/api_test_kit/templates/pytest.ini +11 -0
  23. api_test_toolkit-0.2.0/src/api_test_kit/templates/scripts/new-endpoint.sh +75 -0
  24. api_test_toolkit-0.2.0/src/api_test_kit/templates/tests/__init__.py +1 -0
  25. api_test_toolkit-0.2.0/src/api_test_kit/templates/tests/conftest.py +44 -0
  26. api_test_toolkit-0.2.0/src/api_test_toolkit.egg-info/PKG-INFO +21 -0
  27. api_test_toolkit-0.2.0/src/api_test_toolkit.egg-info/SOURCES.txt +34 -0
  28. api_test_toolkit-0.2.0/src/api_test_toolkit.egg-info/dependency_links.txt +1 -0
  29. api_test_toolkit-0.2.0/src/api_test_toolkit.egg-info/entry_points.txt +2 -0
  30. api_test_toolkit-0.2.0/src/api_test_toolkit.egg-info/requires.txt +17 -0
  31. api_test_toolkit-0.2.0/src/api_test_toolkit.egg-info/top_level.txt +1 -0
  32. api_test_toolkit-0.2.0/tests/test_assertions.py +107 -0
  33. api_test_toolkit-0.2.0/tests/test_client.py +130 -0
  34. api_test_toolkit-0.2.0/tests/test_e2e_context.py +45 -0
  35. api_test_toolkit-0.2.0/tests/test_e2e_scenario.py +104 -0
  36. api_test_toolkit-0.2.0/tests/test_e2e_step.py +69 -0
@@ -0,0 +1,21 @@
1
+ Metadata-Version: 2.4
2
+ Name: api-test-toolkit
3
+ Version: 0.2.0
4
+ Summary: AI 友好的 API 自动化测试工具包
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: requests>=2.31
7
+ Requires-Dist: pydantic<3,>=2.5
8
+ Requires-Dist: pydantic-settings>=2.1
9
+ Requires-Dist: python-dotenv>=1.0
10
+ Requires-Dist: jsonpath-ng>=1.6
11
+ Requires-Dist: deepdiff>=7.0
12
+ Requires-Dist: typer<1,>=0.12
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest>=8.0; extra == "dev"
15
+ Requires-Dist: pytest-xdist>=3.5; extra == "dev"
16
+ Requires-Dist: pytest-html>=4.1; extra == "dev"
17
+ Requires-Dist: allure-pytest>=2.13; extra == "dev"
18
+ Requires-Dist: responses>=0.25; extra == "dev"
19
+ Requires-Dist: faker>=22.0; extra == "dev"
20
+ Requires-Dist: ruff>=0.3; extra == "dev"
21
+ Requires-Dist: mypy>=1.8; extra == "dev"
@@ -0,0 +1,113 @@
1
+ # API Test Kit
2
+
3
+ 基于 Python + pytest + requests 的 AI 友好型 API 自动化测试工具包。
4
+
5
+ 专为 AI 助手(Copilot、Hermes、Claude)设计,让它们**秒懂工程约定**,快速生成一致、类型化、可维护的测试脚本。
6
+
7
+ ## 快速开始
8
+
9
+ ```bash
10
+ make install # 一次性安装
11
+ make test # 并行运行所有测试
12
+ make test-report # 生成 HTML 测试报告
13
+ ```
14
+
15
+ ## 项目结构
16
+
17
+ ```
18
+ src/api_test_kit/ # 测试框架核心
19
+ config.py # 多环境配置(Pydantic)
20
+ client.py # HTTP 客户端(重试、日志、鉴权)
21
+ assertions.py # 可读性断言工具
22
+ logger.py # 日志配置
23
+ endpoints/ # API 端点模型(AI 生成)
24
+ _base.py # CRUD 基类
25
+
26
+ tests/ # 测试脚本
27
+ conftest.py # 全局 fixtures
28
+ helpers/ # 测试数据工厂
29
+ endpoints/ # 按端点的测试文件(AI 生成)
30
+ e2e/ # 业务链路 E2E 测试
31
+
32
+ data/fixtures/ # JSON/YAML 测试数据
33
+ scripts/
34
+ new-endpoint.sh # 一键生成新端点的脚手架脚本
35
+ ```
36
+
37
+ ## 新增 API 测试
38
+
39
+ ```bash
40
+ ./scripts/new-endpoint.sh products
41
+ ```
42
+
43
+ 然后在 `src/api_test_kit/endpoints/products.py` 实现端点模型,运行:
44
+
45
+ ```bash
46
+ pytest tests/endpoints/test_products.py -v
47
+ ```
48
+
49
+ **AI 协助的测试生成流程:**
50
+
51
+ ```
52
+ ① 提供 curl / API 文档
53
+ ② AI 试调接口,设计测试用例 → 展示给你 review
54
+ ③ 你确认/补充业务场景
55
+ ④ AI 写 endpoint 模型 + 测试
56
+ ⑤ pytest -v 跑通
57
+ ```
58
+
59
+ 详细规范见 [AGENTS.md](./AGENTS.md)。
60
+
61
+ ## 常用命令
62
+
63
+ | 命令 | 说明 |
64
+ |------|------|
65
+ | `make install` | 安装依赖 |
66
+ | `make test` | 并行运行所有测试 |
67
+ | `make test-report` | 生成 HTML 报告 |
68
+ | `make lint` | ruff 代码检查 |
69
+ | `make format` | 自动格式化代码 |
70
+ | `pytest -m smoke` | 只跑 smoke 测试 |
71
+ | `pytest -m e2e` | 只跑 E2E 链路测试 |
72
+ | `pytest tests/endpoints/test_xxx.py -v` | 单文件测试 |
73
+
74
+ ## 核心概念
75
+
76
+ - **`HarnessConfig`** — 基于 Pydantic 的多环境配置(`.env` 文件)
77
+ - **`BaseClient`** — `requests.Session` 封装,支持重试、日志、自动鉴权
78
+ - **`ApiResponse(status, data, elapsed)`** — 类型化响应,不暴露原始 `requests.Response`
79
+ - **`BaseEndpoint`** — CRUD 模板:`list / get / create / update / delete`
80
+ - **`assert_*`** — `assert_status`、`assert_json_has`、`assert_json_match`、`assert_error`
81
+ - **`Scenario` / `Step`** — E2E 业务链路测试(详见 [AGENTS.md §10](./AGENTS.md#10-e2e-business-chain-tests))
82
+
83
+ ## E2E 业务链路测试
84
+
85
+ 除了单接口测试,工具包还支持**多步骤 E2E 场景**,数据在步骤间流转:
86
+
87
+ ```python
88
+ from api_test_kit.e2e import Scenario, Step, run_chain
89
+
90
+ scenario = Scenario("业务链路", steps=[
91
+ Step(name="步骤1", execute=..., extract={"key": "$.data.id"}, verify=...),
92
+ Step(name="步骤2", execute=lambda ctx: api.call(ctx.get("key")), verify=...),
93
+ ])
94
+ report = run_chain(scenario)
95
+ report.assert_all_passed()
96
+ ```
97
+
98
+ - `Step(name, execute, extract, verify)` — 业务链中的一个环节
99
+ - `ScenarioContext` — 跨步骤共享状态
100
+ - `ScenarioReport` — 每步的通过/失败汇总
101
+ - 测试类标记 `@pytest.mark.e2e`;用 `pytest -m e2e` 执行
102
+
103
+ ## 配合 AI 使用
104
+
105
+ 给 AI 助手一个 API 的 curl 或接口文档,即可自动生成完整测试:
106
+
107
+ ```
108
+ ① 你提供 curl / API 文档
109
+ ② AI 试调接口 → 设计测试用例 → 展示给你 review
110
+ ③ 你确认 / 补充业务场景
111
+ ④ AI 写 endpoint 模型 + 测试脚本
112
+ ⑤ pytest -v 跑通
113
+ ```
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta:__legacy__"
4
+
5
+ [project]
6
+ name = "api-test-toolkit"
7
+ version = "0.2.0"
8
+ description = "AI 友好的 API 自动化测试工具包"
9
+ requires-python = ">=3.11"
10
+ dependencies = [
11
+ "requests>=2.31",
12
+ "pydantic>=2.5,<3",
13
+ "pydantic-settings>=2.1",
14
+ "python-dotenv>=1.0",
15
+ "jsonpath-ng>=1.6",
16
+ "deepdiff>=7.0",
17
+ "typer>=0.12,<1",
18
+ ]
19
+
20
+ [project.scripts]
21
+ api-test-toolkit = "api_test_kit.cli:app"
22
+
23
+ [tool.setuptools.package-data]
24
+ api_test_kit = ["templates/**/*"]
25
+
26
+ [project.optional-dependencies]
27
+ dev = [
28
+ "pytest>=8.0",
29
+ "pytest-xdist>=3.5",
30
+ "pytest-html>=4.1",
31
+ "allure-pytest>=2.13",
32
+ "responses>=0.25",
33
+ "faker>=22.0",
34
+ "ruff>=0.3",
35
+ "mypy>=1.8",
36
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,18 @@
1
+ """API Test Kit — AI 友好的 API 自动化测试工具包。"""
2
+ from api_test_kit.e2e.context import ScenarioContext
3
+ from api_test_kit.e2e.step import Step, StepResult
4
+ from api_test_kit.e2e.scenario import Scenario, ScenarioReport
5
+ from api_test_kit.e2e.helpers import run_chain, chain_summary
6
+
7
+ __version__ = "0.2.0"
8
+
9
+ __all__ = [
10
+ "ScenarioContext",
11
+ "Step",
12
+ "StepResult",
13
+ "Scenario",
14
+ "ScenarioReport",
15
+ "run_chain",
16
+ "chain_summary",
17
+ "__version__",
18
+ ]
@@ -0,0 +1,128 @@
1
+ """API 测试的断言辅助函数。
2
+
3
+ 使用方法::
4
+
5
+ from api_test_kit.assertions import assert_status, assert_json_has, assert_json_match
6
+
7
+ resp = client.get("/users")
8
+ assert_status(resp, 200)
9
+ assert_json_has(resp, "$.data.items[*].id")
10
+ assert_json_match(resp, {"page": 1})
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from typing import Any
15
+
16
+ from deepdiff import DeepDiff
17
+ from jsonpath_ng import parse as parse_jsonpath
18
+
19
+ from api_test_kit.client import ApiResponse
20
+
21
+
22
+ def assert_status(resp: ApiResponse, expected: int = 200) -> None:
23
+ """断言 HTTP 状态码完全匹配。"""
24
+ assert resp.status == expected, (
25
+ f"Expected status {expected}, got {resp.status}. Body: {resp.data}"
26
+ )
27
+
28
+
29
+ def assert_ok(resp: ApiResponse) -> None:
30
+ """断言 ``resp.ok`` 为 True(状态码在 2xx 范围内)。"""
31
+ assert resp.ok, f"Expected 2xx, got {resp.status}. Body: {resp.data}"
32
+
33
+
34
+ def assert_json_has(resp: ApiResponse, jsonpath: str) -> list[Any]:
35
+ """断言 *jsonpath* 在响应体中至少匹配一次。
36
+
37
+ 返回匹配的值以支持进一步的断言链式调用。
38
+
39
+ 示例::
40
+
41
+ ids = assert_json_has(resp, "$.data.items[*].id")
42
+ assert len(ids) > 0
43
+ """
44
+ matches = parse_jsonpath(jsonpath).find(resp.data)
45
+ assert len(matches) > 0, (
46
+ f"JSONPath ``{jsonpath}`` found no matches in response.\n"
47
+ f"Response body: {resp.data}"
48
+ )
49
+ return [m.value for m in matches]
50
+
51
+
52
+ def assert_json_not_has(resp: ApiResponse, jsonpath: str) -> None:
53
+ """断言 *jsonpath* 在响应体中无匹配项。"""
54
+ matches = parse_jsonpath(jsonpath).find(resp.data)
55
+ assert len(matches) == 0, (
56
+ f"JSONPath ``{jsonpath}`` unexpectedly found: {[m.value for m in matches]}"
57
+ )
58
+
59
+
60
+ def assert_json_match(
61
+ resp: ApiResponse,
62
+ expected: dict,
63
+ exclude_paths: list[str] | None = None,
64
+ ) -> None:
65
+ """断言响应体包含 *expected* 中的所有键/值(子集匹配)。
66
+
67
+ *exclude_paths*: 要忽略的根相对路径列表,例如 ``["created_at"]``。
68
+
69
+ 示例::
70
+
71
+ assert_json_match(resp, {"total": 10, "page": 1})
72
+ """
73
+ body = resp.data if isinstance(resp.data, dict) else {"body": resp.data}
74
+ diff = DeepDiff(
75
+ expected,
76
+ body,
77
+ verbose_level=2,
78
+ exclude_paths=[f"root['{p}']" for p in (exclude_paths or [])],
79
+ )
80
+ # Subset check: body can have extra keys — only fail on mismatches/absences
81
+ diff.pop("dictionary_item_added", None)
82
+ assert not diff, (
83
+ f"Response body does not match expected subset.\n"
84
+ f"Differences: {diff}\n"
85
+ f"Expected subset: {expected}\n"
86
+ f"Actual: {resp.data}"
87
+ )
88
+
89
+
90
+ def assert_paginated(resp: ApiResponse) -> dict:
91
+ """断言响应具有标准分页结构并返回分页元数据。
92
+
93
+ 检查 ``page``、``size``、``total``、``items`` 键是否存在。
94
+ """
95
+ data = resp.data if isinstance(resp.data, dict) else {}
96
+ for key in ("page", "size", "total", "items"):
97
+ assert key in data, f"Missing pagination key ``{key}`` in response: {data}"
98
+ assert isinstance(data["items"], list), (
99
+ f"``items`` must be a list, got {type(data['items'])}"
100
+ )
101
+ return data
102
+
103
+
104
+ def assert_error(
105
+ resp: ApiResponse,
106
+ expected_code: int = 400,
107
+ message_hint: str | None = None,
108
+ ) -> None:
109
+ """断言错误响应,可选的消息提示检查。
110
+
111
+ 处理常见的错误结构::
112
+
113
+ {"code": "VALIDATION_ERROR", "message": "...", "details": [...]}
114
+ {"error": {"code": "NOT_FOUND", "message": "..."}}
115
+ """
116
+ assert resp.status == expected_code, (
117
+ f"Expected error status {expected_code}, got {resp.status}"
118
+ )
119
+ body = resp.data if isinstance(resp.data, dict) else {}
120
+ if message_hint:
121
+ msg = (
122
+ body.get("message", "")
123
+ or (body.get("error", {}) or {}).get("message", "")
124
+ or str(body)
125
+ )
126
+ assert message_hint.lower() in msg.lower(), (
127
+ f"Expected error message containing ``{message_hint}``, got: {msg}"
128
+ )
@@ -0,0 +1,45 @@
1
+ """CLI 入口 — project scaffolding commands."""
2
+ from __future__ import annotations
3
+
4
+ import typer
5
+
6
+ from api_test_kit import __version__
7
+
8
+ app = typer.Typer(
9
+ name="api-test-toolkit",
10
+ help="AI 友好的 API 自动化测试工具包",
11
+ )
12
+
13
+
14
+ @app.command()
15
+ def init(
16
+ project_dir: str = typer.Argument(..., help="新项目目录名"),
17
+ base_url: str | None = typer.Option(None, "--base-url", "-b", help="API 基础地址"),
18
+ api_prefix: str | None = typer.Option(None, "--api-prefix", "-p", help="API 前缀"),
19
+ auth_type: str | None = typer.Option(None, "--auth-type", "-a", help="鉴权方式"),
20
+ env_name: str | None = typer.Option(None, "--env", "-e", help="环境名"),
21
+ yes: bool = typer.Option(
22
+ False, "--yes", "-y", help="非交互模式(使用默认值,不提问)"
23
+ ),
24
+ ) -> None:
25
+ """创建一个新的 API 自动化测试项目。"""
26
+ from api_test_kit.scaffold import create_project
27
+
28
+ create_project(
29
+ project_dir=project_dir,
30
+ base_url=base_url,
31
+ api_prefix=api_prefix,
32
+ auth_type=auth_type,
33
+ env_name=env_name,
34
+ interactive=not yes,
35
+ )
36
+
37
+
38
+ @app.command()
39
+ def version() -> None:
40
+ """显示版本号。"""
41
+ typer.echo(f"api-test-kit v{__version__}")
42
+
43
+
44
+ if __name__ == "__main__":
45
+ app()
@@ -0,0 +1,208 @@
1
+ """支持重试、日志、认证和类型化响应的基础 HTTP 客户端。"""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import time
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+ import requests
10
+ from requests.adapters import HTTPAdapter, Retry
11
+
12
+ from api_test_kit.config import HarnessConfig
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ # ── 类型化响应 ──────────────────────────────────────────────
18
+
19
+
20
+ @dataclass
21
+ class ApiResponse:
22
+ """标准 API 响应——绝不向测试暴露原始的 ``requests.Response``。"""
23
+
24
+ status: int
25
+ data: Any # 解析后的 JSON 正文,或原始文本回退
26
+ elapsed: float # 实际运行秒数
27
+ headers: dict[str, str] = field(default_factory=dict)
28
+ ok: bool = True
29
+
30
+ @classmethod
31
+ def from_requests(cls, resp: requests.Response) -> ApiResponse:
32
+ """从 ``requests.Response`` 构建——尽可能解析 JSON。"""
33
+ try:
34
+ data = resp.json()
35
+ except ValueError:
36
+ data = resp.text
37
+ return cls(
38
+ status=resp.status_code,
39
+ data=data,
40
+ elapsed=resp.elapsed.total_seconds(),
41
+ headers=dict(resp.headers),
42
+ ok=200 <= resp.status_code < 300,
43
+ )
44
+
45
+ def __bool__(self) -> bool:
46
+ return self.ok
47
+
48
+
49
+ # ── Token 提供者 ──────────────────────────────────────────────
50
+
51
+
52
+ class TokenProvider:
53
+ """抽象 Token 提供者——为每种认证方式重写。"""
54
+
55
+ def get_token(self) -> str:
56
+ """返回当前 token。"""
57
+ raise NotImplementedError
58
+
59
+ def refresh(self) -> str:
60
+ """收到 401 时调用。返回一个新的 token。"""
61
+ return self.get_token()
62
+
63
+
64
+ class BearerTokenProvider(TokenProvider):
65
+ """基于固定值(如环境变量或配置)的简单 bearer token。"""
66
+
67
+ def __init__(self, token: str) -> None:
68
+ self._token = token
69
+
70
+ def get_token(self) -> str:
71
+ return self._token
72
+
73
+
74
+ # ── 基础客户端 ─────────────────────────────────────────────────
75
+
76
+
77
+ class BaseClient:
78
+ """封装 ``requests.Session``,支持重试、日志和认证。
79
+
80
+ 使用方法::
81
+
82
+ config = HarnessConfig.from_env("staging")
83
+ client = BaseClient(config)
84
+ client.authenticate(BearerTokenProvider("xxx"))
85
+ resp: ApiResponse = client.get("/api/v1/users")
86
+ """
87
+
88
+ def __init__(self, config: HarnessConfig) -> None:
89
+ self.config = config
90
+ self.session = requests.Session()
91
+ self._setup_retry()
92
+ self._token_provider: TokenProvider | None = None
93
+ self._token: str | None = None
94
+
95
+ # ── 认证 ────────────────────────────────────────────────────
96
+
97
+ def authenticate(self, provider: TokenProvider) -> None:
98
+ """设置认证提供者。Token 在首次请求时惰性获取。"""
99
+ self._token_provider = provider
100
+ self._token = None
101
+
102
+ def _ensure_token(self) -> None:
103
+ if self._token is None and self._token_provider:
104
+ self._token = self._token_provider.get_token()
105
+
106
+ def _refresh_token(self) -> None:
107
+ if self._token_provider:
108
+ self._token = self._token_provider.refresh()
109
+ else:
110
+ self._token = None
111
+
112
+ def _inject_auth(self, headers: dict[str, str]) -> dict[str, str]:
113
+ auth_type = self.config.auth_type
114
+ header_name = self.config.auth_header_name
115
+ if auth_type == "bearer" and self._token:
116
+ headers[header_name] = f"Bearer {self._token}"
117
+ elif auth_type == "api_key" and self._token:
118
+ headers[header_name] = self._token
119
+ return headers
120
+
121
+ # ── HTTP 方法 ────────────────────────────────────────────
122
+
123
+ def get(self, path: str, **kwargs: Any) -> ApiResponse:
124
+ return self._request("GET", path, **kwargs)
125
+
126
+ def post(self, path: str, **kwargs: Any) -> ApiResponse:
127
+ return self._request("POST", path, **kwargs)
128
+
129
+ def put(self, path: str, **kwargs: Any) -> ApiResponse:
130
+ return self._request("PUT", path, **kwargs)
131
+
132
+ def patch(self, path: str, **kwargs: Any) -> ApiResponse:
133
+ return self._request("PATCH", path, **kwargs)
134
+
135
+ def delete(self, path: str, **kwargs: Any) -> ApiResponse:
136
+ return self._request("DELETE", path, **kwargs)
137
+
138
+ # ── 内部方法 ────────────────────────────────────────────────
139
+
140
+ def _request(self, method: str, path: str, **kwargs: Any) -> ApiResponse:
141
+ url = self._resolve_url(path)
142
+ self._ensure_token()
143
+ headers = kwargs.pop("headers", {})
144
+ headers = self._inject_auth(headers)
145
+
146
+ for attempt in range(self.config.max_retries + 1):
147
+ start = time.perf_counter()
148
+ try:
149
+ if self.config.log_requests:
150
+ logger.debug(
151
+ "[REQ] %s %s | body=%s",
152
+ method,
153
+ url,
154
+ kwargs.get("json", kwargs.get("data", "")),
155
+ )
156
+ resp = self.session.request(
157
+ method, url, headers=headers, timeout=self.config.timeout, **kwargs
158
+ )
159
+ elapsed = time.perf_counter() - start
160
+ api_resp = ApiResponse.from_requests(resp)
161
+
162
+ if self.config.log_responses:
163
+ logger.debug(
164
+ "[RES] %s %s → %d (%.3fs)", method, url, resp.status_code, elapsed
165
+ )
166
+
167
+ # Auto-refresh token on 401 and retry
168
+ if resp.status_code == 401 and self._token_provider and attempt < self.config.max_retries:
169
+ logger.info("[AUTH] 401 received — refreshing token (attempt %d)", attempt + 1)
170
+ self._refresh_token()
171
+ headers = self._inject_auth(headers)
172
+ continue
173
+
174
+ return api_resp
175
+
176
+ except requests.RequestException as e:
177
+ elapsed = time.perf_counter() - start
178
+ logger.warning("[ERR] %s %s failed after %.3fs: %s", method, url, elapsed, e)
179
+ if attempt < self.config.max_retries:
180
+ backoff = self.config.retry_backoff * (2**attempt)
181
+ logger.info(
182
+ "[RETRY] waiting %.1fs before retry %d/%d",
183
+ backoff,
184
+ attempt + 1,
185
+ self.config.max_retries,
186
+ )
187
+ time.sleep(backoff)
188
+ continue
189
+ return ApiResponse(status=0, data=str(e), elapsed=elapsed, ok=False)
190
+
191
+ raise RuntimeError("Unreachable — all retries exhausted")
192
+
193
+ def _resolve_url(self, path: str) -> str:
194
+ if path.startswith("http://") or path.startswith("https://"):
195
+ return path
196
+ return f"{self.config.api_url}{path}"
197
+
198
+ def _setup_retry(self) -> None:
199
+ """挂载适配器,为 DNS / 连接错误提供连接级重试。"""
200
+ retry_strategy = Retry(
201
+ total=self.config.max_retries,
202
+ backoff_factor=self.config.retry_backoff * 0.5,
203
+ status_forcelist=[429, 500, 502, 503, 504],
204
+ allowed_methods=["GET", "HEAD", "OPTIONS"],
205
+ )
206
+ adapter = HTTPAdapter(max_retries=retry_strategy)
207
+ self.session.mount("http://", adapter)
208
+ self.session.mount("https://", adapter)
@@ -0,0 +1,82 @@
1
+ """通过 Pydantic Settings 实现的多环境配置。
2
+
3
+ 使用方法::
4
+
5
+ config = HarnessConfig.from_env("staging")
6
+ config.base_url # -> "https://staging-api.example.com"
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+ from typing import Literal
12
+
13
+ from pydantic import Field
14
+ from pydantic_settings import BaseSettings, SettingsConfigDict
15
+
16
+
17
+ class HarnessConfig(BaseSettings):
18
+ """从环境文件或环境变量加载的应用配置。
19
+
20
+ 优先级:环境变量 > 环境文件 > 默认值。
21
+ """
22
+
23
+ model_config = SettingsConfigDict(
24
+ env_file=".env",
25
+ env_file_encoding="utf-8",
26
+ extra="ignore",
27
+ frozen=True,
28
+ )
29
+
30
+ # --- 核心 ---
31
+ env_name: str = Field(default="dev", description="Environment name (dev/staging/prod)")
32
+ base_url: str = Field(default="http://localhost:8000", description="API base URL")
33
+ api_prefix: str = Field(default="/api/v1", description="API version prefix")
34
+
35
+ # --- 认证 ---
36
+ auth_type: Literal["none", "bearer", "api_key", "basic", "oauth2"] = Field(
37
+ default="bearer",
38
+ )
39
+ auth_header_name: str = Field(
40
+ default="authorization",
41
+ description="HTTP header name for the auth token "
42
+ "(default: 'authorization' for bearer, 'x-api-key' for api_key)",
43
+ )
44
+ auth_token_url: str | None = Field(default=None, description="Token endpoint for OAuth2")
45
+ client_id: str | None = Field(default=None)
46
+ client_secret: str | None = Field(default=None, description="Also used as bearer token value")
47
+
48
+ # --- HTTP 客户端 ---
49
+ timeout: int = Field(default=30, ge=1, le=300)
50
+ max_retries: int = Field(default=3, ge=0, le=10)
51
+ retry_backoff: float = Field(default=1.0, ge=0.1)
52
+
53
+ # --- 日志 ---
54
+ log_level: str = Field(default="INFO", pattern=r"^(DEBUG|INFO|WARNING|ERROR)$")
55
+ log_requests: bool = Field(default=True)
56
+ log_responses: bool = Field(default=True)
57
+
58
+ # --- 派生 ---
59
+ @property
60
+ def api_url(self) -> str:
61
+ """完整的 API 基础 URL(base_url + api_prefix)。"""
62
+ return f"{self.base_url}{self.api_prefix}"
63
+
64
+ @classmethod
65
+ def from_env(cls, env_name: str = "dev") -> HarnessConfig:
66
+ """加载指定环境的配置。
67
+
68
+ 查找项目根目录下的 ``config.{env_name}.env``,
69
+ 如果不存在则回退到 ``.env``。
70
+ """
71
+ project_root = cls._find_project_root()
72
+ env_file = project_root / f"config.{env_name}.env"
73
+ return cls(_env_file=str(env_file) if env_file.exists() else None)
74
+
75
+ @staticmethod
76
+ def _find_project_root() -> Path:
77
+ """从当前工作目录向上查找项目根目录(包含 pyproject.toml 的目录)。"""
78
+ start = Path.cwd()
79
+ for parent in [start] + list(start.parents):
80
+ if (parent / "pyproject.toml").exists():
81
+ return parent
82
+ return start
@@ -0,0 +1,15 @@
1
+ """E2E 业务链路测试 — Scenario、Step、Context。"""
2
+ from api_test_kit.e2e.context import ScenarioContext
3
+ from api_test_kit.e2e.step import Step, StepResult
4
+ from api_test_kit.e2e.scenario import Scenario, ScenarioReport
5
+ from api_test_kit.e2e.helpers import run_chain, chain_summary
6
+
7
+ __all__ = [
8
+ "ScenarioContext",
9
+ "Step",
10
+ "StepResult",
11
+ "Scenario",
12
+ "ScenarioReport",
13
+ "run_chain",
14
+ "chain_summary",
15
+ ]