Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion oilpriceapi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
The official Python SDK for OilPriceAPI - Real-time and historical oil prices.
"""

from typing import Optional

from oilpriceapi.version import __version__ # noqa: F401

__author__ = "OilPriceAPI"
Expand Down Expand Up @@ -68,7 +70,7 @@


# Convenience function for quick access
def get_current_price(commodity: str, api_key: str = None) -> float:
def get_current_price(commodity: str, api_key: Optional[str] = None) -> float:
"""
Quick helper to get current price without client initialization.

Expand Down
21 changes: 11 additions & 10 deletions oilpriceapi/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ def __init__(
base_url: Optional[str] = None,
timeout: Optional[float] = None,
max_retries: Optional[int] = None,
retry_on: Optional[list] = None,
retry_on: Optional[List[int]] = None,
headers: Optional[Dict[str, str]] = None,
max_connections: int = 100,
max_keepalive_connections: int = 20,
Expand Down Expand Up @@ -129,7 +129,7 @@ def __init__(
self.headers.update(headers)

# Client will be created in __aenter__ or when needed
self._client = None
self._client: Optional[httpx.AsyncClient] = None

# Initialize resources
self.prices = AsyncPricesResource(self)
Expand Down Expand Up @@ -188,9 +188,10 @@ async def request(
params: Optional[Dict[str, Any]] = None,
json_data: Optional[Dict[str, Any]] = None,
**kwargs
) -> Union[Dict[str, Any], list]:
) -> Union[Dict[str, Any], List[Any]]:
"""Make async HTTP request to API."""
await self._ensure_client()
assert self._client is not None # set by _ensure_client

# Ensure path starts with / for proper urljoin behavior
if not path.startswith('/'):
Expand All @@ -200,7 +201,7 @@ async def request(
# Retry logic
import time as _time
start_time = _time.time()
last_exception = None
last_exception: Optional[OilPriceAPIError] = None
for attempt in range(self.max_retries):
try:
logger.debug(f"Async API request: {method} {url} (attempt {attempt + 1}/{self.max_retries})")
Expand Down Expand Up @@ -327,7 +328,7 @@ def _safe_parse_json(self, response: httpx.Response) -> Dict[str, Any]:
except json.JSONDecodeError:
return {"error": response.text or "Unknown error"}

def _parse_rate_limit_reset(self, headers: Dict[str, str]) -> Optional[datetime]:
def _parse_rate_limit_reset(self, headers: httpx.Headers) -> Optional[datetime]:
"""Parse rate limit reset time."""
reset_header = headers.get("X-RateLimit-Reset")
if reset_header:
Expand Down Expand Up @@ -372,7 +373,7 @@ async def get(self, commodity: str) -> Price:
params={"by_code": commodity}
)

if "data" in response:
if isinstance(response, dict) and "data" in response:
price_data = response["data"]
else:
price_data = response
Expand Down Expand Up @@ -436,7 +437,7 @@ async def get_all(self) -> List[Price]:
path="/v1/prices/all"
)

if "data" in response:
if isinstance(response, dict) and "data" in response:
prices_data = response["data"]
else:
prices_data = response
Expand Down Expand Up @@ -482,9 +483,9 @@ async def get(

# Parse response - handle nested structure
# API returns: {"status": "success", "data": {"prices": [...]}}
if "data" in response and isinstance(response["data"], dict) and "prices" in response["data"]:
if isinstance(response, dict) and isinstance(response.get("data"), dict) and "prices" in response["data"]:
prices_data = response["data"]["prices"]
elif "data" in response and isinstance(response["data"], list):
elif isinstance(response, dict) and isinstance(response.get("data"), list):
prices_data = response["data"]
else:
prices_data = response if isinstance(response, list) else []
Expand Down Expand Up @@ -547,7 +548,7 @@ async def iter_pages(
end_date: Optional[str] = None,
interval: str = "daily",
per_page: int = 100,
) -> AsyncGenerator:
) -> AsyncGenerator[List[HistoricalPrice], None]:
"""Async iterate through pages of historical data.

Memory-efficient async iterator for large datasets.
Expand Down
72 changes: 42 additions & 30 deletions oilpriceapi/async_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -527,58 +527,60 @@ class AsyncAnalyticsResource:
def __init__(self, client):
self.client = client

# Wire params mirror the sync AnalyticsResource: the controller reads
# code/code1/code2/period (NOT commodity/commodity1/commodity2/days).
async def performance(self, commodity: Optional[str] = None, days: int = 30) -> Dict[str, Any]:
params: Dict[str, Any] = {"days": days}
if commodity:
params["commodity"] = commodity
range_value = "7d" if days <= 7 else ("90d" if days >= 90 else "30d")
params: Dict[str, Any] = {"range": range_value}
response = await self.client.request(
method="GET", path="/v1/analytics/performance", params=params
)
if "data" in response:
if isinstance(response, dict) and "data" in response:
return response["data"]
return response

async def statistics(self, commodity: str, days: int = 30) -> Dict[str, Any]:
response = await self.client.request(
method="GET", path="/v1/analytics/statistics",
params={"commodity": commodity, "days": days}
params={"code": commodity, "period": days}
)
if "data" in response:
if isinstance(response, dict) and "data" in response:
return response["data"]
return response

async def correlation(self, commodity1: str, commodity2: str, days: int = 90) -> Dict[str, Any]:
response = await self.client.request(
method="GET", path="/v1/analytics/correlation",
params={"commodity1": commodity1, "commodity2": commodity2, "days": days}
params={"code1": commodity1, "code2": commodity2, "period": days}
)
if "data" in response:
if isinstance(response, dict) and "data" in response:
return response["data"]
return response

async def trend(self, commodity: str, days: int = 30) -> Dict[str, Any]:
response = await self.client.request(
method="GET", path="/v1/analytics/trend",
params={"commodity": commodity, "days": days}
params={"code": commodity, "period": days}
)
if "data" in response:
if isinstance(response, dict) and "data" in response:
return response["data"]
return response

async def spread(self, commodity1: str, commodity2: str) -> Dict[str, Any]:
async def spread(self, spread: str, days: int = 30) -> Dict[str, Any]:
response = await self.client.request(
method="GET", path="/v1/analytics/spread",
params={"commodity1": commodity1, "commodity2": commodity2}
params={"spread": spread, "period": days}
)
if "data" in response:
if isinstance(response, dict) and "data" in response:
return response["data"]
return response

async def forecast(self, commodity: str) -> Dict[str, Any]:
async def forecast(self, commodity: str, method: str = "ema", days: int = 90) -> Dict[str, Any]:
response = await self.client.request(
method="GET", path="/v1/analytics/forecast", params={"commodity": commodity}
method="GET", path="/v1/analytics/forecast",
params={"code": commodity, "method": method, "period": days}
)
if "data" in response:
if isinstance(response, dict) and "data" in response:
return response["data"]
return response

Expand Down Expand Up @@ -1199,7 +1201,12 @@ async def create(
enabled: bool = True,
**kwargs
) -> Dict[str, Any]:
json_data: Dict[str, Any] = {"url": url, "events": events, "enabled": enabled}
# Controller permits `status` ("active"/"inactive"/"paused"), not boolean `enabled`.
json_data: Dict[str, Any] = {
"url": url,
"events": events,
"status": "active" if enabled else "inactive",
}
if description:
json_data["description"] = description
if secret:
Expand Down Expand Up @@ -1230,7 +1237,8 @@ async def update(
if secret is not None:
json_data["secret"] = secret
if enabled is not None:
json_data["enabled"] = enabled
# Controller permits `status`, not boolean `enabled`.
json_data["status"] = "active" if enabled else "inactive"
json_data.update(kwargs)
response = await self.client.request(
method="PATCH", path=f"/v1/webhooks/{webhook_id}", json_data=json_data
Expand Down Expand Up @@ -1282,15 +1290,18 @@ async def create(
enabled: bool = True,
**kwargs
) -> Dict[str, Any]:
json_data: Dict[str, Any] = {
# Controller requires nesting under `data_source` with `status` /
# `scraper_config` (not boolean `enabled` / `config`).
data_source: Dict[str, Any] = {
"name": name, "source_type": source_type,
"credentials": credentials, "enabled": enabled
"credentials": credentials,
"status": "active" if enabled else "paused",
}
if config:
json_data["config"] = config
json_data.update(kwargs)
data_source["scraper_config"] = config
data_source.update(kwargs)
response = await self.client.request(
method="POST", path="/v1/data-sources", json_data=json_data
method="POST", path="/v1/data-sources", json_data={"data_source": data_source}
)
if "data" in response:
return response["data"]
Expand All @@ -1305,18 +1316,19 @@ async def update(
enabled: Optional[bool] = None,
**kwargs
) -> Dict[str, Any]:
json_data: Dict[str, Any] = {}
data_source: Dict[str, Any] = {}
if name is not None:
json_data["name"] = name
data_source["name"] = name
if credentials is not None:
json_data["credentials"] = credentials
data_source["credentials"] = credentials
if config is not None:
json_data["config"] = config
data_source["scraper_config"] = config
if enabled is not None:
json_data["enabled"] = enabled
json_data.update(kwargs)
data_source["status"] = "active" if enabled else "paused"
data_source.update(kwargs)
response = await self.client.request(
method="PATCH", path=f"/v1/data-sources/{source_id}", json_data=json_data
method="PATCH", path=f"/v1/data-sources/{source_id}",
json_data={"data_source": data_source}
)
if "data" in response:
return response["data"]
Expand Down
16 changes: 8 additions & 8 deletions oilpriceapi/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,17 @@

import os
import sys
from typing import Any, List

try:
import click
from rich.console import Console
from rich.table import Table
except ImportError:
def main():
print("CLI requires extra dependencies. Install with:")
print(" pip install oilpriceapi[cli]")
sys.exit(1)
if __name__ == "__main__":
main()
# The [cli] extra (click + rich) is not installed. Surface a helpful message
# and exit at import time; the decorated commands below are never reached.
print("CLI requires extra dependencies. Install with:")
print(" pip install oilpriceapi[cli]")
sys.exit(1)

from oilpriceapi.version import __version__
Expand Down Expand Up @@ -177,12 +176,13 @@ def commodities(search, as_json):
sys.exit(1)

# Handle different response formats
commodity_list: List[Any]
if isinstance(items, dict):
commodity_list = items.get("commodities", items.get("data", []))
commodity_list = items.get("commodities") or items.get("data") or []
elif isinstance(items, list):
commodity_list = items
else:
commodity_list = items.data if hasattr(items, "data") else []
commodity_list = list(items.data) if hasattr(items, "data") else []

if search:
search_lower = search.lower()
Expand Down
19 changes: 13 additions & 6 deletions oilpriceapi/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,14 @@
import os
import time
from datetime import datetime
from typing import Any, Dict, List, Optional
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
from urllib.parse import urljoin

import httpx

if TYPE_CHECKING:
from .visualization import PriceVisualizer

logger = logging.getLogger(__name__)

from .exceptions import (
Expand All @@ -33,6 +36,7 @@
from .resources.commodities import CommoditiesResource
from .resources.data_quality import DataQualityResource
from .resources.data_sources import DataSourcesResource
from .resources.demo import DemoResource
from .resources.diesel import DieselResource
from .resources.drilling import DrillingIntelligenceResource
from .resources.ei import EnergyIntelligenceResource
Expand Down Expand Up @@ -89,7 +93,7 @@ def __init__(
base_url: Optional[str] = None,
timeout: Optional[float] = None,
max_retries: Optional[int] = None,
retry_on: Optional[list] = None,
retry_on: Optional[List[int]] = None,
headers: Optional[Dict[str, str]] = None,
app_url: Optional[str] = None,
app_name: Optional[str] = None,
Expand Down Expand Up @@ -172,8 +176,11 @@ def __init__(
self.ei = EnergyIntelligenceResource(self)
self.webhooks = WebhooksResource(self)
self.data_sources = DataSourcesResource(self)
# Public, no-auth demo endpoints (/v1/demo/*).
self.demo = DemoResource(self)

# Initialize visualization (optional)
self.viz: Optional["PriceVisualizer"]
try:
from .visualization import PriceVisualizer
self.viz = PriceVisualizer(self)
Expand Down Expand Up @@ -226,7 +233,7 @@ def request(
effective_timeout = timeout if timeout is not None else self.timeout

# Retry logic using retry strategy
last_exception = None
last_exception: Optional[OilPriceAPIError] = None
start_time = time.time()
for attempt in range(self.max_retries):
try:
Expand Down Expand Up @@ -365,7 +372,7 @@ def request_with_headers(
json_data: Optional[Dict[str, Any]] = None,
timeout: Optional[float] = None,
**kwargs
) -> tuple:
) -> Tuple[Dict[str, Any], httpx.Headers]:
"""Make HTTP request and return (json_body, headers) tuple.

Identical to request() but also returns response headers so callers
Expand All @@ -381,7 +388,7 @@ def request_with_headers(

effective_timeout = timeout if timeout is not None else self.timeout

last_exception = None
last_exception: Optional[OilPriceAPIError] = None
for attempt in range(self.max_retries):
try:
response = self._client.request(
Expand Down Expand Up @@ -482,7 +489,7 @@ def _safe_parse_json(self, response: httpx.Response) -> Dict[str, Any]:
except json.JSONDecodeError:
return {"error": response.text or "Unknown error"}

def _parse_rate_limit_reset(self, headers: Dict[str, str]) -> Optional[datetime]:
def _parse_rate_limit_reset(self, headers: httpx.Headers) -> Optional[datetime]:
"""Parse rate limit reset time from headers."""
reset_header = headers.get("X-RateLimit-Reset")
if reset_header:
Expand Down
2 changes: 2 additions & 0 deletions oilpriceapi/resources/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from .commodities import CommoditiesResource
from .data_quality import DataQualityResource
from .data_sources import DataSourcesResource
from .demo import DemoResource
from .diesel import DieselResource
from .drilling import DrillingIntelligenceResource
from .ei import EnergyIntelligenceResource
Expand Down Expand Up @@ -38,4 +39,5 @@
"EnergyIntelligenceResource",
"WebhooksResource",
"DataSourcesResource",
"DemoResource",
]
Loading
Loading