Skip to content

Commit 3e528cd

Browse files
karlwaldmanclaude
andcommitted
Merge origin/main (#143 subscriptions, #144 fuel-surcharge) into feat/99-spreads-indicators
Conflicts resolved by keeping every side: - CHANGELOG.md: #99 spreads/indicators entry, then the #100 and #101 Added entries and the #100 Fixed entries, all under [Unreleased]. - oilpriceapi/async_client.py: spreads, indicators and fuel_surcharge all registered (subscriptions untouched). - oilpriceapi/async_resources.py: metrics_models import plus main's multi-line models import; AsyncFuelSurchargeResource kept whole, followed by AsyncSpreadsResource and AsyncIndicatorsResource. Verified on the merged tree by instantiating both clients: spreads, indicators, fuel_surcharge and subscriptions and all their methods exist (88/88). Full suite 1859 passed / 3 failed (known live demo 429s) / 68 skipped; ruff, mypy and storefront validator clean. The .gitignore fixture exception and all 30 fixtures survive. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo
2 parents 329bbe1 + 3b3cdfa commit 3e528cd

19 files changed

Lines changed: 2777 additions & 21 deletions

‎CHANGELOG.md‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,61 @@ All notable changes to the OilPriceAPI Python SDK will be documented in this fil
3535
the first 20 codes.
3636
- `/v1/indicators/congressional-trades` is deliberately not exposed. It has
3737
never returned data in production, so there is no response shape to type.
38+
- **Subscription lifecycle: `get`, `update`, `pause`, `resume` (#100).** Sync
39+
and async, against `GET`/`PATCH /v1/subscriptions/{id}` and
40+
`POST /v1/subscriptions/{id}/pause|resume`. Each returns a typed
41+
`Subscription` with the server's timestamps and nulls as sent. `update()`
42+
sends only the fields you pass (`name`, `codes`, `interval`,
43+
`deliver_webhook`, `status`). Ids and update payloads are validated before
44+
any request is built; an invalid one raises `ValidationError` with
45+
`status_code=None`, `field` naming the argument, and nothing sent. Unknown ids raise `DataNotFoundError`; a refused update (interval
46+
below the plan minimum, webhook delivery the plan lacks) raises
47+
`ValidationError` with the server's `details`. `update`, `pause` and
48+
`resume` are writes and are sent once, like `create`: after an ambiguous
49+
timeout or 5xx the error carries `ambiguous_write=True` and `get()` tells
50+
you whether the change landed.
51+
- **Typed LTL and parcel fuel-surcharge clients (#101).** `client.fuel_surcharge`
52+
on both `OilPriceAPI` and `AsyncOilPriceAPI` covers all six
53+
`/v1/fuel-surcharge` routes: `list()`, `latest(carrier)`,
54+
`history(carrier, page=, per_page=)`, `parcel_list()`,
55+
`parcel_latest(carrier)`, `parcel_latest_rate(carrier, service_level)` and
56+
`parcel_history(carrier, service_level, page=, per_page=)`. Responses are
57+
`FuelSurchargeRate`, `FuelSurchargeHistoryPage` (with the server's
58+
`meta`) and `ParcelFuelSurchargeCarrier` models typed from production
59+
payloads captured on 2026-09-13: `effective_date` is a `date`,
60+
`retrieved_at` a timezone-aware `datetime`, and `source`, nullable
61+
`doe_diesel_price` and `diesel_band` are kept as sent. A success body
62+
missing a field the API always sends raises
63+
`OilPriceAPIError(code="MALFORMED_RESPONSE")` instead of defaulting it.
64+
Carrier slugs, service levels and pagination are validated before any
65+
request; out-of-range `page`/`per_page` are refused because the API clamps
66+
them silently.
67+
- **Fuel-surcharge 400/404 bodies populate `error.suggestions`.** The
68+
`covered_carriers` and `available_service_levels` lists the API returns with
69+
an unknown carrier or a missing service level are now surfaced the same way
70+
commodity suggestions are.
71+
72+
### Fixed
73+
74+
- **`subscriptions.create()` no longer turns a malformed success into a
75+
half-built record.** It fell back to treating the whole `data` object as the
76+
subscription when `data.subscription` was missing, and leaked a raw pydantic
77+
or `TypeError` when the record was null or a list. It now raises
78+
`OilPriceAPIError(code="MALFORMED_RESPONSE")`, the same as the new lifecycle
79+
methods.
80+
- **`subscriptions.delete()` validates the id before sending.** An id such as
81+
`"abc/pause"` or `""` previously produced a request to a different route; it
82+
now raises `ValidationError(field="subscription_id", status_code=None)`.
83+
- **A bad subscription `interval` is now an SDK refusal as well as a
84+
`ValueError`.** `subscriptions.create(interval=...)`, `normalize_interval` and
85+
`build_create_body` raise the new `SubscriptionIntervalError`, a subclass of
86+
both `ValidationError` and `ValueError` (like `FuturesContractError`), so
87+
`except OilPriceAPIError` catches it and existing `except ValueError` code
88+
keeps working. It carries `field="interval"`, the rejected `value`, and
89+
`status_code=None`.
90+
- **`Subscription.codes` is required.** A record with no `codes` used to
91+
default to `[]`, reading as a watch on nothing; the API always sends it, so a
92+
missing value now fails validation instead of being invented.
3893

3994
## [1.15.0] - 2026-09-13
4095

‎README.md‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,33 @@ An empty permit search or production history is a valid data state. Do not
214214
infer broader well-level coverage from the presence of permit data or an SDK
215215
helper; dataset and account availability come from the current API response.
216216

217+
## Carrier Fuel Surcharges
218+
219+
Weekly fuel surcharges for LTL carriers and, per service level, for parcel
220+
carriers. Each rate keeps the carrier's `effective_date` and the `source` URL
221+
and `retrieved_at` time it was retrieved from; a null the API sends (for
222+
example `doe_diesel_price` on parcel rates) stays `None`.
223+
224+
```python
225+
import os
226+
227+
from oilpriceapi import OilPriceAPI
228+
229+
with OilPriceAPI(api_key=os.environ["OILPRICEAPI_KEY"]) as client:
230+
odfl = client.fuel_surcharge.latest("odfl")
231+
history = client.fuel_surcharge.history("odfl", per_page=10)
232+
ups_ground = client.fuel_surcharge.parcel_latest_rate("ups", "ground")
233+
234+
print(odfl.surcharge_percent, odfl.effective_date, odfl.source)
235+
print(history.meta.total_count, [row.effective_date for row in history.history])
236+
print(ups_ground.surcharge_percent, ups_ground.service_level)
237+
```
238+
239+
An unknown or uncovered carrier raises `DataNotFoundError` with the covered
240+
carriers in `error.suggestions`. `page` must be 1 or more and `per_page` 1 to
241+
100; the SDK refuses other values rather than letting the API clamp them.
242+
See [`examples/fuel_surcharge.py`](examples/fuel_surcharge.py).
243+
217244
## Complete pandas DataFrames
218245

219246
Install the optional pandas support, then request a historical DataFrame:

‎docs/reference/resources.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@
6060

6161
::: oilpriceapi.resources.drilling.DrillingIntelligenceResource
6262

63+
## Fuel Surcharges
64+
65+
::: oilpriceapi.resources.fuel_surcharge.FuelSurchargeResource
66+
6367
## Well Production (Beta)
6468

6569
::: oilpriceapi.resources.well_production.WellProductionResource
@@ -71,3 +75,11 @@
7175
## Data Sources
7276

7377
::: oilpriceapi.resources.data_sources.DataSourcesResource
78+
79+
## Subscriptions
80+
81+
Agent price watches: `list`, `create`, `get`, `update`, `pause`, `resume`,
82+
`delete`, and the `events` poll. A subscription here is a watch on commodity
83+
codes, not a billing subscription.
84+
85+
::: oilpriceapi.resources.subscriptions.SubscriptionsResource

‎examples/fuel_surcharge.py‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""Carrier fuel surcharges: LTL and parcel (#101).
2+
3+
Usage:
4+
OILPRICEAPI_KEY=... python examples/fuel_surcharge.py
5+
6+
Prints the latest LTL surcharge per carrier, one carrier's recent weekly
7+
history, and the latest parcel surcharge per service level. Every row shows the
8+
carrier's effective date and where the value was retrieved from.
9+
"""
10+
11+
import os
12+
13+
from oilpriceapi import OilPriceAPI
14+
from oilpriceapi.exceptions import DataNotFoundError
15+
16+
17+
def main() -> None:
18+
with OilPriceAPI(api_key=os.environ["OILPRICEAPI_KEY"]) as client:
19+
print("LTL carriers")
20+
rates = client.fuel_surcharge.list()
21+
for rate in rates:
22+
print(
23+
f" {rate.carrier:<22} {rate.surcharge_percent:>6.2f}% "
24+
f"effective {rate.effective_date} retrieved {rate.retrieved_at:%Y-%m-%d}"
25+
)
26+
27+
if rates:
28+
carrier = rates[0].carrier
29+
page = client.fuel_surcharge.history(carrier, per_page=4)
30+
print(f"\n{carrier} history ({page.meta.total_count} weeks on record)")
31+
for row in page.history:
32+
diesel = "n/a" if row.doe_diesel_price is None else f"${row.doe_diesel_price:.3f}"
33+
print(f" {row.effective_date} {row.surcharge_percent:.2f}% DOE diesel {diesel}")
34+
print(f" source: {page.history[0].source}" if page.history else " no rows")
35+
36+
print("\nParcel carriers")
37+
for parcel in client.fuel_surcharge.parcel_list():
38+
for rate in parcel.service_levels:
39+
print(
40+
f" {parcel.carrier:<6} {rate.service_level:<26} "
41+
f"{rate.surcharge_percent:>6.2f}% effective {rate.effective_date}"
42+
)
43+
44+
try:
45+
client.fuel_surcharge.latest("fedex-freight")
46+
except DataNotFoundError as error:
47+
print(f"\nNot covered: {error.message}")
48+
print(f"Covered carriers: {', '.join(error.suggestions)}")
49+
50+
51+
if __name__ == "__main__":
52+
main()

‎oilpriceapi/__init__.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
PermissionDeniedError,
2626
RateLimitError,
2727
ServerError,
28+
SubscriptionIntervalError,
2829
TimeoutError,
2930
ValidationError,
3031
)
@@ -33,9 +34,14 @@
3334
DieselPrice,
3435
DieselStation,
3536
DieselStationsResponse,
37+
FuelSurchargeDieselBand,
38+
FuelSurchargeHistoryMeta,
39+
FuelSurchargeHistoryPage,
40+
FuelSurchargeRate,
3641
MarketBrief,
3742
MarketBriefCommodity,
3843
MarketBriefForecast,
44+
ParcelFuelSurchargeCarrier,
3945
PriceAlert,
4046
Subscription,
4147
SubscriptionEvent,
@@ -63,6 +69,7 @@
6369
"DataNotFoundError",
6470
"ServerError",
6571
"FuturesContractError",
72+
"SubscriptionIntervalError",
6673
"ValidationError",
6774
"NetworkError",
6875
"TimeoutError",
@@ -76,6 +83,11 @@
7683
"MarketBrief",
7784
"MarketBriefCommodity",
7885
"MarketBriefForecast",
86+
"FuelSurchargeRate",
87+
"FuelSurchargeDieselBand",
88+
"FuelSurchargeHistoryMeta",
89+
"FuelSurchargeHistoryPage",
90+
"ParcelFuelSurchargeCarrier",
7991
"Subscription",
8092
"SubscriptionEvent",
8193
"SubscriptionEventsPage",

0 commit comments

Comments
 (0)