Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,19 @@ All notable changes to the OilPriceAPI Python SDK will be documented in this fil

### 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
Expand Down Expand Up @@ -119,6 +132,23 @@ All notable changes to the OilPriceAPI Python SDK will be documented in this fil
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.

## [1.15.0] - 2026-09-13

### Fixed
Expand Down
4 changes: 4 additions & 0 deletions oilpriceapi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@
PriceAlert,
Subscription,
SubscriptionEvent,
SubscriptionEventDelta,
SubscriptionEventSnapshot,
WebhookTestResponse,
)
from oilpriceapi.resources.subscriptions import SubscriptionEventsPage
Expand Down Expand Up @@ -90,6 +92,8 @@
"ParcelFuelSurchargeCarrier",
"Subscription",
"SubscriptionEvent",
"SubscriptionEventDelta",
"SubscriptionEventSnapshot",
"SubscriptionEventsPage",
"PriceStream",
"StreamUpdate",
Expand Down
2 changes: 1 addition & 1 deletion oilpriceapi/_subscriptions_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,7 @@ def unwrap_events_page(
f"{_field_errors(error)}",
response,
) from error
if event.seq is not None and event.seq > cursor:
if event.seq > cursor:
raise _malformed(
subject,
f"data.cursor {cursor} is behind data.events[{index}].seq {event.seq}; "
Expand Down
131 changes: 111 additions & 20 deletions oilpriceapi/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
Pydantic models for API responses.
"""

import warnings
from datetime import date, datetime
from typing import Any, Dict, List, Optional, Union

Expand Down Expand Up @@ -454,32 +455,122 @@ def parse_datetimes(cls, v):
return v


class SubscriptionEventSnapshot(BaseModel):
"""One watched code's price at the moment a subscription event was recorded.

Built by the API's ``MarketBriefBuilder#snapshot_hash``.
"""

model_config = ConfigDict(populate_by_name=True, extra="allow")

price: float = Field(description="Latest spot price")
currency: str = Field(description="Price currency, e.g. USD")
change_24h_pct: Optional[float] = Field(
default=None, description="24h change in percent; None when there is no 24h comparison"
)
as_of: Optional[datetime] = Field(default=None, description="Timestamp of the price used")


class SubscriptionEventDelta(BaseModel):
"""One code's change since the previous event of the same subscription.

Built by the API's ``Watch#compute_deltas``.
"""

model_config = ConfigDict(populate_by_name=True, extra="allow")

price_change: float = Field(description="Price change since the previous event")
pct_change: Optional[float] = Field(
default=None,
description="Percent change; None when the previous price was 0 (the API omits it)",
)


class SubscriptionEvent(BaseModel):
"""A single event emitted by a subscription, returned from the poll endpoint."""
"""A single event emitted by a subscription, returned from the poll endpoint.

Typed from ``GET /v1/subscriptions/events`` as the API sends it (#149).
``snapshot`` and ``deltas`` are keyed by commodity code. ``deltas`` is
``{}`` on a subscription's first event, and a code is absent from it when
either snapshot lacked a price.
"""

model_config = ConfigDict(populate_by_name=True, extra="allow")

seq: Optional[int] = Field(default=None, description="Monotonic per-user sequence cursor")
watch_id: Optional[str] = Field(default=None, description="Subscription (watch) that produced the event")
type: Optional[str] = Field(default=None, description="Event type")
code: Optional[str] = Field(default=None, description="Commodity code the event relates to")
payload: Optional[Dict[str, Any]] = Field(default=None, description="Event payload")
created_at: Optional[datetime] = Field(default=None, description="Event timestamp")
id: str = Field(description="Event identifier")
seq: int = Field(description="Monotonic per-user sequence cursor")
watch_id: str = Field(description="Subscription (watch) that produced the event")
observed_at: datetime = Field(description="When the snapshot was taken")
snapshot: Dict[str, SubscriptionEventSnapshot] = Field(
description="Price per watched code at observed_at"
)
deltas: Dict[str, SubscriptionEventDelta] = Field(
description="Change per code since the previous event; {} on the first event"
)
source: Optional[str] = Field(default=None, description="Attribution source of the subscription")
tool_name: Optional[str] = Field(default=None, description="Attribution tool name of the subscription")

@field_validator("created_at", mode="before")
@classmethod
def parse_created_at(cls, v):
"""Parse created_at from various formats."""
if v is None:
return None
if isinstance(v, str):
try:
return datetime.fromisoformat(v.replace("Z", "+00:00"))
except ValueError:
from dateutil import parser
# Deprecated accessors (#149). Plain properties, not pydantic fields, so they
# never appear in model_dump() or serialization. Removed in 2.0.0.

return parser.parse(v)
return v
@property
def created_at(self) -> datetime:
"""Deprecated alias for ``observed_at``; removed in 2.0.0.

The API never sent ``created_at`` on an event, so this always read
``None``. The event's timestamp is ``observed_at``.
"""
warnings.warn(
"SubscriptionEvent.created_at is deprecated and will be removed in 2.0.0; "
"use observed_at. The events API does not send created_at (#149).",
DeprecationWarning,
stacklevel=2,
)
return self.observed_at

@property
def type(self) -> None:
"""Deprecated; always ``None``, removed in 2.0.0.

The events API has no event type: every event is an interval snapshot.
"""
warnings.warn(
"SubscriptionEvent.type is deprecated and will be removed in 2.0.0. It was "
"always None: the events API sends no event type, and has no equivalent "
"field; every event is an interval snapshot (#149).",
DeprecationWarning,
stacklevel=2,
)
return None

@property
def code(self) -> None:
"""Deprecated; always ``None``, removed in 2.0.0.

An event can cover several codes: use ``snapshot.keys()``.
"""
warnings.warn(
"SubscriptionEvent.code is deprecated and will be removed in 2.0.0. It was "
"always None: an event covers every watched code, so use "
"list(event.snapshot) (#149).",
DeprecationWarning,
stacklevel=2,
)
return None

@property
def payload(self) -> None:
"""Deprecated; always ``None``, removed in 2.0.0.

The event data is in ``snapshot`` and ``deltas``.
"""
warnings.warn(
"SubscriptionEvent.payload is deprecated and will be removed in 2.0.0. It "
"was always None: use event.snapshot and event.deltas (#149).",
DeprecationWarning,
stacklevel=2,
)
return None


class DataConnectorPrice(BaseModel):
Expand Down

Large diffs are not rendered by default.

13 changes: 12 additions & 1 deletion tests/unit/test_async_subscriptions_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,18 @@ async def test_events(self, client):
"data": {
"cursor": 7,
"has_more": False,
"events": [{"seq": 7, "watch_id": "abc-123", "type": "threshold", "code": "WTI_USD"}],
"events": [
{
"id": "a835a930-f34f-4001-80b1-38fb3cde3797",
"seq": 7,
"watch_id": "abc-123",
"observed_at": "2026-09-08T17:21:19Z",
"snapshot": {"WTI_USD": {"as_of": "2026-09-08T17:20:37Z", "price": 63.02, "currency": "USD", "change_24h_pct": -0.08}},
"deltas": {"WTI_USD": {"pct_change": -0.26, "price_change": -0.16}},
"source": "api",
"tool_name": None,
}
],
}
}
mock = AsyncMock(return_value=payload)
Expand Down
Loading