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.
@@ -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