Skip to content

Releases: OilpriceAPI/python-sdk

v1.16.0

Choose a tag to compare

@karlwaldman karlwaldman released this 13 Sep 21:19
868322c

Added

  • Typed client.spreads and client.indicators resources (#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_historical and physical_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_historical and cftc_positioning_all.
    • Each method returns a pydantic model from the new
      oilpriceapi.metrics_models module. The models are typed from production
      responses captured on 2026-09-13.
    • Timestamps parse to timezone-aware datetime and calendar dates to date.
      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 period and, for crack
      spreads, the coverage actually returned.
    • Blank selectors, invalid dates, start_date after end_date, and more than
      20 codes for annotations_batch are refused before any request is sent,
      with ValidationError (an OilPriceAPIError) carrying field, value
      and status_code=None.
      The API would otherwise return a default window, or silently annotate only
      the first 20 codes.
    • /v1/indicators/congressional-trades is deliberately not exposed. It has
      never returned data in production, so there is no response shape to type.
  • Subscription lifecycle: get, update, pause, resume (#100). Sync
    and async, against GET/PATCH /v1/subscriptions/{id} and
    POST /v1/subscriptions/{id}/pause|resume. Each returns a typed
    Subscription with 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 raises ValidationError with
    status_code=None, field naming the argument, and nothing sent. Unknown ids raise DataNotFoundError; a refused update (interval
    below the plan minimum, webhook delivery the plan lacks) raises
    ValidationError with the server's details. update, pause and
    resume are writes and are sent once, like create: after an ambiguous
    timeout or 5xx the error carries ambiguous_write=True and get() tells
    you whether the change landed.
  • Typed LTL and parcel fuel-surcharge clients (#101). client.fuel_surcharge
    on both OilPriceAPI and AsyncOilPriceAPI covers all six
    /v1/fuel-surcharge routes: 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) and ParcelFuelSurchargeCarrier models typed from production
    payloads captured on 2026-09-13: effective_date is a date,
    retrieved_at a timezone-aware datetime, and source, nullable
    doe_diesel_price and diesel_band are 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-range page/per_page are refused because the API clamps
    them silently.
  • Fuel-surcharge 400/404 bodies populate error.suggestions. The
    covered_carriers and available_service_levels lists the API returns with
    an unknown carrier or a missing service level are now surfaced the same way
    commodity suggestions are.

Fixed

  • SubscriptionEvent is typed from the event the API sends (#149). It
    declared type, code, payload and created_at, which
    GET /v1/subscriptions/events has never sent, so they read None on every
    real event. Meanwhile id, observed_at, snapshot, deltas, source and
    tool_name were untyped extras. The model now declares:
    • required id, seq, watch_id, observed_at (a timezone-aware
      datetime), snapshot and deltas;
    • optional source and tool_name.
      snapshot maps each code to the new SubscriptionEventSnapshot (price,
      currency, optional change_24h_pct and as_of). deltas maps each code
      to the new SubscriptionEventDelta (price_change, optional pct_change).
      An event missing a required field raises
      OilPriceAPIError(code="MALFORMED_RESPONSE").
  • error.code is no longer set to a human sentence (#145). For fail
    envelopes, {"status": "fail", "data": {"error": ...}}, the SDK copied
    data.error into error.code / error.machine_code whatever 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.error is now the code only
    when it is a snake-case token: upper-snake like VALIDATION_ERROR or
    INTERVAL_FLOOR, or lower-snake like invalid_code or no_price_data. A
    sentence stays in error.message and error.code is None. A canonical
    nested error object still takes precedence, and API-key redaction is
    unchanged.
  • subscriptions.list() and subscriptions.events() no longer report a
    malformed success as "nothing there" (#142), sync and async.
    A 200 without
    a data.subscriptions list returned [], and one without data.events /
    data.cursor returned an empty page with cursor=None. Fed back as
    events(since=page.cursor), that None dropped since, and the API reads a
    missing since as 0, so the poller replayed the account's whole event
    history. Both now raise OilPriceAPIError(code="MALFORMED_RESPONSE") with the
    raw body when the collection is missing or mistyped, a record is invalid,
    cursor is not a non-negative integer, has_more is not a boolean, or the
    cursor is behind since or behind an event in the page. A genuinely empty
    list or page is still an empty success, and page.cursor is now always an
    int.
  • subscriptions.events(since=...) refuses a cursor the API would read as
    0.
    The API parses since with to_i, so "abc", "" and -1 replay
    every event and 1.5 becomes 1 (verified against production on
    2026-09-13). Anything but a non-negative int or None now 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 whole data object as the
    subscription when data.subscription was missing, and leaked a raw pydantic
    or TypeError when 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 raises ValidationError(field="subscription_id", status_code=None).
  • A bad subscription interval is now an SDK refusal as well as a
    ValueError.
    subscriptions.create(interval=...), normalize_interval and
    build_create_body raise the new SubscriptionIntervalError, a subclass of
    both ValidationError and ValueError (like FuturesContractError), so
    except OilPriceAPIError catches it and existing except ValueError code
    keeps working. It carries field="interval", the rejected value, and
    status_code=None.
  • Subscription.codes is required. A record with no codes used 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, .payload and .created_at are
    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 a DeprecationWarning on
    access:

    • type returns None and has no equivalent, because every event is an
      interval snapshot.
    • code returns None. An event covers every watched code; use
      list(event.snapshot).
    • payload returns None. Use event.snapshot and event.deltas.
    • created_at returns observed_at, the event's timestamp, where it used to
      return None.

    Parsing, polling and serializing events emit no warning.

v1.15.0

Choose a tag to compare

@karlwaldman karlwaldman released this 13 Sep 19:40
4bd2900

Fixed

  • Energy Intelligence collection methods now return the collection they
    promise (#107).
    Every EI method typed List[Dict[str, Any]] returned
    response["data"] -- but the EI controllers put their records under a named
    key inside data. 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 through by_state (states), historical (records), OPEC
    by_country/historical/top_producers, oil-inventory
    by_product/historical, drilling-productivity
    duc_wells/by_basin/historical/trends, forecast historical, 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() and ei.frac_focus.get() return the record, not its
    wrapper.
    Production nests these under data.well_permit /
    data.frac_focus_disclosure, so the documented permit["operator"] raised
    KeyError.
  • ei.forecasts.prices() and ei.forecasts.production() are typed
    Dict[str, Any].
    Both return a mapping keyed by commodity / series code,
    never a list; the List[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 invented name/rig_count),
    verified live on 2026-09-13.
  • The ActionCable handshake is bounded, and a failed setup no longer leaks the
    socket (#108).
    open_timeout was passed to the WebSocket upgrade and
    nothing else: the waits for welcome and confirm_subscription that 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, welcome and confirm_subscription are now one bounded setup
    lifecycle governed by a new setup_timeout (defaulting to open_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).
    An OSError raised while reconnecting inside the
    ConnectionClosed handler propagated straight out of the iterator, so a
    stream configured with max_reconnect_attempts=10 gave up after one. Transient
    failures now spend the configured consecutive-attempt budget with backoff and
    end in ConnectionError: Stream lost after N reconnect attempts; a permanent
    refusal stops immediately with the new StreamAuthError (a ConnectionError
    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_response keeps 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

Choose a tag to compare

@karlwaldman karlwaldman released this 13 Sep 17:29
7982b0b

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=0 was 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: -30 no longer produces a negative sleep.
  • max_retries=0 is 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_slug split 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 raises ValidationError against the live envelope — the response is unwrapped, and state is read from data.location.state_code rather than the region the model could not accept.
  • ValidationError.__str__ no longer discards the message, so the origin guard's remediation text reaches logs and tracebacks.
  • timeout=0 and base_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)

Choose a tag to compare

@karlwaldman karlwaldman released this 23 Aug 13:40
31b8d80

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

Choose a tag to compare

@karlwaldman karlwaldman released this 12 Aug 10:33
8b2dd20

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

Choose a tag to compare

@karlwaldman karlwaldman released this 12 Aug 09:40
eb40146

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-URL and X-App-Name usage 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

Choose a tag to compare

@karlwaldman karlwaldman released this 11 Aug 20:19
f17936a

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

Choose a tag to compare

@karlwaldman karlwaldman released this 11 Aug 16:45
c4e222e

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

Choose a tag to compare

@karlwaldman karlwaldman released this 11 Aug 13:28
ba974eb

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

Choose a tag to compare

@karlwaldman karlwaldman released this 11 Aug 12:41
82622d1

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.