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
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HTTP client for UK Fuel Finder API.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Dict, Any, Optional
|
|
7
|
+
import requests
|
|
8
|
+
from .auth import OAuth2Authenticator
|
|
9
|
+
from .rate_limiter import RateLimiter
|
|
10
|
+
from .exceptions import (
|
|
11
|
+
NotFoundError,
|
|
12
|
+
RateLimitError,
|
|
13
|
+
ServerError,
|
|
14
|
+
ValidationError,
|
|
15
|
+
TimeoutError as FuelFinderTimeoutError,
|
|
16
|
+
ConnectionError as FuelFinderConnectionError,
|
|
17
|
+
ResponseParseError,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class HTTPClient:
|
|
24
|
+
"""HTTP client with authentication and error handling."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
base_url: str,
|
|
29
|
+
authenticator: OAuth2Authenticator,
|
|
30
|
+
rate_limiter: RateLimiter,
|
|
31
|
+
timeout: int = 30,
|
|
32
|
+
):
|
|
33
|
+
self.base_url = base_url
|
|
34
|
+
self.authenticator = authenticator
|
|
35
|
+
self.rate_limiter = rate_limiter
|
|
36
|
+
self.timeout = timeout
|
|
37
|
+
self.session = requests.Session()
|
|
38
|
+
|
|
39
|
+
def get(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
40
|
+
"""Make GET request to API."""
|
|
41
|
+
return self._make_request("GET", endpoint, params=params)
|
|
42
|
+
|
|
43
|
+
def _make_request(
|
|
44
|
+
self, method: str, endpoint: str, params: Optional[Dict[str, Any]] = None, retries: int = 3
|
|
45
|
+
) -> Any:
|
|
46
|
+
"""Make HTTP request with retries and error handling."""
|
|
47
|
+
url = f"{self.base_url}{endpoint}"
|
|
48
|
+
|
|
49
|
+
for attempt in range(retries):
|
|
50
|
+
try:
|
|
51
|
+
# Acquire rate limit permission
|
|
52
|
+
self.rate_limiter.acquire()
|
|
53
|
+
|
|
54
|
+
# Get valid token
|
|
55
|
+
token = self.authenticator.get_token()
|
|
56
|
+
|
|
57
|
+
# Make request
|
|
58
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
59
|
+
logger.debug(f"{method} {url} params={params}")
|
|
60
|
+
|
|
61
|
+
response = self.session.request(
|
|
62
|
+
method, url, headers=headers, params=params, timeout=self.timeout
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
return self._handle_response(response)
|
|
66
|
+
|
|
67
|
+
except RateLimitError as e:
|
|
68
|
+
if attempt < retries - 1:
|
|
69
|
+
self.rate_limiter.handle_rate_limit_error(e.retry_after)
|
|
70
|
+
continue
|
|
71
|
+
raise
|
|
72
|
+
|
|
73
|
+
except requests.Timeout:
|
|
74
|
+
if attempt < retries - 1:
|
|
75
|
+
continue
|
|
76
|
+
raise FuelFinderTimeoutError(f"Request to {url} timed out")
|
|
77
|
+
|
|
78
|
+
except requests.ConnectionError as e:
|
|
79
|
+
if attempt < retries - 1:
|
|
80
|
+
continue
|
|
81
|
+
raise FuelFinderConnectionError(f"Connection to {url} failed: {e}")
|
|
82
|
+
|
|
83
|
+
raise ServerError("Max retries exceeded")
|
|
84
|
+
|
|
85
|
+
def _handle_response(self, response: requests.Response) -> Any:
|
|
86
|
+
"""Handle HTTP response and errors."""
|
|
87
|
+
logger.debug(f"Response: {response.status_code} in {response.elapsed.total_seconds():.2f}s")
|
|
88
|
+
|
|
89
|
+
if response.status_code == 200:
|
|
90
|
+
try:
|
|
91
|
+
data = response.json()
|
|
92
|
+
# Handle nested response structure with "data" wrapper
|
|
93
|
+
if isinstance(data, dict) and "data" in data:
|
|
94
|
+
return data["data"]
|
|
95
|
+
return data
|
|
96
|
+
except ValueError as e:
|
|
97
|
+
raise ResponseParseError(f"Failed to parse JSON response: {e}")
|
|
98
|
+
|
|
99
|
+
elif response.status_code == 400:
|
|
100
|
+
raise ValidationError(f"Invalid request: {response.text}")
|
|
101
|
+
|
|
102
|
+
elif response.status_code == 401:
|
|
103
|
+
raise ValidationError("Unauthorized - token may be invalid")
|
|
104
|
+
|
|
105
|
+
elif response.status_code == 404:
|
|
106
|
+
raise NotFoundError(f"Resource not found: {response.url}")
|
|
107
|
+
|
|
108
|
+
elif response.status_code == 429:
|
|
109
|
+
retry_after = int(response.headers.get("Retry-After", 0))
|
|
110
|
+
raise RateLimitError("Rate limit exceeded", retry_after=retry_after)
|
|
111
|
+
|
|
112
|
+
elif response.status_code >= 500:
|
|
113
|
+
raise ServerError(f"Server error: {response.status_code} - {response.text}")
|
|
114
|
+
|
|
115
|
+
else:
|
|
116
|
+
raise ServerError(f"Unexpected status code: {response.status_code}")
|
ukfuelfinder/models.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Data models for UK Fuel Finder API responses.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import List, Optional, Dict, Any
|
|
8
|
+
from dateutil import parser
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class FuelPrice:
|
|
13
|
+
"""Fuel price information."""
|
|
14
|
+
|
|
15
|
+
fuel_type: str
|
|
16
|
+
price: Optional[float] # Can be null
|
|
17
|
+
price_last_updated: Optional[datetime] = None
|
|
18
|
+
|
|
19
|
+
@classmethod
|
|
20
|
+
def from_dict(cls, data: Dict[str, Any]) -> "FuelPrice":
|
|
21
|
+
"""Create FuelPrice from API response dictionary."""
|
|
22
|
+
price = None
|
|
23
|
+
if data.get("price"):
|
|
24
|
+
# Price comes as string like "0120.0000"
|
|
25
|
+
price = float(data["price"])
|
|
26
|
+
|
|
27
|
+
price_last_updated = None
|
|
28
|
+
if data.get("price_last_updated"):
|
|
29
|
+
price_last_updated = parser.parse(data["price_last_updated"])
|
|
30
|
+
|
|
31
|
+
return cls(
|
|
32
|
+
fuel_type=data["fuel_type"],
|
|
33
|
+
price=price,
|
|
34
|
+
price_last_updated=price_last_updated,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class PFS:
|
|
40
|
+
"""Petrol Filling Station with fuel prices."""
|
|
41
|
+
|
|
42
|
+
node_id: str
|
|
43
|
+
mft_organisation_name: str
|
|
44
|
+
trading_name: str
|
|
45
|
+
public_phone_number: Optional[str]
|
|
46
|
+
fuel_prices: List[FuelPrice]
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def from_dict(cls, data: Dict[str, Any]) -> "PFS":
|
|
50
|
+
"""Create PFS from API response dictionary."""
|
|
51
|
+
fuel_prices = [FuelPrice.from_dict(fp) for fp in data.get("fuel_prices", [])]
|
|
52
|
+
return cls(
|
|
53
|
+
node_id=data["node_id"],
|
|
54
|
+
mft_organisation_name=data["mft_organisation_name"],
|
|
55
|
+
trading_name=data["trading_name"],
|
|
56
|
+
public_phone_number=data.get("public_phone_number"),
|
|
57
|
+
fuel_prices=fuel_prices,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class Address:
|
|
63
|
+
"""Station address information."""
|
|
64
|
+
|
|
65
|
+
address_line_1: str
|
|
66
|
+
address_line_2: Optional[str]
|
|
67
|
+
city: str
|
|
68
|
+
country: str
|
|
69
|
+
county: Optional[str]
|
|
70
|
+
postcode: str
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def from_dict(cls, data: Dict[str, Any]) -> "Address":
|
|
74
|
+
"""Create Address from API response dictionary."""
|
|
75
|
+
return cls(
|
|
76
|
+
address_line_1=data["address_line_1"],
|
|
77
|
+
address_line_2=data.get("address_line_2"),
|
|
78
|
+
city=data["city"],
|
|
79
|
+
country=data["country"],
|
|
80
|
+
county=data.get("county"),
|
|
81
|
+
postcode=data["postcode"],
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class Location:
|
|
87
|
+
"""Geographic coordinates."""
|
|
88
|
+
|
|
89
|
+
latitude: float
|
|
90
|
+
longitude: float
|
|
91
|
+
address_line_1: Optional[str] = None
|
|
92
|
+
address_line_2: Optional[str] = None
|
|
93
|
+
city: Optional[str] = None
|
|
94
|
+
country: Optional[str] = None
|
|
95
|
+
county: Optional[str] = None
|
|
96
|
+
postcode: Optional[str] = None
|
|
97
|
+
|
|
98
|
+
@classmethod
|
|
99
|
+
def from_dict(cls, data: Dict[str, Any]) -> "Location":
|
|
100
|
+
"""Create Location from API response dictionary."""
|
|
101
|
+
return cls(
|
|
102
|
+
latitude=float(data["latitude"]),
|
|
103
|
+
longitude=float(data["longitude"]),
|
|
104
|
+
address_line_1=data.get("address_line_1"),
|
|
105
|
+
address_line_2=data.get("address_line_2"),
|
|
106
|
+
city=data.get("city"),
|
|
107
|
+
country=data.get("country"),
|
|
108
|
+
county=data.get("county"),
|
|
109
|
+
postcode=data.get("postcode"),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass
|
|
114
|
+
class PFSInfo:
|
|
115
|
+
"""Petrol Filling Station information (without prices)."""
|
|
116
|
+
|
|
117
|
+
node_id: str
|
|
118
|
+
mft_organisation_name: str
|
|
119
|
+
trading_name: str
|
|
120
|
+
public_phone_number: Optional[str]
|
|
121
|
+
is_same_trading_and_brand_name: Optional[bool] = None
|
|
122
|
+
brand_name: Optional[str] = None
|
|
123
|
+
temporary_closure: Optional[bool] = None
|
|
124
|
+
permanent_closure: Optional[bool] = None
|
|
125
|
+
permanent_closure_date: Optional[str] = None
|
|
126
|
+
is_motorway_service_station: Optional[bool] = None
|
|
127
|
+
is_supermarket_service_station: Optional[bool] = None
|
|
128
|
+
location: Optional[Location] = None
|
|
129
|
+
amenities: Optional[List[str]] = None
|
|
130
|
+
opening_times: Optional[Dict[str, Any]] = None
|
|
131
|
+
fuel_types: Optional[List[str]] = None
|
|
132
|
+
|
|
133
|
+
@classmethod
|
|
134
|
+
def from_dict(cls, data: Dict[str, Any]) -> "PFSInfo":
|
|
135
|
+
"""Create PFSInfo from API response dictionary."""
|
|
136
|
+
location = Location.from_dict(data["location"]) if "location" in data else None
|
|
137
|
+
|
|
138
|
+
return cls(
|
|
139
|
+
node_id=data["node_id"],
|
|
140
|
+
mft_organisation_name=data["mft_organisation_name"],
|
|
141
|
+
trading_name=data["trading_name"],
|
|
142
|
+
public_phone_number=data.get("public_phone_number"),
|
|
143
|
+
is_same_trading_and_brand_name=data.get("is_same_trading_and_brand_name"),
|
|
144
|
+
brand_name=data.get("brand_name"),
|
|
145
|
+
temporary_closure=data.get("temporary_closure"),
|
|
146
|
+
permanent_closure=data.get("permanent_closure"),
|
|
147
|
+
permanent_closure_date=data.get("permanent_closure_date"),
|
|
148
|
+
is_motorway_service_station=data.get("is_motorway_service_station"),
|
|
149
|
+
is_supermarket_service_station=data.get("is_supermarket_service_station"),
|
|
150
|
+
location=location,
|
|
151
|
+
amenities=data.get("amenities"),
|
|
152
|
+
opening_times=data.get("opening_times"),
|
|
153
|
+
fuel_types=data.get("fuel_types"),
|
|
154
|
+
)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Rate limiting for UK Fuel Finder API client.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
import threading
|
|
7
|
+
from collections import deque
|
|
8
|
+
from typing import Deque
|
|
9
|
+
from .exceptions import RateLimitError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RateLimiter:
|
|
13
|
+
"""Rate limiter with sliding window and exponential backoff."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, requests_per_minute: int, daily_limit: int):
|
|
16
|
+
self.requests_per_minute = requests_per_minute
|
|
17
|
+
self.daily_limit = daily_limit
|
|
18
|
+
self._minute_window: Deque[float] = deque()
|
|
19
|
+
self._daily_count = 0
|
|
20
|
+
self._daily_reset = time.time() + 86400 # 24 hours
|
|
21
|
+
self._lock = threading.Lock()
|
|
22
|
+
|
|
23
|
+
def acquire(self) -> None:
|
|
24
|
+
"""Acquire permission to make a request, blocking if necessary."""
|
|
25
|
+
with self._lock:
|
|
26
|
+
self._reset_daily_if_needed()
|
|
27
|
+
self._wait_if_needed()
|
|
28
|
+
self._record_request()
|
|
29
|
+
|
|
30
|
+
def _reset_daily_if_needed(self) -> None:
|
|
31
|
+
"""Reset daily counter if 24 hours have passed."""
|
|
32
|
+
if time.time() >= self._daily_reset:
|
|
33
|
+
self._daily_count = 0
|
|
34
|
+
self._daily_reset = time.time() + 86400
|
|
35
|
+
|
|
36
|
+
def _wait_if_needed(self) -> None:
|
|
37
|
+
"""Wait if rate limits would be exceeded."""
|
|
38
|
+
now = time.time()
|
|
39
|
+
|
|
40
|
+
# Remove requests older than 1 minute
|
|
41
|
+
while self._minute_window and self._minute_window[0] < now - 60:
|
|
42
|
+
self._minute_window.popleft()
|
|
43
|
+
|
|
44
|
+
# Check daily limit
|
|
45
|
+
if self._daily_count >= self.daily_limit:
|
|
46
|
+
wait_time = self._daily_reset - now
|
|
47
|
+
raise RateLimitError(
|
|
48
|
+
f"Daily limit of {self.daily_limit} requests exceeded. "
|
|
49
|
+
f"Resets in {int(wait_time)} seconds",
|
|
50
|
+
retry_after=int(wait_time),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Check per-minute limit
|
|
54
|
+
if len(self._minute_window) >= self.requests_per_minute:
|
|
55
|
+
oldest = self._minute_window[0]
|
|
56
|
+
wait_time = 60 - (now - oldest)
|
|
57
|
+
if wait_time > 0:
|
|
58
|
+
time.sleep(wait_time)
|
|
59
|
+
|
|
60
|
+
def _record_request(self) -> None:
|
|
61
|
+
"""Record a request in the sliding window."""
|
|
62
|
+
now = time.time()
|
|
63
|
+
self._minute_window.append(now)
|
|
64
|
+
self._daily_count += 1
|
|
65
|
+
|
|
66
|
+
def handle_rate_limit_error(self, retry_after: int) -> None:
|
|
67
|
+
"""Handle 429 rate limit error with exponential backoff."""
|
|
68
|
+
if retry_after > 0:
|
|
69
|
+
time.sleep(retry_after)
|
|
70
|
+
else:
|
|
71
|
+
# Default exponential backoff
|
|
72
|
+
time.sleep(1)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Services for UK Fuel Finder API."""
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Forecourt service for UK Fuel Finder API.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import List, Optional, Dict, Any, Iterator
|
|
6
|
+
from ..http_client import HTTPClient
|
|
7
|
+
from ..cache import ResponseCache
|
|
8
|
+
from ..models import PFSInfo
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ForecourtService:
|
|
12
|
+
"""Service for forecourt/PFS information operations."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, http_client: HTTPClient, cache: ResponseCache):
|
|
15
|
+
self.http_client = http_client
|
|
16
|
+
self.cache = cache
|
|
17
|
+
self.cache_ttl = 3600 # 1 hour for forecourt info
|
|
18
|
+
|
|
19
|
+
def get_all_pfs(
|
|
20
|
+
self, batch_number: Optional[int] = None, use_cache: bool = True
|
|
21
|
+
) -> List[PFSInfo]:
|
|
22
|
+
"""
|
|
23
|
+
Get all PFS information.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
batch_number: Batch number for pagination (500 per batch)
|
|
27
|
+
use_cache: Whether to use cached response
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
List of PFS information
|
|
31
|
+
"""
|
|
32
|
+
params: Dict[str, Any] = {}
|
|
33
|
+
if batch_number:
|
|
34
|
+
params["batch-number"] = batch_number
|
|
35
|
+
|
|
36
|
+
cache_key = self.cache.generate_key("/pfs", params)
|
|
37
|
+
|
|
38
|
+
if use_cache:
|
|
39
|
+
cached = self.cache.get(cache_key)
|
|
40
|
+
if cached is not None:
|
|
41
|
+
return [PFSInfo.from_dict(item) for item in cached]
|
|
42
|
+
|
|
43
|
+
response = self.http_client.get("/pfs", params=params)
|
|
44
|
+
self.cache.set(cache_key, response, self.cache_ttl)
|
|
45
|
+
|
|
46
|
+
return [PFSInfo.from_dict(item) for item in response]
|
|
47
|
+
|
|
48
|
+
def get_incremental_pfs(
|
|
49
|
+
self,
|
|
50
|
+
effective_start_timestamp: str,
|
|
51
|
+
batch_number: Optional[int] = None,
|
|
52
|
+
use_cache: bool = True,
|
|
53
|
+
) -> List[PFSInfo]:
|
|
54
|
+
"""
|
|
55
|
+
Get incremental PFS information updates.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
effective_start_timestamp: Timestamp in YYYY-MM-DD HH:MM:SS format
|
|
59
|
+
batch_number: Batch number for pagination
|
|
60
|
+
use_cache: Whether to use cached response
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
List of updated PFS information
|
|
64
|
+
"""
|
|
65
|
+
params: Dict[str, Any] = {"effective-start-timestamp": effective_start_timestamp}
|
|
66
|
+
if batch_number:
|
|
67
|
+
params["batch-number"] = batch_number
|
|
68
|
+
|
|
69
|
+
cache_key = self.cache.generate_key("/pfs", params)
|
|
70
|
+
|
|
71
|
+
if use_cache:
|
|
72
|
+
cached = self.cache.get(cache_key)
|
|
73
|
+
if cached is not None:
|
|
74
|
+
return [PFSInfo.from_dict(item) for item in cached]
|
|
75
|
+
|
|
76
|
+
response = self.http_client.get("/pfs", params=params)
|
|
77
|
+
self.cache.set(cache_key, response, self.cache_ttl)
|
|
78
|
+
|
|
79
|
+
return [PFSInfo.from_dict(item) for item in response]
|
|
80
|
+
|
|
81
|
+
def get_pfs_by_node_id(self, node_id: str, pfs_list: List[PFSInfo]) -> Optional[PFSInfo]:
|
|
82
|
+
"""Get specific PFS by node ID from a list."""
|
|
83
|
+
for pfs in pfs_list:
|
|
84
|
+
if pfs.node_id == node_id:
|
|
85
|
+
return pfs
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
def get_all_pfs_paginated(self, use_cache: bool = True) -> Iterator[List[PFSInfo]]:
|
|
89
|
+
"""
|
|
90
|
+
Get all PFS information with automatic pagination.
|
|
91
|
+
|
|
92
|
+
Yields batches of up to 500 PFS records until no more data is available.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
use_cache: Whether to use cached responses
|
|
96
|
+
|
|
97
|
+
Yields:
|
|
98
|
+
Lists of PFS information (up to 500 per batch)
|
|
99
|
+
"""
|
|
100
|
+
batch = 1
|
|
101
|
+
while True:
|
|
102
|
+
pfs_list = self.get_all_pfs(batch_number=batch, use_cache=use_cache)
|
|
103
|
+
if not pfs_list:
|
|
104
|
+
break
|
|
105
|
+
yield pfs_list
|
|
106
|
+
if len(pfs_list) < 500:
|
|
107
|
+
break
|
|
108
|
+
batch += 1
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Price service for UK Fuel Finder API.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import List, Optional, Dict, Any
|
|
6
|
+
from ..http_client import HTTPClient
|
|
7
|
+
from ..cache import ResponseCache
|
|
8
|
+
from ..models import PFS, FuelPrice
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PriceService:
|
|
12
|
+
"""Service for fuel price operations."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, http_client: HTTPClient, cache: ResponseCache):
|
|
15
|
+
self.http_client = http_client
|
|
16
|
+
self.cache = cache
|
|
17
|
+
self.cache_ttl = 900 # 15 minutes for prices
|
|
18
|
+
|
|
19
|
+
def get_all_pfs_prices(
|
|
20
|
+
self,
|
|
21
|
+
batch_number: Optional[int] = None,
|
|
22
|
+
effective_start_timestamp: Optional[str] = None,
|
|
23
|
+
use_cache: bool = True,
|
|
24
|
+
) -> List[PFS]:
|
|
25
|
+
"""
|
|
26
|
+
Get all PFS fuel prices.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
batch_number: Batch number for pagination
|
|
30
|
+
effective_start_timestamp: Timestamp in YYYY-MM-DD HH:MM:SS format for incremental updates
|
|
31
|
+
use_cache: Whether to use cached response
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
List of PFS with fuel prices
|
|
35
|
+
"""
|
|
36
|
+
params: Dict[str, Any] = {}
|
|
37
|
+
if batch_number:
|
|
38
|
+
params["batch-number"] = batch_number
|
|
39
|
+
if effective_start_timestamp:
|
|
40
|
+
params["effective-start-timestamp"] = effective_start_timestamp
|
|
41
|
+
|
|
42
|
+
cache_key = self.cache.generate_key("/pfs/fuel-prices", params)
|
|
43
|
+
|
|
44
|
+
if use_cache:
|
|
45
|
+
cached = self.cache.get(cache_key)
|
|
46
|
+
if cached is not None:
|
|
47
|
+
return [PFS.from_dict(item) for item in cached]
|
|
48
|
+
|
|
49
|
+
response = self.http_client.get("/pfs/fuel-prices", params=params)
|
|
50
|
+
self.cache.set(cache_key, response, self.cache_ttl)
|
|
51
|
+
|
|
52
|
+
return [PFS.from_dict(item) for item in response]
|
|
53
|
+
|
|
54
|
+
def get_pfs_by_node_id(self, node_id: str, pfs_list: List[PFS]) -> Optional[PFS]:
|
|
55
|
+
"""Get specific PFS by node ID from a list."""
|
|
56
|
+
for pfs in pfs_list:
|
|
57
|
+
if pfs.node_id == node_id:
|
|
58
|
+
return pfs
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
def get_prices_by_fuel_type(self, fuel_type: str, pfs_list: List[PFS]) -> List[FuelPrice]:
|
|
62
|
+
"""Get all prices for a specific fuel type."""
|
|
63
|
+
prices = []
|
|
64
|
+
for pfs in pfs_list:
|
|
65
|
+
for price in pfs.fuel_prices:
|
|
66
|
+
if price.fuel_type == fuel_type:
|
|
67
|
+
prices.append(price)
|
|
68
|
+
return prices
|
|
69
|
+
|
|
70
|
+
def get_incremental_updates(self, since_timestamp: str, **kwargs: Any) -> List[PFS]:
|
|
71
|
+
"""Get incremental price updates since a specific timestamp."""
|
|
72
|
+
return self.get_all_pfs_prices(effective_start_timestamp=since_timestamp, **kwargs)
|