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
43 changes: 43 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,49 @@ All notable changes to the OilPriceAPI Python SDK will be documented in this fil

## [Unreleased]

## [1.17.0] - 2026-10-03

### Fixed

- **Eight methods called paths the API does not route, so every call
returned 404 (#153).** Sync and async are both fixed.
- `storage.history(code, period="90d")` now calls
`/v1/storage/history/{code}`. It accepts `CUSHING_STORAGE`, `US_SPR`,
`SINGAPORE_STORAGE_TOTAL`, `ARA_STORAGE_TOTAL` (or `"cushing"` / `"spr"`)
and a `period` of `7d`, `30d`, `90d`, `1y` or `all`. It returns the
API's `{code, period, history, statistics}` object. The
`start_date`/`end_date` arguments are gone: the route never accepted
them.
- `bunker_fuels.spreads(from_port, to_port, grade=None)` now calls
`/v1/bunker-fuels/spreads/ports`. Both ports are required.
- `bunker_fuels.historical(port, fuel_type=None, start_date=None,
end_date=None, interval=None)` now calls
`/v1/bunker-fuels/historical/{port}`. `fuel_type` filters the
returned `historical_data` by grade.
- `forecasts.accuracy()` and `forecasts.archive()` now call
`/v1/forecasts/monthly/accuracy` and `/archive`. While monthly
forecasts are unpublished, the API's `not_available` 404 raises
`DataNotFoundError`.
- `drilling.trends()`, `drilling.basin()` and `futures.spreads()` have
no API route. They now emit `DeprecationWarning` and raise
`ValidationError(code="ENDPOINT_NOT_AVAILABLE", status_code=None)`
without sending a request. The error names the replacement:
`rig_counts.trends()`, `ei.drilling_productivity.by_basin()` and the
new `futures.calendar_spreads(contract)`. They will be removed in 2.0.
- Invalid storage codes, periods, ports and fuel types are rejected
locally, before any request is sent.
- **The price-alert polling example in `EXAMPLES.md` now polls hourly**
instead of every 30 minutes, halving its daily calls. It now shows how to
compute the budget against a plan allowance.

### Added

- `futures.calendar_spreads(contract)` for `/v1/futures/{slug}/spreads`.
- `tests/unit/test_api_path_contract.py` fails when any `/v1` path in the SDK
is missing from the API route table snapshot
(`tests/fixtures/api_paths.json.fixture`). Refresh the snapshot with
`scripts/refresh_api_paths.py`.

## [1.16.0] - 2026-09-13

### Added
Expand Down
7 changes: 5 additions & 2 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -456,10 +456,13 @@ def monitor_prices():
elif price.value < limits['low']:
send_alert(commodity, price.value, limits['low'], 'BELOW')

# Example caller-selected interval. Derive production polling from the
# Each pass makes one request per commodity, so the daily total grows
# with len(thresholds) and shrinks with the sleep interval. Check that
# total against your plan's daily allowance before adding commodities
# or shortening the interval. Derive production polling from the
# account's current limit/reset response and the source timestamps.
# See https://docs.oilpriceapi.com/guides/rate-limiting#how-often-to-poll
time.sleep(1800)
time.sleep(3600)

if __name__ == '__main__':
print("Starting price monitor...")
Expand Down
112 changes: 63 additions & 49 deletions oilpriceapi/async_resources.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

from datetime import date, datetime
from typing import Any, Dict, List, Optional, Sequence, Union
from typing import Any, Dict, List, Optional, Sequence, Union, cast

from . import _fuel_surcharge_common as fs
from ._subscriptions_common import (
Expand Down Expand Up @@ -54,6 +54,15 @@
)
from .resources import _calculated_metrics as metrics_ops
from .resources._futures_slug import normalize_futures_slug
from .resources._removed import removed_endpoint
from .resources._route_args import (
bunker_grade_prefix,
bunker_port_code,
bunker_spread_params,
filter_bunker_history,
storage_history_code,
storage_history_period,
)
from .resources.ei._envelopes import ei_data, unwrap_ei_collection, unwrap_ei_object
from .resources.ei.well_permits import unwrap_well_permit_search_response
from .resources.subscriptions import SubscriptionEventsPage
Expand Down Expand Up @@ -442,10 +451,16 @@ async def intraday(self, contract: str) -> List[Dict[str, Any]]:
return response

async def spreads(self, contract1: str, contract2: str) -> Dict[str, Any]:
response = await self.client.request(
method="GET", path="/v1/futures/spreads",
params={"contract1": contract1, "contract2": contract2}
"""Deprecated: no API route (#153). Use ``calendar_spreads(contract)``."""
removed_endpoint(
"client.futures.spreads()",
"client.futures.calendar_spreads(contract)",
)

async def calendar_spreads(self, contract: str) -> Dict[str, Any]:
"""Calendar spreads for one contract family (``/v1/futures/{slug}/spreads``)."""
slug = normalize_futures_slug(contract)
response = await self.client.request(method="GET", path=f"/v1/futures/{slug}/spreads")
if "data" in response:
return response["data"]
return response
Expand Down Expand Up @@ -512,25 +527,17 @@ async def regional(self, region: Optional[str] = None) -> Dict[str, Any]:
return response["data"]
return response

async def history(
self,
code: str,
start_date: Optional[Union[str, date, datetime]] = None,
end_date: Optional[Union[str, date, datetime]] = None
) -> List[Dict[str, Any]]:
params = {}
if start_date is not None:
params["start_date"] = format_date(start_date)
if end_date is not None:
params["end_date"] = format_date(end_date)
async def history(self, code: str, period: str = "90d") -> Dict[str, Any]:
"""Storage history (``/v1/storage/history/{code}``). See the sync docstring."""
response = await self.client.request(
method="GET", path=f"/v1/storage/{code}/history", params=params
method="GET",
path=f"/v1/storage/history/{storage_history_code(code)}",
params={"period": storage_history_period(period)},
)
if "data" in response:
return response["data"]
return response


class AsyncRigCountsResource:
def __init__(self, client):
self.client = client
Expand Down Expand Up @@ -604,30 +611,41 @@ async def compare(self, ports: List[str]) -> Dict[str, Any]:
return response["data"]
return response

async def spreads(self) -> Dict[str, Any]:
response = await self.client.request(method="GET", path="/v1/bunker-fuels/spreads")
async def spreads(self, from_port: str, to_port: str, grade: Optional[str] = None) -> Dict[str, Any]:
"""Port-to-port spread (``/v1/bunker-fuels/spreads/ports``)."""
response = await self.client.request(
method="GET",
path="/v1/bunker-fuels/spreads/ports",
params=bunker_spread_params(from_port, to_port, grade),
)
if "data" in response:
return response["data"]
return response

async def historical(
self,
port: str,
fuel_type: str,
fuel_type: Optional[str] = None,
start_date: Optional[Union[str, date, datetime]] = None,
end_date: Optional[Union[str, date, datetime]] = None
) -> List[Dict[str, Any]]:
params: Dict[str, Any] = {"port": port, "fuel_type": fuel_type}
end_date: Optional[Union[str, date, datetime]] = None,
interval: Optional[str] = None,
) -> Dict[str, Any]:
"""Port history (``/v1/bunker-fuels/historical/{port}``). See the sync docstring."""
prefix = bunker_grade_prefix(fuel_type)
params: Dict[str, Any] = {}
if start_date is not None:
params["start_date"] = format_date(start_date)
params["from"] = format_date(start_date)
if end_date is not None:
params["end_date"] = format_date(end_date)
params["to"] = format_date(end_date)
if interval is not None:
params["interval"] = interval
response = await self.client.request(
method="GET", path="/v1/bunker-fuels/historical", params=params
method="GET",
path=f"/v1/bunker-fuels/historical/{bunker_port_code(port)}",
params=params,
)
if "data" in response:
return response["data"]
return response
data = response["data"] if "data" in response else response
return filter_bunker_history(data, prefix)

async def export(self, format: str = "json") -> Any:
response = await self.client.request(
Expand Down Expand Up @@ -718,21 +736,23 @@ async def monthly(self, commodity: Optional[str] = None) -> Dict[str, Any]:
return response

async def accuracy(self) -> Dict[str, Any]:
response = await self.client.request(method="GET", path="/v1/forecasts/accuracy")
"""Forecast accuracy (``/v1/forecasts/monthly/accuracy``)."""
response = await self.client.request(method="GET", path="/v1/forecasts/monthly/accuracy")
if "data" in response:
return response["data"]
return response
return cast(Dict[str, Any], response["data"])
return cast(Dict[str, Any], response)

async def archive(self, year: Optional[int] = None) -> List[Dict[str, Any]]:
params = {}
"""Archived forecasts (``/v1/forecasts/monthly/archive``)."""
params: Dict[str, Any] = {}
if year:
params["year"] = year
response = await self.client.request(
method="GET", path="/v1/forecasts/archive", params=params
method="GET", path="/v1/forecasts/monthly/archive", params=params
)
if "data" in response:
return response["data"]
return response
return cast(List[Dict[str, Any]], response["data"])
return cast(List[Dict[str, Any]], response)

async def get(self, period: str, commodity: Optional[str] = None) -> Dict[str, Any]:
params = {}
Expand Down Expand Up @@ -793,13 +813,9 @@ async def summary(self) -> Dict[str, Any]:
return response["data"]
return response

async def trends(self, **params) -> List[Dict[str, Any]]:
response = await self.client.request(
method="GET", path="/v1/drilling-intelligence/trends", params=params
)
if "data" in response:
return response["data"]
return response
async def trends(self, **params: Any) -> List[Dict[str, Any]]:
"""Deprecated: no API route (#153). Use ``client.rig_counts.trends()``."""
removed_endpoint("client.drilling.trends()", "client.rig_counts.trends()")

async def frac_spreads(self, **params) -> List[Dict[str, Any]]:
response = await self.client.request(
Expand Down Expand Up @@ -842,13 +858,11 @@ async def wells_drilled(self, **params) -> List[Dict[str, Any]]:
return response

async def basin(self, name: str) -> Dict[str, Any]:
response = await self.client.request(
method="GET", path=f"/v1/drilling-intelligence/basin/{name}"
"""Deprecated: no API route (#153). Use ``client.ei.drilling_productivity.by_basin()``."""
removed_endpoint(
"client.drilling.basin()",
"client.ei.drilling_productivity.by_basin()",
)
if "data" in response:
return response["data"]
return response


class AsyncWellProductionResource:
"""Async resource for US well production data (beta).
Expand Down
35 changes: 35 additions & 0 deletions oilpriceapi/resources/_removed.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Guard for SDK methods whose API route does not exist.

Each of these methods used to send a request to a path the API has never
routed, so every call came back as a 404 that read like a data problem
(#153). They now fail locally, before any request is sent, and name the
supported replacement.
"""

import warnings
from typing import NoReturn

from ..exceptions import ValidationError


def removed_endpoint(method: str, alternative: str) -> NoReturn:
"""Warn and raise for a method with no backing API route.

Args:
method: Public method name, e.g. ``"client.drilling.trends()"``.
alternative: The supported call to use instead.

Raises:
ValidationError: Always, with ``code="ENDPOINT_NOT_AVAILABLE"`` and
``status_code=None`` because no request was sent.
"""
message = (
f"{method} is not available: the API has no route for it. "
f"Use {alternative} instead. This method will be removed in 2.0."
)
warnings.warn(message, DeprecationWarning, stacklevel=3)
raise ValidationError(
message,
status_code=None,
code="ENDPOINT_NOT_AVAILABLE",
)
Loading
Loading