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 +5 -0
- amap_cli/__main__.py +9 -0
- amap_cli/api.py +90 -0
- amap_cli/cli.py +129 -0
- amap_cli/config.py +155 -0
- amap_cli/distance.py +194 -0
- amap_cli/errors.py +87 -0
- amap_cli/geocode.py +61 -0
- amap_cli/output.py +45 -0
- amap_cli/params.py +77 -0
- amap_cli/route.py +625 -0
- amap_cli/search_poi.py +281 -0
- amap_cli-0.1.0.dist-info/METADATA +290 -0
- amap_cli-0.1.0.dist-info/RECORD +16 -0
- amap_cli-0.1.0.dist-info/WHEEL +4 -0
- amap_cli-0.1.0.dist-info/entry_points.txt +2 -0
amap_cli/__init__.py
ADDED
amap_cli/__main__.py
ADDED
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
|
+
)
|