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,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)