Added
- Typed
client.spreadsandclient.indicatorsresources (#99), sync and
async. They cover the server-calculated/v1/spreads/*routes:crack,
crack_historical,crack_all,gasoil_crack,basis,
basis_historical,basis_all,curve_structure,curve_structure_all,
margin,margin_historical,margin_all,physical_premium,
physical_premium_historicalandphysical_premium_all. They also cover the
/v1/indicators/*routes:fuel_switching,fuel_switching_historical,
price_context,storage_analytics,storage_analytics_all,
annotations,annotations_batch,cftc_positioning,
cftc_positioning_historicalandcftc_positioning_all.- Each method returns a pydantic model from the new
oilpriceapi.metrics_modelsmodule. The models are typed from production
responses captured on 2026-09-13. - Timestamps parse to timezone-aware
datetimeand calendar dates todate.
Units, full-precision values and nulls are kept exactly as sent. - A key the server always emits is required. A 200 that drops it, changes its
type, or breaks the envelope raises
OilPriceAPIError(code="MALFORMED_RESPONSE")with the raw body. It is never
defaulted. - History responses expose the server-applied
periodand, for crack
spreads, thecoverageactually returned. - Blank selectors, invalid dates,
start_dateafterend_date, and more than
20 codes forannotations_batchare refused before any request is sent,
withValidationError(anOilPriceAPIError) carryingfield,value
andstatus_code=None.
The API would otherwise return a default window, or silently annotate only
the first 20 codes. /v1/indicators/congressional-tradesis deliberately not exposed. It has
never returned data in production, so there is no response shape to type.
- Each method returns a pydantic model from the new
- Subscription lifecycle:
get,update,pause,resume(#100). Sync
and async, againstGET/PATCH /v1/subscriptions/{id}and
POST /v1/subscriptions/{id}/pause|resume. Each returns a typed
Subscriptionwith the server's timestamps and nulls as sent.update()
sends only the fields you pass (name,codes,interval,
deliver_webhook,status). Ids and update payloads are validated before
any request is built; an invalid one raisesValidationErrorwith
status_code=None,fieldnaming the argument, and nothing sent. Unknown ids raiseDataNotFoundError; a refused update (interval
below the plan minimum, webhook delivery the plan lacks) raises
ValidationErrorwith the server'sdetails.update,pauseand
resumeare writes and are sent once, likecreate: after an ambiguous
timeout or 5xx the error carriesambiguous_write=Trueandget()tells
you whether the change landed. - Typed LTL and parcel fuel-surcharge clients (#101).
client.fuel_surcharge
on bothOilPriceAPIandAsyncOilPriceAPIcovers all six
/v1/fuel-surchargeroutes:list(),latest(carrier),
history(carrier, page=, per_page=),parcel_list(),
parcel_latest(carrier),parcel_latest_rate(carrier, service_level)and
parcel_history(carrier, service_level, page=, per_page=). Responses are
FuelSurchargeRate,FuelSurchargeHistoryPage(with the server's
meta) andParcelFuelSurchargeCarriermodels typed from production
payloads captured on 2026-09-13:effective_dateis adate,
retrieved_ata timezone-awaredatetime, andsource, nullable
doe_diesel_priceanddiesel_bandare kept as sent. A success body
missing a field the API always sends raises
OilPriceAPIError(code="MALFORMED_RESPONSE")instead of defaulting it.
Carrier slugs, service levels and pagination are validated before any
request; out-of-rangepage/per_pageare refused because the API clamps
them silently. - Fuel-surcharge 400/404 bodies populate
error.suggestions. The
covered_carriersandavailable_service_levelslists the API returns with
an unknown carrier or a missing service level are now surfaced the same way
commodity suggestions are.
Fixed
SubscriptionEventis typed from the event the API sends (#149). It
declaredtype,code,payloadandcreated_at, which
GET /v1/subscriptions/eventshas never sent, so they readNoneon every
real event. Meanwhileid,observed_at,snapshot,deltas,sourceand
tool_namewere untyped extras. The model now declares:- required
id,seq,watch_id,observed_at(a timezone-aware
datetime),snapshotanddeltas; - optional
sourceandtool_name.
snapshotmaps each code to the newSubscriptionEventSnapshot(price,
currency, optionalchange_24h_pctandas_of).deltasmaps each code
to the newSubscriptionEventDelta(price_change, optionalpct_change).
An event missing a required field raises
OilPriceAPIError(code="MALFORMED_RESPONSE").
- required
error.codeis no longer set to a human sentence (#145). For fail
envelopes,{"status": "fail", "data": {"error": ...}}, the SDK copied
data.errorintoerror.code/error.machine_codewhatever it held. Every
fuel-surcharge 400/404 puts the sentence itself there, for example
"Unknown carrier 'nope'. Covered carriers: ...", so code-based branching
saw a different string on every request.data.erroris now the code only
when it is a snake-case token: upper-snake likeVALIDATION_ERRORor
INTERVAL_FLOOR, or lower-snake likeinvalid_codeorno_price_data. A
sentence stays inerror.messageanderror.codeisNone. A canonical
nestederrorobject still takes precedence, and API-key redaction is
unchanged.subscriptions.list()andsubscriptions.events()no longer report a
malformed success as "nothing there" (#142), sync and async. A 200 without
adata.subscriptionslist returned[], and one withoutdata.events/
data.cursorreturned an empty page withcursor=None. Fed back as
events(since=page.cursor), thatNonedroppedsince, and the API reads a
missingsinceas0, so the poller replayed the account's whole event
history. Both now raiseOilPriceAPIError(code="MALFORMED_RESPONSE")with the
raw body when the collection is missing or mistyped, a record is invalid,
cursoris not a non-negative integer,has_moreis not a boolean, or the
cursor is behindsinceor behind an event in the page. A genuinely empty
list or page is still an empty success, andpage.cursoris now always an
int.subscriptions.events(since=...)refuses a cursor the API would read as
0. The API parsessincewithto_i, so"abc",""and-1replay
every event and1.5becomes1(verified against production on
2026-09-13). Anything but a non-negativeintorNonenow raises
ValidationError(field="since", status_code=None)before a request is sent.subscriptions.create()no longer turns a malformed success into a
half-built record. It fell back to treating the wholedataobject as the
subscription whendata.subscriptionwas missing, and leaked a raw pydantic
orTypeErrorwhen the record was null or a list. It now raises
OilPriceAPIError(code="MALFORMED_RESPONSE"), the same as the new lifecycle
methods.subscriptions.delete()validates the id before sending. An id such as
"abc/pause"or""previously produced a request to a different route; it
now raisesValidationError(field="subscription_id", status_code=None).- A bad subscription
intervalis now an SDK refusal as well as a
ValueError.subscriptions.create(interval=...),normalize_intervaland
build_create_bodyraise the newSubscriptionIntervalError, a subclass of
bothValidationErrorandValueError(likeFuturesContractError), so
except OilPriceAPIErrorcatches it and existingexcept ValueErrorcode
keeps working. It carriesfield="interval", the rejectedvalue, and
status_code=None. Subscription.codesis required. A record with nocodesused to
default to[], reading as a watch on nothing; the API always sends it, so a
missing value now fails validation instead of being invented.
Deprecated
-
SubscriptionEvent.type,.code,.payloadand.created_atare
deprecated and will be removed in 2.0.0 (#149). The events API never sends
any of them. They are no longer pydantic fields and do not appear in
model_dump(). Each is now a property that emits aDeprecationWarningon
access:typereturnsNoneand has no equivalent, because every event is an
interval snapshot.codereturnsNone. An event covers every watched code; use
list(event.snapshot).payloadreturnsNone. Useevent.snapshotandevent.deltas.created_atreturnsobserved_at, the event's timestamp, where it used to
returnNone.
Parsing, polling and serializing events emit no warning.