Skip to content

Commit ca9df32

Browse files
karlwaldmanclaude
andauthored
fix: repoint or retire 8 methods that called unrouted paths (#153) [1.17.0] (#154)
* fix: repoint or retire 8 methods that called unrouted paths (#153) Every call to these methods returned 404 because the API never routed the path. The mocked tests never asserted paths, so CI stayed green. - storage.history -> /v1/storage/history/{code} (period, not dates) - bunker_fuels.spreads -> /v1/bunker-fuels/spreads/ports (from/to required) - bunker_fuels.historical -> /v1/bunker-fuels/historical/{port} - forecasts.accuracy/archive -> /v1/forecasts/monthly/{accuracy,archive} - drilling.trends, drilling.basin, futures.spreads: no route; warn and raise ValidationError(ENDPOINT_NOT_AVAILABLE) before any request, naming the replacement. Adds futures.calendar_spreads(). Adds a contract test against a vendored route-table snapshot that fails on any unrouted /v1 path; it reports exactly these 8 against origin/main. Hourly polling in the EXAMPLES.md price alert. Version 1.16.1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: release as 1.17.0 (adds futures.calendar_spreads; changes signatures) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 868322c commit ca9df32

19 files changed

Lines changed: 1351 additions & 205 deletions

‎CHANGELOG.md‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,49 @@ All notable changes to the OilPriceAPI Python SDK will be documented in this fil
44

55
## [Unreleased]
66

7+
## [1.17.0] - 2026-10-03
8+
9+
### Fixed
10+
11+
- **Eight methods called paths the API does not route, so every call
12+
returned 404 (#153).** Sync and async are both fixed.
13+
- `storage.history(code, period="90d")` now calls
14+
`/v1/storage/history/{code}`. It accepts `CUSHING_STORAGE`, `US_SPR`,
15+
`SINGAPORE_STORAGE_TOTAL`, `ARA_STORAGE_TOTAL` (or `"cushing"` / `"spr"`)
16+
and a `period` of `7d`, `30d`, `90d`, `1y` or `all`. It returns the
17+
API's `{code, period, history, statistics}` object. The
18+
`start_date`/`end_date` arguments are gone: the route never accepted
19+
them.
20+
- `bunker_fuels.spreads(from_port, to_port, grade=None)` now calls
21+
`/v1/bunker-fuels/spreads/ports`. Both ports are required.
22+
- `bunker_fuels.historical(port, fuel_type=None, start_date=None,
23+
end_date=None, interval=None)` now calls
24+
`/v1/bunker-fuels/historical/{port}`. `fuel_type` filters the
25+
returned `historical_data` by grade.
26+
- `forecasts.accuracy()` and `forecasts.archive()` now call
27+
`/v1/forecasts/monthly/accuracy` and `/archive`. While monthly
28+
forecasts are unpublished, the API's `not_available` 404 raises
29+
`DataNotFoundError`.
30+
- `drilling.trends()`, `drilling.basin()` and `futures.spreads()` have
31+
no API route. They now emit `DeprecationWarning` and raise
32+
`ValidationError(code="ENDPOINT_NOT_AVAILABLE", status_code=None)`
33+
without sending a request. The error names the replacement:
34+
`rig_counts.trends()`, `ei.drilling_productivity.by_basin()` and the
35+
new `futures.calendar_spreads(contract)`. They will be removed in 2.0.
36+
- Invalid storage codes, periods, ports and fuel types are rejected
37+
locally, before any request is sent.
38+
- **The price-alert polling example in `EXAMPLES.md` now polls hourly**
39+
instead of every 30 minutes, halving its daily calls. It now shows how to
40+
compute the budget against a plan allowance.
41+
42+
### Added
43+
44+
- `futures.calendar_spreads(contract)` for `/v1/futures/{slug}/spreads`.
45+
- `tests/unit/test_api_path_contract.py` fails when any `/v1` path in the SDK
46+
is missing from the API route table snapshot
47+
(`tests/fixtures/api_paths.json.fixture`). Refresh the snapshot with
48+
`scripts/refresh_api_paths.py`.
49+
750
## [1.16.0] - 2026-09-13
851

952
### Added

‎EXAMPLES.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -456,10 +456,13 @@ def monitor_prices():
456456
elif price.value < limits['low']:
457457
send_alert(commodity, price.value, limits['low'], 'BELOW')
458458

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

464467
if __name__ == '__main__':
465468
print("Starting price monitor...")

‎oilpriceapi/async_resources.py‎

Lines changed: 63 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

33
from datetime import date, datetime
4-
from typing import Any, Dict, List, Optional, Sequence, Union
4+
from typing import Any, Dict, List, Optional, Sequence, Union, cast
55

66
from . import _fuel_surcharge_common as fs
77
from ._subscriptions_common import (
@@ -54,6 +54,15 @@
5454
)
5555
from .resources import _calculated_metrics as metrics_ops
5656
from .resources._futures_slug import normalize_futures_slug
57+
from .resources._removed import removed_endpoint
58+
from .resources._route_args import (
59+
bunker_grade_prefix,
60+
bunker_port_code,
61+
bunker_spread_params,
62+
filter_bunker_history,
63+
storage_history_code,
64+
storage_history_period,
65+
)
5766
from .resources.ei._envelopes import ei_data, unwrap_ei_collection, unwrap_ei_object
5867
from .resources.ei.well_permits import unwrap_well_permit_search_response
5968
from .resources.subscriptions import SubscriptionEventsPage
@@ -442,10 +451,16 @@ async def intraday(self, contract: str) -> List[Dict[str, Any]]:
442451
return response
443452

444453
async def spreads(self, contract1: str, contract2: str) -> Dict[str, Any]:
445-
response = await self.client.request(
446-
method="GET", path="/v1/futures/spreads",
447-
params={"contract1": contract1, "contract2": contract2}
454+
"""Deprecated: no API route (#153). Use ``calendar_spreads(contract)``."""
455+
removed_endpoint(
456+
"client.futures.spreads()",
457+
"client.futures.calendar_spreads(contract)",
448458
)
459+
460+
async def calendar_spreads(self, contract: str) -> Dict[str, Any]:
461+
"""Calendar spreads for one contract family (``/v1/futures/{slug}/spreads``)."""
462+
slug = normalize_futures_slug(contract)
463+
response = await self.client.request(method="GET", path=f"/v1/futures/{slug}/spreads")
449464
if "data" in response:
450465
return response["data"]
451466
return response
@@ -512,25 +527,17 @@ async def regional(self, region: Optional[str] = None) -> Dict[str, Any]:
512527
return response["data"]
513528
return response
514529

515-
async def history(
516-
self,
517-
code: str,
518-
start_date: Optional[Union[str, date, datetime]] = None,
519-
end_date: Optional[Union[str, date, datetime]] = None
520-
) -> List[Dict[str, Any]]:
521-
params = {}
522-
if start_date is not None:
523-
params["start_date"] = format_date(start_date)
524-
if end_date is not None:
525-
params["end_date"] = format_date(end_date)
530+
async def history(self, code: str, period: str = "90d") -> Dict[str, Any]:
531+
"""Storage history (``/v1/storage/history/{code}``). See the sync docstring."""
526532
response = await self.client.request(
527-
method="GET", path=f"/v1/storage/{code}/history", params=params
533+
method="GET",
534+
path=f"/v1/storage/history/{storage_history_code(code)}",
535+
params={"period": storage_history_period(period)},
528536
)
529537
if "data" in response:
530538
return response["data"]
531539
return response
532540

533-
534541
class AsyncRigCountsResource:
535542
def __init__(self, client):
536543
self.client = client
@@ -604,30 +611,41 @@ async def compare(self, ports: List[str]) -> Dict[str, Any]:
604611
return response["data"]
605612
return response
606613

607-
async def spreads(self) -> Dict[str, Any]:
608-
response = await self.client.request(method="GET", path="/v1/bunker-fuels/spreads")
614+
async def spreads(self, from_port: str, to_port: str, grade: Optional[str] = None) -> Dict[str, Any]:
615+
"""Port-to-port spread (``/v1/bunker-fuels/spreads/ports``)."""
616+
response = await self.client.request(
617+
method="GET",
618+
path="/v1/bunker-fuels/spreads/ports",
619+
params=bunker_spread_params(from_port, to_port, grade),
620+
)
609621
if "data" in response:
610622
return response["data"]
611623
return response
612624

613625
async def historical(
614626
self,
615627
port: str,
616-
fuel_type: str,
628+
fuel_type: Optional[str] = None,
617629
start_date: Optional[Union[str, date, datetime]] = None,
618-
end_date: Optional[Union[str, date, datetime]] = None
619-
) -> List[Dict[str, Any]]:
620-
params: Dict[str, Any] = {"port": port, "fuel_type": fuel_type}
630+
end_date: Optional[Union[str, date, datetime]] = None,
631+
interval: Optional[str] = None,
632+
) -> Dict[str, Any]:
633+
"""Port history (``/v1/bunker-fuels/historical/{port}``). See the sync docstring."""
634+
prefix = bunker_grade_prefix(fuel_type)
635+
params: Dict[str, Any] = {}
621636
if start_date is not None:
622-
params["start_date"] = format_date(start_date)
637+
params["from"] = format_date(start_date)
623638
if end_date is not None:
624-
params["end_date"] = format_date(end_date)
639+
params["to"] = format_date(end_date)
640+
if interval is not None:
641+
params["interval"] = interval
625642
response = await self.client.request(
626-
method="GET", path="/v1/bunker-fuels/historical", params=params
643+
method="GET",
644+
path=f"/v1/bunker-fuels/historical/{bunker_port_code(port)}",
645+
params=params,
627646
)
628-
if "data" in response:
629-
return response["data"]
630-
return response
647+
data = response["data"] if "data" in response else response
648+
return filter_bunker_history(data, prefix)
631649

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

720738
async def accuracy(self) -> Dict[str, Any]:
721-
response = await self.client.request(method="GET", path="/v1/forecasts/accuracy")
739+
"""Forecast accuracy (``/v1/forecasts/monthly/accuracy``)."""
740+
response = await self.client.request(method="GET", path="/v1/forecasts/monthly/accuracy")
722741
if "data" in response:
723-
return response["data"]
724-
return response
742+
return cast(Dict[str, Any], response["data"])
743+
return cast(Dict[str, Any], response)
725744

726745
async def archive(self, year: Optional[int] = None) -> List[Dict[str, Any]]:
727-
params = {}
746+
"""Archived forecasts (``/v1/forecasts/monthly/archive``)."""
747+
params: Dict[str, Any] = {}
728748
if year:
729749
params["year"] = year
730750
response = await self.client.request(
731-
method="GET", path="/v1/forecasts/archive", params=params
751+
method="GET", path="/v1/forecasts/monthly/archive", params=params
732752
)
733753
if "data" in response:
734-
return response["data"]
735-
return response
754+
return cast(List[Dict[str, Any]], response["data"])
755+
return cast(List[Dict[str, Any]], response)
736756

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

796-
async def trends(self, **params) -> List[Dict[str, Any]]:
797-
response = await self.client.request(
798-
method="GET", path="/v1/drilling-intelligence/trends", params=params
799-
)
800-
if "data" in response:
801-
return response["data"]
802-
return response
816+
async def trends(self, **params: Any) -> List[Dict[str, Any]]:
817+
"""Deprecated: no API route (#153). Use ``client.rig_counts.trends()``."""
818+
removed_endpoint("client.drilling.trends()", "client.rig_counts.trends()")
803819

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

844860
async def basin(self, name: str) -> Dict[str, Any]:
845-
response = await self.client.request(
846-
method="GET", path=f"/v1/drilling-intelligence/basin/{name}"
861+
"""Deprecated: no API route (#153). Use ``client.ei.drilling_productivity.by_basin()``."""
862+
removed_endpoint(
863+
"client.drilling.basin()",
864+
"client.ei.drilling_productivity.by_basin()",
847865
)
848-
if "data" in response:
849-
return response["data"]
850-
return response
851-
852866

853867
class AsyncWellProductionResource:
854868
"""Async resource for US well production data (beta).

‎oilpriceapi/resources/_removed.py‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""Guard for SDK methods whose API route does not exist.
2+
3+
Each of these methods used to send a request to a path the API has never
4+
routed, so every call came back as a 404 that read like a data problem
5+
(#153). They now fail locally, before any request is sent, and name the
6+
supported replacement.
7+
"""
8+
9+
import warnings
10+
from typing import NoReturn
11+
12+
from ..exceptions import ValidationError
13+
14+
15+
def removed_endpoint(method: str, alternative: str) -> NoReturn:
16+
"""Warn and raise for a method with no backing API route.
17+
18+
Args:
19+
method: Public method name, e.g. ``"client.drilling.trends()"``.
20+
alternative: The supported call to use instead.
21+
22+
Raises:
23+
ValidationError: Always, with ``code="ENDPOINT_NOT_AVAILABLE"`` and
24+
``status_code=None`` because no request was sent.
25+
"""
26+
message = (
27+
f"{method} is not available: the API has no route for it. "
28+
f"Use {alternative} instead. This method will be removed in 2.0."
29+
)
30+
warnings.warn(message, DeprecationWarning, stacklevel=3)
31+
raise ValidationError(
32+
message,
33+
status_code=None,
34+
code="ENDPOINT_NOT_AVAILABLE",
35+
)

0 commit comments

Comments
 (0)