diff --git a/oilpriceapi/__init__.py b/oilpriceapi/__init__.py index 3f7842b..1febb9e 100644 --- a/oilpriceapi/__init__.py +++ b/oilpriceapi/__init__.py @@ -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" @@ -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. diff --git a/oilpriceapi/async_client.py b/oilpriceapi/async_client.py index f6d52a6..ff04693 100644 --- a/oilpriceapi/async_client.py +++ b/oilpriceapi/async_client.py @@ -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, @@ -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) @@ -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('/'): @@ -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})") @@ -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: @@ -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 @@ -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 @@ -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 [] @@ -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. diff --git a/oilpriceapi/async_resources.py b/oilpriceapi/async_resources.py index d253f76..20b01f6 100644 --- a/oilpriceapi/async_resources.py +++ b/oilpriceapi/async_resources.py @@ -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 @@ -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: @@ -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 @@ -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"] @@ -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"] diff --git a/oilpriceapi/cli.py b/oilpriceapi/cli.py index 24d30b0..23b46a4 100644 --- a/oilpriceapi/cli.py +++ b/oilpriceapi/cli.py @@ -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__ @@ -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() diff --git a/oilpriceapi/client.py b/oilpriceapi/client.py index 2f61261..86f5bf8 100644 --- a/oilpriceapi/client.py +++ b/oilpriceapi/client.py @@ -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 ( @@ -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 @@ -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, @@ -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) @@ -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: @@ -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 @@ -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( @@ -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: diff --git a/oilpriceapi/resources/__init__.py b/oilpriceapi/resources/__init__.py index d2181af..ab66db6 100644 --- a/oilpriceapi/resources/__init__.py +++ b/oilpriceapi/resources/__init__.py @@ -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 @@ -38,4 +39,5 @@ "EnergyIntelligenceResource", "WebhooksResource", "DataSourcesResource", + "DemoResource", ] diff --git a/oilpriceapi/resources/alerts.py b/oilpriceapi/resources/alerts.py index 41f62be..7f59fe6 100644 --- a/oilpriceapi/resources/alerts.py +++ b/oilpriceapi/resources/alerts.py @@ -389,7 +389,7 @@ def update( ) # Build update payload with only provided fields - update_data = {} + update_data: Dict[str, Any] = {} # Validate fields if provided if name is not None: diff --git a/oilpriceapi/resources/analytics.py b/oilpriceapi/resources/analytics.py index a2c1d24..54374ca 100644 --- a/oilpriceapi/resources/analytics.py +++ b/oilpriceapi/resources/analytics.py @@ -2,15 +2,28 @@ Analytics Resource Price analytics and statistical analysis operations. + +Wire-parameter note +-------------------- +The v1 analytics controller (``app/controllers/v1/analytics_controller.rb``) +expects ``code`` / ``code1`` / ``code2`` and ``period`` query parameters — NOT +``commodity`` / ``commodity1`` / ``commodity2`` / ``days``. The public method +signatures keep the friendlier ``commodity`` / ``days`` names for backwards +compatibility, but this resource maps them to the names the API actually reads. +A mismatch here is the same bug class fixed in the Node SDK (it was sending +``commodity1`` / ``commodity2`` which the controller silently ignored). """ -from typing import Any, Dict, Optional +from typing import TYPE_CHECKING, Any, Dict, Optional + +if TYPE_CHECKING: + from ..client import OilPriceAPI class AnalyticsResource: """Resource for price analytics and statistics.""" - def __init__(self, client): + def __init__(self, client: "OilPriceAPI") -> None: """Initialize analytics resource. Args: @@ -21,35 +34,33 @@ def __init__(self, client): def performance( self, commodity: Optional[str] = None, - days: int = 30 + days: int = 30, ) -> Dict[str, Any]: - """Get price performance analysis. + """Get API usage performance analytics for the authenticated user. Args: - commodity: Commodity code (if None, returns all commodities) - days: Number of days for performance calculation + commodity: Accepted for backwards compatibility; the controller does + not filter performance by commodity. + days: Number of days for the performance window. Mapped to the + controller's ``range`` parameter (``7d`` / ``30d`` / ``90d``). Returns: - Performance metrics with returns, volatility, and trends + Performance metrics for the user's API usage. Example: - >>> perf = client.analytics.performance("BRENT_CRUDE_USD", days=30) - >>> print(f"30-day Return: {perf['return_pct']}%") - >>> print(f"Volatility: {perf['volatility']}") - >>> print(f"Trend: {perf['trend']}") + >>> perf = client.analytics.performance(days=30) """ - params = {"days": days} - if commodity: - params["commodity"] = commodity + # Controller reads params[:range] ("7d"/"30d"/"90d"), not commodity/days. + range_value = "7d" if days <= 7 else ("90d" if days >= 90 else "30d") + params: Dict[str, Any] = {"range": range_value} response = self.client.request( method="GET", path="/v1/analytics/performance", - params=params + params=params, ) - # Parse response - if "data" in response: + if isinstance(response, dict) and "data" in response: return response["data"] return response @@ -57,30 +68,25 @@ def statistics(self, commodity: str, days: int = 30) -> Dict[str, Any]: """Get statistical analysis for a commodity. Args: - commodity: Commodity code - days: Number of days for statistical analysis + commodity: Commodity code (sent to the API as ``code``) + days: Number of days for statistical analysis (sent as ``period``) Returns: Statistical metrics (mean, median, std dev, min, max, etc.) Example: >>> stats = client.analytics.statistics("WTI_USD", days=90) - >>> print(f"Mean: ${stats['mean']:.2f}") - >>> print(f"Std Dev: ${stats['std_dev']:.2f}") - >>> print(f"Min: ${stats['min']:.2f}") - >>> print(f"Max: ${stats['max']:.2f}") """ response = self.client.request( method="GET", path="/v1/analytics/statistics", params={ - "commodity": commodity, - "days": days - } + "code": commodity, + "period": days, + }, ) - # Parse response - if "data" in response: + if isinstance(response, dict) and "data" in response: return response["data"] return response @@ -88,14 +94,14 @@ def correlation( self, commodity1: str, commodity2: str, - days: int = 90 + days: int = 90, ) -> Dict[str, Any]: """Get correlation analysis between two commodities. Args: - commodity1: First commodity code - commodity2: Second commodity code - days: Number of days for correlation calculation + commodity1: First commodity code (sent to the API as ``code1``) + commodity2: Second commodity code (sent to the API as ``code2``) + days: Number of days for correlation calculation (sent as ``period``) Returns: Correlation metrics and analysis @@ -104,23 +110,21 @@ def correlation( >>> corr = client.analytics.correlation( ... "BRENT_CRUDE_USD", ... "WTI_USD", - ... days=90 + ... days=90, ... ) - >>> print(f"Correlation: {corr['correlation']:.3f}") - >>> print(f"P-value: {corr['p_value']:.4f}") """ + # The controller requires code1/code2/period (NOT commodity1/commodity2/days). response = self.client.request( method="GET", path="/v1/analytics/correlation", params={ - "commodity1": commodity1, - "commodity2": commodity2, - "days": days - } + "code1": commodity1, + "code2": commodity2, + "period": days, + }, ) - # Parse response - if "data" in response: + if isinstance(response, dict) and "data" in response: return response["data"] return response @@ -128,84 +132,102 @@ def trend(self, commodity: str, days: int = 30) -> Dict[str, Any]: """Get trend analysis for a commodity. Args: - commodity: Commodity code - days: Number of days for trend analysis + commodity: Commodity code (sent to the API as ``code``) + days: Number of days for trend analysis (sent as ``period``) Returns: Trend metrics with direction, strength, and momentum Example: >>> trend = client.analytics.trend("NATURAL_GAS_USD", days=30) - >>> print(f"Direction: {trend['direction']}") - >>> print(f"Strength: {trend['strength']}") - >>> print(f"Momentum: {trend['momentum']}") """ response = self.client.request( method="GET", path="/v1/analytics/trend", params={ - "commodity": commodity, - "days": days - } + "code": commodity, + "period": days, + }, ) - # Parse response - if "data" in response: + if isinstance(response, dict) and "data" in response: return response["data"] return response - def spread(self, commodity1: str, commodity2: str) -> Dict[str, Any]: - """Get spread analysis between two commodities. + def spread(self, spread: str, days: int = 30) -> Dict[str, Any]: + """Get spread analysis for a named commodity spread. + + The spread endpoint operates on a *named* spread (e.g. ``"wti_brent"``), + not an arbitrary pair of commodity codes. Call without ``spread`` set via + :meth:`available_spreads` to discover valid names. Args: - commodity1: First commodity code - commodity2: Second commodity code + spread: Spread name, e.g. ``"wti_brent"`` (sent to the API as ``spread``) + days: Number of days of history to analyze (sent as ``period``) Returns: Spread analysis with current spread and historical statistics Example: - >>> spread = client.analytics.spread("BRENT_CRUDE_USD", "WTI_USD") - >>> print(f"Current Spread: ${spread['current']:.2f}") - >>> print(f"Average Spread: ${spread['average']:.2f}") - >>> print(f"Spread Percentile: {spread['percentile']}") + >>> spread = client.analytics.spread("wti_brent") """ response = self.client.request( method="GET", path="/v1/analytics/spread", params={ - "commodity1": commodity1, - "commodity2": commodity2 - } + "spread": spread, + "period": days, + }, ) - # Parse response - if "data" in response: + if isinstance(response, dict) and "data" in response: return response["data"] return response - def forecast(self, commodity: str) -> Dict[str, Any]: + def available_spreads(self) -> Dict[str, Any]: + """List the named spreads supported by the spread endpoint. + + Returns: + Catalog of available spread names (the controller returns this when + no ``spread`` parameter is supplied). + + Example: + >>> spreads = client.analytics.available_spreads() + """ + response = self.client.request( + method="GET", + path="/v1/analytics/spread", + params={}, + ) + + if isinstance(response, dict) and "data" in response: + return response["data"] + return response + + def forecast(self, commodity: str, method: str = "ema", days: int = 90) -> Dict[str, Any]: """Get price forecast for a commodity. Args: - commodity: Commodity code + commodity: Commodity code (sent to the API as ``code``) + method: Forecast method (sent as ``method``), e.g. ``"ema"`` + days: Number of days of history to base the forecast on (sent as ``period``) Returns: Forecast with predicted prices and confidence intervals Example: >>> forecast = client.analytics.forecast("BRENT_CRUDE_USD") - >>> print(f"7-day Forecast: ${forecast['7_day']['price']:.2f}") - >>> print(f"30-day Forecast: ${forecast['30_day']['price']:.2f}") - >>> print(f"Confidence: {forecast['confidence']}") """ response = self.client.request( method="GET", path="/v1/analytics/forecast", - params={"commodity": commodity} + params={ + "code": commodity, + "method": method, + "period": days, + }, ) - # Parse response - if "data" in response: + if isinstance(response, dict) and "data" in response: return response["data"] return response diff --git a/oilpriceapi/resources/data_sources.py b/oilpriceapi/resources/data_sources.py index 51940bd..7030037 100644 --- a/oilpriceapi/resources/data_sources.py +++ b/oilpriceapi/resources/data_sources.py @@ -105,23 +105,26 @@ def create( ... ) >>> print(f"Data source created: {source['id']}") """ - json_data = { + # Controller requires params nested under `data_source` and permits + # `status` ("active"/"paused"/"failed") and `scraper_config` — NOT a + # boolean `enabled` or a `config` key. + data_source: Dict[str, Any] = { "name": name, "source_type": source_type, "credentials": credentials, - "enabled": enabled, + "status": "active" if enabled else "paused", } if config: - json_data["config"] = config + data_source["scraper_config"] = config - # Add any additional kwargs - json_data.update(kwargs) + # Add any additional kwargs (caller can override wire keys directly) + data_source.update(kwargs) response = self.client.request( method="POST", path="/v1/data-sources", - json_data=json_data + json_data={"data_source": data_source} ) # Parse response @@ -159,24 +162,26 @@ def update( ... ) >>> print(f"Data source updated: {source['id']}") """ - json_data = {} + # Controller requires params nested under `data_source`, with `status` + # and `scraper_config` (not boolean `enabled` / `config`). + 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 + data_source["status"] = "active" if enabled else "paused" # Add any additional kwargs - json_data.update(kwargs) + data_source.update(kwargs) response = self.client.request( method="PATCH", path=f"/v1/data-sources/{source_id}", - json_data=json_data + json_data={"data_source": data_source} ) # Parse response diff --git a/oilpriceapi/resources/demo.py b/oilpriceapi/resources/demo.py new file mode 100644 index 0000000..3ab4935 --- /dev/null +++ b/oilpriceapi/resources/demo.py @@ -0,0 +1,95 @@ +""" +Demo Resource + +Public, no-authentication demo endpoints (``/v1/demo/*``). These power the +"time to first call" experience: a developer can fetch real free-tier prices +and the full commodity catalog without an API key. + +The demo endpoints ignore authentication entirely, so :class:`DemoResource` +works both as an attribute of an authenticated client (``client.demo``) and +standalone with no key: + + >>> from oilpriceapi.resources.demo import DemoResource + >>> demo = DemoResource() + >>> data = demo.prices() + >>> data["prices"][0]["code"] + 'BRENT_CRUDE_USD' +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Dict, List, Optional + +import httpx + +if TYPE_CHECKING: + from ..client import OilPriceAPI + +DEFAULT_BASE_URL = "https://api.oilpriceapi.com" + + +class DemoResource: + """Resource for the public, no-auth demo endpoints. + + Args: + client: Optional authenticated :class:`OilPriceAPI`. When provided, the + demo calls reuse that client's HTTP transport and base URL. When + omitted, the resource issues its own unauthenticated requests so the + demo works with no API key. + base_url: Base URL used for standalone (no-client) requests. + timeout: Request timeout (seconds) for standalone requests. + """ + + def __init__( + self, + client: Optional["OilPriceAPI"] = None, + base_url: str = DEFAULT_BASE_URL, + timeout: float = 30.0, + ) -> None: + self.client = client + self.base_url = base_url.rstrip("/") + self.timeout = timeout + + def _get(self, path: str) -> Dict[str, Any]: + """GET a demo path and return the parsed JSON envelope. + + Returns the full envelope ``{"status": ..., "data": {...}}`` so callers + can assert on the contract. + """ + if self.client is not None: + # Reuse the authenticated client's transport. The demo endpoints + # ignore the auth header, so this is harmless when a key is set. + return self.client.request(method="GET", path=path) + + url = f"{self.base_url}{path}" + response = httpx.get(url, timeout=self.timeout, follow_redirects=True) + response.raise_for_status() + data: Dict[str, Any] = response.json() + return data + + def prices(self, codes: Optional[List[str]] = None) -> Dict[str, Any]: + """Get latest demo prices for free-tier commodities. + + Args: + codes: Optional list of commodity codes to request. When omitted, + the API returns all free-tier commodities. + + Returns: + The ``data`` payload: ``{"prices": [...], "meta": {...}, "examples": {...}}``. + """ + path = "/v1/demo/prices" + if codes: + path = f"{path}?codes={','.join(codes)}" + envelope = self._get(path) + data: Dict[str, Any] = envelope.get("data", envelope) + return data + + def commodities(self) -> Dict[str, Any]: + """Get the full demo commodity catalog grouped by category. + + Returns: + The ``data`` payload: ``{"commodities": {category: [...]}, "meta": {...}}``. + """ + envelope = self._get("/v1/demo/commodities") + data: Dict[str, Any] = envelope.get("data", envelope) + return data diff --git a/oilpriceapi/resources/prices.py b/oilpriceapi/resources/prices.py index 467fef8..7776781 100644 --- a/oilpriceapi/resources/prices.py +++ b/oilpriceapi/resources/prices.py @@ -250,7 +250,9 @@ def to_dataframe( price = self.get(commodity) df = pd.DataFrame([price.model_dump()]) elif commodities: + # return_failures defaults to False, so this returns a plain List[Price]. prices = self.get_multiple(commodities) + assert isinstance(prices, list) df = pd.DataFrame([p.model_dump() for p in prices]) else: prices = self.get_all(per_page=per_page) diff --git a/oilpriceapi/resources/webhooks.py b/oilpriceapi/resources/webhooks.py index b31d1cc..ded1d3c 100644 --- a/oilpriceapi/resources/webhooks.py +++ b/oilpriceapi/resources/webhooks.py @@ -98,10 +98,12 @@ def create( ... ) >>> print(f"Webhook created: {webhook['id']}") """ - json_data = { + # The controller permits `status` ("active"/"inactive"/"paused"), NOT a + # boolean `enabled`. Map the friendly `enabled` flag to the wire param. + json_data: Dict[str, Any] = { "url": url, "events": events, - "enabled": enabled, + "status": "active" if enabled else "inactive", } if description: @@ -155,7 +157,7 @@ def update( ... ) >>> print(f"Webhook updated: {webhook['id']}") """ - json_data = {} + json_data: Dict[str, Any] = {} if url is not None: json_data["url"] = url @@ -166,7 +168,8 @@ 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" # Add any additional kwargs json_data.update(kwargs) diff --git a/oilpriceapi/telemetry.py b/oilpriceapi/telemetry.py index 422a2cf..23d8a55 100644 --- a/oilpriceapi/telemetry.py +++ b/oilpriceapi/telemetry.py @@ -41,7 +41,7 @@ import threading import time from datetime import datetime -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional try: import httpx @@ -79,7 +79,7 @@ def __init__( self.debug = debug # Event buffer - self._events = [] + self._events: List[Dict[str, Any]] = [] self._lock = threading.Lock() self._last_flush = time.time() @@ -108,7 +108,9 @@ def _generate_session_id(self) -> str: def _get_sdk_version(self) -> str: """Get SDK version.""" try: - from oilpriceapi import __version__ + # Read from the version module directly. Importing `__version__` from + # the top-level package trips no_implicit_reexport under strict mypy. + from oilpriceapi.version import __version__ return __version__ except ImportError: return "unknown" @@ -233,7 +235,7 @@ def configure_telemetry( """ global _global_telemetry - kwargs = {"enabled": enabled, "debug": debug} + kwargs: Dict[str, Any] = {"enabled": enabled, "debug": debug} if endpoint: kwargs["endpoint"] = endpoint diff --git a/pyproject.toml b/pyproject.toml index c2c8243..c208caa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -139,17 +139,20 @@ show_column_numbers = true pretty = true # --- Gradual typing baseline (CI was non-blocking for mypy until v1.7.0) --- -# The SDK has not been fully annotated yet. Until v1.7.0 the mypy CI step ran -# with `continue-on-error: true`, so ~700 "annotation completeness" findings -# (missing annotations / returning Any) never blocked merges. v1.7.0 made the -# step blocking, turning CI red. Rather than hide everything, we: +# The SDK is not yet fully annotated. Until v1.7.0 the mypy CI step ran with +# `continue-on-error: true`, so ~700 "annotation completeness" findings (missing +# annotations / returning Any) never blocked merges. v1.7.0 made the step +# blocking. We: # 1. Relax ONLY the annotation-completeness checks package-wide (the legitimate # "we haven't finished typing the SDK" debt), keeping every bug-catching # check (assignment, arg-type, union-attr, etc.) active. -# 2. Re-enable FULL strict checking for modules that are already clean -# (forecasts, drilling) so they cannot regress. -# 3. Grandfather the handful of legacy modules that still trip real-bug codes -# via narrow per-module overrides (TODO: fix and remove these). +# 2. Keep modules that are fully strict-clean under FULL strict checking so +# they cannot regress. +# +# The previous per-module `TODO(typing-debt)` overrides (client, async_client, +# cli, telemetry, prices, alerts, analytics, data_sources, webhooks, and the +# package __init__) have all been PAID DOWN — the real type errors were fixed +# with proper annotations, so those overrides were removed. [[tool.mypy.overrides]] module = "oilpriceapi.*" disable_error_code = ["no-untyped-def", "no-any-return", "no-untyped-call"] @@ -159,51 +162,13 @@ disable_error_code = ["no-untyped-def", "no-any-return", "no-untyped-call"] module = ["oilpriceapi.resources.forecasts", "oilpriceapi.resources.drilling"] disable_error_code = [] -# TODO(typing-debt): fix the real type issues below, then delete these overrides. -[[tool.mypy.overrides]] -module = "oilpriceapi" -disable_error_code = ["assignment"] - -[[tool.mypy.overrides]] -module = "oilpriceapi.client" -disable_error_code = ["assignment", "arg-type", "misc", "type-arg"] - -[[tool.mypy.overrides]] -module = "oilpriceapi.async_client" -disable_error_code = ["assignment", "attr-defined", "call-overload", "misc", "type-arg"] - -[[tool.mypy.overrides]] -module = "oilpriceapi.cli" -disable_error_code = ["arg-type", "attr-defined", "no-redef", "union-attr"] - +# models.py optionally imports `dateutil` for a fallback timestamp parse. The +# stub package (types-python-dateutil) is not a runtime/CI dependency, so this +# is a missing-stub finding, not a real typing bug. [[tool.mypy.overrides]] module = "oilpriceapi.models" disable_error_code = ["import-untyped"] -[[tool.mypy.overrides]] -module = "oilpriceapi.telemetry" -disable_error_code = ["arg-type", "assignment", "attr-defined", "var-annotated"] - -[[tool.mypy.overrides]] -module = "oilpriceapi.resources.alerts" -disable_error_code = ["assignment"] - -[[tool.mypy.overrides]] -module = "oilpriceapi.resources.analytics" -disable_error_code = ["assignment"] - -[[tool.mypy.overrides]] -module = "oilpriceapi.resources.data_sources" -disable_error_code = ["assignment"] - -[[tool.mypy.overrides]] -module = "oilpriceapi.resources.webhooks" -disable_error_code = ["assignment"] - -[[tool.mypy.overrides]] -module = "oilpriceapi.resources.prices" -disable_error_code = ["union-attr"] - [tool.pytest.ini_options] minversion = "7.0" testpaths = ["tests"] @@ -221,6 +186,7 @@ markers = [ "contract: marks tests as contract tests (validates API assumptions)", "slow: marks tests as slow running (deselect with '-m \"not slow\"')", "unit: marks tests as unit tests (fast, mocked)", + "live: marks tests that hit the public live demo API (no API key needed)", ] [tool.coverage.run] diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 464183a..c4cf0fd 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -5,9 +5,17 @@ import os import pytest from pathlib import Path -from dotenv import dotenv_values from oilpriceapi import OilPriceAPI +try: + from dotenv import dotenv_values +except ImportError: + # python-dotenv is optional. The live demo contract tests need no .env / + # API key, so don't make the whole integration suite uncollectable when it + # isn't installed. + def dotenv_values(_path): # type: ignore[misc] + return {} + # Load .env file from project root project_root = Path(__file__).parent.parent.parent env_path = project_root / '.env' diff --git a/tests/integration/test_demo_contract.py b/tests/integration/test_demo_contract.py new file mode 100644 index 0000000..068e558 --- /dev/null +++ b/tests/integration/test_demo_contract.py @@ -0,0 +1,116 @@ +""" +Live contract tests for the public demo endpoints. + +These hit the REAL, no-authentication demo API: + + GET https://api.oilpriceapi.com/v1/demo/prices + GET https://api.oilpriceapi.com/v1/demo/commodities + +They require no API key, but they DO require network access, so they live under +tests/integration/ and are marked ``live`` — the default unit gate ignores this +directory (``pytest tests/ --ignore=tests/integration``). Run explicitly with: + + pytest tests/integration/test_demo_contract.py -m live + +Purpose: prove the SDK's DemoResource parses the real response envelope +(``{"status": ..., "data": {prices|commodities, meta}}``) and that the live +contract still holds (9 free-tier prices incl. BRENT_CRUDE_USD ≈ $80, 442 +commodities, ``meta.free_commodities`` present). +""" + +import os + +import httpx +import pytest + +from oilpriceapi.resources.demo import DemoResource + +pytestmark = pytest.mark.live + +DEMO_BASE_URL = os.environ.get("OILPRICEAPI_BASE_URL", "https://api.oilpriceapi.com") + + +@pytest.fixture(scope="module") +def demo() -> DemoResource: + """A standalone (no API key) demo resource pointed at the live API.""" + return DemoResource(base_url=DEMO_BASE_URL) + + +def _skip_on_network_error(exc: Exception) -> None: + pytest.skip(f"live demo API unreachable: {exc}") + + +class TestDemoPricesContract: + def test_prices_envelope_and_parsing(self, demo: DemoResource) -> None: + """DemoResource.prices() parses the real {status, data:{prices, meta}} envelope.""" + try: + data = demo.prices() + except (httpx.HTTPError, OSError) as exc: # pragma: no cover - network + _skip_on_network_error(exc) + + # data is the unwrapped `data` payload from the envelope. + assert "prices" in data + assert "meta" in data + + prices = data["prices"] + assert isinstance(prices, list) + # Contract: 9 free-tier demo commodities. + assert len(prices) == 9 + + by_code = {p["code"]: p for p in prices} + assert "BRENT_CRUDE_USD" in by_code + + brent = by_code["BRENT_CRUDE_USD"] + # Each price row carries the documented fields. + for field in ("code", "name", "price", "currency", "updated_at"): + assert field in brent + # Sanity-check Brent is in a plausible band (~$80, allow wide drift). + assert 30 < float(brent["price"]) < 200 + + def test_prices_meta_demo_mode(self, demo: DemoResource) -> None: + """The demo prices meta block flags demo mode and lists free commodities.""" + try: + data = demo.prices() + except (httpx.HTTPError, OSError) as exc: # pragma: no cover - network + _skip_on_network_error(exc) + + meta = data["meta"] + assert meta.get("demo_mode") is True + assert meta.get("available_commodities") == 9 + + +class TestDemoCommoditiesContract: + def test_commodities_envelope_and_count(self, demo: DemoResource) -> None: + """DemoResource.commodities() parses {status, data:{commodities, meta}}; 442 total.""" + try: + data = demo.commodities() + except (httpx.HTTPError, OSError) as exc: # pragma: no cover - network + _skip_on_network_error(exc) + + assert "commodities" in data + assert "meta" in data + + catalog = data["commodities"] + assert isinstance(catalog, dict) # grouped by category + + meta = data["meta"] + # Contract: 442 commodities in the catalog, meta.total agrees with the + # flattened catalog size. + flattened = sum(len(v) for v in catalog.values()) + assert meta["total"] == flattened + assert meta["total"] == 442 + + # meta.free_commodities is present and matches the 9 free-tier codes. + assert "free_commodities" in meta + assert len(meta["free_commodities"]) == 9 + assert "BRENT_CRUDE_USD" in meta["free_commodities"] + + def test_commodities_codes_filter(self, demo: DemoResource) -> None: + """Passing codes= returns only the requested free-tier prices.""" + try: + data = demo.prices(codes=["BRENT_CRUDE_USD", "WTI_USD"]) + except (httpx.HTTPError, OSError) as exc: # pragma: no cover - network + _skip_on_network_error(exc) + + codes = {p["code"] for p in data["prices"]} + assert codes == {"BRENT_CRUDE_USD", "WTI_USD"} diff --git a/tests/unit/test_analytics_resource.py b/tests/unit/test_analytics_resource.py index e981b20..ab534e7 100644 --- a/tests/unit/test_analytics_resource.py +++ b/tests/unit/test_analytics_resource.py @@ -1,9 +1,17 @@ """ -Unit tests for AnalyticsResource +Unit tests for AnalyticsResource. + +These assert both response parsing AND the exact wire parameters sent to the +API. The wire-param assertions guard against the param-mismatch bug class fixed +in the Node SDK: the v1 analytics controller reads ``code`` / ``code1`` / +``code2`` / ``period`` (not ``commodity`` / ``commodity1`` / ``commodity2`` / +``days``), so a wrong key is silently ignored server-side. """ +from unittest.mock import patch + import pytest -from unittest.mock import Mock, patch + from oilpriceapi import OilPriceAPI @@ -16,79 +24,82 @@ def client(self): return OilPriceAPI(api_key="test_key") def test_performance(self, client): - """Test getting price performance analysis""" - mock_perf = { - "return_pct": 5.2, - "volatility": 12.5, - "trend": "bullish" - } + """performance() maps days -> range and parses the data envelope.""" + mock_perf = {"return_pct": 5.2, "volatility": 12.5, "trend": "bullish"} - with patch.object(client, 'request', return_value={"data": mock_perf}): - perf = client.analytics.performance("BRENT_CRUDE_USD", days=30) + with patch.object(client, "request", return_value={"data": mock_perf}) as req: + perf = client.analytics.performance(days=30) assert perf["return_pct"] == 5.2 + _, kwargs = req.call_args + assert kwargs["path"] == "/v1/analytics/performance" + # Controller reads params[:range], not commodity/days. + assert kwargs["params"] == {"range": "30d"} - def test_statistics(self, client): - """Test getting statistical analysis""" - mock_stats = { - "mean": 75.50, - "std_dev": 3.25, - "min": 70.00, - "max": 82.00 - } + def test_statistics_sends_code_and_period(self, client): + """statistics() sends code/period (not commodity/days).""" + mock_stats = {"mean": 75.50, "std_dev": 3.25, "min": 70.00, "max": 82.00} - with patch.object(client, 'request', return_value={"data": mock_stats}): + with patch.object(client, "request", return_value={"data": mock_stats}) as req: stats = client.analytics.statistics("WTI_USD", days=90) assert stats["mean"] == 75.50 + _, kwargs = req.call_args + assert kwargs["params"] == {"code": "WTI_USD", "period": 90} - def test_correlation(self, client): - """Test getting correlation analysis""" - mock_corr = { - "correlation": 0.95, - "p_value": 0.0001 - } + def test_correlation_sends_code1_code2_period(self, client): + """correlation() sends code1/code2/period (the Node SDK bug class).""" + mock_corr = {"correlation": 0.95, "p_value": 0.0001} - with patch.object(client, 'request', return_value={"data": mock_corr}): + with patch.object(client, "request", return_value={"data": mock_corr}) as req: corr = client.analytics.correlation("BRENT_CRUDE_USD", "WTI_USD", days=90) assert corr["correlation"] == 0.95 - - def test_trend(self, client): - """Test getting trend analysis""" - mock_trend = { - "direction": "up", - "strength": "strong", - "momentum": 0.8 - } - - with patch.object(client, 'request', return_value={"data": mock_trend}): + _, kwargs = req.call_args + params = kwargs["params"] + assert params == {"code1": "BRENT_CRUDE_USD", "code2": "WTI_USD", "period": 90} + # Regression guard: the old/Node bug used these keys. + assert "commodity1" not in params + assert "commodity2" not in params + assert "days" not in params + + def test_trend_sends_code_and_period(self, client): + """trend() sends code/period.""" + mock_trend = {"direction": "up", "strength": "strong", "momentum": 0.8} + + with patch.object(client, "request", return_value={"data": mock_trend}) as req: trend = client.analytics.trend("NATURAL_GAS_USD", days=30) assert trend["direction"] == "up" + _, kwargs = req.call_args + assert kwargs["params"] == {"code": "NATURAL_GAS_USD", "period": 30} - def test_spread(self, client): - """Test getting spread analysis""" - mock_spread = { - "current": 2.50, - "average": 2.20, - "percentile": 75 - } + def test_spread_sends_named_spread_and_period(self, client): + """spread() operates on a named spread, sent as spread/period.""" + mock_spread = {"current": 2.50, "average": 2.20, "percentile": 75} - with patch.object(client, 'request', return_value={"data": mock_spread}): - spread = client.analytics.spread("BRENT_CRUDE_USD", "WTI_USD") + with patch.object(client, "request", return_value={"data": mock_spread}) as req: + spread = client.analytics.spread("wti_brent") assert spread["current"] == 2.50 + _, kwargs = req.call_args + assert kwargs["params"] == {"spread": "wti_brent", "period": 30} - def test_forecast(self, client): - """Test getting price forecast""" + def test_forecast_sends_code_method_period(self, client): + """forecast() sends code/method/period.""" mock_forecast = { "7_day": {"price": 76.00}, "30_day": {"price": 77.50}, - "confidence": 0.85 + "confidence": 0.85, } - with patch.object(client, 'request', return_value={"data": mock_forecast}): + with patch.object(client, "request", return_value={"data": mock_forecast}) as req: forecast = client.analytics.forecast("BRENT_CRUDE_USD") assert forecast["7_day"]["price"] == 76.00 + _, kwargs = req.call_args + assert kwargs["params"] == { + "code": "BRENT_CRUDE_USD", + "method": "ema", + "period": 90, + } diff --git a/tests/unit/test_data_sources_resource.py b/tests/unit/test_data_sources_resource.py index 8e4a2e4..2588835 100644 --- a/tests/unit/test_data_sources_resource.py +++ b/tests/unit/test_data_sources_resource.py @@ -2,8 +2,10 @@ Unit tests for DataSourcesResource """ +from unittest.mock import patch + import pytest -from unittest.mock import Mock, patch + from oilpriceapi import OilPriceAPI @@ -98,3 +100,41 @@ def test_rotate_credentials(self, client): ) assert result["status"] == "updated" + + def test_create_nests_and_maps_wire_params(self, client): + """create() nests under `data_source` and maps enabled->status, config->scraper_config. + + The controller does ``params.require(:data_source).permit(:status, + scraper_config: {})`` so flat / mis-named keys are dropped. + """ + with patch.object(client, "request", return_value={"data": {"id": "ds_1"}}) as req: + client.data_sources.create( + name="Platts", + source_type="platts", + credentials={"api_key": "k"}, + config={"fetch_interval": 300}, + enabled=False, + ) + _, kwargs = req.call_args + body = kwargs["json_data"] + assert "data_source" in body + ds = body["data_source"] + assert ds["status"] == "paused" + assert ds["scraper_config"] == {"fetch_interval": 300} + assert "enabled" not in ds + assert "config" not in ds + + def test_update_nests_and_maps_wire_params(self, client): + """update() nests under `data_source` and maps enabled->status, config->scraper_config.""" + with patch.object(client, "request", return_value={"data": {"id": "ds_1"}}) as req: + client.data_sources.update( + "ds_1", + config={"fetch_interval": 600}, + enabled=True, + ) + _, kwargs = req.call_args + ds = kwargs["json_data"]["data_source"] + assert ds["status"] == "active" + assert ds["scraper_config"] == {"fetch_interval": 600} + assert "enabled" not in ds + assert "config" not in ds diff --git a/tests/unit/test_demo_resource.py b/tests/unit/test_demo_resource.py new file mode 100644 index 0000000..5070534 --- /dev/null +++ b/tests/unit/test_demo_resource.py @@ -0,0 +1,96 @@ +""" +Unit tests for DemoResource (mocked — no network). + +The live contract is asserted separately in +tests/integration/test_demo_contract.py (marked `live`). +""" + +from unittest.mock import patch + +import pytest + +from oilpriceapi import OilPriceAPI +from oilpriceapi.resources.demo import DemoResource + +DEMO_PRICES_ENVELOPE = { + "status": "success", + "data": { + "prices": [ + {"code": "BRENT_CRUDE_USD", "name": "Brent Crude", "price": 80.4, + "currency": "USD", "updated_at": "2026-06-20T10:00:00Z"}, + {"code": "WTI_USD", "name": "WTI", "price": 76.1, + "currency": "USD", "updated_at": "2026-06-20T10:00:00Z"}, + ], + "meta": {"demo_mode": True, "available_commodities": 9}, + "examples": {}, + }, +} + +DEMO_COMMODITIES_ENVELOPE = { + "status": "success", + "data": { + "commodities": {"crude_oil": [{"code": "BRENT_CRUDE_USD"}], "fx": [{"code": "EUR_USD"}]}, + "meta": {"total": 2, "categories": ["crude_oil", "fx"], + "free_commodities": ["BRENT_CRUDE_USD", "WTI_USD"]}, + }, +} + + +class TestDemoResourceStandalone: + """DemoResource with no client issues its own unauthenticated requests.""" + + def test_prices_parses_envelope(self): + demo = DemoResource() + with patch("oilpriceapi.resources.demo.httpx.get") as mock_get: + mock_get.return_value.json.return_value = DEMO_PRICES_ENVELOPE + mock_get.return_value.raise_for_status.return_value = None + + data = demo.prices() + + assert [p["code"] for p in data["prices"]] == ["BRENT_CRUDE_USD", "WTI_USD"] + assert data["meta"]["demo_mode"] is True + # No auth header / api key required. + url = mock_get.call_args.args[0] + assert url.endswith("/v1/demo/prices") + + def test_prices_with_codes_filter(self): + demo = DemoResource() + with patch("oilpriceapi.resources.demo.httpx.get") as mock_get: + mock_get.return_value.json.return_value = DEMO_PRICES_ENVELOPE + mock_get.return_value.raise_for_status.return_value = None + + demo.prices(codes=["BRENT_CRUDE_USD", "WTI_USD"]) + + url = mock_get.call_args.args[0] + assert "codes=BRENT_CRUDE_USD,WTI_USD" in url + + def test_commodities_parses_envelope(self): + demo = DemoResource() + with patch("oilpriceapi.resources.demo.httpx.get") as mock_get: + mock_get.return_value.json.return_value = DEMO_COMMODITIES_ENVELOPE + mock_get.return_value.raise_for_status.return_value = None + + data = demo.commodities() + + assert "free_commodities" in data["meta"] + assert data["meta"]["total"] == 2 + + +class TestDemoResourceViaClient: + """client.demo reuses the authenticated client's transport.""" + + @pytest.fixture + def client(self): + return OilPriceAPI(api_key="test_key") + + def test_client_exposes_demo(self, client): + assert isinstance(client.demo, DemoResource) + assert client.demo.client is client + + def test_demo_prices_via_client(self, client): + with patch.object(client, "request", return_value=DEMO_PRICES_ENVELOPE) as req: + data = client.demo.prices() + + assert data["prices"][0]["code"] == "BRENT_CRUDE_USD" + _, kwargs = req.call_args + assert kwargs["path"] == "/v1/demo/prices" diff --git a/tests/unit/test_webhooks_resource.py b/tests/unit/test_webhooks_resource.py index d29ded5..93635ea 100644 --- a/tests/unit/test_webhooks_resource.py +++ b/tests/unit/test_webhooks_resource.py @@ -2,8 +2,10 @@ Unit tests for WebhooksResource """ +from unittest.mock import patch + import pytest -from unittest.mock import Mock, patch + from oilpriceapi import OilPriceAPI @@ -78,3 +80,28 @@ def test_events(self, client): assert len(events) == 1 assert events[0]["type"] == "price.updated" + + def test_create_maps_enabled_to_status(self, client): + """create() sends `status` ("active"/"inactive"), not boolean `enabled`. + + The webhooks controller permits `status` and silently drops `enabled`. + """ + with patch.object(client, "request", return_value={"data": {"id": "wh_1"}}) as req: + client.webhooks.create( + url="https://example.com/wh", + events=["price.updated"], + enabled=False, + ) + _, kwargs = req.call_args + body = kwargs["json_data"] + assert body["status"] == "inactive" + assert "enabled" not in body + + def test_update_maps_enabled_to_status(self, client): + """update() maps enabled -> status.""" + with patch.object(client, "request", return_value={"data": {"id": "wh_1"}}) as req: + client.webhooks.update("wh_1", enabled=True) + _, kwargs = req.call_args + body = kwargs["json_data"] + assert body["status"] == "active" + assert "enabled" not in body