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/search_poi.py ADDED
@@ -0,0 +1,281 @@
1
+ """POI search command registration and handlers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ from amap_cli.api import AmapApiClient
10
+ from amap_cli.errors import ValidationError
11
+ from amap_cli.params import Coordinate, normalize_text_argument, parse_coordinate
12
+
13
+ DEFAULT_RADIUS = 3000
14
+ DEFAULT_PAGE_SIZE = 10
15
+ DEFAULT_PAGE_INDEX = 1
16
+ MAX_PAGE_SIZE = 25
17
+ MAX_RADIUS = 50000
18
+ TEXT_SEARCH_PATH = "/v3/place/text"
19
+ AROUND_SEARCH_PATH = "/v3/place/around"
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class SearchPoiRequest:
24
+ """Normalized search-poi command input."""
25
+
26
+ keyword: str
27
+ city: str | None
28
+ center: Coordinate | None
29
+ radius: int
30
+ page_size: int
31
+ page_index: int
32
+
33
+ @property
34
+ def is_nearby_search(self) -> bool:
35
+ """Return whether the request should use nearby search."""
36
+ return self.center is not None
37
+
38
+ @classmethod
39
+ def from_args(cls, args: argparse.Namespace) -> "SearchPoiRequest":
40
+ """Build a validated request from parsed CLI arguments."""
41
+ keyword = normalize_text_argument(args.keyword, "keyword")
42
+ city = (
43
+ normalize_text_argument(args.city, "city")
44
+ if args.city is not None
45
+ else None
46
+ )
47
+ center = (
48
+ parse_coordinate(args.center, "center")
49
+ if args.center is not None
50
+ else None
51
+ )
52
+
53
+ radius = _validate_positive_int(
54
+ args.radius,
55
+ "radius",
56
+ min_value=1,
57
+ max_value=MAX_RADIUS,
58
+ )
59
+ page_size = _validate_positive_int(
60
+ args.pageSize,
61
+ "pageSize",
62
+ min_value=1,
63
+ max_value=MAX_PAGE_SIZE,
64
+ )
65
+ page_index = _validate_positive_int(
66
+ args.pageIndex,
67
+ "pageIndex",
68
+ min_value=1,
69
+ )
70
+
71
+ if center is None and getattr(args, "radius", None) != DEFAULT_RADIUS:
72
+ raise ValidationError("仅在提供 `--center` 时允许传入 `--radius`。")
73
+
74
+ return cls(
75
+ keyword=keyword,
76
+ city=city,
77
+ center=center,
78
+ radius=radius,
79
+ page_size=page_size,
80
+ page_index=page_index,
81
+ )
82
+
83
+ def to_api_request(self) -> tuple[str, dict[str, Any]]:
84
+ """Convert the normalized request to Amap REST API arguments."""
85
+ params: dict[str, Any] = {
86
+ "keywords": self.keyword,
87
+ "offset": self.page_size,
88
+ "page": self.page_index,
89
+ "extensions": "all",
90
+ }
91
+
92
+ if self.is_nearby_search:
93
+ params["location"] = self.center.to_amap_value() if self.center else None
94
+ params["radius"] = self.radius
95
+ return AROUND_SEARCH_PATH, params
96
+
97
+ if self.city is not None:
98
+ params["city"] = self.city
99
+ return TEXT_SEARCH_PATH, params
100
+
101
+ def build_state(self, total: int) -> dict[str, Any]:
102
+ """Build CLI-facing state metadata."""
103
+ state: dict[str, Any] = {
104
+ "keyword": self.keyword,
105
+ "pageSize": self.page_size,
106
+ "pageIndex": self.page_index,
107
+ "extensions": "all",
108
+ "resultCount": total,
109
+ }
110
+
111
+ if self.is_nearby_search:
112
+ state["center"] = (
113
+ [self.center.longitude, self.center.latitude]
114
+ if self.center is not None
115
+ else None
116
+ )
117
+ state["radius"] = self.radius
118
+ elif self.city is not None:
119
+ state["city"] = self.city
120
+
121
+ return state
122
+
123
+
124
+ def register_search_poi_command(
125
+ subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
126
+ ) -> argparse.ArgumentParser:
127
+ """Register the `search-poi` command on a subparser collection."""
128
+ parser = subparsers.add_parser(
129
+ "search-poi",
130
+ help="搜索 POI",
131
+ description="按关键词或周边范围搜索 POI。",
132
+ )
133
+ add_search_poi_arguments(parser)
134
+ parser.set_defaults(handler=handle_search_poi)
135
+ return parser
136
+
137
+
138
+ def add_search_poi_arguments(parser: argparse.ArgumentParser) -> None:
139
+ """Add search-poi arguments to a parser."""
140
+ parser.add_argument("--keyword", required=True, help="搜索关键词")
141
+ parser.add_argument("--city", help="搜索城市")
142
+ parser.add_argument("--center", help="周边搜索中心,格式为 `经度,纬度`")
143
+ parser.add_argument(
144
+ "--radius",
145
+ type=int,
146
+ default=DEFAULT_RADIUS,
147
+ help=f"周边搜索半径(米),默认 {DEFAULT_RADIUS}",
148
+ )
149
+ parser.add_argument(
150
+ "--pageSize",
151
+ type=int,
152
+ default=DEFAULT_PAGE_SIZE,
153
+ help=f"每页数量,范围 1-{MAX_PAGE_SIZE},默认 {DEFAULT_PAGE_SIZE}",
154
+ )
155
+ parser.add_argument(
156
+ "--pageIndex",
157
+ type=int,
158
+ default=DEFAULT_PAGE_INDEX,
159
+ help=f"页码,从 1 开始,默认 {DEFAULT_PAGE_INDEX}",
160
+ )
161
+
162
+
163
+ def handle_search_poi(
164
+ args: argparse.Namespace,
165
+ *,
166
+ client: AmapApiClient | None = None,
167
+ ) -> dict[str, Any]:
168
+ """Handle the `search-poi` command and normalize the response."""
169
+ request = SearchPoiRequest.from_args(args)
170
+ api_client = client or AmapApiClient()
171
+ path, params = request.to_api_request()
172
+ payload = api_client.get(path, params=params)
173
+
174
+ total = _parse_total(payload.get("count"))
175
+ return {
176
+ "state": request.build_state(total),
177
+ "pois": _normalize_pois(payload.get("pois")),
178
+ "total": total,
179
+ "pageIndex": request.page_index,
180
+ "pageSize": request.page_size,
181
+ }
182
+
183
+
184
+ def _validate_positive_int(
185
+ value: Any,
186
+ field_name: str,
187
+ *,
188
+ min_value: int,
189
+ max_value: int | None = None,
190
+ ) -> int:
191
+ """Validate a positive integer CLI argument."""
192
+ if not isinstance(value, int):
193
+ raise ValidationError(f"`{field_name}` 必须是整数。")
194
+ if value < min_value:
195
+ raise ValidationError(f"`{field_name}` 必须大于等于 {min_value}。")
196
+ if max_value is not None and value > max_value:
197
+ raise ValidationError(f"`{field_name}` 必须小于等于 {max_value}。")
198
+ return value
199
+
200
+
201
+ def _parse_total(value: Any) -> int:
202
+ """Convert the Amap `count` field to an integer."""
203
+ if value in (None, ""):
204
+ return 0
205
+ try:
206
+ return int(value)
207
+ except (TypeError, ValueError):
208
+ return 0
209
+
210
+
211
+ def _normalize_pois(value: Any) -> list[dict[str, Any]]:
212
+ """Normalize the raw Amap POI list to the CLI output shape."""
213
+ if not isinstance(value, list):
214
+ return []
215
+
216
+ return [_normalize_poi(item) for item in value if isinstance(item, dict)]
217
+
218
+
219
+ def _normalize_poi(raw: dict[str, Any]) -> dict[str, Any]:
220
+ """Normalize a single POI payload."""
221
+ return {
222
+ "id": _normalize_optional_text(raw.get("id")),
223
+ "name": _normalize_optional_text(raw.get("name")),
224
+ "type": _normalize_optional_text(raw.get("type")),
225
+ "location": _parse_location(raw.get("location")),
226
+ "address": _normalize_optional_text(raw.get("address")),
227
+ "distance": _parse_distance(raw.get("distance")),
228
+ "tel": _normalize_optional_text(raw.get("tel")),
229
+ "pname": _normalize_optional_text(raw.get("pname")),
230
+ "cityname": _normalize_optional_text(raw.get("cityname")),
231
+ "adname": _normalize_optional_text(raw.get("adname")),
232
+ "photo": _extract_photo(raw.get("photos")),
233
+ }
234
+
235
+
236
+ def _parse_location(value: Any) -> list[float] | None:
237
+ """Parse API location strings into `[lng, lat]` lists."""
238
+ if not isinstance(value, str) or not value.strip():
239
+ return None
240
+
241
+ parts = [part.strip() for part in value.split(",", 1)]
242
+ if len(parts) != 2:
243
+ return None
244
+
245
+ try:
246
+ return [float(parts[0]), float(parts[1])]
247
+ except ValueError:
248
+ return None
249
+
250
+
251
+ def _parse_distance(value: Any) -> int | None:
252
+ """Convert the API distance field to an integer when available."""
253
+ if value in (None, ""):
254
+ return None
255
+ try:
256
+ return int(float(value))
257
+ except (TypeError, ValueError):
258
+ return None
259
+
260
+
261
+ def _extract_photo(value: Any) -> str | None:
262
+ """Extract the first photo URL from the Amap photo list."""
263
+ if not isinstance(value, list):
264
+ return None
265
+
266
+ for item in value:
267
+ if not isinstance(item, dict):
268
+ continue
269
+ url = _normalize_optional_text(item.get("url"))
270
+ if url is not None:
271
+ return url
272
+
273
+ return None
274
+
275
+
276
+ def _normalize_optional_text(value: Any) -> str | None:
277
+ """Trim optional text fields and normalize empty strings to None."""
278
+ if not isinstance(value, str):
279
+ return None
280
+ normalized = value.strip()
281
+ return normalized or None
@@ -0,0 +1,290 @@
1
+ Metadata-Version: 2.5
2
+ Name: amap-cli
3
+ Version: 0.1.0
4
+ Summary: Pure command-line CLI for Amap APIs.
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+
8
+ # amap-cli
9
+
10
+ `amap-cli` 是一个基于高德开放平台 Web API 的纯命令行工具,面向终端环境和 AI Agent 使用。
11
+
12
+ ## 功能特性
13
+
14
+ - 纯命令行调用高德地图 API
15
+ - 支持命令行写入和读取高德 API Key
16
+ - 支持两点直线距离计算
17
+ - 支持路径规划
18
+ - 支持 POI 搜索
19
+ - 所有命令统一返回机器可读 JSON
20
+ - 提供配套的 Agent skill,便于在智能体环境中直接调用
21
+
22
+ ## 安装与使用
23
+
24
+ ### 方式一:直接使用 `uvx` 运行
25
+
26
+ 项目发布到 PyPI 后,可以直接执行:
27
+
28
+ ```bash
29
+ uvx amap-cli --help
30
+ ```
31
+
32
+ 配置高德 API Key:
33
+
34
+ ```bash
35
+ uvx amap-cli config set --api-key <YOUR_AMAP_KEY>
36
+ ```
37
+
38
+ 路径规划示例:
39
+
40
+ ```bash
41
+ uvx amap-cli route \
42
+ --from 北京南站 \
43
+ --to 天安门 \
44
+ --type driving
45
+ ```
46
+
47
+ 直线距离示例:
48
+
49
+ ```bash
50
+ uvx amap-cli distance \
51
+ --from 116.397,39.909 \
52
+ --to 116.407,39.904
53
+ ```
54
+
55
+ POI 搜索示例:
56
+
57
+ ```bash
58
+ uvx amap-cli search-poi \
59
+ --keyword 星巴克 \
60
+ --city 北京 \
61
+ --pageSize 5
62
+ ```
63
+
64
+ ### 方式二:安装后使用
65
+
66
+ 如果希望安装到本机工具目录,执行:
67
+
68
+ ```bash
69
+ uv tool install amap-cli
70
+ ```
71
+
72
+ 安装完成后可直接调用:
73
+
74
+ ```bash
75
+ amap-cli --help
76
+ ```
77
+
78
+ 配置和业务命令示例:
79
+
80
+ ```bash
81
+ amap-cli config set --api-key <YOUR_AMAP_KEY>
82
+ amap-cli distance --from 116.397,39.909 --to 116.407,39.904
83
+ amap-cli route --from 北京南站 --to 天安门 --type driving
84
+ amap-cli search-poi --keyword 星巴克 --city 北京
85
+ ```
86
+
87
+ ## Skill 使用说明
88
+
89
+ 本项目提供了配套的 `amap-cli` skill,适合在支持 skill 的 Agent 环境中使用。
90
+
91
+ - skill 的目标是让 Agent 直接通过 `uvx amap-cli` 调用本工具
92
+ - 使用方式与上面的“直接使用 `uvx` 运行”一致
93
+ - 使用前先配置高德 API Key,之后即可发起路径规划和 POI 搜索
94
+ - `distance` 命令在起终点都为坐标时可直接本地计算,不依赖 API Key
95
+ - 建议 Agent 直接解析命令返回的 JSON 结果
96
+
97
+ ## 命令说明
98
+
99
+ ### 配置
100
+
101
+ 写入 API Key:
102
+
103
+ ```bash
104
+ amap-cli config set --api-key <YOUR_AMAP_KEY>
105
+ ```
106
+
107
+ 查看当前配置:
108
+
109
+ ```bash
110
+ amap-cli config show
111
+ ```
112
+
113
+ 配置会按照当前操作系统规范保存在用户配置目录中。macOS 下默认路径为:
114
+
115
+ ```text
116
+ ~/Library/Application Support/amap-cli/config.json
117
+ ```
118
+
119
+ ### 路径规划
120
+
121
+ 基础格式:
122
+
123
+ ```bash
124
+ amap-cli route \
125
+ --from <结构化地址|地标名称|经度,纬度> \
126
+ --to <结构化地址|地标名称|经度,纬度> \
127
+ --type <driving|walking|riding|transit>
128
+ ```
129
+
130
+ 可选参数:
131
+
132
+ - `--from-name`
133
+ - `--to-name`
134
+ - `--waypoints`
135
+ - `--policy`
136
+ - `--strategy`
137
+ - `--city`
138
+
139
+ 说明:
140
+
141
+ - `--waypoints` 和 `--policy` 仅 `driving` 支持
142
+ - `--strategy` 和 `--city` 仅 `transit` 支持
143
+ - 地理编码仅支持详细的结构化地址,以及地标性名胜景区、建筑物名称
144
+ - 当起点、终点或途经点使用上述名称并解析成功时,返回结果中的对应节点会附带 `formattedAddress`
145
+ - 对于其他较模糊的名称,建议先通过 `search-poi` 获取目标地点坐标,再调用 `route`
146
+ - 不支持 `--json` 输入
147
+
148
+ ### 直线距离
149
+
150
+ 基础格式:
151
+
152
+ ```bash
153
+ amap-cli distance \
154
+ --from <结构化地址|地标名称|经度,纬度> \
155
+ --to <结构化地址|地标名称|经度,纬度>
156
+ ```
157
+
158
+ 可选参数:
159
+
160
+ - `--from-name`
161
+ - `--to-name`
162
+
163
+ 说明:
164
+
165
+ - 当 `--from` 和 `--to` 都是坐标时,直接在本地计算直线距离
166
+ - 当任一输入为结构化地址或地标性名胜景区、建筑物名称时,会先调用高德地理编码接口解析坐标,再做本地计算
167
+ - 当输入为上述名称时,返回结果中的 `state.from.formattedAddress` / `state.to.formattedAddress` 会带上高德解析出的完整地址
168
+ - 对于其他较模糊的名称,建议先通过 `search-poi` 获取目标地点坐标,再调用 `distance`
169
+ - 结果中的 `summary.distance` 单位为米
170
+ - 不支持 `--json` 输入
171
+
172
+ ### POI 搜索
173
+
174
+ 基础格式:
175
+
176
+ ```bash
177
+ amap-cli search-poi --keyword <关键词>
178
+ ```
179
+
180
+ 可选参数:
181
+
182
+ - `--city`
183
+ - `--center`
184
+ - `--radius`
185
+ - `--pageSize`
186
+ - `--pageIndex`
187
+
188
+ 说明:
189
+
190
+ - 传入 `--center` 时执行周边搜索
191
+ - 未传入 `--center` 时执行关键词搜索
192
+ - 不支持 `--json` 输入
193
+
194
+ ## 输出格式
195
+
196
+ 成功时:
197
+
198
+ ```json
199
+ {
200
+ "success": true,
201
+ "data": {},
202
+ "error": null
203
+ }
204
+ ```
205
+
206
+ 失败时:
207
+
208
+ ```json
209
+ {
210
+ "success": false,
211
+ "data": null,
212
+ "error": {
213
+ "code": "INVALID_ARGUMENT",
214
+ "message": "错误说明",
215
+ "details": {}
216
+ }
217
+ }
218
+ ```
219
+
220
+ ## 本地开发
221
+
222
+ 安装项目依赖:
223
+
224
+ ```bash
225
+ uv sync
226
+ ```
227
+
228
+ 本地运行:
229
+
230
+ ```bash
231
+ uv run amap-cli --help
232
+ ```
233
+
234
+ ## PyPI 发布
235
+
236
+ ### 1. 确认版本号
237
+
238
+ 每次发布前,先更新 `pyproject.toml` 中的 `version`。
239
+ PyPI 不允许重复上传同一个版本号的分发包。
240
+
241
+ ### 2. 构建分发包
242
+
243
+ ```bash
244
+ uv build
245
+ ```
246
+
247
+ 构建成功后会在 `dist/` 目录下生成:
248
+
249
+ - `*.tar.gz`
250
+ - `*.whl`
251
+
252
+ ### 3. 准备 PyPI Token
253
+
254
+ 登录 PyPI 后创建 API Token,推荐通过环境变量传入:
255
+
256
+ ```bash
257
+ export UV_PUBLISH_TOKEN="pypi-你的token"
258
+ ```
259
+
260
+ ### 4. 先发布到 TestPyPI 验证(推荐)
261
+
262
+ ```bash
263
+ uv publish \
264
+ --publish-url https://test.pypi.org/legacy/ \
265
+ --check-url https://test.pypi.org/simple/
266
+ ```
267
+
268
+ 如果只是验证包能否被安装,可以执行:
269
+
270
+ ```bash
271
+ uvx --index-url https://test.pypi.org/simple/ amap-cli --help
272
+ ```
273
+
274
+ ### 5. 发布到正式 PyPI
275
+
276
+ 确认 TestPyPI 验证通过后,执行:
277
+
278
+ ```bash
279
+ uv publish
280
+ ```
281
+
282
+ ### 6. 发布后验证
283
+
284
+ 发布完成后,推荐优先使用 `uvx` 做一次快速验证:
285
+
286
+ ```bash
287
+ uvx amap-cli --help
288
+ ```
289
+
290
+ 发布后,推荐优先使用 `uvx amap-cli` 进行一次性调用,或使用 `uv tool install amap-cli` 安装到本地。
@@ -0,0 +1,16 @@
1
+ amap_cli/__init__.py,sha256=fIvpzGG20FCyIKhTdrMw2SaUL6l-rBxWPkka7uCLkoQ,74
2
+ amap_cli/__main__.py,sha256=I_7ztHJAX_h8YivkMSLeC8suZmAsBPiJVVq8JvhIwG4,175
3
+ amap_cli/api.py,sha256=tBxSuJIYcK_vTBuFu0JBNPmkfGiWVGfl2aagI-fDj2s,3284
4
+ amap_cli/cli.py,sha256=hiOu7OanlzoVU4gq03vFPSIOftQQWsmslXXf2mdVZxc,4374
5
+ amap_cli/config.py,sha256=7yd9LgVQphxd-BSnpjN6DbBY0vs-Jvx2GqjMyQT6XR4,5040
6
+ amap_cli/distance.py,sha256=d3cRX6_TjK6wEiosAufSb1n5fnM2nXdL76sU5_ltg6k,6035
7
+ amap_cli/errors.py,sha256=3D4pozE-T073FqcnyHg4XhdExEf9K_P8PpKsVu84RVM,2356
8
+ amap_cli/geocode.py,sha256=zjBLaWY2bbNSHG36N48DLfSqcQy3vWLOGeBLszrzm0A,1842
9
+ amap_cli/output.py,sha256=fDGp9FUnBuHNZc94GfNjNnlKPYFjHXMqxATfhVh-Ea0,1132
10
+ amap_cli/params.py,sha256=TF0-AHHSgCn2ORxb55aZtfwOXpPebAr6MBO_wTCZodw,2398
11
+ amap_cli/route.py,sha256=su4a3U0ZPSrfkhujdRXMxvmEdbP71OeOlWNM4vddvv0,20667
12
+ amap_cli/search_poi.py,sha256=CgRsII7mC2pl2wbIy34Ta9Is_XlOOJXcmuZYB88Cs0k,8686
13
+ amap_cli-0.1.0.dist-info/METADATA,sha256=p-El-zCN-bYrfeDYg1GUkm5hpmdkrsHtiBefZj8QVUk,5803
14
+ amap_cli-0.1.0.dist-info/WHEEL,sha256=THafob7ofN-NsuMN7Mg4qZyHaQI7KkD-QlcQatYhXPo,87
15
+ amap_cli-0.1.0.dist-info/entry_points.txt,sha256=CUABuFvXeD0w7roHluAZamzJws04DcezkMnXpxJAFkA,47
16
+ amap_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.3
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ amap-cli = amap_cli.cli:main