ukfuelfinder 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ukfuelfinder/__init__.py +47 -0
- ukfuelfinder/auth.py +111 -0
- ukfuelfinder/cache.py +70 -0
- ukfuelfinder/client.py +257 -0
- ukfuelfinder/config.py +62 -0
- ukfuelfinder/exceptions.py +83 -0
- ukfuelfinder/http_client.py +116 -0
- ukfuelfinder/models.py +154 -0
- ukfuelfinder/rate_limiter.py +72 -0
- ukfuelfinder/services/__init__.py +1 -0
- ukfuelfinder/services/forecourt_service.py +108 -0
- ukfuelfinder/services/price_service.py +72 -0
- ukfuelfinder-1.0.0.dist-info/METADATA +233 -0
- ukfuelfinder-1.0.0.dist-info/RECORD +17 -0
- ukfuelfinder-1.0.0.dist-info/WHEEL +5 -0
- ukfuelfinder-1.0.0.dist-info/licenses/LICENSE +21 -0
- ukfuelfinder-1.0.0.dist-info/top_level.txt +1 -0
ukfuelfinder/__init__.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""
|
|
2
|
+
UK Fuel Finder Python Library
|
|
3
|
+
|
|
4
|
+
Python library for accessing the UK Government Fuel Finder API.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__version__ = "1.0.0"
|
|
8
|
+
|
|
9
|
+
from .client import FuelFinderClient
|
|
10
|
+
from .exceptions import (
|
|
11
|
+
FuelFinderError,
|
|
12
|
+
AuthenticationError,
|
|
13
|
+
InvalidCredentialsError,
|
|
14
|
+
TokenExpiredError,
|
|
15
|
+
APIError,
|
|
16
|
+
NotFoundError,
|
|
17
|
+
RateLimitError,
|
|
18
|
+
ServerError,
|
|
19
|
+
ValidationError,
|
|
20
|
+
NetworkError,
|
|
21
|
+
TimeoutError,
|
|
22
|
+
ConnectionError,
|
|
23
|
+
ResponseParseError,
|
|
24
|
+
)
|
|
25
|
+
from .models import PFS, PFSInfo, FuelPrice, Address, Location
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"FuelFinderClient",
|
|
29
|
+
"FuelFinderError",
|
|
30
|
+
"AuthenticationError",
|
|
31
|
+
"InvalidCredentialsError",
|
|
32
|
+
"TokenExpiredError",
|
|
33
|
+
"APIError",
|
|
34
|
+
"NotFoundError",
|
|
35
|
+
"RateLimitError",
|
|
36
|
+
"ServerError",
|
|
37
|
+
"ValidationError",
|
|
38
|
+
"NetworkError",
|
|
39
|
+
"TimeoutError",
|
|
40
|
+
"ConnectionError",
|
|
41
|
+
"ResponseParseError",
|
|
42
|
+
"PFS",
|
|
43
|
+
"PFSInfo",
|
|
44
|
+
"FuelPrice",
|
|
45
|
+
"Address",
|
|
46
|
+
"Location",
|
|
47
|
+
]
|
ukfuelfinder/auth.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OAuth 2.0 authentication for UK Fuel Finder API.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
import threading
|
|
7
|
+
from typing import Optional
|
|
8
|
+
import requests
|
|
9
|
+
from .exceptions import AuthenticationError, InvalidCredentialsError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class OAuth2Authenticator:
|
|
13
|
+
"""Manages OAuth 2.0 authentication and token lifecycle."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, client_id: str, client_secret: str, token_url: str, refresh_url: str):
|
|
16
|
+
self.client_id = client_id
|
|
17
|
+
self.client_secret = client_secret
|
|
18
|
+
self.token_url = token_url
|
|
19
|
+
self.refresh_url = refresh_url
|
|
20
|
+
self._access_token: Optional[str] = None
|
|
21
|
+
self._refresh_token: Optional[str] = None
|
|
22
|
+
self._token_expiry: float = 0
|
|
23
|
+
self._lock = threading.Lock()
|
|
24
|
+
|
|
25
|
+
def get_token(self) -> str:
|
|
26
|
+
"""Get valid access token, refreshing if necessary."""
|
|
27
|
+
with self._lock:
|
|
28
|
+
if self._is_token_valid():
|
|
29
|
+
return self._access_token # type: ignore
|
|
30
|
+
|
|
31
|
+
# Try refresh token first if available
|
|
32
|
+
if self._refresh_token:
|
|
33
|
+
try:
|
|
34
|
+
return self._refresh_access_token()
|
|
35
|
+
except AuthenticationError:
|
|
36
|
+
# Fall back to generating new token
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
return self._generate_token()
|
|
40
|
+
|
|
41
|
+
def _is_token_valid(self) -> bool:
|
|
42
|
+
"""Check if current token is valid and not expiring soon."""
|
|
43
|
+
if not self._access_token:
|
|
44
|
+
return False
|
|
45
|
+
# Refresh 60 seconds before expiry
|
|
46
|
+
return time.time() < (self._token_expiry - 60)
|
|
47
|
+
|
|
48
|
+
def _generate_token(self) -> str:
|
|
49
|
+
"""Generate new access token using client credentials."""
|
|
50
|
+
try:
|
|
51
|
+
response = requests.post(
|
|
52
|
+
self.token_url,
|
|
53
|
+
json={"client_id": self.client_id, "client_secret": self.client_secret},
|
|
54
|
+
headers={"Content-Type": "application/json"},
|
|
55
|
+
timeout=30,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
if response.status_code == 401:
|
|
59
|
+
raise InvalidCredentialsError("Invalid client credentials")
|
|
60
|
+
|
|
61
|
+
response.raise_for_status()
|
|
62
|
+
data = response.json()
|
|
63
|
+
|
|
64
|
+
# Handle nested response structure
|
|
65
|
+
if "data" in data:
|
|
66
|
+
token_data = data["data"]
|
|
67
|
+
else:
|
|
68
|
+
token_data = data
|
|
69
|
+
|
|
70
|
+
self._access_token = token_data["access_token"]
|
|
71
|
+
self._refresh_token = token_data.get("refresh_token")
|
|
72
|
+
self._token_expiry = time.time() + token_data["expires_in"]
|
|
73
|
+
|
|
74
|
+
return self._access_token
|
|
75
|
+
|
|
76
|
+
except requests.RequestException as e:
|
|
77
|
+
raise AuthenticationError(f"Failed to generate access token: {e}")
|
|
78
|
+
|
|
79
|
+
def _refresh_access_token(self) -> str:
|
|
80
|
+
"""Refresh access token using refresh token."""
|
|
81
|
+
if not self._refresh_token:
|
|
82
|
+
raise AuthenticationError("No refresh token available")
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
response = requests.post(
|
|
86
|
+
self.refresh_url,
|
|
87
|
+
json={"client_id": self.client_id, "refresh_token": self._refresh_token},
|
|
88
|
+
headers={"Content-Type": "application/json"},
|
|
89
|
+
timeout=30,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
if response.status_code in (400, 401):
|
|
93
|
+
raise AuthenticationError("Refresh token expired or invalid")
|
|
94
|
+
|
|
95
|
+
response.raise_for_status()
|
|
96
|
+
data = response.json()
|
|
97
|
+
|
|
98
|
+
# Handle nested response structure
|
|
99
|
+
if "data" in data:
|
|
100
|
+
token_data = data["data"]
|
|
101
|
+
else:
|
|
102
|
+
token_data = data
|
|
103
|
+
|
|
104
|
+
self._access_token = token_data["access_token"]
|
|
105
|
+
self._refresh_token = token_data.get("refresh_token", self._refresh_token)
|
|
106
|
+
self._token_expiry = time.time() + token_data["expires_in"]
|
|
107
|
+
|
|
108
|
+
return self._access_token
|
|
109
|
+
|
|
110
|
+
except requests.RequestException as e:
|
|
111
|
+
raise AuthenticationError(f"Failed to refresh access token: {e}")
|
ukfuelfinder/cache.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Response caching for UK Fuel Finder API client.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
import threading
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
from typing import Optional, Dict, Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ResponseCache:
|
|
13
|
+
"""In-memory cache with TTL support."""
|
|
14
|
+
|
|
15
|
+
def __init__(self) -> None:
|
|
16
|
+
self._cache: Dict[str, tuple[Any, float]] = {}
|
|
17
|
+
self._lock = threading.Lock()
|
|
18
|
+
self._hits = 0
|
|
19
|
+
self._misses = 0
|
|
20
|
+
|
|
21
|
+
def get(self, key: str) -> Optional[Any]:
|
|
22
|
+
"""Get value from cache if not expired."""
|
|
23
|
+
with self._lock:
|
|
24
|
+
if key in self._cache:
|
|
25
|
+
value, expiry = self._cache[key]
|
|
26
|
+
if time.time() < expiry:
|
|
27
|
+
self._hits += 1
|
|
28
|
+
return value
|
|
29
|
+
else:
|
|
30
|
+
del self._cache[key]
|
|
31
|
+
|
|
32
|
+
self._misses += 1
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
def set(self, key: str, value: Any, ttl: int) -> None:
|
|
36
|
+
"""Store value in cache with TTL in seconds."""
|
|
37
|
+
with self._lock:
|
|
38
|
+
expiry = time.time() + ttl
|
|
39
|
+
self._cache[key] = (value, expiry)
|
|
40
|
+
|
|
41
|
+
def clear(self) -> None:
|
|
42
|
+
"""Clear all cached data."""
|
|
43
|
+
with self._lock:
|
|
44
|
+
self._cache.clear()
|
|
45
|
+
self._hits = 0
|
|
46
|
+
self._misses = 0
|
|
47
|
+
|
|
48
|
+
def generate_key(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> str:
|
|
49
|
+
"""Generate cache key from endpoint and parameters."""
|
|
50
|
+
if params:
|
|
51
|
+
# Sort params for consistent key generation
|
|
52
|
+
sorted_params = json.dumps(params, sort_keys=True)
|
|
53
|
+
key_str = f"{endpoint}:{sorted_params}"
|
|
54
|
+
else:
|
|
55
|
+
key_str = endpoint
|
|
56
|
+
|
|
57
|
+
return hashlib.md5(key_str.encode()).hexdigest()
|
|
58
|
+
|
|
59
|
+
def get_stats(self) -> Dict[str, int]:
|
|
60
|
+
"""Get cache statistics."""
|
|
61
|
+
with self._lock:
|
|
62
|
+
total = self._hits + self._misses
|
|
63
|
+
hit_rate = (self._hits / total * 100) if total > 0 else 0
|
|
64
|
+
return {
|
|
65
|
+
"hits": self._hits,
|
|
66
|
+
"misses": self._misses,
|
|
67
|
+
"total": total,
|
|
68
|
+
"hit_rate": round(hit_rate, 2),
|
|
69
|
+
"size": len(self._cache),
|
|
70
|
+
}
|
ukfuelfinder/client.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Main client for UK Fuel Finder API.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import List, Optional, Iterator, Any, Tuple
|
|
6
|
+
from math import radians, cos, sin, asin, sqrt
|
|
7
|
+
from .config import Config
|
|
8
|
+
from .auth import OAuth2Authenticator
|
|
9
|
+
from .http_client import HTTPClient
|
|
10
|
+
from .cache import ResponseCache
|
|
11
|
+
from .rate_limiter import RateLimiter
|
|
12
|
+
from .services.price_service import PriceService
|
|
13
|
+
from .services.forecourt_service import ForecourtService
|
|
14
|
+
from .models import PFS, PFSInfo, FuelPrice
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FuelFinderClient:
|
|
18
|
+
"""
|
|
19
|
+
Client for accessing the UK Government Fuel Finder API.
|
|
20
|
+
|
|
21
|
+
Example:
|
|
22
|
+
>>> client = FuelFinderClient(
|
|
23
|
+
... client_id="your_client_id",
|
|
24
|
+
... client_secret="your_client_secret"
|
|
25
|
+
... )
|
|
26
|
+
>>> prices = client.get_all_pfs_prices()
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
client_id: Optional[str] = None,
|
|
32
|
+
client_secret: Optional[str] = None,
|
|
33
|
+
environment: str = "production",
|
|
34
|
+
cache_enabled: bool = True,
|
|
35
|
+
timeout: int = 30,
|
|
36
|
+
):
|
|
37
|
+
"""
|
|
38
|
+
Initialize Fuel Finder client.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
client_id: OAuth client ID (reads from env if not provided)
|
|
42
|
+
client_secret: OAuth client secret (reads from env if not provided)
|
|
43
|
+
environment: "production" or "test"
|
|
44
|
+
cache_enabled: Enable response caching
|
|
45
|
+
timeout: Request timeout in seconds
|
|
46
|
+
"""
|
|
47
|
+
if client_id and client_secret:
|
|
48
|
+
self.config = Config(
|
|
49
|
+
client_id=client_id,
|
|
50
|
+
client_secret=client_secret,
|
|
51
|
+
environment=environment,
|
|
52
|
+
timeout=timeout,
|
|
53
|
+
cache_enabled=cache_enabled,
|
|
54
|
+
)
|
|
55
|
+
else:
|
|
56
|
+
self.config = Config.from_env(environment)
|
|
57
|
+
|
|
58
|
+
# Initialize components
|
|
59
|
+
self.authenticator = OAuth2Authenticator(
|
|
60
|
+
client_id=self.config.client_id,
|
|
61
|
+
client_secret=self.config.client_secret,
|
|
62
|
+
token_url=self.config.token_url,
|
|
63
|
+
refresh_url=self.config.refresh_url,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
self.rate_limiter = RateLimiter(
|
|
67
|
+
requests_per_minute=self.config.rate_limit_rpm,
|
|
68
|
+
daily_limit=self.config.rate_limit_daily,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
self.http_client = HTTPClient(
|
|
72
|
+
base_url=self.config.base_url,
|
|
73
|
+
authenticator=self.authenticator,
|
|
74
|
+
rate_limiter=self.rate_limiter,
|
|
75
|
+
timeout=self.config.timeout,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
self.cache = ResponseCache() if self.config.cache_enabled else None
|
|
79
|
+
|
|
80
|
+
# Initialize services
|
|
81
|
+
self.price_service = PriceService(self.http_client, self.cache or ResponseCache())
|
|
82
|
+
self.forecourt_service = ForecourtService(self.http_client, self.cache or ResponseCache())
|
|
83
|
+
|
|
84
|
+
# Price methods
|
|
85
|
+
def get_all_pfs_prices(
|
|
86
|
+
self,
|
|
87
|
+
batch_number: Optional[int] = None,
|
|
88
|
+
effective_start_timestamp: Optional[str] = None,
|
|
89
|
+
**kwargs: Any,
|
|
90
|
+
) -> List[PFS]:
|
|
91
|
+
"""
|
|
92
|
+
Get all PFS fuel prices.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
batch_number: Batch number for pagination
|
|
96
|
+
effective_start_timestamp: Timestamp in YYYY-MM-DD HH:MM:SS for incremental updates
|
|
97
|
+
**kwargs: Additional parameters
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
List of PFS with fuel prices
|
|
101
|
+
"""
|
|
102
|
+
return self.price_service.get_all_pfs_prices(
|
|
103
|
+
batch_number=batch_number, effective_start_timestamp=effective_start_timestamp, **kwargs
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def get_pfs(self, node_id: str) -> Optional[PFS]:
|
|
107
|
+
"""
|
|
108
|
+
Get specific PFS by node ID.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
node_id: Unique PFS identifier
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
PFS object or None if not found
|
|
115
|
+
"""
|
|
116
|
+
all_pfs = self.get_all_pfs_prices()
|
|
117
|
+
return self.price_service.get_pfs_by_node_id(node_id, all_pfs)
|
|
118
|
+
|
|
119
|
+
def get_prices_by_fuel_type(self, fuel_type: str) -> List[FuelPrice]:
|
|
120
|
+
"""
|
|
121
|
+
Get all prices for a specific fuel type.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
fuel_type: Fuel type (e.g., "unleaded", "diesel")
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
List of fuel prices
|
|
128
|
+
"""
|
|
129
|
+
all_pfs = self.get_all_pfs_prices()
|
|
130
|
+
return self.price_service.get_prices_by_fuel_type(fuel_type, all_pfs)
|
|
131
|
+
|
|
132
|
+
def get_incremental_price_updates(self, since_timestamp: str, **kwargs: Any) -> List[PFS]:
|
|
133
|
+
"""
|
|
134
|
+
Get incremental price updates since a specific timestamp.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
since_timestamp: Timestamp in YYYY-MM-DD HH:MM:SS format
|
|
138
|
+
**kwargs: Additional parameters
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
List of PFS with updated prices
|
|
142
|
+
"""
|
|
143
|
+
return self.price_service.get_incremental_updates(since_timestamp, **kwargs)
|
|
144
|
+
|
|
145
|
+
# Forecourt methods
|
|
146
|
+
def get_all_pfs_info(self, batch_number: Optional[int] = None, **kwargs: Any) -> List[PFSInfo]:
|
|
147
|
+
"""
|
|
148
|
+
Get all PFS information.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
batch_number: Batch number for pagination (500 per batch)
|
|
152
|
+
**kwargs: Additional parameters
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
List of PFS information
|
|
156
|
+
"""
|
|
157
|
+
return self.forecourt_service.get_all_pfs(batch_number=batch_number, **kwargs)
|
|
158
|
+
|
|
159
|
+
def get_incremental_pfs_info(self, since_timestamp: str, **kwargs: Any) -> List[PFSInfo]:
|
|
160
|
+
"""
|
|
161
|
+
Get incremental PFS information updates.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
since_timestamp: Timestamp in YYYY-MM-DD HH:MM:SS format
|
|
165
|
+
**kwargs: Additional parameters
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
List of updated PFS information
|
|
169
|
+
"""
|
|
170
|
+
return self.forecourt_service.get_incremental_pfs(
|
|
171
|
+
effective_start_timestamp=since_timestamp, **kwargs
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
def get_pfs_info(self, node_id: str) -> Optional[PFSInfo]:
|
|
175
|
+
"""
|
|
176
|
+
Get specific PFS information by node ID.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
node_id: Unique PFS identifier
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
PFSInfo object or None if not found
|
|
183
|
+
"""
|
|
184
|
+
all_pfs = self.get_all_pfs_info()
|
|
185
|
+
return self.forecourt_service.get_pfs_by_node_id(node_id, all_pfs)
|
|
186
|
+
|
|
187
|
+
def get_all_pfs_paginated(self) -> Iterator[List[PFSInfo]]:
|
|
188
|
+
"""
|
|
189
|
+
Get all PFS information with automatic pagination.
|
|
190
|
+
|
|
191
|
+
Yields:
|
|
192
|
+
Lists of PFS information (up to 500 per batch)
|
|
193
|
+
"""
|
|
194
|
+
return self.forecourt_service.get_all_pfs_paginated()
|
|
195
|
+
|
|
196
|
+
# Utility methods
|
|
197
|
+
def clear_cache(self) -> None:
|
|
198
|
+
"""Clear all cached responses."""
|
|
199
|
+
if self.cache:
|
|
200
|
+
self.cache.clear()
|
|
201
|
+
|
|
202
|
+
def set_cache_ttl(self, resource_type: str, ttl: int) -> None:
|
|
203
|
+
"""
|
|
204
|
+
Set cache TTL for a resource type.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
resource_type: "prices" or "forecourts"
|
|
208
|
+
ttl: Time to live in seconds
|
|
209
|
+
"""
|
|
210
|
+
if resource_type == "prices":
|
|
211
|
+
self.price_service.cache_ttl = ttl
|
|
212
|
+
elif resource_type == "forecourts":
|
|
213
|
+
self.forecourt_service.cache_ttl = ttl
|
|
214
|
+
|
|
215
|
+
def get_cache_stats(self) -> dict:
|
|
216
|
+
"""Get cache statistics."""
|
|
217
|
+
if self.cache:
|
|
218
|
+
return self.cache.get_stats()
|
|
219
|
+
return {}
|
|
220
|
+
|
|
221
|
+
def search_by_location(
|
|
222
|
+
self, latitude: float, longitude: float, radius_km: float = 5.0
|
|
223
|
+
) -> List[Tuple[float, PFSInfo]]:
|
|
224
|
+
"""
|
|
225
|
+
Search for fuel stations near a location.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
latitude: Search center latitude
|
|
229
|
+
longitude: Search center longitude
|
|
230
|
+
radius_km: Search radius in kilometers (default: 5.0)
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
List of tuples (distance_km, PFSInfo) sorted by distance
|
|
234
|
+
"""
|
|
235
|
+
sites = self.get_all_pfs_info()
|
|
236
|
+
nearby = []
|
|
237
|
+
|
|
238
|
+
for site in sites:
|
|
239
|
+
if site.location and site.location.latitude and site.location.longitude:
|
|
240
|
+
distance = self._haversine(
|
|
241
|
+
longitude, latitude, site.location.longitude, site.location.latitude
|
|
242
|
+
)
|
|
243
|
+
if distance <= radius_km:
|
|
244
|
+
nearby.append((distance, site))
|
|
245
|
+
|
|
246
|
+
nearby.sort(key=lambda x: x[0])
|
|
247
|
+
return nearby
|
|
248
|
+
|
|
249
|
+
@staticmethod
|
|
250
|
+
def _haversine(lon1: float, lat1: float, lon2: float, lat2: float) -> float:
|
|
251
|
+
"""Calculate distance between two points in kilometers using Haversine formula."""
|
|
252
|
+
lon1, lat1, lon2, lat2 = map(radians, [lon1, lat1, lon2, lat2])
|
|
253
|
+
dlon = lon2 - lon1
|
|
254
|
+
dlat = lat2 - lat1
|
|
255
|
+
a = sin(dlat / 2) ** 2 + cos(lat1) * cos(lat2) * sin(dlon / 2) ** 2
|
|
256
|
+
c = 2 * asin(sqrt(a))
|
|
257
|
+
return 6371 * c
|
ukfuelfinder/config.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration management for UK Fuel Finder API client.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class Config:
|
|
12
|
+
"""Configuration for Fuel Finder API client."""
|
|
13
|
+
|
|
14
|
+
client_id: str
|
|
15
|
+
client_secret: str
|
|
16
|
+
environment: str = "production"
|
|
17
|
+
timeout: int = 30
|
|
18
|
+
cache_enabled: bool = True
|
|
19
|
+
rate_limit_rpm: int = 120
|
|
20
|
+
rate_limit_daily: int = 10000
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def base_url(self) -> str:
|
|
24
|
+
"""Get base URL for the environment."""
|
|
25
|
+
if self.environment == "test":
|
|
26
|
+
return "https://test.fuel-finder.service.gov.uk/api/v1"
|
|
27
|
+
return "https://www.fuel-finder.service.gov.uk/api/v1"
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def token_url(self) -> str:
|
|
31
|
+
"""Get token URL for the environment."""
|
|
32
|
+
return f"{self.base_url}/oauth/generate_access_token"
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def refresh_url(self) -> str:
|
|
36
|
+
"""Get refresh token URL for the environment."""
|
|
37
|
+
return f"{self.base_url}/oauth/regenerate_access_token"
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def from_env(cls, environment: Optional[str] = None) -> "Config":
|
|
41
|
+
"""Create configuration from environment variables."""
|
|
42
|
+
client_id = os.getenv("FUEL_FINDER_CLIENT_ID")
|
|
43
|
+
client_secret = os.getenv("FUEL_FINDER_CLIENT_SECRET")
|
|
44
|
+
env = environment or os.getenv("FUEL_FINDER_ENVIRONMENT", "production")
|
|
45
|
+
|
|
46
|
+
if not client_id or not client_secret:
|
|
47
|
+
raise ValueError(
|
|
48
|
+
"FUEL_FINDER_CLIENT_ID and FUEL_FINDER_CLIENT_SECRET "
|
|
49
|
+
"environment variables must be set"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Adjust rate limits for test environment
|
|
53
|
+
rate_limit_rpm = 30 if env == "test" else 120
|
|
54
|
+
rate_limit_daily = 5000 if env == "test" else 10000
|
|
55
|
+
|
|
56
|
+
return cls(
|
|
57
|
+
client_id=client_id,
|
|
58
|
+
client_secret=client_secret,
|
|
59
|
+
environment=env,
|
|
60
|
+
rate_limit_rpm=rate_limit_rpm,
|
|
61
|
+
rate_limit_daily=rate_limit_daily,
|
|
62
|
+
)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Exceptions for the UK Fuel Finder API client.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class FuelFinderError(Exception):
|
|
7
|
+
"""Base exception for all Fuel Finder errors."""
|
|
8
|
+
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AuthenticationError(FuelFinderError):
|
|
13
|
+
"""Raised when authentication fails."""
|
|
14
|
+
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class InvalidCredentialsError(AuthenticationError):
|
|
19
|
+
"""Raised when client credentials are invalid."""
|
|
20
|
+
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TokenExpiredError(AuthenticationError):
|
|
25
|
+
"""Raised when access token has expired."""
|
|
26
|
+
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class APIError(FuelFinderError):
|
|
31
|
+
"""Base exception for API-related errors."""
|
|
32
|
+
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class NotFoundError(APIError):
|
|
37
|
+
"""Raised when requested resource is not found (404)."""
|
|
38
|
+
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class RateLimitError(APIError):
|
|
43
|
+
"""Raised when API rate limit is exceeded (429)."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, message: str, retry_after: int = 0) -> None:
|
|
46
|
+
super().__init__(message)
|
|
47
|
+
self.retry_after = retry_after
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ServerError(APIError):
|
|
51
|
+
"""Raised when API returns a server error (5xx)."""
|
|
52
|
+
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ValidationError(APIError):
|
|
57
|
+
"""Raised when request validation fails (400)."""
|
|
58
|
+
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class NetworkError(FuelFinderError):
|
|
63
|
+
"""Base exception for network-related errors."""
|
|
64
|
+
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class TimeoutError(NetworkError):
|
|
69
|
+
"""Raised when a request times out."""
|
|
70
|
+
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class ConnectionError(NetworkError):
|
|
75
|
+
"""Raised when connection to API fails."""
|
|
76
|
+
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ResponseParseError(FuelFinderError):
|
|
81
|
+
"""Raised when API response cannot be parsed."""
|
|
82
|
+
|
|
83
|
+
pass
|