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/route.py ADDED
@@ -0,0 +1,625 @@
1
+ """Route command argument registration and route planning 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 ApiResponseError, ValidationError
11
+ from amap_cli.geocode import geocode_text
12
+ from amap_cli.params import Coordinate, LocationInput, normalize_text_argument, parse_location_input
13
+
14
+ ROUTE_TYPES = ("driving", "walking", "riding", "transit")
15
+ DRIVING_POLICIES = ("fastest", "least_fee", "shortest", "no_highway", "avoid_jam")
16
+ TRANSIT_STRATEGIES = (
17
+ "fastest",
18
+ "least_cost",
19
+ "least_walk",
20
+ "most_comfort",
21
+ "no_subway",
22
+ )
23
+ DRIVING_POLICY_TO_STRATEGY = {
24
+ "fastest": "0",
25
+ "least_fee": "1",
26
+ "shortest": "2",
27
+ "no_highway": "3",
28
+ "avoid_jam": "4",
29
+ }
30
+ TRANSIT_STRATEGY_TO_POLICY = {
31
+ "fastest": "0",
32
+ "least_cost": "1",
33
+ "least_walk": "3",
34
+ "most_comfort": "4",
35
+ "no_subway": "5",
36
+ }
37
+ MAX_WAYPOINTS = 16
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class PreparedRouteRequest:
42
+ """Validated route command arguments before geocoding."""
43
+
44
+ origin: LocationInput
45
+ origin_name: str | None
46
+ destination: LocationInput
47
+ destination_name: str | None
48
+ route_type: str
49
+ waypoints: list[LocationInput]
50
+ policy: str | None
51
+ strategy: str | None
52
+ city: str | None
53
+
54
+
55
+ @dataclass(frozen=True, slots=True)
56
+ class ResolvedRoutePoint:
57
+ """Route point after converting names to coordinates when needed."""
58
+
59
+ raw: str
60
+ name: str
61
+ coordinate: Coordinate
62
+ formatted_address: str | None = None
63
+
64
+ def to_state(self) -> dict[str, Any]:
65
+ """Convert the point to the CLI state shape."""
66
+ state: dict[str, Any] = {
67
+ "name": self.name,
68
+ "position": [
69
+ round(self.coordinate.longitude, 6),
70
+ round(self.coordinate.latitude, 6),
71
+ ],
72
+ }
73
+ if self.formatted_address is not None:
74
+ state["formattedAddress"] = self.formatted_address
75
+ return state
76
+
77
+
78
+ def register_route_command(
79
+ subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
80
+ ) -> None:
81
+ """Register the `route` command and its arguments."""
82
+ parser = subparsers.add_parser("route", help="执行路径规划")
83
+ parser.add_argument(
84
+ "--from",
85
+ dest="origin",
86
+ required=True,
87
+ help="起点,支持地名或 `经度,纬度`",
88
+ )
89
+ parser.add_argument(
90
+ "--from-name",
91
+ dest="origin_name",
92
+ help="起点显示名称,通常配合坐标使用",
93
+ )
94
+ parser.add_argument(
95
+ "--to",
96
+ dest="destination",
97
+ required=True,
98
+ help="终点,支持地名或 `经度,纬度`",
99
+ )
100
+ parser.add_argument(
101
+ "--to-name",
102
+ dest="destination_name",
103
+ help="终点显示名称,通常配合坐标使用",
104
+ )
105
+ parser.add_argument(
106
+ "--type",
107
+ dest="route_type",
108
+ required=True,
109
+ choices=ROUTE_TYPES,
110
+ help="路径类型",
111
+ )
112
+ parser.add_argument(
113
+ "--waypoints",
114
+ help="加号分隔的途经点,仅支持 driving,例如 `A+B` 或 `116.1,39.9+中关村`",
115
+ )
116
+ parser.add_argument(
117
+ "--policy",
118
+ choices=DRIVING_POLICIES,
119
+ help="驾车策略,仅支持 driving",
120
+ )
121
+ parser.add_argument(
122
+ "--strategy",
123
+ choices=TRANSIT_STRATEGIES,
124
+ help="公交策略,仅支持 transit",
125
+ )
126
+ parser.add_argument("--city", help="公交路线规划的城市名")
127
+ parser.set_defaults(handler=handle_route_command)
128
+
129
+
130
+ def handle_route_command(
131
+ args: argparse.Namespace,
132
+ *,
133
+ client: AmapApiClient | None = None,
134
+ ) -> dict[str, Any]:
135
+ """Handle the `route` command and return normalized JSON data."""
136
+ prepared = _prepare_route_request(args)
137
+ api_client = client or AmapApiClient()
138
+
139
+ geocode_city = prepared.city if prepared.route_type == "transit" else None
140
+ origin = _resolve_route_point(
141
+ prepared.origin,
142
+ display_name=prepared.origin_name,
143
+ client=api_client,
144
+ city=geocode_city,
145
+ )
146
+ destination = _resolve_route_point(
147
+ prepared.destination,
148
+ display_name=prepared.destination_name,
149
+ client=api_client,
150
+ city=geocode_city,
151
+ )
152
+ waypoints = [
153
+ _resolve_route_point(waypoint, display_name=None, client=api_client, city=None)
154
+ for waypoint in prepared.waypoints
155
+ ]
156
+
157
+ payload = _request_route_payload(
158
+ api_client,
159
+ route_type=prepared.route_type,
160
+ origin=origin,
161
+ destination=destination,
162
+ waypoints=waypoints,
163
+ policy=prepared.policy,
164
+ strategy=prepared.strategy,
165
+ city=prepared.city,
166
+ )
167
+
168
+ return {
169
+ "state": _build_route_state(
170
+ origin=origin,
171
+ destination=destination,
172
+ waypoints=waypoints,
173
+ route_type=prepared.route_type,
174
+ policy=prepared.policy,
175
+ strategy=prepared.strategy,
176
+ city=prepared.city,
177
+ ),
178
+ "summary": _build_route_summary(prepared.route_type, payload),
179
+ }
180
+
181
+
182
+ def _prepare_route_request(args: argparse.Namespace) -> PreparedRouteRequest:
183
+ """Validate command arguments and normalize the request model."""
184
+ origin = parse_location_input(args.origin, "from")
185
+ destination = parse_location_input(args.destination, "to")
186
+ route_type = normalize_text_argument(args.route_type, "type")
187
+ origin_name = _normalize_optional_text(args.origin_name, "from-name")
188
+ destination_name = _normalize_optional_text(args.destination_name, "to-name")
189
+ policy = _normalize_optional_text(args.policy, "policy")
190
+ strategy = _normalize_optional_text(args.strategy, "strategy")
191
+ city = _normalize_optional_text(args.city, "city")
192
+ waypoints = _parse_waypoints(args.waypoints)
193
+
194
+ if route_type != "driving" and waypoints:
195
+ raise ValidationError("`--waypoints` 仅支持 `--type driving`。")
196
+ if route_type != "driving" and policy is not None:
197
+ raise ValidationError("`--policy` 仅支持 `--type driving`。")
198
+ if route_type != "transit" and strategy is not None:
199
+ raise ValidationError("`--strategy` 仅支持 `--type transit`。")
200
+ if route_type == "transit" and city is None:
201
+ raise ValidationError("`--type transit` 时必须提供 `--city`。")
202
+ if route_type != "transit" and city is not None:
203
+ raise ValidationError("`--city` 仅支持 `--type transit`。")
204
+
205
+ return PreparedRouteRequest(
206
+ origin=origin,
207
+ origin_name=origin_name,
208
+ destination=destination,
209
+ destination_name=destination_name,
210
+ route_type=route_type,
211
+ waypoints=waypoints,
212
+ policy=policy,
213
+ strategy=strategy,
214
+ city=city,
215
+ )
216
+
217
+
218
+ def _normalize_optional_text(value: str | None, field_name: str) -> str | None:
219
+ """Trim an optional text argument when present."""
220
+ if value is None:
221
+ return None
222
+ return normalize_text_argument(value, field_name)
223
+
224
+
225
+ def _parse_waypoints(value: str | None) -> list[LocationInput]:
226
+ """Parse `--waypoints` into validated route points."""
227
+ if value is None:
228
+ return []
229
+
230
+ normalized = normalize_text_argument(value, "waypoints")
231
+ items = [item.strip() for item in normalized.split("+")]
232
+ if any(not item for item in items):
233
+ raise ValidationError(
234
+ "`--waypoints` 必须使用加号分隔非空途经点,例如 `A+B`。"
235
+ )
236
+ if len(items) > MAX_WAYPOINTS:
237
+ raise ValidationError(f"`--waypoints` 最多支持 {MAX_WAYPOINTS} 个途经点。")
238
+
239
+ return [
240
+ parse_location_input(item, f"waypoints[{index}]")
241
+ for index, item in enumerate(items, start=1)
242
+ ]
243
+
244
+
245
+ def _resolve_route_point(
246
+ location: LocationInput,
247
+ *,
248
+ display_name: str | None,
249
+ client: AmapApiClient,
250
+ city: str | None,
251
+ ) -> ResolvedRoutePoint:
252
+ """Resolve a route point to a display name and coordinate."""
253
+ if location.coordinate is not None:
254
+ return ResolvedRoutePoint(
255
+ raw=location.raw,
256
+ name=display_name or location.raw,
257
+ coordinate=location.coordinate,
258
+ )
259
+
260
+ geocoded = geocode_text(location.name or location.raw, client=client, city=city)
261
+ return ResolvedRoutePoint(
262
+ raw=location.raw,
263
+ name=display_name or location.name or location.raw,
264
+ coordinate=geocoded.coordinate,
265
+ formatted_address=geocoded.formatted_address,
266
+ )
267
+
268
+
269
+ def _request_route_payload(
270
+ client: AmapApiClient,
271
+ *,
272
+ route_type: str,
273
+ origin: ResolvedRoutePoint,
274
+ destination: ResolvedRoutePoint,
275
+ waypoints: list[ResolvedRoutePoint],
276
+ policy: str | None,
277
+ strategy: str | None,
278
+ city: str | None,
279
+ ) -> dict[str, Any]:
280
+ """Call the corresponding Amap route API for the requested mode."""
281
+ if route_type == "driving":
282
+ params: dict[str, Any] = {
283
+ "origin": origin.coordinate.to_amap_value(),
284
+ "destination": destination.coordinate.to_amap_value(),
285
+ "extensions": "all",
286
+ }
287
+ if waypoints:
288
+ params["waypoints"] = ";".join(
289
+ waypoint.coordinate.to_amap_value() for waypoint in waypoints
290
+ )
291
+ if policy is not None:
292
+ params["strategy"] = DRIVING_POLICY_TO_STRATEGY[policy]
293
+ return client.get("/v3/direction/driving", params=params)
294
+
295
+ if route_type == "walking":
296
+ return client.get(
297
+ "/v3/direction/walking",
298
+ params={
299
+ "origin": origin.coordinate.to_amap_value(),
300
+ "destination": destination.coordinate.to_amap_value(),
301
+ },
302
+ )
303
+
304
+ if route_type == "riding":
305
+ payload = client.get(
306
+ "/v4/direction/bicycling",
307
+ params={
308
+ "origin": origin.coordinate.to_amap_value(),
309
+ "destination": destination.coordinate.to_amap_value(),
310
+ },
311
+ )
312
+ _raise_for_bicycling_error(payload)
313
+ return payload
314
+
315
+ return client.get(
316
+ "/v3/direction/transit/integrated",
317
+ params={
318
+ "origin": origin.coordinate.to_amap_value(),
319
+ "destination": destination.coordinate.to_amap_value(),
320
+ "city": city,
321
+ "extensions": "all",
322
+ **(
323
+ {"strategy": TRANSIT_STRATEGY_TO_POLICY[strategy]}
324
+ if strategy is not None
325
+ else {}
326
+ ),
327
+ },
328
+ )
329
+
330
+
331
+ def _raise_for_bicycling_error(payload: dict[str, Any]) -> None:
332
+ """Normalize bicycling API errors which use `errcode` instead of `status`."""
333
+ errcode = payload.get("errcode")
334
+ if errcode in (None, 0, "0"):
335
+ return
336
+
337
+ raise ApiResponseError(
338
+ payload.get("errmsg") or "高德骑行路径规划失败。",
339
+ details={
340
+ "errcode": errcode,
341
+ "errdetail": payload.get("errdetail"),
342
+ },
343
+ )
344
+
345
+
346
+ def _build_route_state(
347
+ *,
348
+ origin: ResolvedRoutePoint,
349
+ destination: ResolvedRoutePoint,
350
+ waypoints: list[ResolvedRoutePoint],
351
+ route_type: str,
352
+ policy: str | None,
353
+ strategy: str | None,
354
+ city: str | None,
355
+ ) -> dict[str, Any]:
356
+ """Build the normalized route state payload."""
357
+ state = {
358
+ "from": origin.to_state(),
359
+ "to": destination.to_state(),
360
+ "waypoints": [waypoint.to_state() for waypoint in waypoints],
361
+ "type": route_type,
362
+ }
363
+ if policy is not None:
364
+ state["policy"] = policy
365
+ if strategy is not None:
366
+ state["strategy"] = strategy
367
+ if city is not None:
368
+ state["city"] = city
369
+ return state
370
+
371
+
372
+ def _build_route_summary(route_type: str, payload: dict[str, Any]) -> dict[str, Any]:
373
+ """Convert raw Amap route results to a CLI-friendly summary."""
374
+ if route_type == "driving":
375
+ return _build_path_summary(_extract_route_path(payload, "driving"), include_tolls=True)
376
+ if route_type == "walking":
377
+ return _build_path_summary(_extract_route_path(payload, "walking"), include_tolls=False)
378
+ if route_type == "riding":
379
+ return _build_path_summary(_extract_bicycling_path(payload), include_tolls=False)
380
+ return _build_transit_summary(payload)
381
+
382
+
383
+ def _extract_route_path(payload: dict[str, Any], route_type: str) -> dict[str, Any]:
384
+ """Extract the first path from v3 direction APIs."""
385
+ route = payload.get("route")
386
+ if not isinstance(route, dict):
387
+ raise ApiResponseError(f"{route_type} 路径规划返回缺少 `route`。")
388
+
389
+ paths = route.get("paths")
390
+ if not isinstance(paths, list) or not paths:
391
+ raise ApiResponseError(f"{route_type} 路径规划未返回可用路线。")
392
+
393
+ first = paths[0]
394
+ if not isinstance(first, dict):
395
+ raise ApiResponseError(f"{route_type} 路径规划返回的路径格式无效。")
396
+ return first
397
+
398
+
399
+ def _extract_bicycling_path(payload: dict[str, Any]) -> dict[str, Any]:
400
+ """Extract the first path from the bicycling API payload."""
401
+ data = payload.get("data", payload)
402
+ if not isinstance(data, dict):
403
+ raise ApiResponseError("骑行路径规划返回格式无效。")
404
+
405
+ paths = data.get("paths")
406
+ if not isinstance(paths, list) or not paths:
407
+ raise ApiResponseError("骑行路径规划未返回可用路线。")
408
+
409
+ first = paths[0]
410
+ if not isinstance(first, dict):
411
+ raise ApiResponseError("骑行路径规划返回的路径格式无效。")
412
+ return first
413
+
414
+
415
+ def _build_path_summary(path: dict[str, Any], *, include_tolls: bool) -> dict[str, Any]:
416
+ """Build a summary for driving, walking, or riding path responses."""
417
+ summary = {
418
+ "distance": _parse_int(path.get("distance")),
419
+ "time": _parse_int(path.get("duration")),
420
+ "steps": [_build_step_summary(step) for step in _iter_dict_list(path.get("steps"))],
421
+ }
422
+
423
+ if include_tolls:
424
+ summary["tolls"] = _parse_float(path.get("tolls"))
425
+
426
+ return summary
427
+
428
+
429
+ def _build_step_summary(step: dict[str, Any]) -> dict[str, Any]:
430
+ """Build the normalized step payload for path-based modes."""
431
+ return {
432
+ "instruction": _string_value(step.get("instruction")),
433
+ "road": _string_value(step.get("road")),
434
+ "distance": _parse_int(step.get("distance")),
435
+ "time": _parse_int(step.get("duration")),
436
+ "action": _string_value(step.get("action")),
437
+ }
438
+
439
+
440
+ def _build_transit_summary(payload: dict[str, Any]) -> dict[str, Any]:
441
+ """Build the summary payload for transit route results."""
442
+ route = payload.get("route")
443
+ if not isinstance(route, dict):
444
+ raise ApiResponseError("公交路径规划返回缺少 `route`。")
445
+
446
+ transits = route.get("transits")
447
+ if not isinstance(transits, list) or not transits:
448
+ raise ApiResponseError("公交路径规划未返回可用路线。")
449
+
450
+ transit = transits[0]
451
+ if not isinstance(transit, dict):
452
+ raise ApiResponseError("公交路径规划返回的线路格式无效。")
453
+
454
+ return {
455
+ "distance": _parse_int(transit.get("distance")),
456
+ "time": _parse_int(transit.get("duration")),
457
+ "cost": _parse_float(transit.get("cost")),
458
+ "walking_distance": _parse_int(transit.get("walking_distance")),
459
+ "nightflag": _string_value(transit.get("nightflag")) == "1",
460
+ "steps": _build_transit_steps(transit),
461
+ }
462
+
463
+
464
+ def _build_transit_steps(transit: dict[str, Any]) -> list[dict[str, Any]]:
465
+ """Flatten transit segments into CLI-friendly steps."""
466
+ steps: list[dict[str, Any]] = []
467
+ for segment in _iter_dict_list(transit.get("segments")):
468
+ walking = segment.get("walking")
469
+ if isinstance(walking, dict):
470
+ steps.extend(_build_transit_walking_steps(walking))
471
+
472
+ bus = segment.get("bus")
473
+ if isinstance(bus, dict):
474
+ steps.extend(_build_transit_bus_steps(bus))
475
+
476
+ railway = segment.get("railway")
477
+ if isinstance(railway, dict) and railway:
478
+ railway_step = _build_transit_railway_step(railway)
479
+ if railway_step is not None:
480
+ steps.append(railway_step)
481
+
482
+ taxi = segment.get("taxi")
483
+ if isinstance(taxi, dict) and taxi:
484
+ taxi_step = _build_transit_taxi_step(taxi)
485
+ if taxi_step is not None:
486
+ steps.append(taxi_step)
487
+
488
+ return steps
489
+
490
+
491
+ def _build_transit_walking_steps(walking: dict[str, Any]) -> list[dict[str, Any]]:
492
+ """Convert a transit walking segment into individual steps."""
493
+ raw_steps = _iter_dict_list(walking.get("steps"))
494
+ if raw_steps:
495
+ return [
496
+ {
497
+ "type": "walking",
498
+ "instruction": _string_value(step.get("instruction")),
499
+ "road": _string_value(step.get("road")),
500
+ "distance": _parse_int(step.get("distance")),
501
+ "time": _parse_int(step.get("duration")),
502
+ "action": _string_value(step.get("action")),
503
+ }
504
+ for step in raw_steps
505
+ ]
506
+
507
+ return [
508
+ {
509
+ "type": "walking",
510
+ "instruction": "步行",
511
+ "road": "",
512
+ "distance": _parse_int(walking.get("distance")),
513
+ "time": _parse_int(walking.get("duration")),
514
+ "action": "",
515
+ }
516
+ ]
517
+
518
+
519
+ def _build_transit_bus_steps(bus: dict[str, Any]) -> list[dict[str, Any]]:
520
+ """Convert transit bus sub-segments into CLI-friendly steps."""
521
+ steps: list[dict[str, Any]] = []
522
+
523
+ for line in _iter_dict_list(bus.get("buslines")):
524
+ bus_name = _string_value(line.get("name"))
525
+ departure_stop = _extract_stop_name(line.get("departure_stop"))
526
+ arrival_stop = _extract_stop_name(line.get("arrival_stop"))
527
+ via_num = _parse_int(line.get("via_num"))
528
+
529
+ instruction = f"乘坐 {bus_name}".strip()
530
+ if departure_stop or arrival_stop:
531
+ instruction = (
532
+ f"{instruction},从 {departure_stop or '上车站'} 到 {arrival_stop or '下车站'}"
533
+ )
534
+
535
+ steps.append(
536
+ {
537
+ "type": "bus",
538
+ "instruction": instruction,
539
+ "line": bus_name,
540
+ "distance": _parse_int(line.get("distance")),
541
+ "time": _parse_int(line.get("duration")),
542
+ "departure_stop": departure_stop,
543
+ "arrival_stop": arrival_stop,
544
+ "via_num": via_num,
545
+ }
546
+ )
547
+
548
+ return steps
549
+
550
+
551
+ def _build_transit_railway_step(railway: dict[str, Any]) -> dict[str, Any] | None:
552
+ """Convert a railway transit segment to a step."""
553
+ name = _string_value(railway.get("name"))
554
+ trip = _string_value(railway.get("trip"))
555
+ instruction = f"乘坐 {trip or name}".strip()
556
+ if not instruction:
557
+ return None
558
+
559
+ return {
560
+ "type": "railway",
561
+ "instruction": instruction,
562
+ "line": trip or name,
563
+ "distance": _parse_int(railway.get("distance")),
564
+ "time": _parse_int(railway.get("time")),
565
+ "departure_stop": _extract_stop_name(railway.get("departure_stop")),
566
+ "arrival_stop": _extract_stop_name(railway.get("arrival_stop")),
567
+ }
568
+
569
+
570
+ def _build_transit_taxi_step(taxi: dict[str, Any]) -> dict[str, Any] | None:
571
+ """Convert a taxi transit segment to a step."""
572
+ distance = _parse_int(taxi.get("distance"))
573
+ duration = _parse_int(taxi.get("duration"))
574
+ cost = _parse_float(taxi.get("cost"))
575
+ if distance is None and duration is None and cost is None:
576
+ return None
577
+
578
+ return {
579
+ "type": "taxi",
580
+ "instruction": "打车前往",
581
+ "distance": distance,
582
+ "time": duration,
583
+ "cost": cost,
584
+ }
585
+
586
+
587
+ def _extract_stop_name(value: Any) -> str:
588
+ """Extract a stop name from a nested stop object."""
589
+ if not isinstance(value, dict):
590
+ return ""
591
+ return _string_value(value.get("name"))
592
+
593
+
594
+ def _iter_dict_list(value: Any) -> list[dict[str, Any]]:
595
+ """Return only dictionary items from a list-like payload field."""
596
+ if not isinstance(value, list):
597
+ return []
598
+ return [item for item in value if isinstance(item, dict)]
599
+
600
+
601
+ def _parse_int(value: Any) -> int | None:
602
+ """Parse an integer-like response field."""
603
+ if value in (None, ""):
604
+ return None
605
+ try:
606
+ return int(float(str(value)))
607
+ except (TypeError, ValueError):
608
+ return None
609
+
610
+
611
+ def _parse_float(value: Any) -> float | None:
612
+ """Parse a float-like response field."""
613
+ if value in (None, ""):
614
+ return None
615
+ try:
616
+ return float(str(value))
617
+ except (TypeError, ValueError):
618
+ return None
619
+
620
+
621
+ def _string_value(value: Any) -> str:
622
+ """Convert a response field to a stripped string."""
623
+ if value is None:
624
+ return ""
625
+ return str(value).strip()