Skip to content

Commit 911ed0c

Browse files
karlwaldmanclaude
andcommitted
chore: pay down mypy typing-debt + param audit + live demo contract tests
Task 1 — typing-debt paid down: fixed the real type errors in client, async_client, cli, telemetry, prices, alerts, analytics, data_sources, webhooks and the package __init__ with proper annotations (not type: ignore), then removed all 10 TODO(typing-debt) per-module mypy overrides. The only remaining narrow override is models.py import-untyped (optional dateutil stub, not a real typing bug). `mypy oilpriceapi/ --ignore-missing-imports` is clean with the overrides gone. Task 2 — param-correctness audit vs the real controllers: - analytics: SDK sent commodity/commodity1/commodity2/days; the v1 analytics controller requires code/code1/code2/period. Fixed correlation (the Node SDK bug class), plus statistics/trend/forecast. performance now maps to the controller's `range` param; spread now targets a named spread (the endpoint takes a spread name, not two codes) and a new available_spreads() helper was added. Same fixes applied to the async analytics resource. - webhooks: controller permits `status` (active/inactive/paused), not boolean `enabled`. create/update now map enabled -> status (sync + async). - data_sources: controller requires params nested under `data_source` and uses `status` + `scraper_config`, not flat `enabled` + `config`. create/update now nest and map correctly (sync + async). - alerts already nested correctly under price_alert with the right keys. Added wire-param unit tests asserting the exact params sent. Task 3 — live demo contract tests: new DemoResource (client.demo, also usable standalone with no API key) hitting GET /v1/demo/prices and /v1/demo/commodities. Live tests under tests/integration (marked `live`, excluded from the unit gate) assert the real envelope: 9 free-tier prices incl BRENT_CRUDE_USD ~$80, 442 commodities, meta.free_commodities. Mocked unit tests cover the accessor. Gates: mypy clean, ruff clean (oilpriceapi/), 249 unit tests pass, cov 57.38% (>=50). Version not bumped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 25c9d53 commit 911ed0c

20 files changed

Lines changed: 667 additions & 250 deletions

oilpriceapi/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
The official Python SDK for OilPriceAPI - Real-time and historical oil prices.
55
"""
66

7+
from typing import Optional
8+
79
from oilpriceapi.version import __version__ # noqa: F401
810

911
__author__ = "OilPriceAPI"
@@ -68,7 +70,7 @@
6870

6971

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

oilpriceapi/async_client.py

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ def __init__(
7474
base_url: Optional[str] = None,
7575
timeout: Optional[float] = None,
7676
max_retries: Optional[int] = None,
77-
retry_on: Optional[list] = None,
77+
retry_on: Optional[List[int]] = None,
7878
headers: Optional[Dict[str, str]] = None,
7979
max_connections: int = 100,
8080
max_keepalive_connections: int = 20,
@@ -129,7 +129,7 @@ def __init__(
129129
self.headers.update(headers)
130130

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

134134
# Initialize resources
135135
self.prices = AsyncPricesResource(self)
@@ -188,9 +188,10 @@ async def request(
188188
params: Optional[Dict[str, Any]] = None,
189189
json_data: Optional[Dict[str, Any]] = None,
190190
**kwargs
191-
) -> Union[Dict[str, Any], list]:
191+
) -> Union[Dict[str, Any], List[Any]]:
192192
"""Make async HTTP request to API."""
193193
await self._ensure_client()
194+
assert self._client is not None # set by _ensure_client
194195

195196
# Ensure path starts with / for proper urljoin behavior
196197
if not path.startswith('/'):
@@ -200,7 +201,7 @@ async def request(
200201
# Retry logic
201202
import time as _time
202203
start_time = _time.time()
203-
last_exception = None
204+
last_exception: Optional[OilPriceAPIError] = None
204205
for attempt in range(self.max_retries):
205206
try:
206207
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]:
327328
except json.JSONDecodeError:
328329
return {"error": response.text or "Unknown error"}
329330

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

375-
if "data" in response:
376+
if isinstance(response, dict) and "data" in response:
376377
price_data = response["data"]
377378
else:
378379
price_data = response
@@ -436,7 +437,7 @@ async def get_all(self) -> List[Price]:
436437
path="/v1/prices/all"
437438
)
438439

439-
if "data" in response:
440+
if isinstance(response, dict) and "data" in response:
440441
prices_data = response["data"]
441442
else:
442443
prices_data = response
@@ -482,9 +483,9 @@ async def get(
482483

483484
# Parse response - handle nested structure
484485
# API returns: {"status": "success", "data": {"prices": [...]}}
485-
if "data" in response and isinstance(response["data"], dict) and "prices" in response["data"]:
486+
if isinstance(response, dict) and isinstance(response.get("data"), dict) and "prices" in response["data"]:
486487
prices_data = response["data"]["prices"]
487-
elif "data" in response and isinstance(response["data"], list):
488+
elif isinstance(response, dict) and isinstance(response.get("data"), list):
488489
prices_data = response["data"]
489490
else:
490491
prices_data = response if isinstance(response, list) else []
@@ -547,7 +548,7 @@ async def iter_pages(
547548
end_date: Optional[str] = None,
548549
interval: str = "daily",
549550
per_page: int = 100,
550-
) -> AsyncGenerator:
551+
) -> AsyncGenerator[List[HistoricalPrice], None]:
551552
"""Async iterate through pages of historical data.
552553
553554
Memory-efficient async iterator for large datasets.

oilpriceapi/async_resources.py

Lines changed: 42 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -527,58 +527,60 @@ class AsyncAnalyticsResource:
527527
def __init__(self, client):
528528
self.client = client
529529

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

541542
async def statistics(self, commodity: str, days: int = 30) -> Dict[str, Any]:
542543
response = await self.client.request(
543544
method="GET", path="/v1/analytics/statistics",
544-
params={"commodity": commodity, "days": days}
545+
params={"code": commodity, "period": days}
545546
)
546-
if "data" in response:
547+
if isinstance(response, dict) and "data" in response:
547548
return response["data"]
548549
return response
549550

550551
async def correlation(self, commodity1: str, commodity2: str, days: int = 90) -> Dict[str, Any]:
551552
response = await self.client.request(
552553
method="GET", path="/v1/analytics/correlation",
553-
params={"commodity1": commodity1, "commodity2": commodity2, "days": days}
554+
params={"code1": commodity1, "code2": commodity2, "period": days}
554555
)
555-
if "data" in response:
556+
if isinstance(response, dict) and "data" in response:
556557
return response["data"]
557558
return response
558559

559560
async def trend(self, commodity: str, days: int = 30) -> Dict[str, Any]:
560561
response = await self.client.request(
561562
method="GET", path="/v1/analytics/trend",
562-
params={"commodity": commodity, "days": days}
563+
params={"code": commodity, "period": days}
563564
)
564-
if "data" in response:
565+
if isinstance(response, dict) and "data" in response:
565566
return response["data"]
566567
return response
567568

568-
async def spread(self, commodity1: str, commodity2: str) -> Dict[str, Any]:
569+
async def spread(self, spread: str, days: int = 30) -> Dict[str, Any]:
569570
response = await self.client.request(
570571
method="GET", path="/v1/analytics/spread",
571-
params={"commodity1": commodity1, "commodity2": commodity2}
572+
params={"spread": spread, "period": days}
572573
)
573-
if "data" in response:
574+
if isinstance(response, dict) and "data" in response:
574575
return response["data"]
575576
return response
576577

577-
async def forecast(self, commodity: str) -> Dict[str, Any]:
578+
async def forecast(self, commodity: str, method: str = "ema", days: int = 90) -> Dict[str, Any]:
578579
response = await self.client.request(
579-
method="GET", path="/v1/analytics/forecast", params={"commodity": commodity}
580+
method="GET", path="/v1/analytics/forecast",
581+
params={"code": commodity, "method": method, "period": days}
580582
)
581-
if "data" in response:
583+
if isinstance(response, dict) and "data" in response:
582584
return response["data"]
583585
return response
584586

@@ -1199,7 +1201,12 @@ async def create(
11991201
enabled: bool = True,
12001202
**kwargs
12011203
) -> Dict[str, Any]:
1202-
json_data: Dict[str, Any] = {"url": url, "events": events, "enabled": enabled}
1204+
# Controller permits `status` ("active"/"inactive"/"paused"), not boolean `enabled`.
1205+
json_data: Dict[str, Any] = {
1206+
"url": url,
1207+
"events": events,
1208+
"status": "active" if enabled else "inactive",
1209+
}
12031210
if description:
12041211
json_data["description"] = description
12051212
if secret:
@@ -1230,7 +1237,8 @@ async def update(
12301237
if secret is not None:
12311238
json_data["secret"] = secret
12321239
if enabled is not None:
1233-
json_data["enabled"] = enabled
1240+
# Controller permits `status`, not boolean `enabled`.
1241+
json_data["status"] = "active" if enabled else "inactive"
12341242
json_data.update(kwargs)
12351243
response = await self.client.request(
12361244
method="PATCH", path=f"/v1/webhooks/{webhook_id}", json_data=json_data
@@ -1282,15 +1290,18 @@ async def create(
12821290
enabled: bool = True,
12831291
**kwargs
12841292
) -> Dict[str, Any]:
1285-
json_data: Dict[str, Any] = {
1293+
# Controller requires nesting under `data_source` with `status` /
1294+
# `scraper_config` (not boolean `enabled` / `config`).
1295+
data_source: Dict[str, Any] = {
12861296
"name": name, "source_type": source_type,
1287-
"credentials": credentials, "enabled": enabled
1297+
"credentials": credentials,
1298+
"status": "active" if enabled else "paused",
12881299
}
12891300
if config:
1290-
json_data["config"] = config
1291-
json_data.update(kwargs)
1301+
data_source["scraper_config"] = config
1302+
data_source.update(kwargs)
12921303
response = await self.client.request(
1293-
method="POST", path="/v1/data-sources", json_data=json_data
1304+
method="POST", path="/v1/data-sources", json_data={"data_source": data_source}
12941305
)
12951306
if "data" in response:
12961307
return response["data"]
@@ -1305,18 +1316,19 @@ async def update(
13051316
enabled: Optional[bool] = None,
13061317
**kwargs
13071318
) -> Dict[str, Any]:
1308-
json_data: Dict[str, Any] = {}
1319+
data_source: Dict[str, Any] = {}
13091320
if name is not None:
1310-
json_data["name"] = name
1321+
data_source["name"] = name
13111322
if credentials is not None:
1312-
json_data["credentials"] = credentials
1323+
data_source["credentials"] = credentials
13131324
if config is not None:
1314-
json_data["config"] = config
1325+
data_source["scraper_config"] = config
13151326
if enabled is not None:
1316-
json_data["enabled"] = enabled
1317-
json_data.update(kwargs)
1327+
data_source["status"] = "active" if enabled else "paused"
1328+
data_source.update(kwargs)
13181329
response = await self.client.request(
1319-
method="PATCH", path=f"/v1/data-sources/{source_id}", json_data=json_data
1330+
method="PATCH", path=f"/v1/data-sources/{source_id}",
1331+
json_data={"data_source": data_source}
13201332
)
13211333
if "data" in response:
13221334
return response["data"]

oilpriceapi/cli.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,17 @@
1515

1616
import os
1717
import sys
18+
from typing import Any, List
1819

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

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

179178
# Handle different response formats
179+
commodity_list: List[Any]
180180
if isinstance(items, dict):
181-
commodity_list = items.get("commodities", items.get("data", []))
181+
commodity_list = items.get("commodities") or items.get("data") or []
182182
elif isinstance(items, list):
183183
commodity_list = items
184184
else:
185-
commodity_list = items.data if hasattr(items, "data") else []
185+
commodity_list = list(items.data) if hasattr(items, "data") else []
186186

187187
if search:
188188
search_lower = search.lower()

oilpriceapi/client.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,14 @@
99
import os
1010
import time
1111
from datetime import datetime
12-
from typing import Any, Dict, List, Optional
12+
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
1313
from urllib.parse import urljoin
1414

1515
import httpx
1616

17+
if TYPE_CHECKING:
18+
from .visualization import PriceVisualizer
19+
1720
logger = logging.getLogger(__name__)
1821

1922
from .exceptions import (
@@ -33,6 +36,7 @@
3336
from .resources.commodities import CommoditiesResource
3437
from .resources.data_quality import DataQualityResource
3538
from .resources.data_sources import DataSourcesResource
39+
from .resources.demo import DemoResource
3640
from .resources.diesel import DieselResource
3741
from .resources.drilling import DrillingIntelligenceResource
3842
from .resources.ei import EnergyIntelligenceResource
@@ -89,7 +93,7 @@ def __init__(
8993
base_url: Optional[str] = None,
9094
timeout: Optional[float] = None,
9195
max_retries: Optional[int] = None,
92-
retry_on: Optional[list] = None,
96+
retry_on: Optional[List[int]] = None,
9397
headers: Optional[Dict[str, str]] = None,
9498
app_url: Optional[str] = None,
9599
app_name: Optional[str] = None,
@@ -172,8 +176,11 @@ def __init__(
172176
self.ei = EnergyIntelligenceResource(self)
173177
self.webhooks = WebhooksResource(self)
174178
self.data_sources = DataSourcesResource(self)
179+
# Public, no-auth demo endpoints (/v1/demo/*).
180+
self.demo = DemoResource(self)
175181

176182
# Initialize visualization (optional)
183+
self.viz: Optional["PriceVisualizer"]
177184
try:
178185
from .visualization import PriceVisualizer
179186
self.viz = PriceVisualizer(self)
@@ -226,7 +233,7 @@ def request(
226233
effective_timeout = timeout if timeout is not None else self.timeout
227234

228235
# Retry logic using retry strategy
229-
last_exception = None
236+
last_exception: Optional[OilPriceAPIError] = None
230237
start_time = time.time()
231238
for attempt in range(self.max_retries):
232239
try:
@@ -365,7 +372,7 @@ def request_with_headers(
365372
json_data: Optional[Dict[str, Any]] = None,
366373
timeout: Optional[float] = None,
367374
**kwargs
368-
) -> tuple:
375+
) -> Tuple[Dict[str, Any], httpx.Headers]:
369376
"""Make HTTP request and return (json_body, headers) tuple.
370377
371378
Identical to request() but also returns response headers so callers
@@ -381,7 +388,7 @@ def request_with_headers(
381388

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

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

485-
def _parse_rate_limit_reset(self, headers: Dict[str, str]) -> Optional[datetime]:
492+
def _parse_rate_limit_reset(self, headers: httpx.Headers) -> Optional[datetime]:
486493
"""Parse rate limit reset time from headers."""
487494
reset_header = headers.get("X-RateLimit-Reset")
488495
if reset_header:

oilpriceapi/resources/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from .commodities import CommoditiesResource
1111
from .data_quality import DataQualityResource
1212
from .data_sources import DataSourcesResource
13+
from .demo import DemoResource
1314
from .diesel import DieselResource
1415
from .drilling import DrillingIntelligenceResource
1516
from .ei import EnergyIntelligenceResource
@@ -38,4 +39,5 @@
3839
"EnergyIntelligenceResource",
3940
"WebhooksResource",
4041
"DataSourcesResource",
42+
"DemoResource",
4143
]

0 commit comments

Comments
 (0)