Skip to content

Commit 13ade36

Browse files
karlwaldmanclaude
andcommitted
fix(diesel): unwrap the API envelope before building DieselPrice (#110)
client.diesel.get_price() raised pydantic ValidationError with 7 errors on every call. GET /v1/diesel-prices?state=CA returns {"status": "success", "data": {"regional_average": {...}, "sources": {...}, "upgrade": {...}, "location": {"type": "state", "state_code": "CA"}}} The resource looked for `regional_average` at the TOP level, missed it because it is nested under `data`, fell through to the `"data" in response` branch and handed the whole `data` object to DieselPrice(**...). Two fixes: * unwrap `data` first, then take `regional_average` — for both get_price() and get_stations() * `regional_average` carries `region` ("california"), not the `state` the model requires, so fill `state` from the envelope's location.state_code, falling back to the state the caller asked for Verified live against production on 2026-09-13 with the smoke-test key: client.diesel.get_price("CA") now returns state='CA' price=8.136 currency='USD' unit='gallon' granularity='state' source='aaa' cached=True, matching the raw client.request() form exactly. get_stations() is a POST, so it is verified by signature and fixtures only — not called against production. The older top-level and flat shapes still parse, so the existing test_diesel_resource.py fixtures are untouched and still green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo
1 parent 6e1a66c commit 13ade36

2 files changed

Lines changed: 239 additions & 18 deletions

File tree

‎oilpriceapi/resources/diesel.py‎

Lines changed: 43 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,29 @@
1010
from ..models import DieselPrice, DieselStationsResponse
1111

1212

13+
def _payload(response):
14+
"""Step inside the API's ``{"status": ..., "data": {...}}`` envelope.
15+
16+
``/v1/diesel-prices`` nests everything under ``data``. Responses that are
17+
already unwrapped (older shapes, hand-built fixtures) pass straight through,
18+
so both forms keep working.
19+
"""
20+
if isinstance(response, dict) and isinstance(response.get("data"), dict):
21+
return response["data"]
22+
return response
23+
24+
1325
class DieselResource:
1426
"""Resource for diesel price operations.
1527
1628
Provides access to state-level diesel price averages and station-level pricing.
1729
1830
Example:
19-
>>> # Get the available state average
31+
>>> # Get the available state average
2032
>>> price = client.diesel.get_price("CA")
2133
>>> print(f"California diesel: ${price.price:.2f}/gallon")
2234
23-
>>> # Get nearby stations when enabled for the current account
35+
>>> # Get nearby stations when enabled for the current account
2436
>>> result = client.diesel.get_stations(lat=37.7749, lng=-122.4194)
2537
>>> print(f"Found {len(result.stations)} stations")
2638
"""
@@ -36,8 +48,8 @@ def __init__(self, client):
3648
def get_price(self, state: str) -> DieselPrice:
3749
"""Get average diesel price for a US state.
3850
39-
Returns the available EIA state-level average diesel price. Access and
40-
request limits follow the account's current entitlement and API metadata.
51+
Returns the available EIA state-level average diesel price. Access and
52+
request limits follow the account's current entitlement and API metadata.
4153
4254
Args:
4355
state: Two-letter US state code (e.g., "CA", "TX", "NY")
@@ -85,13 +97,25 @@ def get_price(self, state: str) -> DieselPrice:
8597
params={"state": state.upper()}
8698
)
8799

88-
# Parse response - API returns { regional_average: {...} }
89-
if "regional_average" in response:
90-
price_data = response["regional_average"]
91-
elif "data" in response:
92-
price_data = response["data"]
100+
# Production returns {"status": ..., "data": {"regional_average": {...},
101+
# "location": {...}, ...}}. The record is one level deeper than the old
102+
# code looked, so it fell through and handed the whole envelope to the
103+
# model (#110). Unwrap `data` first, then take `regional_average`.
104+
payload = _payload(response)
105+
106+
if isinstance(payload, dict) and isinstance(payload.get("regional_average"), dict):
107+
price_data = dict(payload["regional_average"])
108+
location = payload.get("location")
93109
else:
94-
price_data = response
110+
price_data = dict(payload) if isinstance(payload, dict) else payload
111+
location = None
112+
113+
# `regional_average` carries `region` ("california"), not `state`, which
114+
# the model requires. The envelope's location block has the code; fall
115+
# back to what the caller asked for.
116+
if isinstance(price_data, dict) and not price_data.get("state"):
117+
state_code = location.get("state_code") if isinstance(location, dict) else None
118+
price_data["state"] = state_code or state.upper()
95119

96120
return DieselPrice(**price_data)
97121

@@ -105,11 +129,11 @@ def get_stations(
105129
106130
Returns station-level diesel prices within specified radius using Google Maps data.
107131
108-
Station-level access and allowances depend on the account's current
109-
entitlement. Review https://www.oilpriceapi.com/pricing and the API's
110-
response metadata instead of relying on SDK-bundled limits.
111-
112-
Use the returned source timestamp to apply the application's freshness policy.
132+
Station-level access and allowances depend on the account's current
133+
entitlement. Review https://www.oilpriceapi.com/pricing and the API's
134+
response metadata instead of relying on SDK-bundled limits.
135+
136+
Use the returned source timestamp to apply the application's freshness policy.
113137
114138
Args:
115139
lat: Latitude (-90 to 90)
@@ -122,8 +146,8 @@ def get_stations(
122146
Raises:
123147
ValidationError: If coordinates or radius are invalid
124148
AuthenticationError: If API key is invalid
125-
RateLimitError: If the API reports the request limit exceeded (429)
126-
OilPriceAPIError: If the account cannot access station queries (403)
149+
RateLimitError: If the API reports the request limit exceeded (429)
150+
OilPriceAPIError: If the account cannot access station queries (403)
127151
128152
Example:
129153
>>> # Get stations near San Francisco
@@ -202,7 +226,8 @@ def get_stations(
202226
}
203227
)
204228

205-
return DieselStationsResponse(**response)
229+
# Same envelope as get_price: the stations block lives under `data`.
230+
return DieselStationsResponse(**_payload(response))
206231

207232
def to_dataframe(
208233
self,

‎tests/unit/test_diesel_envelope.py‎

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
"""client.diesel.* must parse the envelope production actually returns (#110).
2+
3+
The fixtures below are the real response bodies. The state-average one was
4+
captured live from GET /v1/diesel-prices?state=CA on 2026-09-13; the station one
5+
mirrors the documented POST /v1/diesel-prices/stations envelope, verified by
6+
signature only because it is a POST.
7+
8+
The existing tests in test_diesel_resource.py mock a *top-level*
9+
`regional_average`, a shape the API does not return — which is exactly how this
10+
shipped. Those shapes stay supported; these add the shape customers hit.
11+
"""
12+
13+
from unittest.mock import Mock, patch
14+
15+
import pytest
16+
17+
from oilpriceapi import OilPriceAPI
18+
from oilpriceapi.models import DieselPrice, DieselStationsResponse
19+
20+
# Not a credential: a fixture string, every request here is mocked.
21+
FIXTURE_KEY = "-".join(["fixture", "not", "a", "real", "key"])
22+
23+
# Captured live from production on 2026-09-13.
24+
LIVE_STATE_ENVELOPE = {
25+
"status": "success",
26+
"data": {
27+
"regional_average": {
28+
"price": 8.136,
29+
"currency": "USD",
30+
"unit": "gallon",
31+
"region": "california",
32+
"granularity": "state",
33+
"source": "aaa",
34+
"updated_at": "2026-09-13T14:57:19Z",
35+
"cached": True,
36+
},
37+
"sources": {"primary": "aaa"},
38+
"upgrade": {"message": "Station-level pricing available on higher plans"},
39+
"location": {"type": "state", "state_code": "CA"},
40+
},
41+
}
42+
43+
LIVE_STATIONS_ENVELOPE = {
44+
"status": "success",
45+
"data": {
46+
"regional_average": {
47+
"price": 8.136,
48+
"currency": "USD",
49+
"unit": "gallon",
50+
"region": "california",
51+
"granularity": "state",
52+
"source": "aaa",
53+
"updated_at": "2026-09-13T14:57:19Z",
54+
},
55+
"stations": [
56+
{
57+
"name": "Fixture Truck Stop",
58+
"address": "1 Fixture Way, San Francisco, CA",
59+
"location": {"lat": 37.7749, "lng": -122.4194},
60+
"diesel_price": 7.99,
61+
"formatted_price": "$7.99",
62+
"currency": "USD",
63+
"unit": "gallon",
64+
"price_delta": -0.146,
65+
"price_vs_average": "$0.15 below average",
66+
}
67+
],
68+
"search_area": {
69+
"center": {"lat": 37.7749, "lng": -122.4194},
70+
"radius_meters": 8047,
71+
"radius_miles": 5.0,
72+
},
73+
"metadata": {
74+
"total_stations": 1,
75+
"source": "google_maps",
76+
"cached": False,
77+
"api_cost": 0.024,
78+
"timestamp": "2026-09-13T14:57:19Z",
79+
},
80+
},
81+
}
82+
83+
84+
def _mock_client(mock_request, payload):
85+
response = Mock()
86+
response.status_code = 200
87+
response.json.return_value = payload
88+
mock_request.return_value = response
89+
return OilPriceAPI(api_key=FIXTURE_KEY)
90+
91+
92+
@patch("httpx.Client.request")
93+
def test_get_price_parses_the_live_production_envelope(mock_request):
94+
client = _mock_client(mock_request, LIVE_STATE_ENVELOPE)
95+
96+
price = client.diesel.get_price("CA")
97+
98+
assert isinstance(price, DieselPrice)
99+
assert price.price == 8.136
100+
assert price.currency == "USD"
101+
assert price.unit == "gallon"
102+
assert price.granularity == "state"
103+
assert price.source == "aaa"
104+
assert price.cached is True
105+
assert price.updated_at.year == 2026
106+
107+
108+
@patch("httpx.Client.request")
109+
def test_get_price_fills_state_from_the_envelope_location(mock_request):
110+
"""`regional_average` carries `region`, not `state` — the code must not drop it."""
111+
client = _mock_client(mock_request, LIVE_STATE_ENVELOPE)
112+
113+
assert client.diesel.get_price("ca").state == "CA"
114+
115+
116+
@patch("httpx.Client.request")
117+
def test_get_price_falls_back_to_the_requested_state_when_location_is_absent(mock_request):
118+
payload = {
119+
"status": "success",
120+
"data": {
121+
"regional_average": dict(LIVE_STATE_ENVELOPE["data"]["regional_average"]),
122+
},
123+
}
124+
client = _mock_client(mock_request, payload)
125+
126+
assert client.diesel.get_price("tx").state == "TX"
127+
128+
129+
@patch("httpx.Client.request")
130+
def test_get_price_still_accepts_the_legacy_top_level_shape(mock_request):
131+
payload = {
132+
"regional_average": {
133+
"state": "CA",
134+
"price": 3.89,
135+
"currency": "USD",
136+
"unit": "gallon",
137+
"granularity": "state",
138+
"source": "EIA",
139+
"updated_at": "2025-12-15T10:00:00Z",
140+
}
141+
}
142+
client = _mock_client(mock_request, payload)
143+
144+
price = client.diesel.get_price("CA")
145+
assert price.price == 3.89
146+
assert price.state == "CA"
147+
148+
149+
@patch("httpx.Client.request")
150+
def test_get_price_still_accepts_a_flat_record(mock_request):
151+
payload = {
152+
"state": "NY",
153+
"price": 4.21,
154+
"currency": "USD",
155+
"unit": "gallon",
156+
"granularity": "state",
157+
"source": "EIA",
158+
"updated_at": "2025-12-15T10:00:00Z",
159+
}
160+
client = _mock_client(mock_request, payload)
161+
162+
assert client.diesel.get_price("NY").price == 4.21
163+
164+
165+
@patch("httpx.Client.request")
166+
def test_get_stations_parses_the_data_envelope(mock_request):
167+
client = _mock_client(mock_request, LIVE_STATIONS_ENVELOPE)
168+
169+
result = client.diesel.get_stations(lat=37.7749, lng=-122.4194, radius=8047)
170+
171+
assert isinstance(result, DieselStationsResponse)
172+
assert result.regional_average.price == 8.136
173+
assert len(result.stations) == 1
174+
assert result.stations[0].diesel_price == 7.99
175+
assert result.search_area.radius_miles == 5.0
176+
assert result.metadata.total_stations == 1
177+
178+
179+
@patch("httpx.Client.request")
180+
def test_get_stations_still_accepts_the_legacy_top_level_shape(mock_request):
181+
payload = dict(LIVE_STATIONS_ENVELOPE["data"])
182+
client = _mock_client(mock_request, payload)
183+
184+
result = client.diesel.get_stations(lat=37.7749, lng=-122.4194)
185+
assert len(result.stations) == 1
186+
187+
188+
@patch("httpx.Client.request")
189+
def test_to_dataframe_state_works_on_the_live_envelope(mock_request):
190+
pytest.importorskip("pandas")
191+
client = _mock_client(mock_request, LIVE_STATE_ENVELOPE)
192+
193+
df = client.diesel.to_dataframe(state="CA")
194+
195+
assert df["price"].iloc[0] == 8.136
196+
assert df["state"].iloc[0] == "CA"

0 commit comments

Comments
 (0)