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.
@@ -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
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.5
2
+ Name: traffic-cli
3
+ Version: 0.1.1
4
+ Summary: CLI tool to calculate travel times using routing APIs
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: click>=8.0.0
7
+ Requires-Dist: geopy>=2.4.1
8
+ Requires-Dist: routingpy>=1.3.0
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
+ ]