Releases: OilpriceAPI/python-sdk
Release list
v1.16.0
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.
v1.15.0
Fixed
- Energy Intelligence collection methods now return the collection they
promise (#107). Every EI method typedList[Dict[str, Any]]returned
response["data"]-- but the EI controllers put their records under a named
key insidedata.client.ei.rig_counts.by_basin()returned
{"report_date": ..., "basins": [...]}where the signature and the docstring
example promised the basin list, so the documented
for basin in basins: basin["count"]iterated dict keys. The same defect
ran throughby_state(states),historical(records), OPEC
by_country/historical/top_producers, oil-inventory
by_product/historical, drilling-productivity
duc_wells/by_basin/historical/trends, forecasthistorical, and
every well-permit and frac-focus collection -- 27 methods, sync and async.
Each now returns the named list, and a success body missing that list raises
OilPriceAPIError(code="MALFORMED_RESPONSE")instead of handing back the
envelope. An empty collection is still an empty list. ei.well_permits.get()andei.frac_focus.get()return the record, not its
wrapper. Production nests these underdata.well_permit/
data.frac_focus_disclosure, so the documentedpermit["operator"]raised
KeyError.ei.forecasts.prices()andei.forecasts.production()are typed
Dict[str, Any]. Both return a mapping keyed by commodity / series code,
never a list; theList[Dict[str, Any]]annotation was wrong from the start.
No behaviour change.- Docstring examples across the EI resources now use the field names the API
actually returns (region/count, not the inventedname/rig_count),
verified live on 2026-09-13. - The ActionCable handshake is bounded, and a failed setup no longer leaks the
socket (#108).open_timeoutwas passed to the WebSocket upgrade and
nothing else: the waits forwelcomeandconfirm_subscriptionthat follow
had no deadline at all, so a socket that upgraded and then went quiet hung the
caller indefinitely, and cancelling out of that hang left the upgraded socket
open. Connect,welcomeandconfirm_subscriptionare now one bounded setup
lifecycle governed by a newsetup_timeout(defaulting toopen_timeout, so
the timeout you already configure does cover protocol setup), and every socket
the stream allocates is closed on any failure, timeout or cancellation --
including a__aenter__that raises, where__aexit__never runs. - A failed reconnect consumes the reconnect budget instead of escaping on the
first attempt (#108). AnOSErrorraised while reconnecting inside the
ConnectionClosedhandler propagated straight out of the iterator, so a
stream configured withmax_reconnect_attempts=10gave up after one. Transient
failures now spend the configured consecutive-attempt budget with backoff and
end inConnectionError: Stream lost after N reconnect attempts; a permanent
refusal stops immediately with the newStreamAuthError(aConnectionError
subclass, so existing handlers are unaffected) rather than retrying a rejected
key ten times. close()retires the stream. It is idempotent, closes the socket under a
bounded teardown timeout, and prevents any subsequent reconnect;connect()
on a closed stream raises instead of quietly opening a new socket. Reconnects
now close the socket they are replacing.
Changed
- The per-method
if "data" in response: return response["data"]repeated
through every EI resource is replaced by one shared helper
(oilpriceapi/resources/ei/_envelopes.py).
unwrap_well_permit_search_responsekeeps its name and its error message and
now delegates to it, so there is one unwrapping implementation rather than
two.
Behaviour change for callers who adapted to the bug: code reading
by_basin()["basins"], well_permits.list()["well_permits"] or
well_permits.get(id)["well_permit"] must drop that subscript. Code following
the documented signature was broken before and works now. ei.well_permits.latest()
and ei.frac_focus.latest() deliberately keep returning the envelope object so
their pagination and freshness counters stay reachable.
v1.14.0 — credential transport, write safety, and no fabricated codes
Security release. Every user on 1.13.0 or earlier should upgrade.
PyPI has been serving a version in which a caller-supplied raw path can send the customer's API key to a host that is not OilPriceAPI.
Security — raw paths can no longer change the API origin
//fixture.invalid/v1/prices resolved to a foreign host with Authorization: Token <key> still attached. Worse, //user@fixture.invalid/... made httpx read the userinfo and replace our token with Basic dXNlcjo= — a crafted path could both redirect the request and swap the credential.
A path is now rejected unless it resolves to the configured base origin, compared on (scheme, host, port). 27 unicode and ASCII probe forms were driven through the resolver: none changed the origin. follow_redirects was checked separately and does not bypass it — httpx strips Authorization cross-origin.
Fixed — the SDK no longer manufactures a match
A price response that omits code no longer comes back wearing the code you asked for. That fabrication made the consumer-side guard price.commodity == requested pass by construction, on precisely the responses where it needed to fail — disarming the standard defence against a wrong-instrument response rather than merely failing to help. An absent code is now empty, matching what get_all already did.
Three call sites carried the same fallback; all three are fixed, and the hook was removed rather than left ignored.
Fixed — write safety
- Non-idempotent writes are no longer replayed. POST was retried 3× on timeout and 3× on 503 — a duplicated write.
max_retries=0was ignored entirely and still retried three times;retry_on=[]fell back to the default list. - A write refused after a 5xx now carries
.ambiguous_write, the "did my write land?" signal. Previously only the timeout path set it. Retry-After: -30no longer produces a negative sleep.max_retries=0is honoured as one attempt with a deprecation notice rather than raising at construction —int(os.getenv("OPA_MAX_RETRIES", "0"))is exactly how callers produce it.
Fixed — data correctness
- 18 catalogue codes resolved to a different instrument.
normalize_futures_slugsplit on the first_and discarded geography and currency:LNG_NW_EUROPE_EUR→ Japan/Korea Marker,WTI_MIDLAND_USD→ Cushing WTI,WTI_SPOT_CUSHING_USD→ the futures curve. Suffix stripping now requires the discarded tail to actually look like a month or order marker. client.diesel.*no longer raisesValidationErroragainst the live envelope — the response is unwrapped, andstateis read fromdata.location.state_coderather than theregionthe model could not accept.ValidationError.__str__no longer discards the message, so the origin guard's remediation text reaches logs and tracebacks.timeout=0andbase_url=""are honoured instead of silently becoming defaults.
Also in this release
Fixes for three regressions introduced earlier the same day by the changes above, all found by an expert review after they merged green: a forbidden SPACE in query paths that broke the SDK's own demo.py, retries stripped from read-shaped POSTs (diesel.get_stations, webhooks.test, alerts.test), and a bare ValueError escaping the SDK exception hierarchy.
Every fix applies identically to the sync and async clients, pinned by parity tests.
Full diff: v1.13.0...v1.14.0
v1.13.0 — get_multiple batches (up to 20x less quota)
get_multiple() now batches — up to 20× less quota
get_multiple() previously made one HTTP request per commodity code. The REST API accepts up to 20 codes in a single request that counts once against your quota, so the method whose whole purpose is fetching several prices cost up to twenty times more than writing the call by hand.
Through this method the free plan was 50 code-reads a day. Through the raw API it is 1,000.
What changes for you
Nothing in your code. Your quota consumption drops — a get_multiple() call that consumed N requests now consumes ceil(N / 20).
10 codes -> 1 request (was 10)
30 codes -> 2 requests (was 30)
The per-code failure contract is unchanged. Because the API rejects the whole request when any code in it is unknown, a failed batch is retried per code — for that chunk only — so return_failures=True still reports exactly which code was at fault.
Async too, and it was worse
AsyncPricesResource.get_multiple() used asyncio.gather to fan out one request per code concurrently, which could also trip the 60-per-60-second rate limit on a long list. It now gathers chunks, so 25 codes are 2 concurrent requests rather than 25.
Documentation
Polling examples now default to an interval that fits the free plan (30 minutes), with a plan/interval table and the measured update cadence of the underlying data. Nothing we publish moves faster than about every 2.5 minutes, so a shorter timer returns the same number.
Why minor rather than patch
The public signature is unchanged, so strict semver says patch. This is a minor because it materially changes how many HTTP requests a call makes — that keeps it out of tight pins like ~=1.12.8 while staying available to ~=1.12, so anyone on a narrow pin opts in rather than being surprised.
Full changelog: https://github.com/OilpriceAPI/python-sdk/blob/main/CHANGELOG.md
OilPriceAPI Python SDK v1.12.8
Fixed
- Validate every customer-readable member in the exact built source distribution, including root release/configuration files and future nested package data.
- Fail closed on unsafe archives and source/version provenance drift while explicitly excluding reviewed binary and development-only surfaces.
- Remove unsupported universal-entitlement wording from the packaged environment example.
- Pin the build frontend used by pull-request and release verification.
OilPriceAPI Python SDK v1.12.7
Patch release removing an incorrect claim that application attribution metadata changes request entitlements.
- Corrects sync and async client wording.
- Adds relationship-aware authored and exact installed-wheel claim validation.
- Preserves optional
X-App-URLandX-App-Nameusage attribution behavior. - Adds request-context attribution and entitlement-claim regressions.
Runtime API behavior and telemetry header contracts are unchanged.
OilPriceAPI Python SDK v1.12.6
Changed
- Route Brent, WTI, gasoil, and EU carbon futures through instrument-generic API paths in synchronous and asynchronous clients.
- Preserve contract-code and legacy venue-slug inputs as compatibility aliases.
- Publish canonical examples independently on both installed client surfaces.
Verification
- Protected-main Python 3.8-3.12 run 31532248741 passed.
- Protected-main authenticated live run 31532248702 passed.
- Exact 53-file wheel was recursively scanned and cold-installed; canonical, code, legacy, and curve production paths passed.
OilPriceAPI Python SDK v1.12.5
Added
- Document an executable, coverage-gated permit-to-production workflow using 14-digit API numbers.
- Accept live filtered well-permit searches without a legacy free-form query.
Fixed
- Decode raw, data-wrapped, root well_permits, and nested production well-permit envelopes in synchronous and asynchronous clients, failing closed on malformed responses.
- Harden the PyPI release chain with exact artifact-set verification, shared version parsing, both workflow extensions, symlink rejection, and bounded public-index readback.
Verification
- Protected-main test run 31513868208 passed on Python 3.8-3.12.
- Protected-main live run 31513868191 passed keyless, authenticated, and canonical customer snippets.
- Docs deployment 31513868226 passed.
v1.12.4
Fixed
- Detect mutable request, call, query, hit, and credit rate claims using an order-independent bounded sentence rule across authored docs, generated surfaces, and every customer-readable wheel file.
- Cover prefix, suffix, duration-window, hyphenated, and allowed/limit orderings while preserving explicit negative boundaries for tests, versions, page sizes, retry counts, and unrelated sentences.
- Direct allowance guidance to the reviewed live Product Facts contract instead of hard-coding a numeric entitlement.
- Report each physical numeric rate claim once through a single semantic classifier.
Verification
- Release commit:
ba974eb2865b5707c0e1c2c41c86fe160f3aad19 - Python 3.8-3.12 hosted matrix: 458 passed / 13 skipped
- Ruff, mypy, 63 authored/generated public surfaces, and 53 exact wheel files: green
- Exact wheel clean install, dependency check, recursive claim scan, and live production demo: green
- Post-main Test, Live API, and Docs workflows: green
This release supersedes v1.12.3 for storefront-claim enforcement.
v1.12.3
Superseded by v1.12.4, which removes mutable numeric entitlement exceptions and adds order-independent rate-claim enforcement. This artifact remains immutable for auditability.
Fixed
- Match hyphenated free-tier/free-api-key wording and universal price or commodity catalog claims in every readable wheel surface.
- Replace the remaining package and documentation variants with current-account and runtime-response terminology.
- Bind the clean release smoke to the exact versioned wheel instead of the first wheel found in dist.
Verification
- Python 3.8 through 3.12 tests, Ruff, mypy, 63-surface claim validation, exact versioned wheel clean install, pip check, and live production contract tests.
- The wheel scan derives all readable customer files recursively from RECORD and explicitly excludes compiled bytecode.
v1.12.3 supersedes v1.12.2, which remains immutable for auditability.