traffic-cli 0.1.1__tar.gz
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.
- traffic_cli-0.1.1/.github/workflows/publish.yml +41 -0
- traffic_cli-0.1.1/.gitignore +18 -0
- traffic_cli-0.1.1/.python-version +1 -0
- traffic_cli-0.1.1/PKG-INFO +8 -0
- traffic_cli-0.1.1/README.md +0 -0
- traffic_cli-0.1.1/pyproject.toml +21 -0
- traffic_cli-0.1.1/src/traffic/__init__.py +0 -0
- traffic_cli-0.1.1/src/traffic/abstract/__init__.py +0 -0
- traffic_cli-0.1.1/src/traffic/abstract/geo.py +108 -0
- traffic_cli-0.1.1/src/traffic/abstract/routing.py +0 -0
- traffic_cli-0.1.1/src/traffic/cli.py +491 -0
- traffic_cli-0.1.1/src/traffic/config.py +118 -0
- traffic_cli-0.1.1/uv.lock +156 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
release:
|
|
8
|
+
types: [published]
|
|
9
|
+
workflow_dispatch:
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
build-and-publish:
|
|
13
|
+
name: Build and publish Python distribution to PyPI
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
permissions:
|
|
16
|
+
id-token: write # Mandatory for PyPI Trusted Publishing
|
|
17
|
+
contents: read
|
|
18
|
+
|
|
19
|
+
environment:
|
|
20
|
+
name: pypi
|
|
21
|
+
url: https://pypi.org/p/traffic-cli
|
|
22
|
+
|
|
23
|
+
steps:
|
|
24
|
+
- name: Checkout code
|
|
25
|
+
uses: actions/checkout@v4
|
|
26
|
+
|
|
27
|
+
- name: Install uv
|
|
28
|
+
uses: astral-sh/setup-uv@v5
|
|
29
|
+
with:
|
|
30
|
+
enable-cache: true
|
|
31
|
+
|
|
32
|
+
- name: Set up Python
|
|
33
|
+
uses: actions/setup-python@v5
|
|
34
|
+
with:
|
|
35
|
+
python-version: "3.12"
|
|
36
|
+
|
|
37
|
+
- name: Build distributions
|
|
38
|
+
run: uv build
|
|
39
|
+
|
|
40
|
+
- name: Publish package distributions to PyPI
|
|
41
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# Distribution / packaging
|
|
7
|
+
dist/
|
|
8
|
+
build/
|
|
9
|
+
*.egg-info/
|
|
10
|
+
|
|
11
|
+
# Virtual environments
|
|
12
|
+
.venv/
|
|
13
|
+
env/
|
|
14
|
+
venv/
|
|
15
|
+
|
|
16
|
+
# Cache & testing
|
|
17
|
+
.pytest_cache/
|
|
18
|
+
.ruff_cache/
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.14
|
|
File without changes
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "traffic-cli"
|
|
3
|
+
version = "0.1.1"
|
|
4
|
+
description = "CLI tool to calculate travel times using routing APIs"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"click>=8.0.0",
|
|
9
|
+
"geopy>=2.4.1",
|
|
10
|
+
"routingpy>=1.3.0",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[project.scripts]
|
|
14
|
+
traffic = "traffic.cli:traffic"
|
|
15
|
+
|
|
16
|
+
[build-system]
|
|
17
|
+
requires = ["hatchling"]
|
|
18
|
+
build-backend = "hatchling.build"
|
|
19
|
+
|
|
20
|
+
[tool.hatch.build.targets.wheel]
|
|
21
|
+
packages = ["src/traffic"]
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Get all of the geometric info, such as postcode -> lat/long, etc
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from geopy.geocoders import Nominatim
|
|
6
|
+
import routingpy
|
|
7
|
+
from typing import Literal, List
|
|
8
|
+
from traffic.config import Config
|
|
9
|
+
|
|
10
|
+
geocoder = Nominatim(user_agent="traffic")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
provider_map: dict = {
|
|
14
|
+
"ors": {
|
|
15
|
+
"class_name": "ORS",
|
|
16
|
+
"profiles": {"bike": "cycling-regular", "car": "driving-car", "walk": "foot-walking"},
|
|
17
|
+
},
|
|
18
|
+
"graphhopper": {
|
|
19
|
+
"class_name": "Graphhopper",
|
|
20
|
+
"profiles": {"bike": "bike", "car": "car", "walk": "foot"},
|
|
21
|
+
},
|
|
22
|
+
"mapbox": {
|
|
23
|
+
"class_name": "MapboxOSRM",
|
|
24
|
+
"profiles": {"bike": "cycling", "car": "driving", "walk": "walking"},
|
|
25
|
+
},
|
|
26
|
+
"google_maps": {
|
|
27
|
+
"class_name": "Google",
|
|
28
|
+
"profiles": {"bike": "bicycling", "car": "driving", "walk": "walking"},
|
|
29
|
+
},
|
|
30
|
+
"tomtom": {
|
|
31
|
+
"class_name": "TomTom",
|
|
32
|
+
"profiles": {
|
|
33
|
+
"bike": "bicycle",
|
|
34
|
+
"car": "car",
|
|
35
|
+
"walk": "pedestrian",
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Router:
|
|
42
|
+
def __init__(self, config: Config):
|
|
43
|
+
self.config = config
|
|
44
|
+
provider_name = config.provider_name
|
|
45
|
+
profile = config.profile
|
|
46
|
+
if not provider_name:
|
|
47
|
+
raise ValueError("Provider name not set in config.")
|
|
48
|
+
|
|
49
|
+
provider_info = next(
|
|
50
|
+
(v for k, v in provider_map.items() if k.lower() == provider_name.lower() or v.get("class_name", "").lower() == provider_name.lower()),
|
|
51
|
+
None
|
|
52
|
+
)
|
|
53
|
+
if not provider_info:
|
|
54
|
+
raise ValueError(f"Provider '{provider_name}' is not supported.")
|
|
55
|
+
|
|
56
|
+
self.profile = provider_info["profiles"][profile]
|
|
57
|
+
self.routing_client = config.router_class(api_key=config.api_key)
|
|
58
|
+
|
|
59
|
+
def resolve_var(self, *vars):
|
|
60
|
+
"""Checks if inputs are stored in config, replaces them, and returns them all."""
|
|
61
|
+
|
|
62
|
+
resolved = [self.config.config.get("vars", {}).get(var, self.config.config.get(var, var)) for var in vars]
|
|
63
|
+
|
|
64
|
+
if not resolved:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
if len(resolved) == 1:
|
|
68
|
+
return resolved[0]
|
|
69
|
+
|
|
70
|
+
return tuple(resolved)
|
|
71
|
+
|
|
72
|
+
def _geocode_location(self, query: str, country_code: str = None):
|
|
73
|
+
"""Geocodes a query string, applying country bias if configured."""
|
|
74
|
+
country = country_code or getattr(self.config, "country", None) or (self.config.config.get("country") if self.config.config else None)
|
|
75
|
+
if country:
|
|
76
|
+
cc = country.lower().strip()
|
|
77
|
+
if cc == "uk":
|
|
78
|
+
cc = "gb"
|
|
79
|
+
try:
|
|
80
|
+
location = geocoder.geocode(query, country_codes=cc)
|
|
81
|
+
if location:
|
|
82
|
+
return location
|
|
83
|
+
except Exception:
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
location = geocoder.geocode(query)
|
|
88
|
+
except Exception as e:
|
|
89
|
+
raise ValueError(f"Geocoding service error while searching for '{query}': {e}")
|
|
90
|
+
|
|
91
|
+
if not location:
|
|
92
|
+
raise ValueError(f"Coordinates for '{query}' could not be found.")
|
|
93
|
+
return location
|
|
94
|
+
|
|
95
|
+
def get_travel_time(self, home: str, destination: str, country_code: str = None):
|
|
96
|
+
home, destination = self.resolve_var(home, destination)
|
|
97
|
+
home_loc = self._geocode_location(home, country_code=country_code)
|
|
98
|
+
destination_loc = self._geocode_location(destination, country_code=country_code)
|
|
99
|
+
|
|
100
|
+
self.last_home_location = home_loc
|
|
101
|
+
self.last_destination_location = destination_loc
|
|
102
|
+
|
|
103
|
+
home_coords = [home_loc.longitude, home_loc.latitude]
|
|
104
|
+
destination_coords = [destination_loc.longitude, destination_loc.latitude]
|
|
105
|
+
route = self.routing_client.directions(
|
|
106
|
+
locations=[home_coords, destination_coords], profile=self.profile
|
|
107
|
+
)
|
|
108
|
+
return route.duration
|
|
File without changes
|
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
import click
|
|
4
|
+
from traffic.abstract.geo import Router, geocoder
|
|
5
|
+
from traffic.config import Config
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_initialized_config(headless: bool = False):
|
|
9
|
+
"""Gets the configuration, automatically prompting for initialization if missing (unless headless)."""
|
|
10
|
+
config = Config()
|
|
11
|
+
if not config.provider_name:
|
|
12
|
+
if headless:
|
|
13
|
+
click.echo(
|
|
14
|
+
"Error: Configuration not found or not initialized. Run `traffic init` first.",
|
|
15
|
+
err=True,
|
|
16
|
+
)
|
|
17
|
+
raise SystemExit(1)
|
|
18
|
+
click.echo("Configuration not found or not initialized.")
|
|
19
|
+
provider_name = click.prompt(
|
|
20
|
+
"Please enter provider name (e.g. ORS, Graphhopper)"
|
|
21
|
+
)
|
|
22
|
+
api_key = click.prompt("Please enter API key")
|
|
23
|
+
profile = click.prompt("Please enter default profile", default="car")
|
|
24
|
+
config.initialise(provider_name, api_key, profile)
|
|
25
|
+
click.echo(f"Configuration initialised with provider: {provider_name}\n")
|
|
26
|
+
return config
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def format_duration(seconds: float) -> str:
|
|
30
|
+
"""Formats a duration in seconds into a readable string of days, hours, mins, seconds."""
|
|
31
|
+
|
|
32
|
+
sec = int(seconds)
|
|
33
|
+
days, sec = divmod(sec, 86400)
|
|
34
|
+
hours, sec = divmod(sec, 3600)
|
|
35
|
+
mins, sec = divmod(sec, 60)
|
|
36
|
+
|
|
37
|
+
parts = []
|
|
38
|
+
if days > 0:
|
|
39
|
+
parts.append(f"{days} day{'s' if days != 1 else ''}")
|
|
40
|
+
if hours > 0:
|
|
41
|
+
parts.append(f"{hours} hour{'s' if hours != 1 else ''}")
|
|
42
|
+
if mins > 0:
|
|
43
|
+
parts.append(f"{mins} min{'s' if mins != 1 else ''}")
|
|
44
|
+
if sec > 0 or not parts:
|
|
45
|
+
parts.append(f"{sec} second{'s' if sec != 1 else ''}")
|
|
46
|
+
|
|
47
|
+
return ", ".join(parts)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def format_duration_compact(seconds: float) -> str:
|
|
51
|
+
"""Formats a duration in seconds into a compact string like '1d 2h 15m' or '45m'."""
|
|
52
|
+
sec = int(seconds)
|
|
53
|
+
days, sec = divmod(sec, 86400)
|
|
54
|
+
hours, sec = divmod(sec, 3600)
|
|
55
|
+
mins, sec = divmod(sec, 60)
|
|
56
|
+
|
|
57
|
+
parts = []
|
|
58
|
+
if days > 0:
|
|
59
|
+
parts.append(f"{days}d")
|
|
60
|
+
if hours > 0:
|
|
61
|
+
parts.append(f"{hours}h")
|
|
62
|
+
if mins > 0:
|
|
63
|
+
parts.append(f"{mins}m")
|
|
64
|
+
if not parts or (days == 0 and hours == 0 and mins == 0):
|
|
65
|
+
parts.append(f"{sec}s")
|
|
66
|
+
|
|
67
|
+
return " ".join(parts)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def format_error_message(exc: Exception) -> str:
|
|
71
|
+
"""
|
|
72
|
+
Extracts a clean, human-readable error message from exceptions,
|
|
73
|
+
parsing JSON payloads from routing APIs and filtering out raw JSON/metadata.
|
|
74
|
+
"""
|
|
75
|
+
status = getattr(exc, "status", None)
|
|
76
|
+
raw_msg = getattr(exc, "message", str(exc))
|
|
77
|
+
|
|
78
|
+
parsed_json = None
|
|
79
|
+
|
|
80
|
+
if isinstance(raw_msg, str):
|
|
81
|
+
json_match = re.search(r"(\{.*\})", raw_msg, re.DOTALL)
|
|
82
|
+
if json_match:
|
|
83
|
+
candidate = json_match.group(1)
|
|
84
|
+
try:
|
|
85
|
+
parsed_json = json.loads(candidate)
|
|
86
|
+
except Exception:
|
|
87
|
+
try:
|
|
88
|
+
import ast
|
|
89
|
+
parsed_json = ast.literal_eval(candidate)
|
|
90
|
+
except Exception:
|
|
91
|
+
pass
|
|
92
|
+
elif isinstance(raw_msg, dict):
|
|
93
|
+
parsed_json = raw_msg
|
|
94
|
+
|
|
95
|
+
extracted_msg = None
|
|
96
|
+
if isinstance(parsed_json, dict):
|
|
97
|
+
# 1. ORS / OpenRouteService or generic error object
|
|
98
|
+
err_field = parsed_json.get("error")
|
|
99
|
+
if isinstance(err_field, dict):
|
|
100
|
+
extracted_msg = (
|
|
101
|
+
err_field.get("message")
|
|
102
|
+
or err_field.get("detail")
|
|
103
|
+
or err_field.get("description")
|
|
104
|
+
)
|
|
105
|
+
elif isinstance(err_field, str):
|
|
106
|
+
extracted_msg = err_field
|
|
107
|
+
|
|
108
|
+
# 2. GraphHopper / Mapbox standard 'message'
|
|
109
|
+
if not extracted_msg and "message" in parsed_json:
|
|
110
|
+
extracted_msg = str(parsed_json["message"])
|
|
111
|
+
|
|
112
|
+
# 3. Google Maps 'error_message'
|
|
113
|
+
if not extracted_msg and "error_message" in parsed_json:
|
|
114
|
+
extracted_msg = str(parsed_json["error_message"])
|
|
115
|
+
|
|
116
|
+
# 4. TomTom 'detailedError'
|
|
117
|
+
if not extracted_msg and isinstance(parsed_json.get("detailedError"), dict):
|
|
118
|
+
extracted_msg = parsed_json["detailedError"].get("message")
|
|
119
|
+
|
|
120
|
+
# 5. TomTom 'errorText'
|
|
121
|
+
if not extracted_msg and "errorText" in parsed_json:
|
|
122
|
+
extracted_msg = str(parsed_json["errorText"])
|
|
123
|
+
|
|
124
|
+
# 6. GraphHopper 'hints'
|
|
125
|
+
if not extracted_msg and isinstance(parsed_json.get("hints"), list) and parsed_json["hints"]:
|
|
126
|
+
first_hint = parsed_json["hints"][0]
|
|
127
|
+
if isinstance(first_hint, dict):
|
|
128
|
+
extracted_msg = first_hint.get("message")
|
|
129
|
+
elif isinstance(first_hint, str):
|
|
130
|
+
extracted_msg = first_hint
|
|
131
|
+
|
|
132
|
+
# Fallback to string representation if no JSON field extracted
|
|
133
|
+
if not extracted_msg:
|
|
134
|
+
extracted_msg = str(exc)
|
|
135
|
+
clean_match = re.match(r"^\d{3}\s*\((.*)\)$", extracted_msg, re.DOTALL)
|
|
136
|
+
if clean_match:
|
|
137
|
+
extracted_msg = clean_match.group(1).strip()
|
|
138
|
+
|
|
139
|
+
user_msg = extracted_msg.strip()
|
|
140
|
+
|
|
141
|
+
# Provide actionable context for common failure modes
|
|
142
|
+
lower_msg = user_msg.lower()
|
|
143
|
+
if (
|
|
144
|
+
status in (401, 403)
|
|
145
|
+
or "unauthorized" in lower_msg
|
|
146
|
+
or "invalid token" in lower_msg
|
|
147
|
+
or "invalid api key" in lower_msg
|
|
148
|
+
or "not authorized" in lower_msg
|
|
149
|
+
):
|
|
150
|
+
return f"{user_msg} (Check your API key using `traffic set api_key <key>`)"
|
|
151
|
+
|
|
152
|
+
if (
|
|
153
|
+
status == 404
|
|
154
|
+
or "could not find point" in lower_msg
|
|
155
|
+
or "cannot find point" in lower_msg
|
|
156
|
+
or "within a radius" in lower_msg
|
|
157
|
+
or "noroute" in lower_msg
|
|
158
|
+
or "no route" in lower_msg
|
|
159
|
+
):
|
|
160
|
+
return f"{user_msg} (No drivable route found between these locations for the selected travel profile)."
|
|
161
|
+
|
|
162
|
+
if (
|
|
163
|
+
status == 429
|
|
164
|
+
or "rate limit" in lower_msg
|
|
165
|
+
or "quota" in lower_msg
|
|
166
|
+
or "over query limit" in lower_msg
|
|
167
|
+
):
|
|
168
|
+
return f"{user_msg} (Rate limit exceeded. Please wait a moment before trying again)."
|
|
169
|
+
|
|
170
|
+
return user_msg
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def resolve_locations(locations, home_opt=None, dest_opt=None, config=None, country_code=None):
|
|
174
|
+
"""
|
|
175
|
+
Resolves the origin (home) and destination from CLI options and positional arguments.
|
|
176
|
+
Supports delimiters ('to', '->'), config variables, country biasing, and heuristic multi-word splitting.
|
|
177
|
+
Returns a tuple: (origin, destination, info_message, error_message).
|
|
178
|
+
"""
|
|
179
|
+
vars_dict = config.config.get("vars", {}) if config and config.config else {}
|
|
180
|
+
cc = country_code or getattr(config, "country", None) or (config.config.get("country") if config and config.config else None)
|
|
181
|
+
if cc:
|
|
182
|
+
cc = cc.lower().strip()
|
|
183
|
+
if cc == "uk":
|
|
184
|
+
cc = "gb"
|
|
185
|
+
|
|
186
|
+
# Case 1: Both options provided explicitly
|
|
187
|
+
if home_opt and dest_opt:
|
|
188
|
+
return home_opt, dest_opt, None, None
|
|
189
|
+
|
|
190
|
+
# Case 2: One option provided explicitly, positional args provide the other
|
|
191
|
+
if home_opt and locations:
|
|
192
|
+
return home_opt, " ".join(locations), None, None
|
|
193
|
+
if dest_opt and locations:
|
|
194
|
+
return " ".join(locations), dest_opt, None, None
|
|
195
|
+
|
|
196
|
+
# Case 3: No positional locations provided
|
|
197
|
+
if not locations:
|
|
198
|
+
err = (
|
|
199
|
+
"Error: Missing origin and destination.\n\n"
|
|
200
|
+
"Usage:\n"
|
|
201
|
+
" traffic <origin> <destination>\n"
|
|
202
|
+
" traffic <origin> to <destination>\n\n"
|
|
203
|
+
"Examples:\n"
|
|
204
|
+
" traffic california nevada\n"
|
|
205
|
+
' traffic "las vegas" california\n'
|
|
206
|
+
" traffic las vegas to california\n"
|
|
207
|
+
" traffic home work"
|
|
208
|
+
)
|
|
209
|
+
return None, None, None, err
|
|
210
|
+
|
|
211
|
+
# Case 4: Single positional string passed (e.g. 'las vegas to california')
|
|
212
|
+
if len(locations) == 1:
|
|
213
|
+
text = locations[0].strip()
|
|
214
|
+
to_split = re.split(r"\s+to\s+", text, maxsplit=1, flags=re.IGNORECASE)
|
|
215
|
+
if len(to_split) == 2 and to_split[0].strip() and to_split[1].strip():
|
|
216
|
+
return to_split[0].strip(), to_split[1].strip(), None, None
|
|
217
|
+
|
|
218
|
+
if "->" in text:
|
|
219
|
+
parts = text.split("->", 1)
|
|
220
|
+
if parts[0].strip() and parts[1].strip():
|
|
221
|
+
return parts[0].strip(), parts[1].strip(), None, None
|
|
222
|
+
|
|
223
|
+
if "," in text:
|
|
224
|
+
parts = text.split(",", 1)
|
|
225
|
+
if parts[0].strip() and parts[1].strip():
|
|
226
|
+
return parts[0].strip(), parts[1].strip(), None, None
|
|
227
|
+
|
|
228
|
+
err = (
|
|
229
|
+
f"Error: Could not determine both origin and destination from '{text}'.\n\n"
|
|
230
|
+
"Tip: Separate locations with spaces or use 'to', for example:\n"
|
|
231
|
+
f' traffic "{text}" <destination>\n'
|
|
232
|
+
f" traffic {text} to <destination>"
|
|
233
|
+
)
|
|
234
|
+
return None, None, None, err
|
|
235
|
+
|
|
236
|
+
# Case 5: Exactly 2 positional arguments
|
|
237
|
+
if len(locations) == 2:
|
|
238
|
+
return locations[0], locations[1], None, None
|
|
239
|
+
|
|
240
|
+
# Case 6: 3 or more positional arguments
|
|
241
|
+
# Check for keyword delimiter 'to' or '->'
|
|
242
|
+
lower_tokens = [t.lower() for t in locations]
|
|
243
|
+
if "to" in lower_tokens:
|
|
244
|
+
idx = lower_tokens.index("to")
|
|
245
|
+
if 0 < idx < len(locations) - 1:
|
|
246
|
+
h = " ".join(locations[:idx])
|
|
247
|
+
d = " ".join(locations[idx + 1:])
|
|
248
|
+
return h, d, None, None
|
|
249
|
+
|
|
250
|
+
if "->" in locations:
|
|
251
|
+
idx = locations.index("->")
|
|
252
|
+
if 0 < idx < len(locations) - 1:
|
|
253
|
+
h = " ".join(locations[:idx])
|
|
254
|
+
d = " ".join(locations[idx + 1:])
|
|
255
|
+
return h, d, None, None
|
|
256
|
+
|
|
257
|
+
# Check for saved variable boundaries in config (e.g. 'home las vegas')
|
|
258
|
+
if locations[0] in vars_dict:
|
|
259
|
+
return locations[0], " ".join(locations[1:]), None, None
|
|
260
|
+
if locations[-1] in vars_dict:
|
|
261
|
+
return " ".join(locations[:-1]), locations[-1], None, None
|
|
262
|
+
|
|
263
|
+
# Heuristic split testing using geocoding
|
|
264
|
+
best_split = None
|
|
265
|
+
best_score = -1.0
|
|
266
|
+
|
|
267
|
+
for i in range(1, len(locations)):
|
|
268
|
+
cand_h = " ".join(locations[:i])
|
|
269
|
+
cand_d = " ".join(locations[i:])
|
|
270
|
+
try:
|
|
271
|
+
loc_h = None
|
|
272
|
+
loc_d = None
|
|
273
|
+
if cc:
|
|
274
|
+
try:
|
|
275
|
+
loc_h = geocoder.geocode(cand_h, country_codes=cc)
|
|
276
|
+
loc_d = geocoder.geocode(cand_d, country_codes=cc)
|
|
277
|
+
except Exception:
|
|
278
|
+
pass
|
|
279
|
+
if not loc_h:
|
|
280
|
+
loc_h = geocoder.geocode(cand_h)
|
|
281
|
+
if not loc_d:
|
|
282
|
+
loc_d = geocoder.geocode(cand_d)
|
|
283
|
+
|
|
284
|
+
if loc_h and loc_d:
|
|
285
|
+
imp_h = getattr(loc_h, "raw", {}).get("importance", 0.5)
|
|
286
|
+
imp_d = getattr(loc_d, "raw", {}).get("importance", 0.5)
|
|
287
|
+
score = imp_h + imp_d
|
|
288
|
+
if score > best_score:
|
|
289
|
+
best_score = score
|
|
290
|
+
best_split = (cand_h, cand_d)
|
|
291
|
+
except Exception:
|
|
292
|
+
pass
|
|
293
|
+
|
|
294
|
+
if best_split:
|
|
295
|
+
h, d = best_split
|
|
296
|
+
info = (
|
|
297
|
+
f"Interpreting route as: '{h}' -> '{d}'\n"
|
|
298
|
+
f"Tip: You can wrap multi-word locations in quotes (e.g. traffic \"{h}\" \"{d}\") or use 'to' (e.g. traffic {h} to {d}).\n"
|
|
299
|
+
)
|
|
300
|
+
return h, d, info, None
|
|
301
|
+
|
|
302
|
+
raw_query = " ".join(locations)
|
|
303
|
+
err = (
|
|
304
|
+
f"Error: Could not determine origin and destination from '{raw_query}'.\n\n"
|
|
305
|
+
"Tip: Wrap locations containing spaces in quotes, e.g.:\n"
|
|
306
|
+
f' traffic "{locations[0]} {locations[1]}" "{" ".join(locations[2:])}"\n'
|
|
307
|
+
"Or use 'to' as a separator:\n"
|
|
308
|
+
f" traffic {' '.join(locations[:2])} to {' '.join(locations[2:])}"
|
|
309
|
+
)
|
|
310
|
+
return None, None, None, err
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
class DefaultCommandGroup(click.Group):
|
|
314
|
+
"""A Click Group that routes non-subcommand invocations to a default command."""
|
|
315
|
+
|
|
316
|
+
def __init__(self, *args, **kwargs):
|
|
317
|
+
self.default_cmd_name = kwargs.pop("default_cmd", "route")
|
|
318
|
+
super().__init__(*args, **kwargs)
|
|
319
|
+
|
|
320
|
+
def parse_args(self, ctx, args):
|
|
321
|
+
if not args:
|
|
322
|
+
return super().parse_args(ctx, args)
|
|
323
|
+
first_arg = args[0]
|
|
324
|
+
if first_arg in self.commands:
|
|
325
|
+
return super().parse_args(ctx, args)
|
|
326
|
+
if first_arg in ("--help", "--version"):
|
|
327
|
+
return super().parse_args(ctx, args)
|
|
328
|
+
args = [self.default_cmd_name] + list(args)
|
|
329
|
+
return super().parse_args(ctx, args)
|
|
330
|
+
|
|
331
|
+
def format_usage(self, ctx, formatter):
|
|
332
|
+
formatter.write_usage(
|
|
333
|
+
ctx.command_path,
|
|
334
|
+
"[OPTIONS] [ORIGIN] [DESTINATION] | COMMAND [ARGS]...",
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
@click.group(cls=DefaultCommandGroup, default_cmd="route", invoke_without_command=True)
|
|
339
|
+
@click.pass_context
|
|
340
|
+
def traffic(ctx):
|
|
341
|
+
"""Calculates route and travel time between locations.
|
|
342
|
+
|
|
343
|
+
\b
|
|
344
|
+
Examples:
|
|
345
|
+
traffic california nevada
|
|
346
|
+
traffic "las vegas" california
|
|
347
|
+
traffic las vegas to california
|
|
348
|
+
traffic home work
|
|
349
|
+
traffic --country gb b46 cv7
|
|
350
|
+
traffic --json home work
|
|
351
|
+
traffic --headless --compact home work
|
|
352
|
+
traffic --home London --destination Paris
|
|
353
|
+
"""
|
|
354
|
+
if ctx.invoked_subcommand is None and not ctx.params:
|
|
355
|
+
click.echo(ctx.get_help())
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
@traffic.command("route", hidden=True)
|
|
359
|
+
@click.argument("locations", nargs=-1)
|
|
360
|
+
@click.option("--home", "-h", help="Starting postcode or address")
|
|
361
|
+
@click.option("--destination", "-d", help="Ending postcode or address")
|
|
362
|
+
@click.option("--country", "--cc", help="Country code bias for geocoding (e.g. 'gb', 'us', 'fr')")
|
|
363
|
+
@click.option("--no-motorways", is_flag=True, help="Avoid motorways")
|
|
364
|
+
@click.option("--include-tolls", is_flag=True, help="Include toll roads")
|
|
365
|
+
@click.option("--headless", "-q", "--quiet", is_flag=True, help="Headless mode: suppress tips and output clean duration")
|
|
366
|
+
@click.option("--json", "json_output", is_flag=True, help="Output result in JSON format (Waybar compatible)")
|
|
367
|
+
@click.option("--compact", "-c", is_flag=True, help="Output compact duration (e.g. '1h 15m')")
|
|
368
|
+
@click.option("--raw", "--seconds", is_flag=True, help="Output raw duration in seconds")
|
|
369
|
+
@click.pass_context
|
|
370
|
+
def route_cmd(
|
|
371
|
+
ctx,
|
|
372
|
+
locations,
|
|
373
|
+
home,
|
|
374
|
+
destination,
|
|
375
|
+
country,
|
|
376
|
+
no_motorways,
|
|
377
|
+
include_tolls,
|
|
378
|
+
headless,
|
|
379
|
+
json_output,
|
|
380
|
+
compact,
|
|
381
|
+
raw,
|
|
382
|
+
):
|
|
383
|
+
"""Calculates route and travel time between locations."""
|
|
384
|
+
is_headless = headless or json_output or compact or raw
|
|
385
|
+
config = get_initialized_config(headless=is_headless)
|
|
386
|
+
|
|
387
|
+
origin, dest, info, err = resolve_locations(
|
|
388
|
+
locations=locations,
|
|
389
|
+
home_opt=home,
|
|
390
|
+
dest_opt=destination,
|
|
391
|
+
config=config,
|
|
392
|
+
country_code=country,
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
if err:
|
|
396
|
+
if json_output:
|
|
397
|
+
click.echo(
|
|
398
|
+
json.dumps(
|
|
399
|
+
{
|
|
400
|
+
"text": "error",
|
|
401
|
+
"alt": "traffic-error",
|
|
402
|
+
"tooltip": err.splitlines()[0],
|
|
403
|
+
"class": "error",
|
|
404
|
+
"error": err,
|
|
405
|
+
}
|
|
406
|
+
)
|
|
407
|
+
)
|
|
408
|
+
else:
|
|
409
|
+
click.echo(err, err=True)
|
|
410
|
+
ctx.exit(1)
|
|
411
|
+
|
|
412
|
+
if info and not is_headless:
|
|
413
|
+
click.echo(info)
|
|
414
|
+
|
|
415
|
+
router = Router(config)
|
|
416
|
+
|
|
417
|
+
try:
|
|
418
|
+
duration = router.get_travel_time(origin, dest, country_code=country)
|
|
419
|
+
home_addr = getattr(getattr(router, "last_home_location", None), "address", origin)
|
|
420
|
+
dest_addr = getattr(getattr(router, "last_destination_location", None), "address", dest)
|
|
421
|
+
resolved_h, resolved_d = router.resolve_var(origin, dest)
|
|
422
|
+
|
|
423
|
+
if json_output:
|
|
424
|
+
formatted = format_duration(duration)
|
|
425
|
+
compact_fmt = format_duration_compact(duration)
|
|
426
|
+
output_data = {
|
|
427
|
+
"text": compact_fmt,
|
|
428
|
+
"alt": "traffic",
|
|
429
|
+
"tooltip": f"From: {resolved_h} ({home_addr})\nTo: {resolved_d} ({dest_addr})\nTravel time: {formatted}",
|
|
430
|
+
"class": "traffic",
|
|
431
|
+
"duration_seconds": int(duration),
|
|
432
|
+
"formatted": formatted,
|
|
433
|
+
"compact": compact_fmt,
|
|
434
|
+
"origin": resolved_h,
|
|
435
|
+
"origin_address": home_addr,
|
|
436
|
+
"destination": resolved_d,
|
|
437
|
+
"destination_address": dest_addr,
|
|
438
|
+
}
|
|
439
|
+
click.echo(json.dumps(output_data))
|
|
440
|
+
elif raw:
|
|
441
|
+
click.echo(int(duration))
|
|
442
|
+
elif compact:
|
|
443
|
+
click.echo(format_duration_compact(duration))
|
|
444
|
+
elif headless:
|
|
445
|
+
click.echo(format_duration(duration))
|
|
446
|
+
else:
|
|
447
|
+
click.echo(f"From: {resolved_h} ({home_addr})")
|
|
448
|
+
click.echo(f"To: {resolved_d} ({dest_addr})")
|
|
449
|
+
click.echo(f"Travel time: {format_duration(duration)}")
|
|
450
|
+
except Exception as e:
|
|
451
|
+
error_msg = format_error_message(e)
|
|
452
|
+
if json_output:
|
|
453
|
+
click.echo(
|
|
454
|
+
json.dumps(
|
|
455
|
+
{
|
|
456
|
+
"text": "error",
|
|
457
|
+
"alt": "traffic-error",
|
|
458
|
+
"tooltip": f"Traffic error: {error_msg}",
|
|
459
|
+
"class": "error",
|
|
460
|
+
"error": error_msg,
|
|
461
|
+
}
|
|
462
|
+
)
|
|
463
|
+
)
|
|
464
|
+
else:
|
|
465
|
+
click.echo(f"Error calculating travel time: {error_msg}", err=True)
|
|
466
|
+
ctx.exit(1)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
@traffic.command()
|
|
470
|
+
@click.argument("provider_name")
|
|
471
|
+
@click.argument("api_key")
|
|
472
|
+
@click.argument("profile", default="car")
|
|
473
|
+
def init(provider_name, api_key, profile):
|
|
474
|
+
"""Creates the initial config"""
|
|
475
|
+
config = Config()
|
|
476
|
+
config.initialise(provider_name, api_key, profile)
|
|
477
|
+
click.echo(f"Initialised with {provider_name}")
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
@traffic.command()
|
|
481
|
+
@click.argument("key")
|
|
482
|
+
@click.argument("value")
|
|
483
|
+
def set(key, value):
|
|
484
|
+
"""Sets a configuration variable or setting (e.g. country, profile, api_key, home)."""
|
|
485
|
+
config = get_initialized_config()
|
|
486
|
+
config.set_config_value(key, value)
|
|
487
|
+
click.echo(f"Set {key} to {value}")
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
if __name__ == "__main__":
|
|
491
|
+
traffic()
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import logging
|
|
4
|
+
import routingpy
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
CONFIG_DIR = Path.home() / ".traffic"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Config:
|
|
11
|
+
def __init__(self) -> None:
|
|
12
|
+
self.config_file = CONFIG_DIR / "config.json"
|
|
13
|
+
self.config = self._load_config()
|
|
14
|
+
|
|
15
|
+
if self.config:
|
|
16
|
+
self.provider_name = self.config.get("provider_name")
|
|
17
|
+
self.api_key = self.config.get("api_key")
|
|
18
|
+
self.profile = self.config.get("profile", "car")
|
|
19
|
+
self.country = self.config.get("country") or self.config.get("country_code")
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
self.router_class = self._get_router_class(self.provider_name)
|
|
23
|
+
except AttributeError:
|
|
24
|
+
raise ValueError(
|
|
25
|
+
f"Provider {self.provider_name} is not part of the supported list."
|
|
26
|
+
)
|
|
27
|
+
else:
|
|
28
|
+
logger.warning("Config has not been initialised - init script must be ran")
|
|
29
|
+
self.provider_name = None
|
|
30
|
+
self.api_key = None
|
|
31
|
+
self.profile = None
|
|
32
|
+
self.country = None
|
|
33
|
+
self.router_class = None
|
|
34
|
+
|
|
35
|
+
def _get_router_class(self, provider_name):
|
|
36
|
+
if not provider_name:
|
|
37
|
+
return None
|
|
38
|
+
name_lower = provider_name.lower()
|
|
39
|
+
if name_lower == "mapbox":
|
|
40
|
+
target = "mapboxosrm"
|
|
41
|
+
elif name_lower == "google_maps":
|
|
42
|
+
target = "google"
|
|
43
|
+
else:
|
|
44
|
+
target = name_lower
|
|
45
|
+
class_name = next(
|
|
46
|
+
(attr for attr in dir(routingpy) if attr.lower() == target),
|
|
47
|
+
provider_name
|
|
48
|
+
)
|
|
49
|
+
return getattr(routingpy, class_name)
|
|
50
|
+
|
|
51
|
+
def _load_config(self):
|
|
52
|
+
"""Safely loads the config file if it exists."""
|
|
53
|
+
if self.config_file.exists():
|
|
54
|
+
with open(self.config_file, "r") as f:
|
|
55
|
+
return json.load(f)
|
|
56
|
+
return {}
|
|
57
|
+
|
|
58
|
+
def initialise(self, provider_name, api_key, profile):
|
|
59
|
+
"""Creates the initial config.json from CLI arguments."""
|
|
60
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
self.config = {
|
|
62
|
+
"provider_name": provider_name,
|
|
63
|
+
"api_key": api_key,
|
|
64
|
+
"profile": profile,
|
|
65
|
+
"vars": {},
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
self.provider_name = provider_name
|
|
69
|
+
self.api_key = api_key
|
|
70
|
+
self.profile = profile
|
|
71
|
+
self.country = None
|
|
72
|
+
self.router_class = self._get_router_class(self.provider_name)
|
|
73
|
+
|
|
74
|
+
self.save()
|
|
75
|
+
logger.info("Config created successfully")
|
|
76
|
+
|
|
77
|
+
def save(self):
|
|
78
|
+
"""Writes the current state back to the disk."""
|
|
79
|
+
with open(self.config_file, "w") as f:
|
|
80
|
+
json.dump(self.config, f, indent=4)
|
|
81
|
+
|
|
82
|
+
def set_config_value(self, key: str, value: str):
|
|
83
|
+
"""Sets a configuration variable or system setting."""
|
|
84
|
+
if not self.config:
|
|
85
|
+
raise ValueError("Config not initialized. Run `init` first.")
|
|
86
|
+
|
|
87
|
+
norm_key = key.lower()
|
|
88
|
+
if norm_key in ("profile", "default_profile"):
|
|
89
|
+
self.config["profile"] = value
|
|
90
|
+
self.profile = value
|
|
91
|
+
logger.info("Changed default profile to %s", value)
|
|
92
|
+
elif norm_key in ("country", "country_code", "cc"):
|
|
93
|
+
val = value.lower().strip()
|
|
94
|
+
if val == "uk":
|
|
95
|
+
val = "gb"
|
|
96
|
+
self.config["country"] = val
|
|
97
|
+
self.country = val
|
|
98
|
+
logger.info("Changed default country to %s", val)
|
|
99
|
+
elif norm_key == "api_key":
|
|
100
|
+
self.config["api_key"] = value
|
|
101
|
+
self.api_key = value
|
|
102
|
+
elif norm_key == "provider_name":
|
|
103
|
+
self.config["provider_name"] = value
|
|
104
|
+
self.provider_name = value
|
|
105
|
+
self.router_class = self._get_router_class(value)
|
|
106
|
+
else:
|
|
107
|
+
if "vars" not in self.config:
|
|
108
|
+
self.config["vars"] = {}
|
|
109
|
+
self.config["vars"][key] = value
|
|
110
|
+
logger.info("Added variable %s to config with value %s", key, value)
|
|
111
|
+
|
|
112
|
+
self.save()
|
|
113
|
+
|
|
114
|
+
def add_var_to_config(self, key: str, value: str):
|
|
115
|
+
self.set_config_value(key, value)
|
|
116
|
+
|
|
117
|
+
def set_default_profile(self, profile: str):
|
|
118
|
+
self.set_config_value("profile", profile)
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
version = 1
|
|
2
|
+
revision = 3
|
|
3
|
+
requires-python = ">=3.14"
|
|
4
|
+
|
|
5
|
+
[[package]]
|
|
6
|
+
name = "certifi"
|
|
7
|
+
version = "2026.5.20"
|
|
8
|
+
source = { registry = "https://pypi.org/simple" }
|
|
9
|
+
sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" }
|
|
10
|
+
wheels = [
|
|
11
|
+
{ url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" },
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[[package]]
|
|
15
|
+
name = "charset-normalizer"
|
|
16
|
+
version = "3.4.7"
|
|
17
|
+
source = { registry = "https://pypi.org/simple" }
|
|
18
|
+
sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" }
|
|
19
|
+
wheels = [
|
|
20
|
+
{ url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" },
|
|
21
|
+
{ url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" },
|
|
22
|
+
{ url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" },
|
|
23
|
+
{ url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" },
|
|
24
|
+
{ url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" },
|
|
25
|
+
{ url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" },
|
|
26
|
+
{ url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" },
|
|
27
|
+
{ url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" },
|
|
28
|
+
{ url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" },
|
|
29
|
+
{ url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" },
|
|
30
|
+
{ url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" },
|
|
31
|
+
{ url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" },
|
|
32
|
+
{ url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" },
|
|
33
|
+
{ url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" },
|
|
34
|
+
{ url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" },
|
|
35
|
+
{ url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" },
|
|
36
|
+
{ url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" },
|
|
37
|
+
{ url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" },
|
|
38
|
+
{ url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" },
|
|
39
|
+
{ url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" },
|
|
40
|
+
{ url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" },
|
|
41
|
+
{ url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" },
|
|
42
|
+
{ url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" },
|
|
43
|
+
{ url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" },
|
|
44
|
+
{ url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" },
|
|
45
|
+
{ url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" },
|
|
46
|
+
{ url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" },
|
|
47
|
+
{ url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" },
|
|
48
|
+
{ url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" },
|
|
49
|
+
{ url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" },
|
|
50
|
+
{ url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" },
|
|
51
|
+
{ url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" },
|
|
52
|
+
{ url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
[[package]]
|
|
56
|
+
name = "click"
|
|
57
|
+
version = "8.4.1"
|
|
58
|
+
source = { registry = "https://pypi.org/simple" }
|
|
59
|
+
dependencies = [
|
|
60
|
+
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
|
61
|
+
]
|
|
62
|
+
sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" }
|
|
63
|
+
wheels = [
|
|
64
|
+
{ url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" },
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
[[package]]
|
|
68
|
+
name = "colorama"
|
|
69
|
+
version = "0.4.6"
|
|
70
|
+
source = { registry = "https://pypi.org/simple" }
|
|
71
|
+
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
|
72
|
+
wheels = [
|
|
73
|
+
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
[[package]]
|
|
77
|
+
name = "geographiclib"
|
|
78
|
+
version = "2.1"
|
|
79
|
+
source = { registry = "https://pypi.org/simple" }
|
|
80
|
+
sdist = { url = "https://files.pythonhosted.org/packages/df/78/4892343230a9d29faa1364564e525307a37e54ad776ea62c12129dbba704/geographiclib-2.1.tar.gz", hash = "sha256:6a6545e6262d0ed3522e13c515713718797e37ed8c672c31ad7b249f372ef108", size = 37004, upload-time = "2025-08-21T21:34:26Z" }
|
|
81
|
+
wheels = [
|
|
82
|
+
{ url = "https://files.pythonhosted.org/packages/31/b3/802576f2ea5dcb48501bb162e4c7b7b3ca5654a42b2c968ef98a797a4c79/geographiclib-2.1-py3-none-any.whl", hash = "sha256:e2a873b9b9e7fc38721ad73d5f4e6c9ed140d428a339970f505c07056997d40b", size = 40740, upload-time = "2025-08-21T21:34:24.955Z" },
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
[[package]]
|
|
86
|
+
name = "geopy"
|
|
87
|
+
version = "2.4.1"
|
|
88
|
+
source = { registry = "https://pypi.org/simple" }
|
|
89
|
+
dependencies = [
|
|
90
|
+
{ name = "geographiclib" },
|
|
91
|
+
]
|
|
92
|
+
sdist = { url = "https://files.pythonhosted.org/packages/0e/fd/ef6d53875ceab72c1fad22dbed5ec1ad04eb378c2251a6a8024bad890c3b/geopy-2.4.1.tar.gz", hash = "sha256:50283d8e7ad07d89be5cb027338c6365a32044df3ae2556ad3f52f4840b3d0d1", size = 117625, upload-time = "2023-11-23T21:49:32.734Z" }
|
|
93
|
+
wheels = [
|
|
94
|
+
{ url = "https://files.pythonhosted.org/packages/e5/15/cf2a69ade4b194aa524ac75112d5caac37414b20a3a03e6865dfe0bd1539/geopy-2.4.1-py3-none-any.whl", hash = "sha256:ae8b4bc5c1131820f4d75fce9d4aaaca0c85189b3aa5d64c3dcaf5e3b7b882a7", size = 125437, upload-time = "2023-11-23T21:49:30.421Z" },
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
[[package]]
|
|
98
|
+
name = "idna"
|
|
99
|
+
version = "3.18"
|
|
100
|
+
source = { registry = "https://pypi.org/simple" }
|
|
101
|
+
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
|
|
102
|
+
wheels = [
|
|
103
|
+
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
[[package]]
|
|
107
|
+
name = "requests"
|
|
108
|
+
version = "2.34.2"
|
|
109
|
+
source = { registry = "https://pypi.org/simple" }
|
|
110
|
+
dependencies = [
|
|
111
|
+
{ name = "certifi" },
|
|
112
|
+
{ name = "charset-normalizer" },
|
|
113
|
+
{ name = "idna" },
|
|
114
|
+
{ name = "urllib3" },
|
|
115
|
+
]
|
|
116
|
+
sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
|
|
117
|
+
wheels = [
|
|
118
|
+
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
[[package]]
|
|
122
|
+
name = "routingpy"
|
|
123
|
+
version = "1.3.0"
|
|
124
|
+
source = { registry = "https://pypi.org/simple" }
|
|
125
|
+
dependencies = [
|
|
126
|
+
{ name = "requests" },
|
|
127
|
+
]
|
|
128
|
+
wheels = [
|
|
129
|
+
{ url = "https://files.pythonhosted.org/packages/ce/01/72f3ed477b8bbf0774c95e11c6eaabdc1ac7e9bf9417b1c9bc3741a126f9/routingpy-1.3.0-py3-none-any.whl", hash = "sha256:4ccbe74691d1ffa492bd668e3468ce4a184c7c86ced6fa041fd16d85fac8fde3", size = 84433, upload-time = "2023-08-03T09:12:26.146Z" },
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
[[package]]
|
|
133
|
+
name = "traffic-cli"
|
|
134
|
+
version = "0.1.1"
|
|
135
|
+
source = { editable = "." }
|
|
136
|
+
dependencies = [
|
|
137
|
+
{ name = "click" },
|
|
138
|
+
{ name = "geopy" },
|
|
139
|
+
{ name = "routingpy" },
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
[package.metadata]
|
|
143
|
+
requires-dist = [
|
|
144
|
+
{ name = "click", specifier = ">=8.4.1" },
|
|
145
|
+
{ name = "geopy", specifier = ">=2.4.1" },
|
|
146
|
+
{ name = "routingpy", specifier = ">=1.3.0" },
|
|
147
|
+
]
|
|
148
|
+
|
|
149
|
+
[[package]]
|
|
150
|
+
name = "urllib3"
|
|
151
|
+
version = "2.7.0"
|
|
152
|
+
source = { registry = "https://pypi.org/simple" }
|
|
153
|
+
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
|
154
|
+
wheels = [
|
|
155
|
+
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
|
156
|
+
]
|