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.
- oilpriceapi/__init__.py +50 -0
- oilpriceapi/async_client.py +413 -0
- oilpriceapi/client.py +306 -0
- oilpriceapi/exceptions.py +147 -0
- oilpriceapi/models.py +183 -0
- oilpriceapi/py.typed +0 -0
- oilpriceapi/visualization.py +530 -0
- oilpriceapi-1.0.0.dist-info/METADATA +295 -0
- oilpriceapi-1.0.0.dist-info/RECORD +13 -0
- oilpriceapi-1.0.0.dist-info/WHEEL +5 -0
- oilpriceapi-1.0.0.dist-info/entry_points.txt +2 -0
- oilpriceapi-1.0.0.dist-info/licenses/LICENSE +21 -0
- oilpriceapi-1.0.0.dist-info/top_level.txt +1 -0
oilpriceapi/__init__.py
ADDED
|
@@ -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
|