Skip to content

Commit c4bce22

Browse files
karlwaldmanclaude
andauthored
fix(models): type SubscriptionEvent from the event the API sends (#149) (#150)
* fix(models): type SubscriptionEvent from the event the API sends (#149) SubscriptionEvent declared type, code, payload and created_at, none of which GET /v1/subscriptions/events sends, so they read None on every real event, while id, observed_at, snapshot, deltas, source and tool_name were untyped pydantic extras. - Required id, seq, watch_id, observed_at (datetime), snapshot and deltas, matching null: false in the API's watch_events schema; optional source and tool_name (nullable columns). - snapshot -> Dict[str, SubscriptionEventSnapshot] (price, currency, optional change_24h_pct / as_of); deltas -> Dict[str, SubscriptionEventDelta] (price_change, optional pct_change, which the API omits for a zero prior price). Both exported. - type, code and payload removed: no event field corresponds to them. created_at kept as a deprecated property returning observed_at. - unwrap_events_page drops its seq-is-None guard; seq is now required. - Existing event fixtures updated to the live shape. Closes #149 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo * fix(models): keep never-sent SubscriptionEvent names as deprecated accessors (#149) 1.16.0 is a minor release, so type, code and payload are not removed. They become @Property accessors that return None and emit DeprecationWarning naming the replacement (or that there is none), like created_at -> observed_at. They are not pydantic fields and are absent from model_dump(). All four are scheduled for removal in 2.0.0; CHANGELOG moves them under ### Deprecated. Tests: each accessor returns None/observed_at and warns on every access; none is serialized; parsing and polling all 223 live events (fixture captured 2026-09-13) emits no deprecation warning. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 0266d08 commit c4bce22

9 files changed

Lines changed: 451 additions & 27 deletions

‎CHANGELOG.md‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,19 @@ All notable changes to the OilPriceAPI Python SDK will be documented in this fil
7171

7272
### Fixed
7373

74+
- **`SubscriptionEvent` is typed from the event the API sends (#149).** It
75+
declared `type`, `code`, `payload` and `created_at`, which
76+
`GET /v1/subscriptions/events` has never sent, so they read `None` on every
77+
real event. Meanwhile `id`, `observed_at`, `snapshot`, `deltas`, `source` and
78+
`tool_name` were untyped extras. The model now declares:
79+
- required `id`, `seq`, `watch_id`, `observed_at` (a timezone-aware
80+
`datetime`), `snapshot` and `deltas`;
81+
- optional `source` and `tool_name`.
82+
`snapshot` maps each code to the new `SubscriptionEventSnapshot` (`price`,
83+
`currency`, optional `change_24h_pct` and `as_of`). `deltas` maps each code
84+
to the new `SubscriptionEventDelta` (`price_change`, optional `pct_change`).
85+
An event missing a required field raises
86+
`OilPriceAPIError(code="MALFORMED_RESPONSE")`.
7487
- **`error.code` is no longer set to a human sentence (#145).** For fail
7588
envelopes, `{"status": "fail", "data": {"error": ...}}`, the SDK copied
7689
`data.error` into `error.code` / `error.machine_code` whatever it held. Every
@@ -119,6 +132,23 @@ All notable changes to the OilPriceAPI Python SDK will be documented in this fil
119132
default to `[]`, reading as a watch on nothing; the API always sends it, so a
120133
missing value now fails validation instead of being invented.
121134

135+
### Deprecated
136+
137+
- **`SubscriptionEvent.type`, `.code`, `.payload` and `.created_at` are
138+
deprecated and will be removed in 2.0.0 (#149).** The events API never sends
139+
any of them. They are no longer pydantic fields and do not appear in
140+
`model_dump()`. Each is now a property that emits a `DeprecationWarning` on
141+
access:
142+
- `type` returns `None` and has no equivalent, because every event is an
143+
interval snapshot.
144+
- `code` returns `None`. An event covers every watched code; use
145+
`list(event.snapshot)`.
146+
- `payload` returns `None`. Use `event.snapshot` and `event.deltas`.
147+
- `created_at` returns `observed_at`, the event's timestamp, where it used to
148+
return `None`.
149+
150+
Parsing, polling and serializing events emit no warning.
151+
122152
## [1.15.0] - 2026-09-13
123153

124154
### Fixed

‎oilpriceapi/__init__.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@
4545
PriceAlert,
4646
Subscription,
4747
SubscriptionEvent,
48+
SubscriptionEventDelta,
49+
SubscriptionEventSnapshot,
4850
WebhookTestResponse,
4951
)
5052
from oilpriceapi.resources.subscriptions import SubscriptionEventsPage
@@ -90,6 +92,8 @@
9092
"ParcelFuelSurchargeCarrier",
9193
"Subscription",
9294
"SubscriptionEvent",
95+
"SubscriptionEventDelta",
96+
"SubscriptionEventSnapshot",
9397
"SubscriptionEventsPage",
9498
"PriceStream",
9599
"StreamUpdate",

‎oilpriceapi/_subscriptions_common.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -386,7 +386,7 @@ def unwrap_events_page(
386386
f"{_field_errors(error)}",
387387
response,
388388
) from error
389-
if event.seq is not None and event.seq > cursor:
389+
if event.seq > cursor:
390390
raise _malformed(
391391
subject,
392392
f"data.cursor {cursor} is behind data.events[{index}].seq {event.seq}; "

‎oilpriceapi/models.py‎

Lines changed: 111 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
Pydantic models for API responses.
55
"""
66

7+
import warnings
78
from datetime import date, datetime
89
from typing import Any, Dict, List, Optional, Union
910

@@ -454,32 +455,122 @@ def parse_datetimes(cls, v):
454455
return v
455456

456457

458+
class SubscriptionEventSnapshot(BaseModel):
459+
"""One watched code's price at the moment a subscription event was recorded.
460+
461+
Built by the API's ``MarketBriefBuilder#snapshot_hash``.
462+
"""
463+
464+
model_config = ConfigDict(populate_by_name=True, extra="allow")
465+
466+
price: float = Field(description="Latest spot price")
467+
currency: str = Field(description="Price currency, e.g. USD")
468+
change_24h_pct: Optional[float] = Field(
469+
default=None, description="24h change in percent; None when there is no 24h comparison"
470+
)
471+
as_of: Optional[datetime] = Field(default=None, description="Timestamp of the price used")
472+
473+
474+
class SubscriptionEventDelta(BaseModel):
475+
"""One code's change since the previous event of the same subscription.
476+
477+
Built by the API's ``Watch#compute_deltas``.
478+
"""
479+
480+
model_config = ConfigDict(populate_by_name=True, extra="allow")
481+
482+
price_change: float = Field(description="Price change since the previous event")
483+
pct_change: Optional[float] = Field(
484+
default=None,
485+
description="Percent change; None when the previous price was 0 (the API omits it)",
486+
)
487+
488+
457489
class SubscriptionEvent(BaseModel):
458-
"""A single event emitted by a subscription, returned from the poll endpoint."""
490+
"""A single event emitted by a subscription, returned from the poll endpoint.
491+
492+
Typed from ``GET /v1/subscriptions/events`` as the API sends it (#149).
493+
``snapshot`` and ``deltas`` are keyed by commodity code. ``deltas`` is
494+
``{}`` on a subscription's first event, and a code is absent from it when
495+
either snapshot lacked a price.
496+
"""
459497

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

462-
seq: Optional[int] = Field(default=None, description="Monotonic per-user sequence cursor")
463-
watch_id: Optional[str] = Field(default=None, description="Subscription (watch) that produced the event")
464-
type: Optional[str] = Field(default=None, description="Event type")
465-
code: Optional[str] = Field(default=None, description="Commodity code the event relates to")
466-
payload: Optional[Dict[str, Any]] = Field(default=None, description="Event payload")
467-
created_at: Optional[datetime] = Field(default=None, description="Event timestamp")
500+
id: str = Field(description="Event identifier")
501+
seq: int = Field(description="Monotonic per-user sequence cursor")
502+
watch_id: str = Field(description="Subscription (watch) that produced the event")
503+
observed_at: datetime = Field(description="When the snapshot was taken")
504+
snapshot: Dict[str, SubscriptionEventSnapshot] = Field(
505+
description="Price per watched code at observed_at"
506+
)
507+
deltas: Dict[str, SubscriptionEventDelta] = Field(
508+
description="Change per code since the previous event; {} on the first event"
509+
)
510+
source: Optional[str] = Field(default=None, description="Attribution source of the subscription")
511+
tool_name: Optional[str] = Field(default=None, description="Attribution tool name of the subscription")
468512

469-
@field_validator("created_at", mode="before")
470-
@classmethod
471-
def parse_created_at(cls, v):
472-
"""Parse created_at from various formats."""
473-
if v is None:
474-
return None
475-
if isinstance(v, str):
476-
try:
477-
return datetime.fromisoformat(v.replace("Z", "+00:00"))
478-
except ValueError:
479-
from dateutil import parser
513+
# Deprecated accessors (#149). Plain properties, not pydantic fields, so they
514+
# never appear in model_dump() or serialization. Removed in 2.0.0.
480515

481-
return parser.parse(v)
482-
return v
516+
@property
517+
def created_at(self) -> datetime:
518+
"""Deprecated alias for ``observed_at``; removed in 2.0.0.
519+
520+
The API never sent ``created_at`` on an event, so this always read
521+
``None``. The event's timestamp is ``observed_at``.
522+
"""
523+
warnings.warn(
524+
"SubscriptionEvent.created_at is deprecated and will be removed in 2.0.0; "
525+
"use observed_at. The events API does not send created_at (#149).",
526+
DeprecationWarning,
527+
stacklevel=2,
528+
)
529+
return self.observed_at
530+
531+
@property
532+
def type(self) -> None:
533+
"""Deprecated; always ``None``, removed in 2.0.0.
534+
535+
The events API has no event type: every event is an interval snapshot.
536+
"""
537+
warnings.warn(
538+
"SubscriptionEvent.type is deprecated and will be removed in 2.0.0. It was "
539+
"always None: the events API sends no event type, and has no equivalent "
540+
"field; every event is an interval snapshot (#149).",
541+
DeprecationWarning,
542+
stacklevel=2,
543+
)
544+
return None
545+
546+
@property
547+
def code(self) -> None:
548+
"""Deprecated; always ``None``, removed in 2.0.0.
549+
550+
An event can cover several codes: use ``snapshot.keys()``.
551+
"""
552+
warnings.warn(
553+
"SubscriptionEvent.code is deprecated and will be removed in 2.0.0. It was "
554+
"always None: an event covers every watched code, so use "
555+
"list(event.snapshot) (#149).",
556+
DeprecationWarning,
557+
stacklevel=2,
558+
)
559+
return None
560+
561+
@property
562+
def payload(self) -> None:
563+
"""Deprecated; always ``None``, removed in 2.0.0.
564+
565+
The event data is in ``snapshot`` and ``deltas``.
566+
"""
567+
warnings.warn(
568+
"SubscriptionEvent.payload is deprecated and will be removed in 2.0.0. It "
569+
"was always None: use event.snapshot and event.deltas (#149).",
570+
DeprecationWarning,
571+
stacklevel=2,
572+
)
573+
return None
483574

484575

485576
class DataConnectorPrice(BaseModel):

‎tests/unit/fixtures/subscription_events_live_2026-09-13.json‎

Lines changed: 1 addition & 0 deletions
Large diffs are not rendered by default.

‎tests/unit/test_async_subscriptions_resource.py‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,18 @@ async def test_events(self, client):
7373
"data": {
7474
"cursor": 7,
7575
"has_more": False,
76-
"events": [{"seq": 7, "watch_id": "abc-123", "type": "threshold", "code": "WTI_USD"}],
76+
"events": [
77+
{
78+
"id": "a835a930-f34f-4001-80b1-38fb3cde3797",
79+
"seq": 7,
80+
"watch_id": "abc-123",
81+
"observed_at": "2026-09-08T17:21:19Z",
82+
"snapshot": {"WTI_USD": {"as_of": "2026-09-08T17:20:37Z", "price": 63.02, "currency": "USD", "change_24h_pct": -0.08}},
83+
"deltas": {"WTI_USD": {"pct_change": -0.26, "price_change": -0.16}},
84+
"source": "api",
85+
"tool_name": None,
86+
}
87+
],
7788
}
7889
}
7990
mock = AsyncMock(return_value=payload)

0 commit comments

Comments
 (0)