amap-cli 0.1.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.
amap_cli/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """amap-cli package."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.1.0"
amap_cli/__main__.py ADDED
@@ -0,0 +1,9 @@
1
+ """Module entrypoint for `python -m amap_cli`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from amap_cli.cli import main
6
+
7
+
8
+ if __name__ == "__main__":
9
+ raise SystemExit(main())
amap_cli/api.py ADDED
@@ -0,0 +1,90 @@
1
+ """Common Amap API client for future business commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+ from urllib.error import HTTPError, URLError
8
+ from urllib.parse import urlencode
9
+ from urllib.request import Request, urlopen
10
+
11
+ from amap_cli import __version__
12
+ from amap_cli.config import AmapConfig, load_config
13
+ from amap_cli.errors import ApiRequestError, ApiResponseError, MissingConfigError
14
+
15
+
16
+ class AmapApiClient:
17
+ """HTTP client that injects config and normalizes Amap failures."""
18
+
19
+ def __init__(self, config: AmapConfig | None = None) -> None:
20
+ self.config = config or load_config(required=True)
21
+ if self.config is None or not self.config.api_key:
22
+ raise MissingConfigError(
23
+ "缺少高德 API Key,请先执行 `amap-cli config set --api-key <KEY>`。"
24
+ )
25
+
26
+ def get(self, path: str, *, params: dict[str, Any] | None = None) -> dict[str, Any]:
27
+ """Send a GET request to the Amap REST API."""
28
+ query_params = dict(params or {})
29
+ query_params["key"] = self.config.api_key
30
+
31
+ url = self._build_url(path, query_params)
32
+ request = Request(
33
+ url,
34
+ headers={
35
+ "Accept": "application/json",
36
+ "User-Agent": f"amap-cli/{__version__}",
37
+ },
38
+ method="GET",
39
+ )
40
+
41
+ try:
42
+ with urlopen(request, timeout=self.config.timeout_seconds) as response:
43
+ body = response.read().decode("utf-8")
44
+ except HTTPError as exc:
45
+ response_body = exc.read().decode("utf-8", errors="ignore")
46
+ raise ApiRequestError(
47
+ f"高德 API HTTP 请求失败:{exc.code}",
48
+ details={"status": exc.code, "body": response_body},
49
+ ) from exc
50
+ except URLError as exc:
51
+ raise ApiRequestError(
52
+ "无法连接高德 API。",
53
+ details={"reason": str(exc.reason)},
54
+ ) from exc
55
+ except TimeoutError as exc:
56
+ raise ApiRequestError("请求高德 API 超时。") from exc
57
+
58
+ try:
59
+ payload = json.loads(body)
60
+ except json.JSONDecodeError as exc:
61
+ raise ApiRequestError(
62
+ "高德 API 返回了非 JSON 响应。",
63
+ details={"body": body[:500]},
64
+ ) from exc
65
+
66
+ self._raise_for_amap_error(payload)
67
+ return payload
68
+
69
+ def _build_url(self, path: str, params: dict[str, Any]) -> str:
70
+ """Build a full request URL from config and query params."""
71
+ normalized_path = path if path.startswith("/") else f"/{path}"
72
+ query = urlencode(params, doseq=True)
73
+ return f"{self.config.base_url.rstrip('/')}{normalized_path}?{query}"
74
+
75
+ def _raise_for_amap_error(self, payload: dict[str, Any]) -> None:
76
+ """Convert business-level Amap error payloads to exceptions."""
77
+ status = payload.get("status")
78
+ if status is None:
79
+ return
80
+
81
+ if str(status) == "1":
82
+ return
83
+
84
+ raise ApiResponseError(
85
+ payload.get("info") or "高德 API 返回失败。",
86
+ details={
87
+ "infocode": payload.get("infocode"),
88
+ "info": payload.get("info"),
89
+ },
90
+ )
amap_cli/cli.py ADDED
@@ -0,0 +1,129 @@
1
+ """CLI entrypoint and command registration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from typing import Any, Sequence
7
+
8
+ from amap_cli.config import get_config_path, load_config, save_config
9
+ from amap_cli.distance import register_distance_command
10
+ from amap_cli.errors import AmapCliError, ValidationError
11
+ from amap_cli.output import emit_error, emit_success
12
+ from amap_cli.route import register_route_command
13
+ from amap_cli.search_poi import register_search_poi_command
14
+
15
+
16
+ class JsonArgumentParser(argparse.ArgumentParser):
17
+ """Argument parser that routes validation failures to JSON output."""
18
+
19
+ def error(self, message: str) -> None:
20
+ raise ValidationError(message)
21
+
22
+
23
+ def build_parser() -> JsonArgumentParser:
24
+ """Create the root CLI parser."""
25
+ parser = JsonArgumentParser(
26
+ prog="amap-cli",
27
+ description="纯命令行高德地图 CLI 基础工具。",
28
+ )
29
+ subparsers = parser.add_subparsers(dest="command")
30
+
31
+ _register_config_command(subparsers)
32
+ register_distance_command(subparsers)
33
+ register_route_command(subparsers)
34
+ register_search_poi_command(subparsers)
35
+ return parser
36
+
37
+
38
+ def _register_config_command(
39
+ subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
40
+ ) -> None:
41
+ """Register configuration management commands."""
42
+ parser = subparsers.add_parser("config", help="写入或查看高德 API 配置")
43
+ parser.set_defaults(handler=_handle_config_root)
44
+
45
+ config_subparsers = parser.add_subparsers(dest="config_command")
46
+
47
+ set_parser = config_subparsers.add_parser("set", help="写入高德 API 配置")
48
+ set_parser.add_argument("--api-key", "--key", dest="api_key", help="高德 API Key")
49
+ set_parser.add_argument("--base-url", help="高德 API 基础地址")
50
+ set_parser.add_argument(
51
+ "--timeout-seconds",
52
+ type=float,
53
+ help="请求超时时间(秒)",
54
+ )
55
+ set_parser.set_defaults(handler=_handle_config_set)
56
+
57
+ show_parser = config_subparsers.add_parser("show", help="查看当前配置(敏感信息脱敏)")
58
+ show_parser.set_defaults(handler=_handle_config_show)
59
+
60
+
61
+ def _handle_config_root(_: argparse.Namespace) -> dict[str, Any]:
62
+ """Show help when `config` is called without a subcommand."""
63
+ raise ValidationError("缺少配置子命令,请使用 `config set` 或 `config show`。")
64
+
65
+
66
+ def _handle_config_set(args: argparse.Namespace) -> dict[str, Any]:
67
+ """Persist configuration values."""
68
+ if (
69
+ args.api_key is None
70
+ and args.base_url is None
71
+ and args.timeout_seconds is None
72
+ ):
73
+ raise ValidationError(
74
+ "请至少提供一个配置项,例如 `--api-key`、`--base-url` 或 `--timeout-seconds`。"
75
+ )
76
+
77
+ config = save_config(
78
+ api_key=args.api_key,
79
+ base_url=args.base_url,
80
+ timeout_seconds=args.timeout_seconds,
81
+ )
82
+ return {
83
+ "message": "配置已保存。",
84
+ "config_path": str(get_config_path()),
85
+ "config": config.to_dict(mask_secrets=True),
86
+ }
87
+
88
+
89
+ def _handle_config_show(_: argparse.Namespace) -> dict[str, Any]:
90
+ """Display the current configuration with masked secrets."""
91
+ config = load_config(required=False)
92
+ return {
93
+ "configured": config is not None and bool(config.api_key),
94
+ "config_path": str(get_config_path()),
95
+ "config": config.to_dict(mask_secrets=True) if config is not None else None,
96
+ }
97
+
98
+
99
+ def main(argv: Sequence[str] | None = None) -> int:
100
+ """Run the CLI and emit unified JSON output for command results."""
101
+ parser = build_parser()
102
+
103
+ try:
104
+ args = parser.parse_args(argv)
105
+ if not hasattr(args, "handler"):
106
+ parser.print_help()
107
+ return 0
108
+
109
+ result = args.handler(args)
110
+ emit_success(result)
111
+ return 0
112
+ except AmapCliError as exc:
113
+ emit_error(exc)
114
+ return exc.exit_code
115
+ except KeyboardInterrupt:
116
+ error = AmapCliError("操作已取消。", code="INTERRUPTED", exit_code=130)
117
+ emit_error(error)
118
+ return error.exit_code
119
+ except SystemExit:
120
+ raise
121
+ except Exception as exc:
122
+ error = AmapCliError(
123
+ "发生未预期错误。",
124
+ code="UNEXPECTED_ERROR",
125
+ details={"type": type(exc).__name__},
126
+ exit_code=1,
127
+ )
128
+ emit_error(error)
129
+ return error.exit_code
amap_cli/config.py ADDED
@@ -0,0 +1,155 @@
1
+ """Configuration persistence for Amap API access."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import platform
8
+ from dataclasses import asdict, dataclass
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from amap_cli.errors import ConfigError, MissingConfigError, ValidationError
13
+
14
+ APP_NAME = "amap-cli"
15
+ CONFIG_FILE_NAME = "config.json"
16
+ DEFAULT_BASE_URL = "https://restapi.amap.com"
17
+ DEFAULT_TIMEOUT_SECONDS = 10.0
18
+
19
+
20
+ @dataclass(slots=True)
21
+ class AmapConfig:
22
+ """User configuration required by the CLI."""
23
+
24
+ api_key: str | None = None
25
+ base_url: str = DEFAULT_BASE_URL
26
+ timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS
27
+
28
+ @classmethod
29
+ def from_dict(cls, raw: dict[str, Any]) -> "AmapConfig":
30
+ """Create a config object from persisted JSON data."""
31
+ api_key = raw.get("api_key")
32
+ base_url = raw.get("base_url", DEFAULT_BASE_URL)
33
+ timeout_seconds = raw.get("timeout_seconds", DEFAULT_TIMEOUT_SECONDS)
34
+
35
+ if api_key is not None and not isinstance(api_key, str):
36
+ raise ConfigError("配置文件中的 api_key 格式无效。")
37
+ if not isinstance(base_url, str) or not base_url.strip():
38
+ raise ConfigError("配置文件中的 base_url 格式无效。")
39
+ if not isinstance(timeout_seconds, (int, float)) or timeout_seconds <= 0:
40
+ raise ConfigError("配置文件中的 timeout_seconds 格式无效。")
41
+
42
+ normalized_api_key = None
43
+ if api_key is not None:
44
+ normalized_api_key = api_key.strip() or None
45
+
46
+ return cls(
47
+ api_key=normalized_api_key,
48
+ base_url=base_url.strip(),
49
+ timeout_seconds=float(timeout_seconds),
50
+ )
51
+
52
+ def to_dict(self, *, mask_secrets: bool = False) -> dict[str, Any]:
53
+ """Serialize the config for persistence or display."""
54
+ payload = asdict(self)
55
+ if mask_secrets:
56
+ payload["api_key"] = mask_secret(self.api_key)
57
+ return payload
58
+
59
+
60
+ def get_config_dir() -> Path:
61
+ """Return the OS-specific configuration directory."""
62
+ system_name = platform.system().lower()
63
+
64
+ if system_name == "darwin":
65
+ return Path.home() / "Library" / "Application Support" / APP_NAME
66
+
67
+ if system_name == "windows":
68
+ app_data = os.environ.get("APPDATA")
69
+ if app_data:
70
+ return Path(app_data) / APP_NAME
71
+ return Path.home() / "AppData" / "Roaming" / APP_NAME
72
+
73
+ xdg_config_home = os.environ.get("XDG_CONFIG_HOME")
74
+ if xdg_config_home:
75
+ return Path(xdg_config_home) / APP_NAME
76
+ return Path.home() / ".config" / APP_NAME
77
+
78
+
79
+ def get_config_path() -> Path:
80
+ """Return the full configuration file path."""
81
+ return get_config_dir() / CONFIG_FILE_NAME
82
+
83
+
84
+ def load_config(*, required: bool = False) -> AmapConfig | None:
85
+ """Load config from disk, optionally requiring it to exist."""
86
+ path = get_config_path()
87
+ if not path.exists():
88
+ if required:
89
+ raise MissingConfigError(
90
+ "未找到高德 API 配置,请先执行 `amap-cli config set --api-key <KEY>`。"
91
+ )
92
+ return None
93
+
94
+ try:
95
+ raw = json.loads(path.read_text(encoding="utf-8"))
96
+ except json.JSONDecodeError as exc:
97
+ raise ConfigError("配置文件不是合法的 JSON。", details={"path": str(path)}) from exc
98
+ except OSError as exc:
99
+ raise ConfigError("读取配置文件失败。", details={"path": str(path)}) from exc
100
+
101
+ if not isinstance(raw, dict):
102
+ raise ConfigError("配置文件内容格式无效。", details={"path": str(path)})
103
+
104
+ return AmapConfig.from_dict(raw)
105
+
106
+
107
+ def save_config(
108
+ *,
109
+ api_key: str | None = None,
110
+ base_url: str | None = None,
111
+ timeout_seconds: float | None = None,
112
+ ) -> AmapConfig:
113
+ """Persist config updates to the OS-specific config path."""
114
+ config = load_config(required=False) or AmapConfig()
115
+
116
+ if api_key is not None:
117
+ api_key = api_key.strip()
118
+ if not api_key:
119
+ raise ValidationError("`--api-key` 不能为空。")
120
+ config.api_key = api_key
121
+
122
+ if base_url is not None:
123
+ base_url = base_url.strip()
124
+ if not base_url:
125
+ raise ValidationError("`--base-url` 不能为空。")
126
+ config.base_url = base_url
127
+
128
+ if timeout_seconds is not None:
129
+ if timeout_seconds <= 0:
130
+ raise ValidationError("`--timeout-seconds` 必须大于 0。")
131
+ config.timeout_seconds = float(timeout_seconds)
132
+
133
+ path = get_config_path()
134
+ path.parent.mkdir(parents=True, exist_ok=True)
135
+ path.write_text(
136
+ json.dumps(config.to_dict(), ensure_ascii=False, indent=2) + "\n",
137
+ encoding="utf-8",
138
+ )
139
+
140
+ if os.name != "nt":
141
+ try:
142
+ path.chmod(0o600)
143
+ except OSError:
144
+ pass
145
+
146
+ return config
147
+
148
+
149
+ def mask_secret(value: str | None) -> str | None:
150
+ """Mask a secret for display in CLI output."""
151
+ if value is None:
152
+ return None
153
+ if len(value) <= 4:
154
+ return "*" * len(value)
155
+ return "*" * (len(value) - 4) + value[-4:]
amap_cli/distance.py ADDED
@@ -0,0 +1,194 @@
1
+ """Straight-line distance command registration and handlers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import math
7
+ from dataclasses import dataclass
8
+ from typing import Callable
9
+
10
+ from amap_cli.api import AmapApiClient
11
+ from amap_cli.geocode import geocode_text
12
+ from amap_cli.params import (
13
+ Coordinate,
14
+ LocationInput,
15
+ normalize_text_argument,
16
+ parse_location_input,
17
+ )
18
+
19
+ EARTH_RADIUS_METERS = 6_371_008.8
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class PreparedDistanceRequest:
24
+ """Validated distance command arguments before geocoding."""
25
+
26
+ origin: LocationInput
27
+ origin_name: str | None
28
+ destination: LocationInput
29
+ destination_name: str | None
30
+
31
+
32
+ @dataclass(frozen=True, slots=True)
33
+ class ResolvedDistancePoint:
34
+ """Distance point after resolving names to coordinates when needed."""
35
+
36
+ raw: str
37
+ name: str
38
+ coordinate: Coordinate
39
+ formatted_address: str | None = None
40
+
41
+ def to_state(self) -> dict[str, object]:
42
+ """Convert the point to the CLI state shape."""
43
+ state: dict[str, object] = {
44
+ "name": self.name,
45
+ "position": [
46
+ round(self.coordinate.longitude, 6),
47
+ round(self.coordinate.latitude, 6),
48
+ ],
49
+ }
50
+ if self.formatted_address is not None:
51
+ state["formattedAddress"] = self.formatted_address
52
+ return state
53
+
54
+
55
+ def register_distance_command(
56
+ subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
57
+ ) -> argparse.ArgumentParser:
58
+ """Register the `distance` command on a subparser collection."""
59
+ parser = subparsers.add_parser(
60
+ "distance",
61
+ help="计算两个地点之间的直线距离",
62
+ description="计算两个地点之间的直线距离;坐标直接本地计算,地名会先做地理编码。",
63
+ )
64
+ parser.add_argument(
65
+ "--from",
66
+ dest="origin",
67
+ required=True,
68
+ help="起点,支持地名或 `经度,纬度`",
69
+ )
70
+ parser.add_argument(
71
+ "--from-name",
72
+ dest="origin_name",
73
+ help="起点显示名称,通常配合坐标使用",
74
+ )
75
+ parser.add_argument(
76
+ "--to",
77
+ dest="destination",
78
+ required=True,
79
+ help="终点,支持地名或 `经度,纬度`",
80
+ )
81
+ parser.add_argument(
82
+ "--to-name",
83
+ dest="destination_name",
84
+ help="终点显示名称,通常配合坐标使用",
85
+ )
86
+ parser.set_defaults(handler=handle_distance_command)
87
+ return parser
88
+
89
+
90
+ def handle_distance_command(
91
+ args: argparse.Namespace,
92
+ *,
93
+ client: AmapApiClient | None = None,
94
+ ) -> dict[str, object]:
95
+ """Handle the `distance` command and return normalized JSON data."""
96
+ request = _prepare_distance_request(args)
97
+ client_holder: list[AmapApiClient | None] = [client]
98
+
99
+ def get_client() -> AmapApiClient:
100
+ if client_holder[0] is None:
101
+ client_holder[0] = AmapApiClient()
102
+ return client_holder[0]
103
+
104
+ origin = _resolve_distance_point(
105
+ request.origin,
106
+ display_name=request.origin_name,
107
+ client_factory=get_client,
108
+ )
109
+
110
+ destination = _resolve_distance_point(
111
+ request.destination,
112
+ display_name=request.destination_name,
113
+ client_factory=get_client,
114
+ )
115
+
116
+ distance_meters = _calculate_straight_line_distance(
117
+ origin.coordinate,
118
+ destination.coordinate,
119
+ )
120
+
121
+ return {
122
+ "state": {
123
+ "from": origin.to_state(),
124
+ "to": destination.to_state(),
125
+ "mode": "straight_line",
126
+ "unit": "meter",
127
+ "source": "local_haversine",
128
+ },
129
+ "summary": {
130
+ "distance": distance_meters,
131
+ "distanceKilometers": round(distance_meters / 1000, 3),
132
+ },
133
+ }
134
+
135
+
136
+ def _prepare_distance_request(args: argparse.Namespace) -> PreparedDistanceRequest:
137
+ """Validate and normalize distance command arguments."""
138
+ return PreparedDistanceRequest(
139
+ origin=parse_location_input(args.origin, "from"),
140
+ origin_name=_normalize_optional_text(args.origin_name, "from-name"),
141
+ destination=parse_location_input(args.destination, "to"),
142
+ destination_name=_normalize_optional_text(args.destination_name, "to-name"),
143
+ )
144
+
145
+
146
+ def _normalize_optional_text(value: str | None, field_name: str) -> str | None:
147
+ """Trim an optional text argument when present."""
148
+ if value is None:
149
+ return None
150
+ return normalize_text_argument(value, field_name)
151
+
152
+
153
+ def _resolve_distance_point(
154
+ location: LocationInput,
155
+ *,
156
+ display_name: str | None,
157
+ client_factory: Callable[[], AmapApiClient],
158
+ ) -> ResolvedDistancePoint:
159
+ """Resolve a distance point to a display name and coordinate."""
160
+ if location.coordinate is not None:
161
+ return ResolvedDistancePoint(
162
+ raw=location.raw,
163
+ name=display_name or location.raw,
164
+ coordinate=location.coordinate,
165
+ )
166
+
167
+ client = client_factory()
168
+ geocoded = geocode_text(location.name or location.raw, client=client)
169
+ return ResolvedDistancePoint(
170
+ raw=location.raw,
171
+ name=display_name or location.name or location.raw,
172
+ coordinate=geocoded.coordinate,
173
+ formatted_address=geocoded.formatted_address,
174
+ )
175
+
176
+
177
+ def _calculate_straight_line_distance(origin: Coordinate, destination: Coordinate) -> int:
178
+ """Calculate the great-circle distance in meters with the Haversine formula."""
179
+ origin_lat = math.radians(origin.latitude)
180
+ origin_lng = math.radians(origin.longitude)
181
+ destination_lat = math.radians(destination.latitude)
182
+ destination_lng = math.radians(destination.longitude)
183
+
184
+ delta_lat = destination_lat - origin_lat
185
+ delta_lng = destination_lng - origin_lng
186
+
187
+ haversine = (
188
+ math.sin(delta_lat / 2) ** 2
189
+ + math.cos(origin_lat)
190
+ * math.cos(destination_lat)
191
+ * math.sin(delta_lng / 2) ** 2
192
+ )
193
+ central_angle = 2 * math.atan2(math.sqrt(haversine), math.sqrt(1 - haversine))
194
+ return round(EARTH_RADIUS_METERS * central_angle)
amap_cli/errors.py ADDED
@@ -0,0 +1,87 @@
1
+ """Custom error types used by the CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ class AmapCliError(Exception):
9
+ """Base exception with a stable machine-readable payload."""
10
+
11
+ def __init__(
12
+ self,
13
+ message: str,
14
+ *,
15
+ code: str = "CLI_ERROR",
16
+ details: Any | None = None,
17
+ exit_code: int = 1,
18
+ ) -> None:
19
+ super().__init__(message)
20
+ self.message = message
21
+ self.code = code
22
+ self.details = details
23
+ self.exit_code = exit_code
24
+
25
+ def to_dict(self) -> dict[str, Any]:
26
+ """Convert the error to the unified JSON output shape."""
27
+ return {
28
+ "code": self.code,
29
+ "message": self.message,
30
+ "details": self.details,
31
+ }
32
+
33
+
34
+ class ValidationError(AmapCliError):
35
+ """Raised when command arguments fail validation."""
36
+
37
+ def __init__(self, message: str, *, details: Any | None = None) -> None:
38
+ super().__init__(
39
+ message,
40
+ code="INVALID_ARGUMENT",
41
+ details=details,
42
+ exit_code=2,
43
+ )
44
+
45
+
46
+ class ConfigError(AmapCliError):
47
+ """Raised when configuration is missing or invalid."""
48
+
49
+ def __init__(
50
+ self,
51
+ message: str,
52
+ *,
53
+ code: str = "CONFIG_ERROR",
54
+ details: Any | None = None,
55
+ ) -> None:
56
+ super().__init__(message, code=code, details=details, exit_code=1)
57
+
58
+
59
+ class MissingConfigError(ConfigError):
60
+ """Raised when a required config item has not been configured yet."""
61
+
62
+ def __init__(self, message: str, *, details: Any | None = None) -> None:
63
+ super().__init__(message, code="MISSING_CONFIG", details=details)
64
+
65
+
66
+ class ApiRequestError(AmapCliError):
67
+ """Raised when the HTTP request to Amap cannot be completed."""
68
+
69
+ def __init__(self, message: str, *, details: Any | None = None) -> None:
70
+ super().__init__(
71
+ message,
72
+ code="API_REQUEST_ERROR",
73
+ details=details,
74
+ exit_code=1,
75
+ )
76
+
77
+
78
+ class ApiResponseError(AmapCliError):
79
+ """Raised when the Amap API returns a business-level failure."""
80
+
81
+ def __init__(self, message: str, *, details: Any | None = None) -> None:
82
+ super().__init__(
83
+ message,
84
+ code="API_RESPONSE_ERROR",
85
+ details=details,
86
+ exit_code=1,
87
+ )