api-test-toolkit 0.2.0__tar.gz → 0.3.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.
- {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/PKG-INFO +2 -1
- {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/pyproject.toml +2 -1
- {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_kit/client.py +20 -1
- api_test_toolkit-0.3.0/src/api_test_kit/config.py +185 -0
- api_test_toolkit-0.3.0/src/api_test_kit/db/__init__.py +8 -0
- api_test_toolkit-0.3.0/src/api_test_kit/db/config.py +30 -0
- api_test_toolkit-0.3.0/src/api_test_kit/db/database.py +138 -0
- {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_kit/scaffold.py +1 -0
- api_test_toolkit-0.3.0/src/api_test_kit/templates/tests/e2e/__init__.py +0 -0
- {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_toolkit.egg-info/PKG-INFO +2 -1
- {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_toolkit.egg-info/SOURCES.txt +6 -0
- {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_toolkit.egg-info/requires.txt +1 -0
- api_test_toolkit-0.3.0/tests/test_config.py +56 -0
- api_test_toolkit-0.3.0/tests/test_db.py +108 -0
- api_test_toolkit-0.2.0/src/api_test_kit/config.py +0 -82
- {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/README.md +0 -0
- {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/setup.cfg +0 -0
- {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_kit/__init__.py +0 -0
- {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_kit/assertions.py +0 -0
- {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_kit/cli.py +0 -0
- {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_kit/e2e/__init__.py +0 -0
- {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_kit/e2e/context.py +0 -0
- {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_kit/e2e/helpers.py +0 -0
- {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_kit/e2e/scenario.py +0 -0
- {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_kit/e2e/step.py +0 -0
- {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_kit/endpoints/__init__.py +0 -0
- {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_kit/endpoints/_base.py +0 -0
- {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_kit/logger.py +0 -0
- {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_kit/templates/AGENTS.md +0 -0
- {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_kit/templates/README.md +0 -0
- {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_kit/templates/pyproject.toml +0 -0
- {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_kit/templates/pytest.ini +0 -0
- {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_kit/templates/scripts/new-endpoint.sh +0 -0
- {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_kit/templates/tests/__init__.py +0 -0
- {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_kit/templates/tests/conftest.py +0 -0
- {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_toolkit.egg-info/dependency_links.txt +0 -0
- {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_toolkit.egg-info/entry_points.txt +0 -0
- {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_toolkit.egg-info/top_level.txt +0 -0
- {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/tests/test_assertions.py +0 -0
- {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/tests/test_client.py +0 -0
- {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/tests/test_e2e_context.py +0 -0
- {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/tests/test_e2e_scenario.py +0 -0
- {api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/tests/test_e2e_step.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: api-test-toolkit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: AI 友好的 API 自动化测试工具包
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Requires-Dist: requests>=2.31
|
|
@@ -19,3 +19,4 @@ Requires-Dist: responses>=0.25; extra == "dev"
|
|
|
19
19
|
Requires-Dist: faker>=22.0; extra == "dev"
|
|
20
20
|
Requires-Dist: ruff>=0.3; extra == "dev"
|
|
21
21
|
Requires-Dist: mypy>=1.8; extra == "dev"
|
|
22
|
+
Requires-Dist: pymysql>=1.1; extra == "dev"
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta:__legacy__"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "api-test-toolkit"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "AI 友好的 API 自动化测试工具包"
|
|
9
9
|
requires-python = ">=3.11"
|
|
10
10
|
dependencies = [
|
|
@@ -33,4 +33,5 @@ dev = [
|
|
|
33
33
|
"faker>=22.0",
|
|
34
34
|
"ruff>=0.3",
|
|
35
35
|
"mypy>=1.8",
|
|
36
|
+
"pymysql>=1.1",
|
|
36
37
|
]
|
|
@@ -9,7 +9,7 @@ from typing import Any
|
|
|
9
9
|
import requests
|
|
10
10
|
from requests.adapters import HTTPAdapter, Retry
|
|
11
11
|
|
|
12
|
-
from api_test_kit.config import HarnessConfig
|
|
12
|
+
from api_test_kit.config import DomainConfig, HarnessConfig
|
|
13
13
|
|
|
14
14
|
logger = logging.getLogger(__name__)
|
|
15
15
|
|
|
@@ -83,6 +83,11 @@ class BaseClient:
|
|
|
83
83
|
client = BaseClient(config)
|
|
84
84
|
client.authenticate(BearerTokenProvider("xxx"))
|
|
85
85
|
resp: ApiResponse = client.get("/api/v1/users")
|
|
86
|
+
|
|
87
|
+
从域名配置创建::
|
|
88
|
+
|
|
89
|
+
domain = config.get_domain("order")
|
|
90
|
+
client = BaseClient.from_domain(domain)
|
|
86
91
|
"""
|
|
87
92
|
|
|
88
93
|
def __init__(self, config: HarnessConfig) -> None:
|
|
@@ -92,6 +97,20 @@ class BaseClient:
|
|
|
92
97
|
self._token_provider: TokenProvider | None = None
|
|
93
98
|
self._token: str | None = None
|
|
94
99
|
|
|
100
|
+
@classmethod
|
|
101
|
+
def from_domain(cls, domain: DomainConfig) -> BaseClient:
|
|
102
|
+
"""从 ``DomainConfig`` 创建客户端(用于跨系统 E2E)。"""
|
|
103
|
+
config = HarnessConfig(
|
|
104
|
+
base_url=domain.base_url,
|
|
105
|
+
api_prefix=domain.api_prefix,
|
|
106
|
+
auth_type=domain.auth_type,
|
|
107
|
+
auth_header_name=domain.auth_header_name,
|
|
108
|
+
client_secret=domain.client_secret,
|
|
109
|
+
timeout=domain.timeout,
|
|
110
|
+
max_retries=domain.max_retries,
|
|
111
|
+
)
|
|
112
|
+
return cls(config)
|
|
113
|
+
|
|
95
114
|
# ── 认证 ────────────────────────────────────────────────────
|
|
96
115
|
|
|
97
116
|
def authenticate(self, provider: TokenProvider) -> None:
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""通过 Pydantic Settings 实现的多环境配置。
|
|
2
|
+
|
|
3
|
+
使用方法::
|
|
4
|
+
|
|
5
|
+
config = HarnessConfig.from_env("staging")
|
|
6
|
+
config.base_url # -> "https://staging-api.example.com"
|
|
7
|
+
|
|
8
|
+
多域名支持(E2E 跨系统调用)::
|
|
9
|
+
|
|
10
|
+
# config.staging.env 中配置:
|
|
11
|
+
# DOMAIN_ORDER__BASE_URL=https://order-api.example.com
|
|
12
|
+
# DOMAIN_INVENTORY__BASE_URL=https://inv-api.example.com
|
|
13
|
+
|
|
14
|
+
config.get_domain("order") # -> DomainConfig(base_url="...")
|
|
15
|
+
config.list_domains() # -> ["inventory", "order"]
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, Literal
|
|
23
|
+
|
|
24
|
+
from pydantic import Field
|
|
25
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ── 域名配置 ──────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class DomainConfig:
|
|
33
|
+
"""单个 API 域名的连接配置。
|
|
34
|
+
|
|
35
|
+
通过 ``DOMAIN_{NAME}__{KEY}`` 环境变量驱动。
|
|
36
|
+
"""
|
|
37
|
+
name: str
|
|
38
|
+
base_url: str = ""
|
|
39
|
+
api_prefix: str = ""
|
|
40
|
+
auth_type: str = "bearer"
|
|
41
|
+
auth_header_name: str = "authorization"
|
|
42
|
+
client_secret: str | None = None
|
|
43
|
+
timeout: int = 30
|
|
44
|
+
max_retries: int = 3
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class HarnessConfig(BaseSettings):
|
|
48
|
+
"""从环境文件或环境变量加载的应用配置。
|
|
49
|
+
|
|
50
|
+
优先级:环境变量 > 环境文件 > 默认值。
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
model_config = SettingsConfigDict(
|
|
54
|
+
env_file=".env",
|
|
55
|
+
env_file_encoding="utf-8",
|
|
56
|
+
extra="ignore",
|
|
57
|
+
frozen=True,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# --- 核心 ---
|
|
61
|
+
env_name: str = Field(default="dev", description="Environment name (dev/staging/prod)")
|
|
62
|
+
base_url: str = Field(default="http://localhost:8000", description="API base URL")
|
|
63
|
+
api_prefix: str = Field(default="/api/v1", description="API version prefix")
|
|
64
|
+
|
|
65
|
+
# --- 认证 ---
|
|
66
|
+
auth_type: Literal["none", "bearer", "api_key", "basic", "oauth2"] = Field(
|
|
67
|
+
default="bearer",
|
|
68
|
+
)
|
|
69
|
+
auth_header_name: str = Field(
|
|
70
|
+
default="authorization",
|
|
71
|
+
description="HTTP header name for the auth token "
|
|
72
|
+
"(default: 'authorization' for bearer, 'x-api-key' for api_key)",
|
|
73
|
+
)
|
|
74
|
+
auth_token_url: str | None = Field(default=None, description="Token endpoint for OAuth2")
|
|
75
|
+
client_id: str | None = Field(default=None)
|
|
76
|
+
client_secret: str | None = Field(default=None, description="Also used as bearer token value")
|
|
77
|
+
|
|
78
|
+
# --- HTTP 客户端 ---
|
|
79
|
+
timeout: int = Field(default=30, ge=1, le=300)
|
|
80
|
+
max_retries: int = Field(default=3, ge=0, le=10)
|
|
81
|
+
retry_backoff: float = Field(default=1.0, ge=0.1)
|
|
82
|
+
|
|
83
|
+
# --- 日志 ---
|
|
84
|
+
log_level: str = Field(default="INFO", pattern=r"^(DEBUG|INFO|WARNING|ERROR)$")
|
|
85
|
+
log_requests: bool = Field(default=True)
|
|
86
|
+
log_responses: bool = Field(default=True)
|
|
87
|
+
|
|
88
|
+
# --- 派生 ---
|
|
89
|
+
@property
|
|
90
|
+
def api_url(self) -> str:
|
|
91
|
+
"""完整的 API 基础 URL(base_url + api_prefix)。"""
|
|
92
|
+
return f"{self.base_url}{self.api_prefix}"
|
|
93
|
+
|
|
94
|
+
# ── 域名发现 ──────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
_domain_cache: dict[str, DomainConfig] | None = None
|
|
97
|
+
|
|
98
|
+
def get_domain(self, name: str) -> DomainConfig | None:
|
|
99
|
+
"""按名称获取域名配置。
|
|
100
|
+
|
|
101
|
+
从环境变量读取 ``DOMAIN_{NAME}__{KEY}`` 模式的值::
|
|
102
|
+
|
|
103
|
+
config.get_domain("order")
|
|
104
|
+
# -> DomainConfig(name="order", base_url="https://order-api.example.com", ...)
|
|
105
|
+
|
|
106
|
+
如果未配置返回 ``None``。
|
|
107
|
+
"""
|
|
108
|
+
prefix = f"DOMAIN_{name.upper()}__"
|
|
109
|
+
base_url = os.getenv(f"{prefix}BASE_URL")
|
|
110
|
+
if not base_url:
|
|
111
|
+
return None
|
|
112
|
+
return DomainConfig(
|
|
113
|
+
name=name.lower(),
|
|
114
|
+
base_url=base_url,
|
|
115
|
+
api_prefix=os.getenv(f"{prefix}API_PREFIX", ""),
|
|
116
|
+
auth_type=os.getenv(f"{prefix}AUTH_TYPE", "bearer"),
|
|
117
|
+
auth_header_name=os.getenv(f"{prefix}AUTH_HEADER_NAME", "authorization"),
|
|
118
|
+
client_secret=os.getenv(f"{prefix}CLIENT_SECRET"),
|
|
119
|
+
timeout=int(os.getenv(f"{prefix}TIMEOUT", "30")),
|
|
120
|
+
max_retries=int(os.getenv(f"{prefix}MAX_RETRIES", "3")),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def list_domains(self) -> list[str]:
|
|
124
|
+
"""列出所有已配置的域名。"""
|
|
125
|
+
names: set[str] = set()
|
|
126
|
+
for key, val in os.environ.items():
|
|
127
|
+
if key.startswith("DOMAIN_") and "__" in key:
|
|
128
|
+
name = key.split("__")[0].replace("DOMAIN_", "").lower()
|
|
129
|
+
names.add(name)
|
|
130
|
+
return sorted(names)
|
|
131
|
+
|
|
132
|
+
# ── 数据库配置发现 ─────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
def get_db_config(self, name: str) -> DatabaseConfig | None:
|
|
135
|
+
"""按名称获取数据库配置。
|
|
136
|
+
|
|
137
|
+
从环境变量读取 ``DB_{NAME}__{KEY}`` 模式的值::
|
|
138
|
+
|
|
139
|
+
config.get_db_config("order")
|
|
140
|
+
# -> DatabaseConfig(name="order", engine="mysql", host="...")
|
|
141
|
+
"""
|
|
142
|
+
from api_test_kit.db.config import DatabaseConfig
|
|
143
|
+
|
|
144
|
+
prefix = f"DB_{name.upper()}__"
|
|
145
|
+
engine = os.getenv(f"{prefix}ENGINE")
|
|
146
|
+
if not engine:
|
|
147
|
+
return None
|
|
148
|
+
return DatabaseConfig(
|
|
149
|
+
name=name.lower(),
|
|
150
|
+
engine=engine.lower(),
|
|
151
|
+
host=os.getenv(f"{prefix}HOST", "localhost"),
|
|
152
|
+
port=int(os.getenv(f"{prefix}PORT", "3306")),
|
|
153
|
+
user=os.getenv(f"{prefix}USER", "root"),
|
|
154
|
+
password=os.getenv(f"{prefix}PASSWORD", ""),
|
|
155
|
+
database=os.getenv(f"{prefix}DATABASE", name.lower()),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def list_db_configs(self) -> list[str]:
|
|
159
|
+
"""列出所有已配置的数据库名称。"""
|
|
160
|
+
names: set[str] = set()
|
|
161
|
+
for key, val in os.environ.items():
|
|
162
|
+
if key.startswith("DB_") and "__" in key:
|
|
163
|
+
name = key.split("__")[0].replace("DB_", "").lower()
|
|
164
|
+
names.add(name)
|
|
165
|
+
return sorted(names)
|
|
166
|
+
|
|
167
|
+
@classmethod
|
|
168
|
+
def from_env(cls, env_name: str = "dev") -> HarnessConfig:
|
|
169
|
+
"""加载指定环境的配置。
|
|
170
|
+
|
|
171
|
+
查找项目根目录下的 ``config.{env_name}.env``,
|
|
172
|
+
如果不存在则回退到 ``.env``。
|
|
173
|
+
"""
|
|
174
|
+
project_root = cls._find_project_root()
|
|
175
|
+
env_file = project_root / f"config.{env_name}.env"
|
|
176
|
+
return cls(_env_file=str(env_file) if env_file.exists() else None)
|
|
177
|
+
|
|
178
|
+
@staticmethod
|
|
179
|
+
def _find_project_root() -> Path:
|
|
180
|
+
"""从当前工作目录向上查找项目根目录(包含 pyproject.toml 的目录)。"""
|
|
181
|
+
start = Path.cwd()
|
|
182
|
+
for parent in [start] + list(start.parents):
|
|
183
|
+
if (parent / "pyproject.toml").exists():
|
|
184
|
+
return parent
|
|
185
|
+
return start
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""数据库连接配置。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class DatabaseConfig:
|
|
10
|
+
"""单个数据库的连接配置。
|
|
11
|
+
|
|
12
|
+
通过 ``DB_{NAME}__{KEY}`` 环境变量驱动。
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
name: str
|
|
16
|
+
engine: str = "mysql" # mysql / postgresql / sqlite
|
|
17
|
+
host: str = "localhost"
|
|
18
|
+
port: int = 3306
|
|
19
|
+
user: str = "root"
|
|
20
|
+
password: str = ""
|
|
21
|
+
database: str = ""
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def is_sqlite(self) -> bool:
|
|
25
|
+
return self.engine == "sqlite"
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def sqlite_in_memory(cls, name: str = "test") -> DatabaseConfig:
|
|
29
|
+
"""创建一个内存 SQLite 配置(用于单元测试)。"""
|
|
30
|
+
return cls(name=name, engine="sqlite", database=":memory:")
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""数据库操作封装 — 连接、查询、写入、数据提取。
|
|
2
|
+
|
|
3
|
+
支持 MySQL、PostgreSQL、SQLite。MySQL 需要安装 ``pymysql``。
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from api_test_kit.db.config import DatabaseConfig
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Database:
|
|
16
|
+
"""数据库操作封装。
|
|
17
|
+
|
|
18
|
+
用法::
|
|
19
|
+
|
|
20
|
+
config = DatabaseConfig(name="order", engine="mysql",
|
|
21
|
+
host="localhost", database="orders")
|
|
22
|
+
db = Database(config)
|
|
23
|
+
rows = db.query("SELECT * FROM orders WHERE id = %(id)s", {"id": 1})
|
|
24
|
+
affected = db.execute("UPDATE orders SET status = %(s)s", {"s": "shipped"})
|
|
25
|
+
val = db.extract("SELECT name FROM users WHERE id = %(id)s", "name", {"id": 1})
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, config: DatabaseConfig) -> None:
|
|
29
|
+
self.config = config
|
|
30
|
+
self._conn: Any = None
|
|
31
|
+
|
|
32
|
+
# ── 连接 ──────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def conn(self) -> Any:
|
|
36
|
+
"""惰性获取数据库连接。"""
|
|
37
|
+
if self._conn is None:
|
|
38
|
+
self._conn = self._create_connection()
|
|
39
|
+
logger.info("[DB] 已连接: %s/%s", self.config.engine, self.config.database)
|
|
40
|
+
return self._conn
|
|
41
|
+
|
|
42
|
+
def _create_connection(self) -> Any:
|
|
43
|
+
cfg = self.config
|
|
44
|
+
if cfg.is_sqlite:
|
|
45
|
+
import sqlite3
|
|
46
|
+
conn = sqlite3.connect(cfg.database)
|
|
47
|
+
conn.row_factory = sqlite3.Row
|
|
48
|
+
return conn
|
|
49
|
+
elif cfg.engine == "mysql":
|
|
50
|
+
return self._connect_mysql(cfg)
|
|
51
|
+
elif cfg.engine == "postgresql":
|
|
52
|
+
return self._connect_postgres(cfg)
|
|
53
|
+
raise ValueError(f"不支持的数据库引擎: {cfg.engine}")
|
|
54
|
+
|
|
55
|
+
def _connect_mysql(self, cfg: DatabaseConfig) -> Any:
|
|
56
|
+
try:
|
|
57
|
+
import pymysql
|
|
58
|
+
import pymysql.cursors
|
|
59
|
+
except ImportError:
|
|
60
|
+
raise ImportError(
|
|
61
|
+
"MySQL 支持需要安装 pymysql: pip install pymysql"
|
|
62
|
+
)
|
|
63
|
+
return pymysql.connect(
|
|
64
|
+
host=cfg.host,
|
|
65
|
+
port=cfg.port,
|
|
66
|
+
user=cfg.user,
|
|
67
|
+
password=cfg.password,
|
|
68
|
+
database=cfg.database,
|
|
69
|
+
charset="utf8mb4",
|
|
70
|
+
cursorclass=pymysql.cursors.DictCursor,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def _connect_postgres(self, cfg: DatabaseConfig) -> Any:
|
|
74
|
+
try:
|
|
75
|
+
import psycopg2
|
|
76
|
+
import psycopg2.extras
|
|
77
|
+
except ImportError:
|
|
78
|
+
raise ImportError(
|
|
79
|
+
"PostgreSQL 支持需要安装 psycopg2: pip install psycopg2-binary"
|
|
80
|
+
)
|
|
81
|
+
return psycopg2.connect(
|
|
82
|
+
host=cfg.host,
|
|
83
|
+
port=cfg.port,
|
|
84
|
+
user=cfg.user,
|
|
85
|
+
password=cfg.password,
|
|
86
|
+
dbname=cfg.database,
|
|
87
|
+
cursor_factory=psycopg2.extras.RealDictCursor,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# ── 操作 ──────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
def query(self, sql: str, params: dict[str, Any] | None = None) -> list[dict[str, Any]]:
|
|
93
|
+
"""执行查询,返回字典列表。"""
|
|
94
|
+
cursor = self.conn.cursor()
|
|
95
|
+
cursor.execute(sql, params or {})
|
|
96
|
+
rows = cursor.fetchall()
|
|
97
|
+
cols = [d[0] for d in cursor.description] if cursor.description else []
|
|
98
|
+
cursor.close()
|
|
99
|
+
result: list[dict[str, Any]] = []
|
|
100
|
+
for r in rows:
|
|
101
|
+
if isinstance(r, dict):
|
|
102
|
+
result.append(r)
|
|
103
|
+
elif hasattr(r, "keys"):
|
|
104
|
+
result.append({k: r[k] for k in r.keys()})
|
|
105
|
+
else:
|
|
106
|
+
result.append(dict(zip(cols, r)) if cols else {})
|
|
107
|
+
return result
|
|
108
|
+
|
|
109
|
+
def execute(self, sql: str, params: dict[str, Any] | None = None) -> int:
|
|
110
|
+
"""执行写入(insert/update/delete),返回影响行数。"""
|
|
111
|
+
cursor = self.conn.cursor()
|
|
112
|
+
cursor.execute(sql, params or {})
|
|
113
|
+
self.conn.commit()
|
|
114
|
+
affected = cursor.rowcount
|
|
115
|
+
cursor.close()
|
|
116
|
+
return affected
|
|
117
|
+
|
|
118
|
+
def extract(self, sql: str, column: str, params: dict[str, Any] | None = None) -> Any:
|
|
119
|
+
"""查询并提取某一列的值(用于 E2E 数据传递)。"""
|
|
120
|
+
rows = self.query(sql, params)
|
|
121
|
+
if rows:
|
|
122
|
+
return rows[0].get(column)
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
def close(self) -> None:
|
|
126
|
+
"""关闭数据库连接。"""
|
|
127
|
+
if self._conn:
|
|
128
|
+
self._conn.close()
|
|
129
|
+
self._conn = None
|
|
130
|
+
logger.info("[DB] 已断开: %s/%s", self.config.engine, self.config.database)
|
|
131
|
+
|
|
132
|
+
# ── 上下文管理器 ───────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
def __enter__(self) -> Database:
|
|
135
|
+
return self
|
|
136
|
+
|
|
137
|
+
def __exit__(self, *args: Any) -> None:
|
|
138
|
+
self.close()
|
|
File without changes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: api-test-toolkit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: AI 友好的 API 自动化测试工具包
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Requires-Dist: requests>=2.31
|
|
@@ -19,3 +19,4 @@ Requires-Dist: responses>=0.25; extra == "dev"
|
|
|
19
19
|
Requires-Dist: faker>=22.0; extra == "dev"
|
|
20
20
|
Requires-Dist: ruff>=0.3; extra == "dev"
|
|
21
21
|
Requires-Dist: mypy>=1.8; extra == "dev"
|
|
22
|
+
Requires-Dist: pymysql>=1.1; extra == "dev"
|
|
@@ -7,6 +7,9 @@ src/api_test_kit/client.py
|
|
|
7
7
|
src/api_test_kit/config.py
|
|
8
8
|
src/api_test_kit/logger.py
|
|
9
9
|
src/api_test_kit/scaffold.py
|
|
10
|
+
src/api_test_kit/db/__init__.py
|
|
11
|
+
src/api_test_kit/db/config.py
|
|
12
|
+
src/api_test_kit/db/database.py
|
|
10
13
|
src/api_test_kit/e2e/__init__.py
|
|
11
14
|
src/api_test_kit/e2e/context.py
|
|
12
15
|
src/api_test_kit/e2e/helpers.py
|
|
@@ -21,6 +24,7 @@ src/api_test_kit/templates/pytest.ini
|
|
|
21
24
|
src/api_test_kit/templates/scripts/new-endpoint.sh
|
|
22
25
|
src/api_test_kit/templates/tests/__init__.py
|
|
23
26
|
src/api_test_kit/templates/tests/conftest.py
|
|
27
|
+
src/api_test_kit/templates/tests/e2e/__init__.py
|
|
24
28
|
src/api_test_toolkit.egg-info/PKG-INFO
|
|
25
29
|
src/api_test_toolkit.egg-info/SOURCES.txt
|
|
26
30
|
src/api_test_toolkit.egg-info/dependency_links.txt
|
|
@@ -29,6 +33,8 @@ src/api_test_toolkit.egg-info/requires.txt
|
|
|
29
33
|
src/api_test_toolkit.egg-info/top_level.txt
|
|
30
34
|
tests/test_assertions.py
|
|
31
35
|
tests/test_client.py
|
|
36
|
+
tests/test_config.py
|
|
37
|
+
tests/test_db.py
|
|
32
38
|
tests/test_e2e_context.py
|
|
33
39
|
tests/test_e2e_scenario.py
|
|
34
40
|
tests/test_e2e_step.py
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""配置模块单元测试。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from api_test_kit.config import DomainConfig, HarnessConfig
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestDomainConfig:
|
|
12
|
+
"""DomainConfig — 多域名配置。"""
|
|
13
|
+
|
|
14
|
+
def test_get_domain_exists(self) -> None:
|
|
15
|
+
os.environ["DOMAIN_ORDER__BASE_URL"] = "https://order.test.com"
|
|
16
|
+
os.environ["DOMAIN_ORDER__API_PREFIX"] = "/api/v2"
|
|
17
|
+
try:
|
|
18
|
+
c = HarnessConfig()
|
|
19
|
+
d = c.get_domain("order")
|
|
20
|
+
assert d is not None
|
|
21
|
+
assert d.base_url == "https://order.test.com"
|
|
22
|
+
assert d.api_prefix == "/api/v2"
|
|
23
|
+
assert d.name == "order"
|
|
24
|
+
finally:
|
|
25
|
+
del os.environ["DOMAIN_ORDER__BASE_URL"]
|
|
26
|
+
del os.environ["DOMAIN_ORDER__API_PREFIX"]
|
|
27
|
+
|
|
28
|
+
def test_get_domain_missing(self) -> None:
|
|
29
|
+
c = HarnessConfig()
|
|
30
|
+
assert c.get_domain("nonexistent") is None
|
|
31
|
+
|
|
32
|
+
def test_list_domains(self) -> None:
|
|
33
|
+
os.environ["DOMAIN_A__BASE_URL"] = "https://a.test.com"
|
|
34
|
+
os.environ["DOMAIN_B__BASE_URL"] = "https://b.test.com"
|
|
35
|
+
try:
|
|
36
|
+
c = HarnessConfig()
|
|
37
|
+
domains = c.list_domains()
|
|
38
|
+
assert "a" in domains
|
|
39
|
+
assert "b" in domains
|
|
40
|
+
finally:
|
|
41
|
+
del os.environ["DOMAIN_A__BASE_URL"]
|
|
42
|
+
del os.environ["DOMAIN_B__BASE_URL"]
|
|
43
|
+
|
|
44
|
+
def test_get_db_config(self) -> None:
|
|
45
|
+
os.environ["DB_ORDER__ENGINE"] = "mysql"
|
|
46
|
+
os.environ["DB_ORDER__HOST"] = "order-db.test.com"
|
|
47
|
+
try:
|
|
48
|
+
c = HarnessConfig()
|
|
49
|
+
dbc = c.get_db_config("order")
|
|
50
|
+
assert dbc is not None
|
|
51
|
+
assert dbc.engine == "mysql"
|
|
52
|
+
assert dbc.host == "order-db.test.com"
|
|
53
|
+
assert dbc.port == 3306
|
|
54
|
+
finally:
|
|
55
|
+
del os.environ["DB_ORDER__ENGINE"]
|
|
56
|
+
del os.environ["DB_ORDER__HOST"]
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Database 单元测试(使用 SQLite,无需外部数据库)。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from api_test_kit.db.config import DatabaseConfig
|
|
7
|
+
from api_test_kit.db.database import Database
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.fixture
|
|
11
|
+
def db() -> Database:
|
|
12
|
+
"""创建内存 SQLite 数据库并初始化测试表。"""
|
|
13
|
+
config = DatabaseConfig.sqlite_in_memory()
|
|
14
|
+
database = Database(config)
|
|
15
|
+
database.execute(
|
|
16
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)"
|
|
17
|
+
)
|
|
18
|
+
database.execute(
|
|
19
|
+
"INSERT INTO users (name, email) VALUES (:name, :email)",
|
|
20
|
+
{"name": "Alice", "email": "alice@test.com"},
|
|
21
|
+
)
|
|
22
|
+
database.execute(
|
|
23
|
+
"INSERT INTO users (name, email) VALUES (:name, :email)",
|
|
24
|
+
{"name": "Bob", "email": "bob@test.com"},
|
|
25
|
+
)
|
|
26
|
+
return database
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TestDatabase:
|
|
30
|
+
"""Database — 数据库操作封装。"""
|
|
31
|
+
|
|
32
|
+
def test_query_returns_dicts(self, db: Database) -> None:
|
|
33
|
+
rows = db.query("SELECT * FROM users ORDER BY id")
|
|
34
|
+
assert len(rows) == 2
|
|
35
|
+
assert rows[0]["name"] == "Alice"
|
|
36
|
+
assert rows[0]["email"] == "alice@test.com"
|
|
37
|
+
|
|
38
|
+
def test_query_with_params(self, db: Database) -> None:
|
|
39
|
+
rows = db.query(
|
|
40
|
+
"SELECT * FROM users WHERE name = :name",
|
|
41
|
+
{"name": "Bob"},
|
|
42
|
+
)
|
|
43
|
+
assert len(rows) == 1
|
|
44
|
+
assert rows[0]["email"] == "bob@test.com"
|
|
45
|
+
|
|
46
|
+
def test_execute_insert(self, db: Database) -> None:
|
|
47
|
+
affected = db.execute(
|
|
48
|
+
"INSERT INTO users (name, email) VALUES (:name, :email)",
|
|
49
|
+
{"name": "Charlie", "email": "charlie@test.com"},
|
|
50
|
+
)
|
|
51
|
+
assert affected == 1
|
|
52
|
+
rows = db.query("SELECT * FROM users")
|
|
53
|
+
assert len(rows) == 3
|
|
54
|
+
|
|
55
|
+
def test_execute_update(self, db: Database) -> None:
|
|
56
|
+
affected = db.execute(
|
|
57
|
+
"UPDATE users SET email = :email WHERE name = :name",
|
|
58
|
+
{"email": "alice@new.com", "name": "Alice"},
|
|
59
|
+
)
|
|
60
|
+
assert affected == 1
|
|
61
|
+
row = db.query("SELECT * FROM users WHERE name = 'Alice'")
|
|
62
|
+
assert row[0]["email"] == "alice@new.com"
|
|
63
|
+
|
|
64
|
+
def test_execute_delete(self, db: Database) -> None:
|
|
65
|
+
affected = db.execute("DELETE FROM users WHERE name = :name", {"name": "Alice"})
|
|
66
|
+
assert affected == 1
|
|
67
|
+
rows = db.query("SELECT * FROM users")
|
|
68
|
+
assert len(rows) == 1
|
|
69
|
+
|
|
70
|
+
def test_extract_column_value(self, db: Database) -> None:
|
|
71
|
+
val = db.extract(
|
|
72
|
+
"SELECT email FROM users WHERE name = :name",
|
|
73
|
+
"email",
|
|
74
|
+
{"name": "Alice"},
|
|
75
|
+
)
|
|
76
|
+
assert val == "alice@test.com"
|
|
77
|
+
|
|
78
|
+
def test_extract_returns_none_for_missing(self, db: Database) -> None:
|
|
79
|
+
val = db.extract(
|
|
80
|
+
"SELECT email FROM users WHERE name = :name",
|
|
81
|
+
"email",
|
|
82
|
+
{"name": "Nonexistent"},
|
|
83
|
+
)
|
|
84
|
+
assert val is None
|
|
85
|
+
|
|
86
|
+
def test_context_manager(self) -> None:
|
|
87
|
+
config = DatabaseConfig.sqlite_in_memory()
|
|
88
|
+
with Database(config) as db:
|
|
89
|
+
db.execute("CREATE TABLE t (x INTEGER)")
|
|
90
|
+
db.execute("INSERT INTO t VALUES (42)")
|
|
91
|
+
rows = db.query("SELECT * FROM t")
|
|
92
|
+
assert rows[0]["x"] == 42
|
|
93
|
+
# 退出上下文后连接应关闭
|
|
94
|
+
assert db._conn is None
|
|
95
|
+
|
|
96
|
+
def test_config_sqlite_in_memory(self) -> None:
|
|
97
|
+
config = DatabaseConfig.sqlite_in_memory("testdb")
|
|
98
|
+
assert config.name == "testdb"
|
|
99
|
+
assert config.engine == "sqlite"
|
|
100
|
+
assert config.database == ":memory:"
|
|
101
|
+
assert config.is_sqlite is True
|
|
102
|
+
|
|
103
|
+
def test_config_mysql_defaults(self) -> None:
|
|
104
|
+
config = DatabaseConfig(name="order", engine="mysql", host="db.example.com")
|
|
105
|
+
assert config.host == "db.example.com"
|
|
106
|
+
assert config.port == 3306
|
|
107
|
+
assert config.user == "root"
|
|
108
|
+
assert config.is_sqlite is False
|
|
@@ -1,82 +0,0 @@
|
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_kit/templates/scripts/new-endpoint.sh
RENAMED
|
File without changes
|
{api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_kit/templates/tests/__init__.py
RENAMED
|
File without changes
|
{api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_kit/templates/tests/conftest.py
RENAMED
|
File without changes
|
{api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_toolkit.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_toolkit.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{api_test_toolkit-0.2.0 → api_test_toolkit-0.3.0}/src/api_test_toolkit.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|