diff --git a/py_gasbuddy/parsers.py b/py_gasbuddy/parsers.py
index 85ef4ad..5e7a467 100644
--- a/py_gasbuddy/parsers.py
+++ b/py_gasbuddy/parsers.py
@@ -25,11 +25,18 @@ def parse_distance(value: Any) -> float | None:
return None
-def build_discount_map(offers: list[dict[str, Any]]) -> dict[str, float]:
+def build_discount_map(offers: list[Any]) -> dict[str, float]:
"""Return fuel product key to total pwgbDiscount mapping across all offers."""
discount_map: dict[str, float] = {}
for offer in offers:
- for disc in offer.get("discounts") or []:
+ if not isinstance(offer, dict):
+ continue
+ discounts = offer.get("discounts")
+ if not isinstance(discounts, list):
+ continue
+ for disc in discounts:
+ if not isinstance(disc, dict):
+ continue
raw = disc.get("pwgbDiscount")
if raw is None:
continue
@@ -37,7 +44,12 @@ def build_discount_map(offers: list[dict[str, Any]]) -> dict[str, float]:
amount = float(raw)
except (ValueError, TypeError):
continue
- for grade in disc.get("grades") or []:
+ grades = disc.get("grades")
+ if not isinstance(grades, list):
+ continue
+ for grade in grades:
+ if not isinstance(grade, str):
+ continue
discount_map[grade] = discount_map.get(grade, 0.0) + amount
return discount_map
@@ -46,8 +58,12 @@ def format_price_node(
price_node: dict[str, Any], deal_discount: float | None = None
) -> PriceNode:
"""Format a single price node."""
- credit_data = price_node.get("credit") or {}
- cash_data = price_node.get("cash") or {}
+ credit_data = price_node.get("credit")
+ if not isinstance(credit_data, dict):
+ credit_data = {}
+ cash_data = price_node.get("cash")
+ if not isinstance(cash_data, dict):
+ cash_data = {}
credit_price = credit_data.get("price", 0)
cash_price = cash_data.get("price", 0)
@@ -67,15 +83,39 @@ def format_price_node(
)
+def _stations_block(response: dict[str, Any]) -> dict[str, Any]:
+ """Return the ``stations`` dict from a locationBySearchTerm response.
+
+ Defensive against the GraphQL spec's allowance of partial responses
+ (``data: null``, missing keys, ``stations`` being null or a non-dict).
+ Returns an empty dict when the expected shape isn't present so callers
+ can downgrade to "no results" rather than crash.
+ """
+ data = response.get("data")
+ if not isinstance(data, dict):
+ return {}
+ location = data.get("locationBySearchTerm")
+ if not isinstance(location, dict):
+ return {}
+ stations = location.get("stations")
+ return stations if isinstance(stations, dict) else {}
+
+
def parse_cursor(response: dict[str, Any]) -> str | None:
"""Extract the next-page cursor from a locationBySearchTerm response."""
- stations = response["data"]["locationBySearchTerm"]["stations"]
- return (stations.get("cursor") or {}).get("next")
+ stations = _stations_block(response)
+ cursor = stations.get("cursor")
+ if not isinstance(cursor, dict):
+ return None
+ return cursor.get("next")
def parse_location_results(response: dict[str, Any]) -> LocationSearchResult:
"""Parse location search results into a LocationSearchResult."""
- stations = response["data"]["locationBySearchTerm"]["stations"]
+ stations = _stations_block(response)
+ raw_results = stations.get("results") or []
+ if not isinstance(raw_results, list):
+ raw_results = []
results = [
cast(
StationSummary,
@@ -91,7 +131,8 @@ def parse_location_results(response: dict[str, Any]) -> LocationSearchResult:
"price_unit": r.get("priceUnit"),
},
)
- for r in stations["results"]
+ for r in raw_results
+ if isinstance(r, dict) and "id" in r
]
return cast(
LocationSearchResult,
@@ -102,7 +143,15 @@ def parse_location_results(response: dict[str, Any]) -> LocationSearchResult:
def parse_results(response: dict[str, Any], limit: int) -> list[StationPrice]:
"""Parse price-service API results into a StationPrice list."""
result_list: list[StationPrice] = []
- results = response["data"]["locationBySearchTerm"]["stations"]["results"]
+ raw_results = _stations_block(response).get("results") or []
+ if not isinstance(raw_results, list):
+ raw_results = []
+ required_station_keys = {"id", "priceUnit", "currency", "latitude", "longitude"}
+ results = [
+ r
+ for r in raw_results
+ if isinstance(r, dict) and required_station_keys.issubset(r)
+ ]
for result in results[:limit]:
raw: dict[str, Any] = {
@@ -128,21 +177,26 @@ def parse_results(response: dict[str, Any], limit: int) -> list[StationPrice]:
"phone": result.get("phone") or None,
}
pay_status_obj = result.get("payStatus")
- is_pay_available = (pay_status_obj is None) or bool(
- (pay_status_obj or {}).get("isPayAvailable", False)
+ is_pay_available = (pay_status_obj is None) or (
+ isinstance(pay_status_obj, dict)
+ and bool(pay_status_obj.get("isPayAvailable", False))
)
raw["pay_status"] = is_pay_available
- offers = result.get("offers") or []
+ offers = [o for o in (result.get("offers") or []) if isinstance(o, dict)]
discount_map = build_discount_map(offers) if is_pay_available else {}
for price in result.get("prices") or []:
+ if not isinstance(price, dict) or "fuelProduct" not in price:
+ continue
fuel_key = price["fuelProduct"]
raw[fuel_key] = format_price_node(price, discount_map.get(fuel_key))
result_list.append(cast(StationPrice, raw))
return result_list
-def parse_ev_stations(stations_data: list[dict[str, Any]]) -> list[EvStation]:
+def parse_ev_stations(stations_data: Any) -> list[EvStation]:
"""Parse raw EV station dicts into EvStation list."""
+ if not isinstance(stations_data, list):
+ return []
return [
cast(
EvStation,
@@ -179,16 +233,30 @@ def parse_ev_stations(stations_data: list[dict[str, Any]]) -> list[EvStation]:
},
)
for s in stations_data
+ if isinstance(s, dict) and "id" in s
]
def parse_trends(response: dict[str, Any]) -> list[TrendData]:
"""Parse price-service API results into a TrendData list."""
+ data = response.get("data")
+ if not isinstance(data, dict):
+ return []
+ location = data.get("locationBySearchTerm")
+ if not isinstance(location, dict):
+ return []
+ trends = location.get("trends") or []
+ if not isinstance(trends, list):
+ return []
return [
TrendData(
average_price=trend["today"],
lowest_price=trend["todayLow"],
area=trend["areaName"],
)
- for trend in response["data"]["locationBySearchTerm"]["trends"]
+ for trend in trends
+ if isinstance(trend, dict)
+ and "today" in trend
+ and "todayLow" in trend
+ and "areaName" in trend
]
diff --git a/tests/test_init.py b/tests/test_init.py
index 00af1b1..133c7d7 100644
--- a/tests/test_init.py
+++ b/tests/test_init.py
@@ -338,7 +338,10 @@ async def test_retry_logic(mock_aioclient, caplog):
)
# Patch asyncio.sleep used by backoff so the test doesn't actually
# wait through the exponential delay between retries.
- with caplog.at_level(logging.DEBUG), patch("backoff._async.asyncio.sleep", new=AsyncMock()):
+ with (
+ caplog.at_level(logging.DEBUG),
+ patch("backoff._async.asyncio.sleep", new=AsyncMock()),
+ ):
with pytest.raises(py_gasbuddy.LibraryError):
manager = py_gasbuddy.GasBuddy(station_id=205033)
await manager.price_lookup()
@@ -358,11 +361,14 @@ async def test_retry_succeeds_on_second_attempt(mock_aioclient, caplog):
mock_aioclient.post(
TEST_URL,
status=403,
- body='
Just a moment...',
+ body="Just a moment...",
)
mock_aioclient.post(TEST_URL, status=200, body=load_fixture("station.json"))
- with caplog.at_level(logging.DEBUG), patch("backoff._async.asyncio.sleep", new=AsyncMock()):
+ with (
+ caplog.at_level(logging.DEBUG),
+ patch("backoff._async.asyncio.sleep", new=AsyncMock()),
+ ):
manager = py_gasbuddy.GasBuddy(station_id=205033)
data = await manager.price_lookup()
diff --git a/tests/test_parsers.py b/tests/test_parsers.py
new file mode 100644
index 0000000..7aa09d2
--- /dev/null
+++ b/tests/test_parsers.py
@@ -0,0 +1,253 @@
+"""Tests for defensive parser behavior on malformed GraphQL payloads."""
+
+from py_gasbuddy.parsers import (
+ parse_cursor,
+ parse_location_results,
+ parse_results,
+ parse_trends,
+)
+
+
+def test_parse_cursor_data_null() -> None:
+ """parse_cursor returns None when data is null."""
+ assert parse_cursor({"data": None}) is None
+
+
+def test_parse_cursor_location_null() -> None:
+ """parse_cursor returns None when locationBySearchTerm is null."""
+ assert parse_cursor({"data": {"locationBySearchTerm": None}}) is None
+
+
+def test_parse_cursor_stations_not_dict() -> None:
+ """parse_cursor returns None when stations is a list (not a dict)."""
+ payload = {"data": {"locationBySearchTerm": {"stations": []}}}
+ assert parse_cursor(payload) is None
+
+
+def test_parse_cursor_missing_cursor_key() -> None:
+ """parse_cursor returns None when there is no cursor key."""
+ payload = {"data": {"locationBySearchTerm": {"stations": {"results": []}}}}
+ assert parse_cursor(payload) is None
+
+
+def test_parse_cursor_cursor_not_dict() -> None:
+ """parse_cursor returns None when cursor is not a dict."""
+ payload = {"data": {"locationBySearchTerm": {"stations": {"cursor": None}}}}
+ assert parse_cursor(payload) is None
+
+
+def test_parse_location_results_empty() -> None:
+ """parse_location_results returns an empty list when data is null."""
+ result = parse_location_results({"data": None})
+ assert result == {"results": [], "next_cursor": None}
+
+
+def test_parse_location_results_results_not_list() -> None:
+ """parse_location_results tolerates results not being a list."""
+ payload = {"data": {"locationBySearchTerm": {"stations": {"results": None}}}}
+ result = parse_location_results(payload)
+ assert result == {"results": [], "next_cursor": None}
+
+
+def test_parse_location_results_skips_malformed_entries() -> None:
+ """Entries without an id are skipped, not raised."""
+ payload = {
+ "data": {
+ "locationBySearchTerm": {
+ "stations": {
+ "results": [
+ {"id": "1", "name": "Good"},
+ {"name": "no id"},
+ None,
+ ]
+ }
+ }
+ }
+ }
+ result = parse_location_results(payload)
+ assert len(result["results"]) == 1
+ assert result["results"][0]["station_id"] == "1"
+
+
+def test_parse_results_empty() -> None:
+ """parse_results returns an empty list when data is null."""
+ assert parse_results({"data": None}, limit=5) == []
+
+
+def test_parse_results_results_not_list() -> None:
+ """parse_results tolerates results not being a list."""
+ payload = {"data": {"locationBySearchTerm": {"stations": {"results": "oops"}}}}
+ assert parse_results(payload, limit=5) == []
+
+
+def test_parse_trends_empty() -> None:
+ """parse_trends returns an empty list when data is null."""
+ assert parse_trends({"data": None}) == []
+
+
+def test_parse_trends_trends_not_list() -> None:
+ """parse_trends tolerates trends not being a list."""
+ payload = {"data": {"locationBySearchTerm": {"trends": None}}}
+ assert parse_trends(payload) == []
+
+
+def test_parse_trends_skips_malformed_entries() -> None:
+ """Trend entries missing required keys are skipped."""
+ payload = {
+ "data": {
+ "locationBySearchTerm": {
+ "trends": [
+ {"today": 3.5, "todayLow": 3.2, "areaName": "A"},
+ {"today": 3.5}, # missing keys
+ ]
+ }
+ }
+ }
+ result = parse_trends(payload)
+ assert len(result) == 1
+ assert result[0]["area"] == "A"
+
+
+def test_parse_location_results_results_truthy_non_list() -> None:
+ """A truthy non-list 'results' is coerced to an empty list (not just None)."""
+ payload = {"data": {"locationBySearchTerm": {"stations": {"results": "oops"}}}}
+ result = parse_location_results(payload)
+ assert result["results"] == []
+
+
+def test_parse_trends_truthy_non_list() -> None:
+ """A truthy non-list 'trends' returns an empty list (not just None)."""
+ payload = {"data": {"locationBySearchTerm": {"trends": "oops"}}}
+ assert parse_trends(payload) == []
+
+
+def test_parse_results_skips_partially_malformed_entries() -> None:
+ """parse_results skips entries that lack required keys, or contain non-dict nested lists."""
+ payload = {
+ "data": {
+ "locationBySearchTerm": {
+ "stations": {
+ "results": [
+ # 1. Valid station
+ {
+ "id": "1",
+ "priceUnit": "gallon",
+ "currency": "USD",
+ "latitude": 45.0,
+ "longitude": -90.0,
+ "name": "Good Station",
+ "prices": [
+ {
+ "fuelProduct": "regular",
+ "credit": {"price": 3.10},
+ }
+ ],
+ },
+ # 2. Missing required key (priceUnit)
+ {
+ "id": "2",
+ "currency": "USD",
+ "latitude": 45.0,
+ "longitude": -90.0,
+ "name": "Bad Station 1",
+ },
+ # 3. Non-dict prices
+ {
+ "id": "3",
+ "priceUnit": "gallon",
+ "currency": "USD",
+ "latitude": 45.0,
+ "longitude": -90.0,
+ "name": "Bad Station 2",
+ "prices": ["not-a-dict"],
+ },
+ # 4. None/Null entry
+ None,
+ ]
+ }
+ }
+ }
+ }
+ results = parse_results(payload, limit=5)
+ assert len(results) == 2 # Good Station + Bad Station 2
+ assert results[0]["station_id"] == "1"
+ assert "regular" in results[0]
+ assert results[1]["station_id"] == "3"
+ assert "regular" not in results[1] # skipped the malformed price
+
+
+def test_parsers_truthy_non_dict_guards() -> None:
+ """_stations_block and parse_trends return empty values when data or locationBySearchTerm is a truthy non-dict."""
+ # 1. data is a string "oops"
+ payload_str_data = {"data": "oops"}
+ assert parse_cursor(payload_str_data) is None
+ assert parse_location_results(payload_str_data) == {
+ "results": [],
+ "next_cursor": None,
+ }
+ assert parse_results(payload_str_data, limit=5) == []
+ assert parse_trends(payload_str_data) == []
+
+ # 2. locationBySearchTerm is a boolean True
+ payload_bool_loc = {"data": {"locationBySearchTerm": True}}
+ assert parse_cursor(payload_bool_loc) is None
+ assert parse_location_results(payload_bool_loc) == {
+ "results": [],
+ "next_cursor": None,
+ }
+ assert parse_results(payload_bool_loc, limit=5) == []
+ assert parse_trends(payload_bool_loc) == []
+
+
+def test_build_discount_map_non_string_grades() -> None:
+ """build_discount_map ignores non-string grade items gracefully."""
+ from py_gasbuddy.parsers import build_discount_map
+
+ offers = [
+ {"discounts": [{"pwgbDiscount": "0.05", "grades": ["regular", 123, None, {}]}]}
+ ]
+ discount_map = build_discount_map(offers)
+ assert discount_map == {"regular": 0.05}
+
+
+def test_build_discount_map_defensive_coverage() -> None:
+ """build_discount_map covers defensive type check branches."""
+ from py_gasbuddy.parsers import build_discount_map
+
+ offers = [
+ # 1. Non-dict offer (line 33)
+ "not-a-dict",
+ # 2. Non-list discounts (line 36)
+ {"discounts": "not-a-list"},
+ # 3. Non-dict disc in discounts (line 39)
+ {"discounts": ["not-a-dict-disc"]},
+ # 4. Non-list grades in disc (line 49)
+ {
+ "discounts": [
+ {
+ "pwgbDiscount": "0.05",
+ "grades": "not-a-list-grades",
+ }
+ ]
+ },
+ # 5. Valid offer to verify it keeps working
+ {
+ "discounts": [
+ {
+ "pwgbDiscount": "0.10",
+ "grades": ["regular"],
+ }
+ ]
+ },
+ ]
+
+ result = build_discount_map(offers)
+ assert result == {"regular": 0.10}
+
+
+def test_parse_ev_stations_non_list_coverage() -> None:
+ """parse_ev_stations returns empty list when stations_data is not a list (line 199)."""
+ from py_gasbuddy.parsers import parse_ev_stations
+
+ assert parse_ev_stations("not-a-list") == []
+ assert parse_ev_stations(None) == []