dd-apitest 0.2.0__py3-none-any.whl

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 (67) hide show
  1. core/__init__.py +1 -0
  2. core/__main__.py +99 -0
  3. core/assert_engine.py +34 -0
  4. core/bundled/__init__.py +1 -0
  5. core/bundled/suites/demo-ui/cases/flows/post_then_get.json +23 -0
  6. core/bundled/suites/demo-ui/cases/get-basic.json +6 -0
  7. core/bundled/suites/demo-ui/cases/get-delay.json +6 -0
  8. core/bundled/suites/demo-ui/cases/get-query.json +7 -0
  9. core/bundled/suites/demo-ui/cases/post-form.json +8 -0
  10. core/bundled/suites/demo-ui/cases/post-json.json +7 -0
  11. core/bundled/suites/demo-ui/forms/post_form.json +6 -0
  12. core/bundled/suites/demo-ui/groups/flows/group.json +9 -0
  13. core/bundled/suites/demo-ui/groups/readonly/group.json +11 -0
  14. core/bundled/suites/demo-ui/groups/write/group.json +10 -0
  15. core/bundled/suites/demo-ui/payloads/get_query.json +4 -0
  16. core/bundled/suites/demo-ui/payloads/post_json.json +4 -0
  17. core/bundled/suites/demo-ui/payloads/post_seed.json +3 -0
  18. core/bundled/suites/demo-ui/suite.json +48 -0
  19. core/bundled/suites/xsiam-credential/cases/agent-list.json +7 -0
  20. core/bundled/suites/xsiam-credential/cases/associate-by-mcp-name.json +7 -0
  21. core/bundled/suites/xsiam-credential/cases/associate.json +7 -0
  22. core/bundled/suites/xsiam-credential/cases/detail.json +7 -0
  23. core/bundled/suites/xsiam-credential/cases/disassociate.json +7 -0
  24. core/bundled/suites/xsiam-credential/cases/flows/create_then_delete.json +20 -0
  25. core/bundled/suites/xsiam-credential/cases/page.json +7 -0
  26. core/bundled/suites/xsiam-credential/cases/restore.json +7 -0
  27. core/bundled/suites/xsiam-credential/groups/readonly/group.json +11 -0
  28. core/bundled/suites/xsiam-credential/groups/write/group.json +12 -0
  29. core/bundled/suites/xsiam-credential/payloads/agent_list.json +3 -0
  30. core/bundled/suites/xsiam-credential/payloads/associate.json +5 -0
  31. core/bundled/suites/xsiam-credential/payloads/associate_by_mcp_name.json +4 -0
  32. core/bundled/suites/xsiam-credential/payloads/create.json +12 -0
  33. core/bundled/suites/xsiam-credential/payloads/delete.json +3 -0
  34. core/bundled/suites/xsiam-credential/payloads/detail.json +3 -0
  35. core/bundled/suites/xsiam-credential/payloads/disassociate.json +4 -0
  36. core/bundled/suites/xsiam-credential/payloads/page.json +5 -0
  37. core/bundled/suites/xsiam-credential/payloads/restore.json +4 -0
  38. core/bundled/suites/xsiam-credential/payloads/token_bind.json +6 -0
  39. core/bundled/suites/xsiam-credential/payloads/update.json +13 -0
  40. core/bundled/suites/xsiam-credential/suite.json +30 -0
  41. core/context.py +67 -0
  42. core/errors.py +6 -0
  43. core/extract.py +15 -0
  44. core/hooks.py +70 -0
  45. core/http.py +120 -0
  46. core/init_cmd.py +25 -0
  47. core/loader.py +330 -0
  48. core/menu.py +131 -0
  49. core/paths.py +84 -0
  50. core/plugins/__init__.py +3 -0
  51. core/plugins/auth_hengnao.py +28 -0
  52. core/plugins/auth_none.py +10 -0
  53. core/plugins/base.py +14 -0
  54. core/plugins/registry.py +83 -0
  55. core/report.py +111 -0
  56. core/report_html.py +78 -0
  57. core/runner.py +337 -0
  58. core/setuptools_build.py +19 -0
  59. core/template.py +34 -0
  60. core/templates/report.html +112 -0
  61. core/tui/__init__.py +3 -0
  62. core/tui/app.py +690 -0
  63. dd_apitest-0.2.0.dist-info/METADATA +197 -0
  64. dd_apitest-0.2.0.dist-info/RECORD +67 -0
  65. dd_apitest-0.2.0.dist-info/WHEEL +5 -0
  66. dd_apitest-0.2.0.dist-info/entry_points.txt +3 -0
  67. dd_apitest-0.2.0.dist-info/top_level.txt +1 -0
core/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
core/__main__.py ADDED
@@ -0,0 +1,99 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from dotenv import load_dotenv
8
+
9
+ from core.errors import ConfigError
10
+ from core.init_cmd import init_user_data
11
+ from core.loader import discover_suites, find_suite_by_name
12
+ from core.menu import run_menu
13
+ from core.paths import resolve_data_root, script_root, set_runtime_data_root, suites_template_source
14
+ from core.report import write_run_report
15
+ from core.runner import run_all_groups, run_group
16
+
17
+
18
+ def _file_uri(path: Path) -> str:
19
+ return path.resolve().as_uri()
20
+
21
+
22
+ def _cmd_run(args: argparse.Namespace) -> int:
23
+ suite = find_suite_by_name(args.suite)
24
+ session_env = args.env or suite.default_env
25
+ if args.all:
26
+ result = run_all_groups(suite, session_env=session_env)
27
+ else:
28
+ if not args.group:
29
+ print("--group or --all is required", file=sys.stderr)
30
+ return 2
31
+ result = run_group(suite, args.group, session_env=session_env)
32
+ out = write_run_report(suite_name=suite.name, session_env=session_env, result=result)
33
+ report_html = (out / "report.html").resolve()
34
+ total = result.total
35
+ rate = (result.passed / total * 100) if total else 0
36
+ print(f"Summary: passed={result.passed} failed={result.failed} total={total} ({rate:.0f}%)")
37
+ print(f"Report: {report_html}")
38
+ print(f"Report URL: {_file_uri(report_html)}")
39
+ return 0 if result.failed == 0 else 1
40
+
41
+
42
+ def _cmd_init(args: argparse.Namespace) -> int:
43
+ source = suites_template_source()
44
+ target = resolve_data_root(user_data=args.user_data, data_dir=args.data_dir)
45
+ initialized = init_user_data(source_data_root=source, target_data_root=target, force=args.force)
46
+ print(f"Initialized data root: {initialized}")
47
+ return 0
48
+
49
+
50
+ def main(argv: list[str] | None = None) -> int:
51
+ parser = argparse.ArgumentParser(prog="apitest")
52
+ parser.add_argument("--user-data", action="store_true", help="Use ~/.apitest as data root")
53
+ parser.add_argument("--data-dir", help="Custom data root directory")
54
+ sub = parser.add_subparsers(dest="command")
55
+
56
+ run_parser = sub.add_parser("run", help="Run suite groups non-interactively")
57
+ run_parser.add_argument("--suite", required=True)
58
+ run_parser.add_argument("--group")
59
+ run_parser.add_argument("--all", action="store_true")
60
+ run_parser.add_argument("--env")
61
+ run_parser.set_defaults(func=_cmd_run)
62
+
63
+ sub.add_parser("menu", help="Interactive menu")
64
+ init_parser = sub.add_parser("init", help="Initialize data root with suites template")
65
+ init_parser.add_argument("--force", action="store_true")
66
+ init_parser.set_defaults(func=_cmd_init)
67
+
68
+ args = parser.parse_args(argv)
69
+ root = resolve_data_root(user_data=args.user_data, data_dir=args.data_dir)
70
+ set_runtime_data_root(root)
71
+ # Keep compatibility with current repos; user-space .env should override script/.env.
72
+ load_dotenv(script_root() / ".env")
73
+ load_dotenv(root / ".env", override=True)
74
+
75
+ if args.command == "run":
76
+ try:
77
+ return _cmd_run(args)
78
+ except ConfigError as exc:
79
+ print(exc, file=sys.stderr)
80
+ return 1
81
+ if args.command == "init":
82
+ try:
83
+ return _cmd_init(args)
84
+ except ConfigError as exc:
85
+ print(exc, file=sys.stderr)
86
+ return 1
87
+
88
+ if args.command == "menu" or (args.command is None and sys.stdin.isatty()):
89
+ return run_menu()
90
+
91
+ if args.command is None:
92
+ parser.print_help()
93
+ return 0
94
+
95
+ return 0
96
+
97
+
98
+ if __name__ == "__main__":
99
+ raise SystemExit(main())
core/assert_engine.py ADDED
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ def _json_subset(expected: Any, actual: Any) -> bool:
7
+ if isinstance(expected, dict):
8
+ if not isinstance(actual, dict):
9
+ return False
10
+ return all(k in actual and _json_subset(expected[k], actual[k]) for k in expected)
11
+ return expected == actual
12
+
13
+
14
+ def check_success(
15
+ http_status: int,
16
+ body: Any,
17
+ rules: dict[str, Any],
18
+ ) -> tuple[bool, list[str]]:
19
+ reasons: list[str] = []
20
+ expected_status = rules.get("httpStatus")
21
+ if expected_status is not None and http_status not in expected_status:
22
+ reasons.append(f"httpStatus expected {expected_status}, got {http_status}")
23
+
24
+ json_rule = rules.get("json")
25
+ if json_rule is not None:
26
+ if isinstance(body, dict):
27
+ if not _json_subset(json_rule, body):
28
+ reasons.append(f"json subset mismatch: expected {json_rule}, got {body}")
29
+ elif body is not None and not isinstance(body, dict):
30
+ pass
31
+ else:
32
+ reasons.append("json rule configured but response body is not a JSON object")
33
+
34
+ return (len(reasons) == 0, reasons)
@@ -0,0 +1 @@
1
+ """Bundled suite templates (populated at wheel build time)."""
@@ -0,0 +1,23 @@
1
+ {
2
+ "id": "post-then-get",
3
+ "type": "flow",
4
+ "name": "POST 写入变量再 GET",
5
+ "steps": [
6
+ {
7
+ "name": "post-seed",
8
+ "method": "POST",
9
+ "path": "/post",
10
+ "bodyFile": "payloads/post_seed.json",
11
+ "extract": { "echoToken": "$.json.echoToken" }
12
+ },
13
+ {
14
+ "name": "get-echo",
15
+ "method": "GET",
16
+ "path": "/get",
17
+ "query": {
18
+ "echoToken": "{{echoToken}}",
19
+ "tag": "{{demoTag}}"
20
+ }
21
+ }
22
+ ]
23
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "id": "get-basic",
3
+ "name": "GET 连通性",
4
+ "method": "GET",
5
+ "path": "/get"
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "id": "get-delay",
3
+ "name": "GET 延迟 2s(看进度)",
4
+ "method": "GET",
5
+ "path": "/delay/2"
6
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "id": "get-query",
3
+ "name": "GET 带 Query",
4
+ "method": "GET",
5
+ "path": "/get",
6
+ "queryFile": "payloads/get_query.json"
7
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "id": "post-form",
3
+ "name": "POST 表单",
4
+ "method": "POST",
5
+ "path": "/post",
6
+ "bodyType": "form-urlencoded",
7
+ "formFile": "forms/post_form.json"
8
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "id": "post-json",
3
+ "name": "POST JSON",
4
+ "method": "POST",
5
+ "path": "/post",
6
+ "bodyFile": "payloads/post_json.json"
7
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "fields": {
3
+ "field1": "hello",
4
+ "tag": "{{demoTag}}"
5
+ }
6
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "flows",
3
+ "tags": ["flow", "demo"],
4
+ "env": "httpbin",
5
+ "inheritSuccess": true,
6
+ "cases": [
7
+ { "id": "post-then-get", "file": "cases/flows/post_then_get.json" }
8
+ ]
9
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "readonly",
3
+ "tags": ["read-only", "demo"],
4
+ "env": "httpbin",
5
+ "inheritSuccess": true,
6
+ "cases": [
7
+ { "id": "get-basic", "file": "cases/get-basic.json" },
8
+ { "id": "get-query", "file": "cases/get-query.json" },
9
+ { "id": "get-delay", "file": "cases/get-delay.json" }
10
+ ]
11
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "write",
3
+ "tags": ["write", "demo"],
4
+ "env": "httpbin",
5
+ "inheritSuccess": true,
6
+ "cases": [
7
+ { "id": "post-json", "file": "cases/post-json.json" },
8
+ { "id": "post-form", "file": "cases/post-form.json" }
9
+ ]
10
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "foo": "bar",
3
+ "tag": "{{demoTag}}"
4
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "name": "apitest-demo",
3
+ "tag": "{{demoTag}}"
4
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "echoToken": "token-{{demoTag}}-001"
3
+ }
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "demo-ui",
3
+ "version": "1",
4
+ "defaultEnv": "httpbin",
5
+ "plugins": {
6
+ "paths": ["plugins"]
7
+ },
8
+ "auth": { "plugin": "none" },
9
+ "environments": {
10
+ "httpbin": {
11
+ "baseUrl": "https://httpbin.org",
12
+ "variables": {
13
+ "demoTag": "apitest-tui"
14
+ }
15
+ },
16
+ "httpbin-delay": {
17
+ "baseUrl": "https://httpbin.org",
18
+ "variables": {
19
+ "demoTag": "apitest-slow"
20
+ }
21
+ },
22
+ "playground": {
23
+ "baseUrl": "https://httpbin.org",
24
+ "variables": {
25
+ "demoTag": "playground",
26
+ "note": "按 e 切到此环境后跑 get-query,可在响应 args 里看到 tag"
27
+ }
28
+ },
29
+ "sandbox": {
30
+ "baseUrl": "https://httpbin.org",
31
+ "variables": {
32
+ "demoTag": "sandbox",
33
+ "channel": "tui-round-robin"
34
+ }
35
+ }
36
+ },
37
+ "success": {
38
+ "httpStatus": [200]
39
+ },
40
+ "hooks": {
41
+ "module": "hooks.py",
42
+ "enabled": false
43
+ },
44
+ "http": {
45
+ "timeout": 15,
46
+ "retries": 1
47
+ }
48
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "id": "agent-list",
3
+ "name": "智能体凭据列表",
4
+ "method": "GET",
5
+ "path": "/open/api/v2/credential/agent/list",
6
+ "queryFile": "payloads/agent_list.json"
7
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "id": "associate-by-mcp-name",
3
+ "name": "按 MCP 名称关联",
4
+ "method": "POST",
5
+ "path": "/open/api/v2/credential/agent/associate-by-mcp-name",
6
+ "bodyFile": "payloads/associate_by_mcp_name.json"
7
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "id": "associate",
3
+ "name": "租户级关联凭据",
4
+ "method": "POST",
5
+ "path": "/open/api/v2/credential/agent/associate",
6
+ "bodyFile": "payloads/associate.json"
7
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "id": "detail",
3
+ "name": "凭据详情",
4
+ "method": "GET",
5
+ "path": "/open/api/v2/credential/detail",
6
+ "queryFile": "payloads/detail.json"
7
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "id": "disassociate",
3
+ "name": "租户级解绑凭据",
4
+ "method": "POST",
5
+ "path": "/open/api/v2/credential/agent/disassociate",
6
+ "bodyFile": "payloads/disassociate.json"
7
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "id": "create-then-delete",
3
+ "type": "flow",
4
+ "name": "创建后删除凭据",
5
+ "steps": [
6
+ {
7
+ "name": "create",
8
+ "method": "POST",
9
+ "path": "/open/api/v2/credential/create",
10
+ "bodyFile": "payloads/create.json",
11
+ "extract": { "credentialId": "$.data.id" }
12
+ },
13
+ {
14
+ "name": "delete",
15
+ "method": "POST",
16
+ "path": "/open/api/v2/credential/delete",
17
+ "body": { "ids": ["{{credentialId}}"] }
18
+ }
19
+ ]
20
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "id": "page",
3
+ "name": "凭据分页",
4
+ "method": "POST",
5
+ "path": "/open/api/v2/credential/page",
6
+ "bodyFile": "payloads/page.json"
7
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "id": "restore",
3
+ "name": "恢复凭据",
4
+ "method": "POST",
5
+ "path": "/open/api/v2/credential/agent/restore",
6
+ "bodyFile": "payloads/restore.json"
7
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "readonly",
3
+ "tags": ["read-only"],
4
+ "env": "dev",
5
+ "inheritSuccess": true,
6
+ "cases": [
7
+ { "id": "page", "file": "cases/page.json" },
8
+ { "id": "detail", "file": "cases/detail.json" },
9
+ { "id": "agent-list", "file": "cases/agent-list.json" }
10
+ ]
11
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "write",
3
+ "env": "dev",
4
+ "inheritSuccess": true,
5
+ "cases": [
6
+ { "id": "create-then-delete", "file": "cases/flows/create_then_delete.json" },
7
+ { "id": "associate", "file": "cases/associate.json" },
8
+ { "id": "disassociate", "file": "cases/disassociate.json" },
9
+ { "id": "restore", "file": "cases/restore.json" },
10
+ { "id": "associate-by-mcp-name", "file": "cases/associate-by-mcp-name.json" }
11
+ ]
12
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "agentId": "51f39d22-11bc-4e76-80a6-ee62aa862ec1"
3
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "agentId": "1987654321000000002",
3
+ "originalCredentialId": "1987654321000000003",
4
+ "myCredentialId": "1987654321000000001"
5
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "mcpServerName": "MCP共享插件",
3
+ "myCredentialId": "2059930349805682689"
4
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "xsiam-mcp-oauth2",
3
+ "description": "XSIAM 对接 MCP OAuth2(测试示例)",
4
+ "credentialType": "oauth",
5
+ "credentialSubType": "authorization_code_mcp",
6
+ "authorizationCodeMcpConfig": {
7
+ "issuerUrl": "https://10.20.152.211/mcp",
8
+ "redirectUrl": "http://10.20.152.90:9092/credential/oauth/callback",
9
+ "clientId": "already-registered-client-id",
10
+ "scopes": ["openid", "profile"]
11
+ }
12
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "ids": ["1987654321000000001", "1987654321000000002"]
3
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "id": "2059930349805682689"
3
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "agentId": "1987654321000000002",
3
+ "originalCredentialId": "1987654321000000001"
4
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "page": 1,
3
+ "size": 10,
4
+ "name": "xsiam"
5
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "agentId": "1987654321000000002",
3
+ "myCredentialId": "1987654321000000001"
4
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "credentialId": "2059930349805682689",
3
+ "accessToken": "REPLACE_ME",
4
+ "expiresIn": 3600,
5
+ "tokenType": "Bearer"
6
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "id": "2059930349805682689",
3
+ "name": "xsiam-mcp-oauth-updated",
4
+ "description": "更新 MCP OAuth2 配置(测试示例)",
5
+ "credentialType": "oauth",
6
+ "credentialSubType": "authorization_code_mcp",
7
+ "authorizationCodeMcpConfig": {
8
+ "issuerUrl": "https://10.20.152.211/mcp",
9
+ "redirectUrl": "http://10.20.152.90:9092/credential/oauth/callback",
10
+ "clientId": "already-registered-client-id",
11
+ "scopes": ["openid", "profile"]
12
+ }
13
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "xsiam-credential",
3
+ "version": "1",
4
+ "defaultEnv": "dev",
5
+ "plugins": {
6
+ "paths": ["plugins"]
7
+ },
8
+ "auth": { "plugin": "hengnao_openapi", "options": {} },
9
+ "environments": {
10
+ "dev": {
11
+ "baseUrl": "http://10.20.152.90:9092",
12
+ "variables": {
13
+ "appKey": "${HENGNAO_APPKEY}",
14
+ "appSecret": "${HENGNAO_APPSECRET}"
15
+ }
16
+ }
17
+ },
18
+ "success": {
19
+ "httpStatus": [200],
20
+ "json": { "code": 0 }
21
+ },
22
+ "hooks": {
23
+ "module": "hooks.py",
24
+ "enabled": false
25
+ },
26
+ "http": {
27
+ "timeout": 10,
28
+ "retries": 3
29
+ }
30
+ }
core/context.py ADDED
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+
7
+ @dataclass
8
+ class HttpRequest:
9
+ method: str
10
+ url: str
11
+ path: str
12
+ headers: dict[str, str] = field(default_factory=dict)
13
+ body_type: str = "none"
14
+ body: dict[str, Any] | None = None
15
+ form: dict[str, Any] | None = None
16
+ query: dict[str, str] | None = None
17
+
18
+
19
+ @dataclass
20
+ class HttpResponse:
21
+ status_code: int
22
+ headers: dict[str, str]
23
+ json_body: dict[str, Any] | list[Any] | str | None
24
+ text: str
25
+ elapsed_ms: float
26
+
27
+
28
+ class RunContext:
29
+ def __init__(
30
+ self,
31
+ *,
32
+ suite: str,
33
+ group: str,
34
+ case_id: str,
35
+ case_type: str,
36
+ env: str,
37
+ base_url: str,
38
+ session_env: str,
39
+ env_variables: dict[str, Any],
40
+ ) -> None:
41
+ self.suite = suite
42
+ self.group = group
43
+ self.case_id = case_id
44
+ self.case_type = case_type
45
+ self.env = env
46
+ self.base_url = base_url
47
+ self.session_env = session_env
48
+ self._env_variables = dict(env_variables)
49
+ self._case_variables: dict[str, Any] = {}
50
+ self.step_name: str | None = None
51
+
52
+ def reset_case_variables(self) -> None:
53
+ self._case_variables = {}
54
+
55
+ def set_case(self, key: str, value: Any) -> None:
56
+ self._case_variables[key] = value
57
+
58
+ def set_env(self, key: str, value: Any) -> None:
59
+ raise ValueError("env_variables are read-only for hooks")
60
+
61
+ def resolve(self, key: str) -> Any:
62
+ if key in self._case_variables:
63
+ return self._case_variables[key]
64
+ return self._env_variables.get(key)
65
+
66
+ def resolver(self):
67
+ return self.resolve
core/errors.py ADDED
@@ -0,0 +1,6 @@
1
+ class ConfigError(Exception):
2
+ """Invalid suite/group/case configuration."""
3
+
4
+
5
+ class HookError(Exception):
6
+ """Suite hook raised an error."""
core/extract.py ADDED
@@ -0,0 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ def extract_path(path: str, body: dict[str, Any]) -> Any:
7
+ normalized = path[2:] if path.startswith("$.") else path
8
+ if not normalized:
9
+ raise KeyError("empty extract path")
10
+ current: Any = body
11
+ for segment in normalized.split("."):
12
+ if not isinstance(current, dict) or segment not in current:
13
+ raise KeyError(f"missing path segment: {segment}")
14
+ current = current[segment]
15
+ return current