Skip to content

fix(models): type SubscriptionEvent from the event the API sends (#149) - #150

Merged
karlwaldman merged 3 commits into
mainfrom
fix/149-subscription-event-model
Sep 13, 2026
Merged

karlwaldman merged 3 commits into
mainfrom
fix/149-subscription-event-model

Conversation

@karlwaldman

@karlwaldman karlwaldman commented Sep 13, 2026

Copy link
Copy Markdown
Member

Summary

SubscriptionEvent declared type, code, payload and created_at. GET /v1/subscriptions/events has never sent any of them, so they read None on every real event. The fields the API does send (id, observed_at, snapshot, deltas, source, tool_name) were untyped pydantic extras, and observed_at stayed a plain string.

The model is now typed from the wire shape. That shape is WatchEvent#as_poll_json on oilpriceapi-api origin/main, confirmed against 223 live events.

Field Type Why
id str, required uuid primary key
seq int, required null: false
watch_id str, required null: false
observed_at datetime (tz-aware), required null: false, validated presence
snapshot Dict[str, SubscriptionEventSnapshot], required jsonb null: false, default {}
deltas Dict[str, SubscriptionEventDelta], required jsonb null: false, default {}. {} on a watch's first event
source Optional[str] nullable column
tool_name Optional[str] nullable column

SubscriptionEventSnapshot comes from MarketBriefBuilder#snapshot_hash:

  • price: float and currency: str are always built.
  • change_24h_pct: Optional[float] is nil when there is no 24h comparison.
  • as_of: Optional[datetime] is built with a nil-safe call.

SubscriptionEventDelta comes from Watch#compute_deltas:

  • price_change: float is always set.
  • pct_change: Optional[float] is removed by .compact when the prior price is 0.
  • A code is left out of deltas entirely when either snapshot lacks a price. The SDK does not fill it in.

Both new models are exported from oilpriceapi. Sync and async share the model.

An event missing a required field raises OilPriceAPIError(code="MALFORMED_RESPONSE") through subscriptions.events(). unwrap_events_page drops its seq is None guard, because seq is now required.

No pydantic validator was added. The old created_at field_validator is removed; pydantic parses the ISO timestamps natively.

Deprecations (no attribute removed; removal in 2.0.0)

1.16.0 is a minor release, so no public attribute is removed. The four names the API never sends stop being pydantic fields and become plain @property accessors. Each emits a DeprecationWarning on every access, and none appears in model_dump() / model_dump_json().

Accessor Returns Warning points to
type None, as before no equivalent: the API sends no event type, every event is an interval snapshot
code None, as before list(event.snapshot): an event covers every watched code
payload None, as before event.snapshot and event.deltas
created_at observed_at; was None observed_at
  • event.type, event.code and event.payload keep returning None, as they always did, so existing callers do not crash.
  • The one visible difference: created_at now returns the real timestamp instead of None.
  • Parsing, polling or serializing events emits no warning. Only reading a deprecated name does.
  • All four are scheduled for removal in 2.0.0.

Existing fixtures in test_subscriptions_resource.py, test_async_subscriptions_resource.py and test_subscriptions_list_events_strict.py used the invented {seq, watch_id, type, code} shape. They are updated to the live shape.

Live evidence (api.oilpriceapi.com, 2026-09-13)

A full page-through of GET /v1/subscriptions/events (read-only) returned 223 events, seq 1–223, one watch.

  • Every event had all eight keys, with types id str, seq int, watch_id str, observed_at str, snapshot dict, deltas dict, source str, tool_name str.
  • Snapshot entries had as_of str, price float, currency str and change_24h_pct float in 223 of 223.
  • Delta entries had pct_change and price_change floats in 222 of 222. Only seq 1 had deltas: {}.

All 223 raw events parsed with the new model:

parsed 223 of 223
events with untyped extras: 0 | snapshot/delta entries with extras: 0
observed_at tz-aware: True
empty deltas at seq: [1]
null change_24h_pct: 0 | null pct_change: 0
hasattr type/code/payload: [False, False, False]

This run used -W error::DeprecationWarning, so parsing is also proven warning-free. The nullable inner fields were never null in this sample. They are Optional because the API code allows nil, not because it was observed.

Red (verbatim live events seq 1 and seq 223, run on unchanged origin/main c6f76ec)

$ pytest tests/unit/test_subscription_event_model.py -q --no-cov -rf
...
========================= 25 failed, 3 passed in 0.51s =========================

Failure reasons, counted:

  16 E       Failed: DID NOT RAISE OilPriceAPIError
   2 E       AssertionError: assert '2026-09-13T20:32:18Z' == datetime.datetime(2026, 9, 13, 20, 32, 18, tzinfo=datetime.timezone.utc)
   1 E       Failed: DID NOT WARN. No warnings of type (<class 'DeprecationWarning'>,) were emitted.
   1 E       AttributeError: 'dict' object has no attribute 'price_change'
   1 E       AttributeError: 'dict' object has no attribute 'change_24h_pct'
   1 E       AssertionError: assert {'id': 'f5419... -0.15}}, ...} == {}
   1 E       AssertionError: assert 'type' not in {'seq': FieldInfo(annotation=Union[int, NoneType], required=False, default=None, ...
   1 E       AssertionError: assert 'payload' not in {'seq': FieldInfo(annotation=Union[int, NoneType], required=False, default=None, ...
   1 E       AssertionError: assert 'code' not in {'seq': FieldInfo(annotation=Union[int, NoneType], required=False, default=None, ...

The 3 that pass on main are guards: nullable attribution stays None, a code absent from deltas is not filled in, and parsing emits no warning.

Green

$ pytest tests/unit/test_subscription_event_model.py -q --no-cov
============================== 34 passed in 0.35s ==============================

The 34 tests cover:

  • Accessors: type, code and payload return None, and created_at returns observed_at. Each warns once per access, including on a repeat access, and the warning names 2.0.0 and the replacement.
  • Serialization: none of the four is a model field, and none appears in model_dump() or model_dump_json().
  • No warnings from normal use: parsing and dumping all 223 live events (committed as tests/unit/fixtures/subscription_events_live_2026-09-13.json) runs under simplefilter("error"). Polling them through the real sync and async clients raises no DeprecationWarning.
  • Scope of the polling check: it looks only at deprecation and SubscriptionEvent warnings. Building a sync client without matplotlib emits an unrelated ImportWarning, filed as [P3][bug] Constructing OilPriceAPI emits ImportWarning when matplotlib is not installed #151.

Full suite. The 3 failures are the live tests/integration/test_demo_contract.py 429s on both runs.

passed failed skipped
origin/main c6f76ec (measured on #147's tree) 1956 3 54
this branch before merging main 1984 3 54
this branch merged with origin/main 0266d08 (head 30048cd) 2009 3 54
with deprecated accessors (head d9990f5) 2015 3 54
  • ruff check oilpriceapi/ plus the touched test files: All checks passed!
  • mypy oilpriceapi/ --ignore-missing-imports, in a CI-equivalent .[dev] environment: Success, no issues found in 55 source files.
  • python scripts/validate_storefront_claims.py: validated 76 public surfaces.

Merge order

#148 is merged (0266d08). This branch has origin/main merged in, with the CHANGELOG.md ### Fixed conflict resolved by keeping both entries. No other open PR touches these files.

Closes #149

🤖 Generated with Claude Code

https://claude.ai/code/session_015ao5paex73xXvuM424Libo

karlwaldman and others added 2 commits September 13, 2026 17:05
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
Resolve the CHANGELOG ### Fixed conflict with #148 by keeping both entries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo
@coderabbitai

coderabbitai Bot commented Sep 13, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 3d8159af-721e-4a08-b34c-c60a63ddac3f


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…cessors (#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
@karlwaldman
karlwaldman merged commit c4bce22 into main Sep 13, 2026
7 checks passed
@karlwaldman
karlwaldman deleted the fix/149-subscription-event-model branch September 13, 2026 21:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[P2][models] SubscriptionEvent types fields the events API never sends (type, code, payload, created_at) and leaves observed_at/snapshot/deltas untyped

1 participant