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.
- core/__init__.py +1 -0
- core/__main__.py +99 -0
- core/assert_engine.py +34 -0
- core/bundled/__init__.py +1 -0
- core/bundled/suites/demo-ui/cases/flows/post_then_get.json +23 -0
- core/bundled/suites/demo-ui/cases/get-basic.json +6 -0
- core/bundled/suites/demo-ui/cases/get-delay.json +6 -0
- core/bundled/suites/demo-ui/cases/get-query.json +7 -0
- core/bundled/suites/demo-ui/cases/post-form.json +8 -0
- core/bundled/suites/demo-ui/cases/post-json.json +7 -0
- core/bundled/suites/demo-ui/forms/post_form.json +6 -0
- core/bundled/suites/demo-ui/groups/flows/group.json +9 -0
- core/bundled/suites/demo-ui/groups/readonly/group.json +11 -0
- core/bundled/suites/demo-ui/groups/write/group.json +10 -0
- core/bundled/suites/demo-ui/payloads/get_query.json +4 -0
- core/bundled/suites/demo-ui/payloads/post_json.json +4 -0
- core/bundled/suites/demo-ui/payloads/post_seed.json +3 -0
- core/bundled/suites/demo-ui/suite.json +48 -0
- core/bundled/suites/xsiam-credential/cases/agent-list.json +7 -0
- core/bundled/suites/xsiam-credential/cases/associate-by-mcp-name.json +7 -0
- core/bundled/suites/xsiam-credential/cases/associate.json +7 -0
- core/bundled/suites/xsiam-credential/cases/detail.json +7 -0
- core/bundled/suites/xsiam-credential/cases/disassociate.json +7 -0
- core/bundled/suites/xsiam-credential/cases/flows/create_then_delete.json +20 -0
- core/bundled/suites/xsiam-credential/cases/page.json +7 -0
- core/bundled/suites/xsiam-credential/cases/restore.json +7 -0
- core/bundled/suites/xsiam-credential/groups/readonly/group.json +11 -0
- core/bundled/suites/xsiam-credential/groups/write/group.json +12 -0
- core/bundled/suites/xsiam-credential/payloads/agent_list.json +3 -0
- core/bundled/suites/xsiam-credential/payloads/associate.json +5 -0
- core/bundled/suites/xsiam-credential/payloads/associate_by_mcp_name.json +4 -0
- core/bundled/suites/xsiam-credential/payloads/create.json +12 -0
- core/bundled/suites/xsiam-credential/payloads/delete.json +3 -0
- core/bundled/suites/xsiam-credential/payloads/detail.json +3 -0
- core/bundled/suites/xsiam-credential/payloads/disassociate.json +4 -0
- core/bundled/suites/xsiam-credential/payloads/page.json +5 -0
- core/bundled/suites/xsiam-credential/payloads/restore.json +4 -0
- core/bundled/suites/xsiam-credential/payloads/token_bind.json +6 -0
- core/bundled/suites/xsiam-credential/payloads/update.json +13 -0
- core/bundled/suites/xsiam-credential/suite.json +30 -0
- core/context.py +67 -0
- core/errors.py +6 -0
- core/extract.py +15 -0
- core/hooks.py +70 -0
- core/http.py +120 -0
- core/init_cmd.py +25 -0
- core/loader.py +330 -0
- core/menu.py +131 -0
- core/paths.py +84 -0
- core/plugins/__init__.py +3 -0
- core/plugins/auth_hengnao.py +28 -0
- core/plugins/auth_none.py +10 -0
- core/plugins/base.py +14 -0
- core/plugins/registry.py +83 -0
- core/report.py +111 -0
- core/report_html.py +78 -0
- core/runner.py +337 -0
- core/setuptools_build.py +19 -0
- core/template.py +34 -0
- core/templates/report.html +112 -0
- core/tui/__init__.py +3 -0
- core/tui/app.py +690 -0
- dd_apitest-0.2.0.dist-info/METADATA +197 -0
- dd_apitest-0.2.0.dist-info/RECORD +67 -0
- dd_apitest-0.2.0.dist-info/WHEEL +5 -0
- dd_apitest-0.2.0.dist-info/entry_points.txt +3 -0
- 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)
|
core/bundled/__init__.py
ADDED
|
@@ -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,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,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,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,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,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,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
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
|