oilpriceapi 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,50 @@
1
+ """
2
+ OilPriceAPI Python SDK
3
+
4
+ The official Python SDK for OilPriceAPI - Real-time and historical oil prices.
5
+ """
6
+
7
+ __version__ = "1.0.0"
8
+ __author__ = "OilPriceAPI"
9
+ __email__ = "support@oilpriceapi.com"
10
+
11
+ from oilpriceapi.client import OilPriceAPI
12
+ from oilpriceapi.async_client import AsyncOilPriceAPI
13
+ from oilpriceapi.exceptions import (
14
+ OilPriceAPIError,
15
+ AuthenticationError,
16
+ RateLimitError,
17
+ DataNotFoundError,
18
+ ServerError,
19
+ )
20
+
21
+ __all__ = [
22
+ "OilPriceAPI",
23
+ "AsyncOilPriceAPI",
24
+ "OilPriceAPIError",
25
+ "AuthenticationError",
26
+ "RateLimitError",
27
+ "DataNotFoundError",
28
+ "ServerError",
29
+ ]
30
+
31
+ # Convenience function for quick access
32
+ def get_current_price(commodity: str, api_key: str = None) -> float:
33
+ """
34
+ Quick helper to get current price without client initialization.
35
+
36
+ Args:
37
+ commodity: Commodity code (e.g., "BRENT_CRUDE_USD")
38
+ api_key: Optional API key (uses environment variable if not provided)
39
+
40
+ Returns:
41
+ Current price as float
42
+
43
+ Example:
44
+ >>> import oilpriceapi as opa
45
+ >>> price = opa.get_current_price("BRENT_CRUDE_USD")
46
+ >>> print(f"Oil: ${price}")
47
+ """
48
+ client = OilPriceAPI(api_key=api_key)
49
+ price = client.prices.get(commodity)
50
+ return price.value
@@ -0,0 +1,413 @@
1
+ """
2
+ Asynchronous OilPriceAPI Client
3
+
4
+ Async/await support for high-performance applications.
5
+ """
6
+
7
+ import os
8
+ import logging
9
+ from typing import Optional, Dict, Any, Union, List
10
+ import httpx
11
+ from datetime import datetime
12
+ import json
13
+ import asyncio
14
+ from urllib.parse import urljoin
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ from .exceptions import (
19
+ OilPriceAPIError,
20
+ AuthenticationError,
21
+ RateLimitError,
22
+ DataNotFoundError,
23
+ ServerError,
24
+ TimeoutError,
25
+ ValidationError,
26
+ ConfigurationError,
27
+ )
28
+ from .models import Price, HistoricalPrice, HistoricalResponse
29
+
30
+
31
+ class AsyncOilPriceAPI:
32
+ """Asynchronous client for OilPriceAPI.
33
+
34
+ Provides async/await support for all API operations.
35
+
36
+ Args:
37
+ api_key: API key for authentication
38
+ base_url: Base URL for API
39
+ timeout: Request timeout in seconds
40
+ max_retries: Maximum retry attempts
41
+
42
+ Example:
43
+ >>> async with AsyncOilPriceAPI() as client:
44
+ ... price = await client.prices.get("BRENT_CRUDE_USD")
45
+ ... print(f"Brent: ${price.value:.2f}")
46
+ """
47
+
48
+ DEFAULT_BASE_URL = "https://api.oilpriceapi.com"
49
+ DEFAULT_TIMEOUT = 30
50
+ DEFAULT_MAX_RETRIES = 3
51
+ DEFAULT_RETRY_CODES = [429, 500, 502, 503, 504]
52
+
53
+ def __init__(
54
+ self,
55
+ api_key: Optional[str] = None,
56
+ base_url: Optional[str] = None,
57
+ timeout: Optional[float] = None,
58
+ max_retries: Optional[int] = None,
59
+ retry_on: Optional[list] = None,
60
+ headers: Optional[Dict[str, str]] = None,
61
+ ):
62
+ # Get API key
63
+ self.api_key = api_key or os.environ.get("OILPRICEAPI_KEY")
64
+ if not self.api_key:
65
+ raise ConfigurationError(
66
+ "API key required. Set OILPRICEAPI_KEY environment variable or pass api_key parameter."
67
+ )
68
+
69
+ # Configuration
70
+ self.base_url = (base_url or self.DEFAULT_BASE_URL).rstrip("/")
71
+ self.timeout = timeout or self.DEFAULT_TIMEOUT
72
+ self.max_retries = max_retries or self.DEFAULT_MAX_RETRIES
73
+ self.retry_on = retry_on or self.DEFAULT_RETRY_CODES
74
+
75
+ # Build headers
76
+ self.headers = {
77
+ "Authorization": f"Token {self.api_key}",
78
+ "Content-Type": "application/json",
79
+ "Accept": "application/json",
80
+ "User-Agent": "OilPriceAPI-Python-Async/1.0.0",
81
+ }
82
+ if headers:
83
+ self.headers.update(headers)
84
+
85
+ # Client will be created in __aenter__ or when needed
86
+ self._client = None
87
+
88
+ # Initialize resources
89
+ self.prices = AsyncPricesResource(self)
90
+ self.historical = AsyncHistoricalResource(self)
91
+
92
+ async def _ensure_client(self):
93
+ """Ensure HTTP client is created."""
94
+ if self._client is None:
95
+ self._client = httpx.AsyncClient(
96
+ base_url=self.base_url,
97
+ headers=self.headers,
98
+ timeout=self.timeout,
99
+ follow_redirects=True,
100
+ )
101
+
102
+ async def request(
103
+ self,
104
+ method: str,
105
+ path: str,
106
+ params: Optional[Dict[str, Any]] = None,
107
+ json_data: Optional[Dict[str, Any]] = None,
108
+ **kwargs
109
+ ) -> Union[Dict[str, Any], list]:
110
+ """Make async HTTP request to API."""
111
+ await self._ensure_client()
112
+
113
+ # Ensure path starts with / for proper urljoin behavior
114
+ if not path.startswith('/'):
115
+ path = '/' + path
116
+ url = urljoin(self.base_url + '/', path)
117
+
118
+ # Retry logic
119
+ last_exception = None
120
+ for attempt in range(self.max_retries):
121
+ try:
122
+ logger.debug(f"Async API request: {method} {url} (attempt {attempt + 1}/{self.max_retries})")
123
+
124
+ response = await self._client.request(
125
+ method=method,
126
+ url=url,
127
+ params=params,
128
+ json=json_data,
129
+ **kwargs
130
+ )
131
+
132
+ logger.debug(f"Async API response: {response.status_code} for {method} {url}")
133
+
134
+ # Handle response codes
135
+ if response.status_code == 200:
136
+ return await response.json()
137
+ elif response.status_code == 401:
138
+ logger.error(f"Authentication failed for {url}")
139
+ raise AuthenticationError()
140
+ elif response.status_code == 404:
141
+ error_data = await self._safe_parse_json(response)
142
+ raise DataNotFoundError(
143
+ message=error_data.get("error", "Not found"),
144
+ commodity=params.get("commodity") if params else None,
145
+ )
146
+ elif response.status_code == 429:
147
+ reset_time = self._parse_rate_limit_reset(response.headers)
148
+ logger.warning(
149
+ f"Rate limit exceeded. Limit: {response.headers.get('X-RateLimit-Limit')}, "
150
+ f"Remaining: {response.headers.get('X-RateLimit-Remaining')}"
151
+ )
152
+ raise RateLimitError(
153
+ reset_time=reset_time,
154
+ limit=response.headers.get("X-RateLimit-Limit"),
155
+ remaining=response.headers.get("X-RateLimit-Remaining"),
156
+ )
157
+ elif response.status_code >= 500:
158
+ if response.status_code in self.retry_on and attempt < self.max_retries - 1:
159
+ wait_time = min(2 ** attempt, 60)
160
+ logger.warning(
161
+ f"Server error {response.status_code}, retrying in {wait_time}s "
162
+ f"(attempt {attempt + 1}/{self.max_retries})"
163
+ )
164
+ await asyncio.sleep(wait_time)
165
+ continue
166
+ raise ServerError(
167
+ message=f"Server error: {response.status_code}",
168
+ status_code=response.status_code,
169
+ )
170
+ else:
171
+ error_data = await self._safe_parse_json(response)
172
+ raise OilPriceAPIError(
173
+ message=error_data.get("error", f"Error: {response.status_code}"),
174
+ status_code=response.status_code,
175
+ )
176
+
177
+ except httpx.TimeoutException:
178
+ last_exception = TimeoutError(timeout=self.timeout)
179
+ if attempt < self.max_retries - 1:
180
+ await asyncio.sleep(min(2 ** attempt, 60))
181
+ continue
182
+ raise last_exception
183
+ except httpx.RequestError as e:
184
+ last_exception = OilPriceAPIError(message=str(e))
185
+ if attempt < self.max_retries - 1:
186
+ await asyncio.sleep(min(2 ** attempt, 60))
187
+ continue
188
+ raise last_exception
189
+
190
+ if last_exception:
191
+ raise last_exception
192
+
193
+ raise OilPriceAPIError("Max retries exceeded")
194
+
195
+ async def _safe_parse_json(self, response: httpx.Response) -> Dict[str, Any]:
196
+ """Safely parse JSON response."""
197
+ try:
198
+ return await response.json()
199
+ except json.JSONDecodeError:
200
+ return {"error": response.text or "Unknown error"}
201
+
202
+ def _parse_rate_limit_reset(self, headers: Dict[str, str]) -> Optional[datetime]:
203
+ """Parse rate limit reset time."""
204
+ reset_header = headers.get("X-RateLimit-Reset")
205
+ if reset_header:
206
+ try:
207
+ timestamp = float(reset_header)
208
+ return datetime.fromtimestamp(timestamp)
209
+ except (ValueError, TypeError):
210
+ try:
211
+ return datetime.fromisoformat(reset_header)
212
+ except (ValueError, TypeError):
213
+ pass
214
+ return None
215
+
216
+ async def close(self):
217
+ """Close the HTTP client."""
218
+ if self._client:
219
+ await self._client.aclose()
220
+ self._client = None
221
+
222
+ async def __aenter__(self):
223
+ """Async context manager entry."""
224
+ await self._ensure_client()
225
+ return self
226
+
227
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
228
+ """Async context manager exit."""
229
+ await self.close()
230
+
231
+
232
+ class AsyncPricesResource:
233
+ """Async resource for current prices."""
234
+
235
+ def __init__(self, client: AsyncOilPriceAPI):
236
+ self.client = client
237
+
238
+ async def get(self, commodity: str) -> Price:
239
+ """Get current price for commodity."""
240
+ response = await self.client.request(
241
+ method="GET",
242
+ path="/v1/prices/latest",
243
+ params={"by_code": commodity}
244
+ )
245
+
246
+ if "data" in response:
247
+ price_data = response["data"]
248
+ else:
249
+ price_data = response
250
+
251
+ # Map API response to Price model
252
+ # Note: API should provide 'unit' field. If missing, we default to 'barrel'
253
+ # for backwards compatibility, but this may be incorrect for non-oil commodities
254
+ mapped_data = {
255
+ "commodity": price_data.get("code", commodity),
256
+ "value": price_data.get("price"),
257
+ "currency": price_data.get("currency", "USD"),
258
+ "unit": price_data.get("unit", "barrel"),
259
+ "timestamp": price_data.get("created_at"),
260
+ }
261
+
262
+ return Price(**mapped_data)
263
+
264
+ async def get_multiple(
265
+ self,
266
+ commodities: List[str],
267
+ raise_on_error: bool = False,
268
+ return_failures: bool = False
269
+ ) -> Union[List[Price], tuple[List[Price], List[tuple[str, str]]]]:
270
+ """Get prices for multiple commodities concurrently.
271
+
272
+ Args:
273
+ commodities: List of commodity codes
274
+ raise_on_error: If True, raise exception on first failure. If False, skip failed commodities.
275
+ return_failures: If True, return tuple of (prices, failures). Failures is list of (commodity, error_message).
276
+
277
+ Returns:
278
+ List of Price objects, or tuple of (prices, failures) if return_failures=True
279
+
280
+ Raises:
281
+ OilPriceAPIError: If raise_on_error=True and any commodity fails
282
+ """
283
+ from .exceptions import OilPriceAPIError
284
+
285
+ # Use gather for concurrent requests
286
+ tasks = [self.get(commodity) for commodity in commodities]
287
+ results = await asyncio.gather(*tasks, return_exceptions=True)
288
+
289
+ prices = []
290
+ failures = []
291
+
292
+ for commodity, result in zip(commodities, results):
293
+ if isinstance(result, Price):
294
+ prices.append(result)
295
+ elif isinstance(result, Exception):
296
+ if raise_on_error:
297
+ raise result
298
+ failures.append((commodity, str(result)))
299
+
300
+ if return_failures:
301
+ return prices, failures
302
+ return prices
303
+
304
+ async def get_all(self) -> List[Price]:
305
+ """Get all available prices."""
306
+ response = await self.client.request(
307
+ method="GET",
308
+ path="/v1/prices/all"
309
+ )
310
+
311
+ if "data" in response:
312
+ prices_data = response["data"]
313
+ else:
314
+ prices_data = response
315
+
316
+ return [Price(**price_data) for price_data in prices_data]
317
+
318
+
319
+ class AsyncHistoricalResource:
320
+ """Async resource for historical data."""
321
+
322
+ def __init__(self, client: AsyncOilPriceAPI):
323
+ self.client = client
324
+
325
+ async def get(
326
+ self,
327
+ commodity: str,
328
+ start_date: Optional[str] = None,
329
+ end_date: Optional[str] = None,
330
+ interval: str = "daily",
331
+ page: int = 1,
332
+ per_page: int = 100,
333
+ type_name: str = "spot_price"
334
+ ) -> HistoricalResponse:
335
+ """Get historical price data."""
336
+ params = {
337
+ "commodity": commodity,
338
+ "interval": interval,
339
+ "page": page,
340
+ "per_page": min(per_page, 1000),
341
+ "by_type": type_name,
342
+ }
343
+
344
+ if start_date:
345
+ params["start_date"] = start_date
346
+ if end_date:
347
+ params["end_date"] = end_date
348
+
349
+ response = await self.client.request(
350
+ method="GET",
351
+ path="/v1/prices/past_year",
352
+ params=params
353
+ )
354
+
355
+ # Parse response - handle nested structure
356
+ # API returns: {"status": "success", "data": {"prices": [...]}}
357
+ if "data" in response and isinstance(response["data"], dict) and "prices" in response["data"]:
358
+ prices_data = response["data"]["prices"]
359
+ elif "data" in response and isinstance(response["data"], list):
360
+ prices_data = response["data"]
361
+ else:
362
+ prices_data = response if isinstance(response, list) else []
363
+
364
+ # Create HistoricalPrice objects
365
+ prices = []
366
+ for price_data in prices_data:
367
+ if isinstance(price_data, dict):
368
+ # Map API fields to model fields
369
+ mapped_data = {
370
+ "created_at": price_data.get("created_at"),
371
+ "commodity_name": price_data.get("code", price_data.get("commodity_name")),
372
+ "price": price_data.get("price"),
373
+ "unit_of_measure": price_data.get("unit", "barrel"),
374
+ "type_name": price_data.get("type", "spot_price"),
375
+ }
376
+ prices.append(HistoricalPrice(**mapped_data))
377
+
378
+ return HistoricalResponse(
379
+ success=True,
380
+ data=prices,
381
+ meta=None # Simplified for now
382
+ )
383
+
384
+ async def get_all(
385
+ self,
386
+ commodity: str,
387
+ start_date: Optional[str] = None,
388
+ end_date: Optional[str] = None,
389
+ interval: str = "daily"
390
+ ) -> List[HistoricalPrice]:
391
+ """Get all historical data with automatic pagination."""
392
+ all_prices = []
393
+ page = 1
394
+
395
+ while True:
396
+ response = await self.get(
397
+ commodity=commodity,
398
+ start_date=start_date,
399
+ end_date=end_date,
400
+ interval=interval,
401
+ page=page,
402
+ per_page=1000
403
+ )
404
+
405
+ all_prices.extend(response.data)
406
+
407
+ # Check if we got a full page (might be more)
408
+ if len(response.data) < 1000:
409
+ break
410
+
411
+ page += 1
412
+
413
+ return all_prices