From 0f5d158451cfb31df63587c054f67834d811162a Mon Sep 17 00:00:00 2001 From: Karl Waldman Date: Sun, 13 Sep 2026 16:09:37 -0400 Subject: [PATCH 1/2] feat(fuel-surcharge): typed LTL and parcel fuel-surcharge clients (#101) Add client.fuel_surcharge on OilPriceAPI and AsyncOilPriceAPI covering all six /v1/fuel-surcharge routes, with FuelSurchargeRate, FuelSurchargeHistoryPage and ParcelFuelSurchargeCarrier models typed from production payloads captured 2026-09-13. - effective_date is a date, retrieved_at a tz-aware datetime; source, nullable doe_diesel_price and diesel_band are preserved as sent. - A success body missing a field the API always sends raises OilPriceAPIError(code="MALFORMED_RESPONSE"); nothing is defaulted. - Carrier slugs, service levels and pagination are validated before the request; out-of-range page/per_page are refused because the API clamps them silently (verified live: per_page=500&page=0 -> meta page 1/100). - covered_carriers / available_service_levels from 400/404 bodies populate error.suggestions. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo --- CHANGELOG.md | 23 + README.md | 27 + docs/reference/resources.md | 4 + examples/fuel_surcharge.py | 52 ++ oilpriceapi/__init__.py | 10 + oilpriceapi/_fuel_surcharge_common.py | 280 +++++++ oilpriceapi/async_client.py | 3 + oilpriceapi/async_resources.py | 93 ++- oilpriceapi/client.py | 3 + oilpriceapi/exceptions.py | 2 + oilpriceapi/models.py | 96 ++- oilpriceapi/resources/fuel_surcharge.py | 198 +++++ tests/integration/test_live_fuel_surcharge.py | 100 +++ tests/unit/test_fuel_surcharge_resource.py | 782 ++++++++++++++++++ 14 files changed, 1671 insertions(+), 2 deletions(-) create mode 100644 examples/fuel_surcharge.py create mode 100644 oilpriceapi/_fuel_surcharge_common.py create mode 100644 oilpriceapi/resources/fuel_surcharge.py create mode 100644 tests/integration/test_live_fuel_surcharge.py create mode 100644 tests/unit/test_fuel_surcharge_resource.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a7f341..e018d52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,29 @@ All notable changes to the OilPriceAPI Python SDK will be documented in this fil ## [Unreleased] +### Added + +- **Typed LTL and parcel fuel-surcharge clients (#101).** `client.fuel_surcharge` + on both `OilPriceAPI` and `AsyncOilPriceAPI` covers all six + `/v1/fuel-surcharge` routes: `list()`, `latest(carrier)`, + `history(carrier, page=, per_page=)`, `parcel_list()`, + `parcel_latest(carrier)`, `parcel_latest_rate(carrier, service_level)` and + `parcel_history(carrier, service_level, page=, per_page=)`. Responses are + `FuelSurchargeRate`, `FuelSurchargeHistoryPage` (with the server's + `meta`) and `ParcelFuelSurchargeCarrier` models typed from production + payloads captured on 2026-09-13: `effective_date` is a `date`, + `retrieved_at` a timezone-aware `datetime`, and `source`, nullable + `doe_diesel_price` and `diesel_band` are kept as sent. A success body + missing a field the API always sends raises + `OilPriceAPIError(code="MALFORMED_RESPONSE")` instead of defaulting it. + Carrier slugs, service levels and pagination are validated before any + request; out-of-range `page`/`per_page` are refused because the API clamps + them silently. +- **Fuel-surcharge 400/404 bodies populate `error.suggestions`.** The + `covered_carriers` and `available_service_levels` lists the API returns with + an unknown carrier or a missing service level are now surfaced the same way + commodity suggestions are. + ## [1.15.0] - 2026-09-13 ### Fixed diff --git a/README.md b/README.md index 59e1d80..d46f9ca 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,33 @@ An empty permit search or production history is a valid data state. Do not infer broader well-level coverage from the presence of permit data or an SDK helper; dataset and account availability come from the current API response. +## Carrier Fuel Surcharges + +Weekly fuel surcharges for LTL carriers and, per service level, for parcel +carriers. Each rate keeps the carrier's `effective_date` and the `source` URL +and `retrieved_at` time it was retrieved from; a null the API sends (for +example `doe_diesel_price` on parcel rates) stays `None`. + +```python +import os + +from oilpriceapi import OilPriceAPI + +with OilPriceAPI(api_key=os.environ["OILPRICEAPI_KEY"]) as client: + odfl = client.fuel_surcharge.latest("odfl") + history = client.fuel_surcharge.history("odfl", per_page=10) + ups_ground = client.fuel_surcharge.parcel_latest_rate("ups", "ground") + +print(odfl.surcharge_percent, odfl.effective_date, odfl.source) +print(history.meta.total_count, [row.effective_date for row in history.history]) +print(ups_ground.surcharge_percent, ups_ground.service_level) +``` + +An unknown or uncovered carrier raises `DataNotFoundError` with the covered +carriers in `error.suggestions`. `page` must be 1 or more and `per_page` 1 to +100; the SDK refuses other values rather than letting the API clamp them. +See [`examples/fuel_surcharge.py`](examples/fuel_surcharge.py). + ## Complete pandas DataFrames Install the optional pandas support, then request a historical DataFrame: diff --git a/docs/reference/resources.md b/docs/reference/resources.md index 196819c..b506d26 100644 --- a/docs/reference/resources.md +++ b/docs/reference/resources.md @@ -52,6 +52,10 @@ ::: oilpriceapi.resources.drilling.DrillingIntelligenceResource +## Fuel Surcharges + +::: oilpriceapi.resources.fuel_surcharge.FuelSurchargeResource + ## Well Production (Beta) ::: oilpriceapi.resources.well_production.WellProductionResource diff --git a/examples/fuel_surcharge.py b/examples/fuel_surcharge.py new file mode 100644 index 0000000..657d284 --- /dev/null +++ b/examples/fuel_surcharge.py @@ -0,0 +1,52 @@ +"""Carrier fuel surcharges: LTL and parcel (#101). + +Usage: + OILPRICEAPI_KEY=... python examples/fuel_surcharge.py + +Prints the latest LTL surcharge per carrier, one carrier's recent weekly +history, and the latest parcel surcharge per service level. Every row shows the +carrier's effective date and where the value was retrieved from. +""" + +import os + +from oilpriceapi import OilPriceAPI +from oilpriceapi.exceptions import DataNotFoundError + + +def main() -> None: + with OilPriceAPI(api_key=os.environ["OILPRICEAPI_KEY"]) as client: + print("LTL carriers") + rates = client.fuel_surcharge.list() + for rate in rates: + print( + f" {rate.carrier:<22} {rate.surcharge_percent:>6.2f}% " + f"effective {rate.effective_date} retrieved {rate.retrieved_at:%Y-%m-%d}" + ) + + if rates: + carrier = rates[0].carrier + page = client.fuel_surcharge.history(carrier, per_page=4) + print(f"\n{carrier} history ({page.meta.total_count} weeks on record)") + for row in page.history: + diesel = "n/a" if row.doe_diesel_price is None else f"${row.doe_diesel_price:.3f}" + print(f" {row.effective_date} {row.surcharge_percent:.2f}% DOE diesel {diesel}") + print(f" source: {page.history[0].source}" if page.history else " no rows") + + print("\nParcel carriers") + for parcel in client.fuel_surcharge.parcel_list(): + for rate in parcel.service_levels: + print( + f" {parcel.carrier:<6} {rate.service_level:<26} " + f"{rate.surcharge_percent:>6.2f}% effective {rate.effective_date}" + ) + + try: + client.fuel_surcharge.latest("fedex-freight") + except DataNotFoundError as error: + print(f"\nNot covered: {error.message}") + print(f"Covered carriers: {', '.join(error.suggestions)}") + + +if __name__ == "__main__": + main() diff --git a/oilpriceapi/__init__.py b/oilpriceapi/__init__.py index 8640979..9a3d9e3 100644 --- a/oilpriceapi/__init__.py +++ b/oilpriceapi/__init__.py @@ -33,9 +33,14 @@ DieselPrice, DieselStation, DieselStationsResponse, + FuelSurchargeDieselBand, + FuelSurchargeHistoryMeta, + FuelSurchargeHistoryPage, + FuelSurchargeRate, MarketBrief, MarketBriefCommodity, MarketBriefForecast, + ParcelFuelSurchargeCarrier, PriceAlert, Subscription, SubscriptionEvent, @@ -76,6 +81,11 @@ "MarketBrief", "MarketBriefCommodity", "MarketBriefForecast", + "FuelSurchargeRate", + "FuelSurchargeDieselBand", + "FuelSurchargeHistoryMeta", + "FuelSurchargeHistoryPage", + "ParcelFuelSurchargeCarrier", "Subscription", "SubscriptionEvent", "SubscriptionEventsPage", diff --git a/oilpriceapi/_fuel_surcharge_common.py b/oilpriceapi/_fuel_surcharge_common.py new file mode 100644 index 0000000..5bd8256 --- /dev/null +++ b/oilpriceapi/_fuel_surcharge_common.py @@ -0,0 +1,280 @@ +"""Shared request validation and response parsing for fuel surcharges (#101). + +The sync ``FuelSurchargeResource`` and the async ``AsyncFuelSurchargeResource`` +both go through this module, so the two clients build identical requests and +reject identical responses. + +Routes (``V1::FuelSurchargeController`` on the API): + +* ``GET /v1/fuel-surcharge`` latest LTL rate per carrier +* ``GET /v1/fuel-surcharge/{carrier}/latest`` latest LTL rate +* ``GET /v1/fuel-surcharge/{carrier}/history`` weekly LTL series, paginated +* ``GET /v1/fuel-surcharge/parcel`` latest parcel rates by carrier +* ``GET /v1/fuel-surcharge/parcel/{carrier}/latest[?service_level=]`` +* ``GET /v1/fuel-surcharge/parcel/{carrier}/history?service_level=`` + +Every success body is ``{"status": "success", "data": {...}}``. A success body +that does not carry what the route promises raises +``OilPriceAPIError(code="MALFORMED_RESPONSE")``; nothing is defaulted. +""" + +import re +from typing import Any, Dict, List, Optional, Type, TypeVar + +from pydantic import BaseModel +from pydantic import ValidationError as PydanticValidationError + +from .exceptions import OilPriceAPIError, ValidationError +from .models import ( + FuelSurchargeHistoryPage, + FuelSurchargeRate, + ParcelFuelSurchargeCarrier, +) + +__all__ = [ + "MAX_PER_PAGE", + "LTL_LIST_PATH", + "PARCEL_LIST_PATH", + "carrier_path", + "parcel_carrier_path", + "validate_slug", + "history_params", + "parcel_history_params", + "parse_rate", + "parse_rate_list", + "parse_history", + "parse_parcel_carrier", + "parse_parcel_carrier_list", +] + +#: The API caps ``per_page`` at 100 and silently clamps anything larger. +MAX_PER_PAGE = 100 + +LTL_LIST_PATH = "/v1/fuel-surcharge" +PARCEL_LIST_PATH = "/v1/fuel-surcharge/parcel" + +# Public carrier slugs are lowercase and hyphenated (``southeastern-freight``); +# parcel service levels use underscores (``international_air_export``). Either +# way a path segment or query value made of anything else -- a slash, a ``?``, +# whitespace, ``..`` -- is refused before a request is built. +_SEGMENT = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9_-]*[A-Za-z0-9])?$") + +M = TypeVar("M", bound=BaseModel) + + +def validate_slug(value: Any, field: str) -> str: + """Return ``value`` if it is a safe carrier slug / service level. + + Raises: + ValidationError: locally, with ``status_code=None`` -- no request is sent. + """ + if not isinstance(value, str) or not _SEGMENT.match(value): + raise ValidationError( + f"{field} must be a non-empty slug of letters, digits, '-' or '_' " + f"(for example 'odfl' or 'ground'), got {value!r}", + field=field, + value=value, + status_code=None, + ) + return value + + +def carrier_path(carrier: str, action: str) -> str: + return f"/v1/fuel-surcharge/{validate_slug(carrier, 'carrier')}/{action}" + + +def parcel_carrier_path(carrier: str, action: str) -> str: + return f"/v1/fuel-surcharge/parcel/{validate_slug(carrier, 'carrier')}/{action}" + + +def _positive_int(value: Any, field: str, maximum: Optional[int] = None) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value < 1 or ( + maximum is not None and value > maximum + ): + bound = f"between 1 and {maximum}" if maximum is not None else "at least 1" + raise ValidationError( + f"{field} must be an integer {bound}, got {value!r}. The API silently " + f"clamps out-of-range values, so the SDK refuses them instead of " + f"returning a different page than the one requested.", + field=field, + value=value, + status_code=None, + ) + return value + + +def history_params(page: Optional[int], per_page: Optional[int]) -> Dict[str, Any]: + """Build LTL history query params; omitted arguments are not sent.""" + return _pagination({}, page, per_page) + + +def parcel_history_params( + service_level: Any, page: Optional[int], per_page: Optional[int] +) -> Dict[str, Any]: + """Build parcel history query params. ``service_level`` is required by the API.""" + return _pagination( + {"service_level": validate_slug(service_level, "service_level")}, page, per_page + ) + + +def _pagination( + params: Dict[str, Any], page: Optional[int], per_page: Optional[int] +) -> Dict[str, Any]: + if page is not None: + params["page"] = _positive_int(page, "page") + if per_page is not None: + params["per_page"] = _positive_int(per_page, "per_page", MAX_PER_PAGE) + return params + + +# --- response parsing ---------------------------------------------------------- + + +def _malformed(subject: str, detail: str, response: Any) -> OilPriceAPIError: + return OilPriceAPIError( + f"Malformed {subject} response: {detail}", + code="MALFORMED_RESPONSE", + raw_body=response, + ) + + +def _data(response: Any, subject: str) -> Dict[str, Any]: + data = response.get("data") if isinstance(response, dict) else None + if not isinstance(data, dict): + raise _malformed(subject, "expected a 'data' object", response) + return data + + +def _build(model: Type[M], value: Any, subject: str, response: Any) -> M: + try: + return model.model_validate(value) + except PydanticValidationError as error: + detail = "; ".join( + "%s: %s" % (".".join(str(part) for part in err["loc"]) or "", err["msg"]) + for err in error.errors() + ) + raise _malformed(subject, detail, response) from None + + +def _check_rate( + rate: FuelSurchargeRate, + *, + mode: str, + subject: str, + response: Any, + carrier: Optional[str] = None, + service_level: Optional[str] = None, +) -> FuelSurchargeRate: + if rate.mode != mode: + raise _malformed(subject, f"expected mode {mode!r}, got {rate.mode!r}", response) + if carrier is not None and rate.carrier.lower() != carrier.lower(): + raise _malformed(subject, f"expected carrier {carrier!r}, got {rate.carrier!r}", response) + if mode == "parcel" and not rate.service_level: + raise _malformed(subject, "parcel rate is missing service_level", response) + if service_level is not None and (rate.service_level or "").lower() != service_level.lower(): + raise _malformed( + subject, + f"expected service_level {service_level!r}, got {rate.service_level!r}", + response, + ) + return rate + + +def parse_rate( + response: Any, + *, + mode: str, + subject: str, + carrier: Optional[str] = None, + service_level: Optional[str] = None, +) -> FuelSurchargeRate: + """One rate object (``/latest``).""" + rate = _build(FuelSurchargeRate, _data(response, subject), subject, response) + return _check_rate( + rate, + mode=mode, + subject=subject, + response=response, + carrier=carrier, + service_level=service_level, + ) + + +def _collection(response: Any, key: str, subject: str) -> List[Any]: + rows = _data(response, subject).get(key) + if not isinstance(rows, list): + raise _malformed(subject, f"expected a '{key}' list", response) + return rows + + +def parse_rate_list(response: Any, *, subject: str) -> List[FuelSurchargeRate]: + """``data.carriers`` from the LTL list route. An empty list is valid.""" + rows = _collection(response, "carriers", subject) + return [ + _check_rate( + _build(FuelSurchargeRate, row, subject, response), + mode="ltl", + subject=subject, + response=response, + ) + for row in rows + ] + + +def parse_history( + response: Any, + *, + mode: str, + subject: str, + carrier: str, + service_level: Optional[str] = None, +) -> FuelSurchargeHistoryPage: + """``data.history`` + ``data.meta`` from a history route.""" + data = _data(response, subject) + if not isinstance(data.get("history"), list): + raise _malformed(subject, "expected a 'history' list", response) + if not isinstance(data.get("meta"), dict): + raise _malformed(subject, "expected a 'meta' pagination object", response) + page = _build(FuelSurchargeHistoryPage, data, subject, response) + for rate in page.history: + _check_rate( + rate, + mode=mode, + subject=subject, + response=response, + carrier=carrier, + service_level=service_level, + ) + return page + + +def _check_parcel_carrier( + carrier: ParcelFuelSurchargeCarrier, subject: str, response: Any +) -> ParcelFuelSurchargeCarrier: + if carrier.mode != "parcel": + raise _malformed(subject, f"expected mode 'parcel', got {carrier.mode!r}", response) + for rate in carrier.service_levels: + _check_rate(rate, mode="parcel", subject=subject, response=response, carrier=carrier.carrier) + return carrier + + +def parse_parcel_carrier(response: Any, *, subject: str, carrier: str) -> ParcelFuelSurchargeCarrier: + """A parcel carrier with its latest rate per service level.""" + data = _data(response, subject) + if not isinstance(data.get("service_levels"), list): + raise _malformed(subject, "expected a 'service_levels' list", response) + parsed = _build(ParcelFuelSurchargeCarrier, data, subject, response) + if parsed.carrier.lower() != carrier.lower(): + raise _malformed(subject, f"expected carrier {carrier!r}, got {parsed.carrier!r}", response) + return _check_parcel_carrier(parsed, subject, response) + + +def parse_parcel_carrier_list(response: Any, *, subject: str) -> List[ParcelFuelSurchargeCarrier]: + """``data.carriers`` from the parcel list route. An empty list is valid.""" + rows = _collection(response, "carriers", subject) + return [ + _check_parcel_carrier( + _build(ParcelFuelSurchargeCarrier, row, subject, response), subject, response + ) + for row in rows + ] diff --git a/oilpriceapi/async_client.py b/oilpriceapi/async_client.py index 678addf..5867c2e 100644 --- a/oilpriceapi/async_client.py +++ b/oilpriceapi/async_client.py @@ -30,6 +30,7 @@ AsyncDrillingIntelligenceResource, AsyncEnergyIntelligenceResource, AsyncForecastsResource, + AsyncFuelSurchargeResource, AsyncFuturesResource, AsyncRigCountsResource, AsyncStorageResource, @@ -178,6 +179,8 @@ def __init__( self.data_sources = AsyncDataSourcesResource(self) # Agent watch subscriptions + event polling (#3245 Phase 2). self.subscriptions = AsyncSubscriptionsResource(self) + # LTL + parcel carrier fuel surcharges (#101). + self.fuel_surcharge = AsyncFuelSurchargeResource(self) # WebSocket price-update namespace (requires the [stream] extra). # Lazily imports `websockets` only when a stream is actually opened. diff --git a/oilpriceapi/async_resources.py b/oilpriceapi/async_resources.py index dc01c9a..d16507f 100644 --- a/oilpriceapi/async_resources.py +++ b/oilpriceapi/async_resources.py @@ -3,13 +3,23 @@ from datetime import date, datetime from typing import Any, Dict, List, Optional, Union +from . import _fuel_surcharge_common as fs from ._subscriptions_common import ( build_attribution_headers, build_create_body, unwrap_data, ) from .exceptions import ValidationError -from .models import DieselPrice, DieselStationsResponse, PriceAlert, Subscription, SubscriptionEvent +from .models import ( + DieselPrice, + DieselStationsResponse, + FuelSurchargeHistoryPage, + FuelSurchargeRate, + ParcelFuelSurchargeCarrier, + PriceAlert, + Subscription, + SubscriptionEvent, +) from .resource_validators import ( VALID_OPERATORS, extract_commodity_catalog, @@ -1636,3 +1646,84 @@ async def events( cursor = data.get("cursor") has_more = bool(data.get("has_more", False)) return SubscriptionEventsPage(events=events, cursor=cursor, has_more=has_more) + + +class AsyncFuelSurchargeResource: + """Async LTL and parcel carrier fuel surcharges (#101). + + Same routes, validation and parsing as ``FuelSurchargeResource``; see its + docstrings for arguments, return types and errors. + """ + + def __init__(self, client: Any) -> None: + self.client = client + + async def list(self) -> List[FuelSurchargeRate]: + """Latest LTL surcharge for every carrier that has data.""" + response = await self.client.request(method="GET", path=fs.LTL_LIST_PATH) + return fs.parse_rate_list(response, subject="fuel-surcharge list") + + async def latest(self, carrier: str) -> FuelSurchargeRate: + """Latest LTL surcharge for one carrier.""" + path = fs.carrier_path(carrier, "latest") + response = await self.client.request(method="GET", path=path) + return fs.parse_rate(response, mode="ltl", subject="fuel-surcharge latest", carrier=carrier) + + async def history( + self, + carrier: str, + page: Optional[int] = None, + per_page: Optional[int] = None, + ) -> FuelSurchargeHistoryPage: + """Weekly LTL surcharge history for one carrier, newest first.""" + path = fs.carrier_path(carrier, "history") + params = fs.history_params(page, per_page) + response = await self.client.request(method="GET", path=path, params=params or None) + return fs.parse_history( + response, mode="ltl", subject="fuel-surcharge history", carrier=carrier + ) + + async def parcel_list(self) -> List[ParcelFuelSurchargeCarrier]: + """Latest parcel surcharge per service level, for every parcel carrier.""" + response = await self.client.request(method="GET", path=fs.PARCEL_LIST_PATH) + return fs.parse_parcel_carrier_list(response, subject="parcel fuel-surcharge list") + + async def parcel_latest(self, carrier: str) -> ParcelFuelSurchargeCarrier: + """Latest surcharge for every service level of one parcel carrier.""" + path = fs.parcel_carrier_path(carrier, "latest") + response = await self.client.request(method="GET", path=path) + return fs.parse_parcel_carrier( + response, subject="parcel fuel-surcharge latest", carrier=carrier + ) + + async def parcel_latest_rate(self, carrier: str, service_level: str) -> FuelSurchargeRate: + """Latest surcharge for one parcel carrier and service level.""" + path = fs.parcel_carrier_path(carrier, "latest") + params = {"service_level": fs.validate_slug(service_level, "service_level")} + response = await self.client.request(method="GET", path=path, params=params) + return fs.parse_rate( + response, + mode="parcel", + subject="parcel fuel-surcharge latest", + carrier=carrier, + service_level=service_level, + ) + + async def parcel_history( + self, + carrier: str, + service_level: str, + page: Optional[int] = None, + per_page: Optional[int] = None, + ) -> FuelSurchargeHistoryPage: + """Weekly surcharge history for one parcel carrier and service level.""" + path = fs.parcel_carrier_path(carrier, "history") + params = fs.parcel_history_params(service_level, page, per_page) + response = await self.client.request(method="GET", path=path, params=params) + return fs.parse_history( + response, + mode="parcel", + subject="parcel fuel-surcharge history", + carrier=carrier, + service_level=service_level, + ) diff --git a/oilpriceapi/client.py b/oilpriceapi/client.py index 3c567d0..0000f8b 100644 --- a/oilpriceapi/client.py +++ b/oilpriceapi/client.py @@ -38,6 +38,7 @@ from .resources.drilling import DrillingIntelligenceResource from .resources.ei import EnergyIntelligenceResource from .resources.forecasts import ForecastsResource +from .resources.fuel_surcharge import FuelSurchargeResource from .resources.futures import FuturesResource from .resources.historical import HistoricalResource from .resources.prices import PricesResource @@ -212,6 +213,8 @@ def __init__( self.subscriptions = SubscriptionsResource(self) # Public, no-auth demo endpoints (/v1/demo/*). self.demo = DemoResource(self) + # LTL + parcel carrier fuel surcharges (#101). + self.fuel_surcharge = FuelSurchargeResource(self) # Initialize visualization (optional) self.viz: Optional["PriceVisualizer"] diff --git a/oilpriceapi/exceptions.py b/oilpriceapi/exceptions.py index 7329ede..8f8f6e5 100644 --- a/oilpriceapi/exceptions.py +++ b/oilpriceapi/exceptions.py @@ -532,6 +532,8 @@ def error_from_response( "suggestions", "did_you_mean", "valid_commodities", + "covered_carriers", + "available_service_levels", ) ) invalid_codes = _string_list(_first_value(sources, "invalid_codes")) diff --git a/oilpriceapi/models.py b/oilpriceapi/models.py index 803e101..71f638a 100644 --- a/oilpriceapi/models.py +++ b/oilpriceapi/models.py @@ -4,7 +4,7 @@ Pydantic models for API responses. """ -from datetime import datetime +from datetime import date, datetime from typing import Any, Dict, List, Optional, Union from pydantic import BaseModel, ConfigDict, Field, field_validator @@ -508,3 +508,97 @@ def parse_timestamp(cls, v): def __str__(self) -> str: """String representation.""" return f"{self.fuel_type} @ {self.port}: {self.currency}{self.price:.2f}/{self.unit}" + + +class FuelSurchargeDieselBand(BaseModel): + """The DOE diesel price band a carrier's published table matched. + + Either bound may be null: an open-ended top band has no ``max``. + """ + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + min: Optional[float] = Field(strict=True, description="Band lower bound, USD/gal") + max: Optional[float] = Field(strict=True, description="Band upper bound, USD/gal") + + +class FuelSurchargeRate(BaseModel): + """One carrier fuel-surcharge rate (LTL, or one parcel service level). + + Every key the API always sends is required here. A key the API sends as + null stays ``None``; a key missing from the payload is a malformed response, + never a default. ``service_level`` is only sent for parcel rates. + """ + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + carrier: str = Field(min_length=1, description="Public carrier slug, e.g. 'odfl'") + carrier_name: str = Field(min_length=1, description="Carrier display name") + mode: str = Field(min_length=1, description="'ltl' or 'parcel'") + surcharge_percent: float = Field(strict=True, description="Surcharge as a percent of the line-haul/transport charge") + effective_date: date = Field(description="Date the carrier's surcharge takes effect") + doe_diesel_price: Optional[float] = Field( + strict=True, description="DOE diesel price the rate is indexed to, USD/gal; null when not published" + ) + diesel_band: Optional[FuelSurchargeDieselBand] = Field( + description="Diesel price band matched in the carrier's table; null when not applicable" + ) + source: str = Field(min_length=1, description="URL the rate was retrieved from") + retrieved_at: datetime = Field(description="When the rate was retrieved (UTC)") + service_level: Optional[str] = Field(default=None, description="Parcel service level; absent for LTL") + + @field_validator("effective_date", mode="before") + @classmethod + def parse_effective_date(cls, v: Any) -> Any: + """Accept only the API's ``YYYY-MM-DD`` string (or a ``date``).""" + if isinstance(v, date) and not isinstance(v, datetime): + return v + if isinstance(v, str) and len(v) == 10 and v[4] == "-" and v[7] == "-": + return date.fromisoformat(v) + raise ValueError(f"expected a YYYY-MM-DD date string, got {v!r}") + + @field_validator("retrieved_at", mode="before") + @classmethod + def parse_retrieved_at(cls, v: Any) -> Any: + """Parse an ISO-8601 timestamp and require an explicit UTC offset.""" + if isinstance(v, str): + try: + v = datetime.fromisoformat(v.replace("Z", "+00:00")) + except ValueError: + raise ValueError(f"expected an ISO-8601 timestamp, got {v!r}") from None + if not isinstance(v, datetime): + raise ValueError(f"expected an ISO-8601 timestamp, got {v!r}") + if v.tzinfo is None: + raise ValueError("retrieved_at has no UTC offset; refusing to guess its zone") + return v + + +class FuelSurchargeHistoryMeta(BaseModel): + """Pagination metadata exactly as the history routes send it.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + page: int = Field(strict=True, description="Page returned") + per_page: int = Field(strict=True, description="Rows per page the server applied") + total_count: int = Field(strict=True, description="Rows available across all pages") + total_pages: int = Field(strict=True, description="Pages available") + + +class FuelSurchargeHistoryPage(BaseModel): + """One page of weekly fuel-surcharge history, newest first.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + history: List[FuelSurchargeRate] = Field(description="Rates on this page") + meta: FuelSurchargeHistoryMeta = Field(description="Pagination metadata") + + +class ParcelFuelSurchargeCarrier(BaseModel): + """A parcel carrier with its latest rate for each service level.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + carrier: str = Field(min_length=1, description="Public parcel carrier slug, e.g. 'ups'") + carrier_name: Optional[str] = Field(description="Carrier display name") + mode: str = Field(min_length=1, description="Always 'parcel'") + service_levels: List[FuelSurchargeRate] = Field(description="Latest rate per service level") diff --git a/oilpriceapi/resources/fuel_surcharge.py b/oilpriceapi/resources/fuel_surcharge.py new file mode 100644 index 0000000..95e735c --- /dev/null +++ b/oilpriceapi/resources/fuel_surcharge.py @@ -0,0 +1,198 @@ +""" +Fuel Surcharge Resource + +Carrier fuel surcharges for LTL freight and parcel shipping (#101). + +``list``, ``latest`` and ``history`` cover LTL carriers (Old Dominion, Saia, +Estes, XPO, ABF, TForce, Averitt, Southeastern Freight). The ``parcel_*`` +methods cover parcel carriers (UPS, FedEx, DHL), which publish one surcharge +per service level. + +Each rate carries the carrier's own ``effective_date`` and the ``source`` URL +and ``retrieved_at`` time of the retrieval it came from. The API serves the +latest stored row as-is; a stale row keeps its real dates, and a carrier with +no retrieved data raises ``DataNotFoundError`` instead of returning a rate. + +The API does not feature-gate these routes by plan. Standard authentication +and request limits apply. +""" + +from typing import Any, List, Optional + +from .._fuel_surcharge_common import ( + LTL_LIST_PATH, + PARCEL_LIST_PATH, + carrier_path, + history_params, + parcel_carrier_path, + parcel_history_params, + parse_history, + parse_parcel_carrier, + parse_parcel_carrier_list, + parse_rate, + parse_rate_list, + validate_slug, +) +from ..models import FuelSurchargeHistoryPage, FuelSurchargeRate, ParcelFuelSurchargeCarrier + + +class FuelSurchargeResource: + """Resource for LTL and parcel carrier fuel surcharges.""" + + def __init__(self, client: Any) -> None: + """Initialize fuel surcharge resource. + + Args: + client: OilPriceAPI client instance + """ + self.client = client + + # --- LTL --------------------------------------------------------------- + + def list(self) -> List[FuelSurchargeRate]: + """Latest LTL surcharge for every carrier that has data. + + Returns: + One ``FuelSurchargeRate`` per carrier. Carriers with no retrieved + data are absent, not zero. + + Example: + >>> for rate in client.fuel_surcharge.list(): + ... print(rate.carrier, rate.surcharge_percent, rate.effective_date) + """ + response = self.client.request(method="GET", path=LTL_LIST_PATH) + return parse_rate_list(response, subject="fuel-surcharge list") + + def latest(self, carrier: str) -> FuelSurchargeRate: + """Latest LTL surcharge for one carrier. + + Args: + carrier: Public carrier slug, e.g. ``"odfl"`` or ``"southeastern-freight"``. + + Raises: + ValidationError: The slug is empty or not a slug (no request sent). + DataNotFoundError: Unknown, not-yet-covered, or no data retrieved. + ``error.suggestions`` lists the covered carriers. + + Example: + >>> rate = client.fuel_surcharge.latest("odfl") + >>> print(rate.surcharge_percent, rate.effective_date, rate.source) + """ + path = carrier_path(carrier, "latest") + response = self.client.request(method="GET", path=path) + return parse_rate(response, mode="ltl", subject="fuel-surcharge latest", carrier=carrier) + + def history( + self, + carrier: str, + page: Optional[int] = None, + per_page: Optional[int] = None, + ) -> FuelSurchargeHistoryPage: + """Weekly LTL surcharge history for one carrier, newest first. + + Args: + carrier: Public carrier slug. + page: Page number, 1 or more. Server default is 1. + per_page: Rows per page, 1 to 100. Server default is 100. + + Returns: + ``FuelSurchargeHistoryPage`` with ``history`` rows and the server's + ``meta`` (``page``, ``per_page``, ``total_count``, ``total_pages``). + + Raises: + ValidationError: Bad slug or out-of-range pagination (no request sent). + DataNotFoundError: Unknown carrier or no data retrieved. + + Example: + >>> page = client.fuel_surcharge.history("odfl", per_page=10) + >>> print(page.meta.total_count) + """ + path = carrier_path(carrier, "history") + params = history_params(page, per_page) + response = self.client.request(method="GET", path=path, params=params or None) + return parse_history( + response, mode="ltl", subject="fuel-surcharge history", carrier=carrier + ) + + # --- parcel -------------------------------------------------------------- + + def parcel_list(self) -> List[ParcelFuelSurchargeCarrier]: + """Latest parcel surcharge per service level, for every parcel carrier. + + Example: + >>> for carrier in client.fuel_surcharge.parcel_list(): + ... for rate in carrier.service_levels: + ... print(carrier.carrier, rate.service_level, rate.surcharge_percent) + """ + response = self.client.request(method="GET", path=PARCEL_LIST_PATH) + return parse_parcel_carrier_list(response, subject="parcel fuel-surcharge list") + + def parcel_latest(self, carrier: str) -> ParcelFuelSurchargeCarrier: + """Latest surcharge for every service level of one parcel carrier. + + Args: + carrier: Parcel carrier slug, e.g. ``"ups"``. + + Example: + >>> ups = client.fuel_surcharge.parcel_latest("ups") + >>> [rate.service_level for rate in ups.service_levels] + """ + path = parcel_carrier_path(carrier, "latest") + response = self.client.request(method="GET", path=path) + return parse_parcel_carrier( + response, subject="parcel fuel-surcharge latest", carrier=carrier + ) + + def parcel_latest_rate(self, carrier: str, service_level: str) -> FuelSurchargeRate: + """Latest surcharge for one parcel carrier and service level. + + Args: + carrier: Parcel carrier slug, e.g. ``"ups"``. + service_level: Service level, e.g. ``"ground"``. Use + ``parcel_latest`` to see which levels a carrier publishes. + + Raises: + DataNotFoundError: No data for that carrier and service level. + + Example: + >>> rate = client.fuel_surcharge.parcel_latest_rate("ups", "ground") + """ + path = parcel_carrier_path(carrier, "latest") + params = {"service_level": validate_slug(service_level, "service_level")} + response = self.client.request(method="GET", path=path, params=params) + return parse_rate( + response, + mode="parcel", + subject="parcel fuel-surcharge latest", + carrier=carrier, + service_level=service_level, + ) + + def parcel_history( + self, + carrier: str, + service_level: str, + page: Optional[int] = None, + per_page: Optional[int] = None, + ) -> FuelSurchargeHistoryPage: + """Weekly surcharge history for one parcel carrier and service level. + + Args: + carrier: Parcel carrier slug. + service_level: Required by the API, e.g. ``"ground"``. + page: Page number, 1 or more. + per_page: Rows per page, 1 to 100. + + Example: + >>> page = client.fuel_surcharge.parcel_history("ups", "ground", per_page=4) + """ + path = parcel_carrier_path(carrier, "history") + params = parcel_history_params(service_level, page, per_page) + response = self.client.request(method="GET", path=path, params=params) + return parse_history( + response, + mode="parcel", + subject="parcel fuel-surcharge history", + carrier=carrier, + service_level=service_level, + ) diff --git a/tests/integration/test_live_fuel_surcharge.py b/tests/integration/test_live_fuel_surcharge.py new file mode 100644 index 0000000..fa1bf59 --- /dev/null +++ b/tests/integration/test_live_fuel_surcharge.py @@ -0,0 +1,100 @@ +""" +Live integration smoke for the fuel-surcharge endpoints (#101). + +These hit the REAL OilPriceAPI and require a key in the ``OILPRICEAPI_TEST_KEY`` +environment variable. They are marked ``live`` and excluded from the default +unit gate (``--ignore=tests/integration``), and skipped when the key is absent. + +Read-only. The carrier and service level used for the per-carrier calls are +taken from the list responses rather than hard-coded, so coverage changes on the +API side do not turn this into a false failure. The API does not feature-gate +these routes, so a 403 here is a real failure. +""" + +import os +import time +from datetime import date, datetime + +import pytest + +from oilpriceapi import ( + FuelSurchargeHistoryPage, + FuelSurchargeRate, + OilPriceAPI, + ParcelFuelSurchargeCarrier, +) + +TEST_KEY = os.environ.get("OILPRICEAPI_TEST_KEY") + +pytestmark = [ + pytest.mark.live, + pytest.mark.integration, + pytest.mark.skipif( + not TEST_KEY, + reason="OILPRICEAPI_TEST_KEY not set; skipping live fuel-surcharge tests", + ), +] + +# Respect the 1 req/sec rate limit. +RATE_LIMIT_SLEEP = 1.1 + + +@pytest.fixture(scope="module") +def client(): + c = OilPriceAPI(api_key=TEST_KEY) + yield c + c.close() + + +def _assert_rate(rate: FuelSurchargeRate, mode: str) -> None: + assert isinstance(rate, FuelSurchargeRate) + assert rate.mode == mode + assert isinstance(rate.surcharge_percent, float) + assert type(rate.effective_date) is date + assert isinstance(rate.retrieved_at, datetime) and rate.retrieved_at.tzinfo is not None + assert rate.source.startswith("http") + + +def test_ltl_list_latest_and_history_live(client): + rates = client.fuel_surcharge.list() + assert rates, "expected at least one LTL carrier with data" + for rate in rates: + _assert_rate(rate, "ltl") + carrier = rates[0].carrier + time.sleep(RATE_LIMIT_SLEEP) + + latest = client.fuel_surcharge.latest(carrier) + _assert_rate(latest, "ltl") + assert latest.carrier == carrier + assert latest.effective_date == rates[0].effective_date + time.sleep(RATE_LIMIT_SLEEP) + + page = client.fuel_surcharge.history(carrier, per_page=2) + assert isinstance(page, FuelSurchargeHistoryPage) + assert page.meta.page == 1 + assert page.meta.per_page == 2 + assert page.meta.total_count >= len(page.history) + assert 1 <= len(page.history) <= 2 + dates = [row.effective_date for row in page.history] + assert dates == sorted(dates, reverse=True) + time.sleep(RATE_LIMIT_SLEEP) + + +def test_parcel_list_and_latest_rate_live(client): + carriers = client.fuel_surcharge.parcel_list() + assert carriers, "expected at least one parcel carrier with data" + for carrier in carriers: + assert isinstance(carrier, ParcelFuelSurchargeCarrier) + for rate in carrier.service_levels: + _assert_rate(rate, "parcel") + assert rate.service_level + first = carriers[0] + level = first.service_levels[0].service_level + assert level is not None + time.sleep(RATE_LIMIT_SLEEP) + + rate = client.fuel_surcharge.parcel_latest_rate(first.carrier, level) + _assert_rate(rate, "parcel") + assert rate.service_level == level + assert rate.surcharge_percent == first.service_levels[0].surcharge_percent + time.sleep(RATE_LIMIT_SLEEP) diff --git a/tests/unit/test_fuel_surcharge_resource.py b/tests/unit/test_fuel_surcharge_resource.py new file mode 100644 index 0000000..22a77d4 --- /dev/null +++ b/tests/unit/test_fuel_surcharge_resource.py @@ -0,0 +1,782 @@ +"""Typed LTL + parcel fuel-surcharge clients (#101). + +Every test drives the REAL sync or async client against a mocked transport +(`httpx.Client.request` / `httpx.AsyncClient.request`, the repo convention used +by tests/unit/test_empty_204_response.py). Nothing here stubs the resource or +asserts that a method "was called": each test checks what went out on the wire +and what came back to the caller. + +Success fixtures are trimmed copies of production bodies captured on +2026-09-13 from https://api.oilpriceapi.com (GET /v1/fuel-surcharge, +/v1/fuel-surcharge/parcel, /odfl/latest, /odfl/history?per_page=3&page=2, +/parcel/ups/latest[?service_level=ground], /parcel/ups/history?service_level= +ground&per_page=2). The 404 bodies are production captures too (unknown +carrier `nope`, reserved carrier `fedex-freight`, parcel no-data for an unknown +service level). The 400 body is the production response to +/parcel/ups/history without a service level. The 401 body is the production +response to an unauthenticated call. 402/403/429 use the canonical nested error +envelope; only their status mapping is under test. +""" + +import asyncio +import copy +import json +from datetime import date, datetime, timezone +from unittest.mock import AsyncMock, Mock, patch + +import httpx +import pytest + +from oilpriceapi import ( + AsyncOilPriceAPI, + FuelSurchargeDieselBand, + FuelSurchargeHistoryPage, + FuelSurchargeRate, + OilPriceAPI, + ParcelFuelSurchargeCarrier, +) +from oilpriceapi.exceptions import ( + AuthenticationError, + BadRequestError, + DataNotFoundError, + OilPriceAPIError, + PaymentRequiredError, + PermissionDeniedError, + RateLimitError, + TimeoutError, + ValidationError, +) + +# Not a credential: a fixture string, every request here is mocked. +FIXTURE_KEY = "-".join(["fixture", "not", "a", "real", "key"]) + +# --- production captures (2026-09-13) --------------------------------------- + +ODFL_LATEST = { + "carrier": "odfl", + "carrier_name": "Old Dominion Freight Line", + "mode": "ltl", + "surcharge_percent": 46.32, + "effective_date": "2026-09-09", + "doe_diesel_price": 5.599, + "diesel_band": None, + "source": "https://www.odfl.com/us/en/resources/fuel-surcharge.html", + "retrieved_at": "2026-09-08T16:10:10Z", +} + +ABF_LATEST = { + "carrier": "abf", + "carrier_name": "ABF Freight (ArcBest)", + "mode": "ltl", + "surcharge_percent": 50.0, + "effective_date": "2026-09-02", + "doe_diesel_price": None, + "diesel_band": None, + "source": "https://arcb.com/abf-freight/resources/fuel-surcharge.html", + "retrieved_at": "2026-09-08T16:10:29Z", +} + +SEFL_LATEST = { + "carrier": "southeastern-freight", + "carrier_name": "Southeastern Freight Lines", + "mode": "ltl", + "surcharge_percent": 44.63, + "effective_date": "2026-09-02", + "doe_diesel_price": 5.599, + "diesel_band": {"min": 5.57, "max": 5.6}, + "source": "https://www.sefl.com/seflWebsite/servlet/FUELSURCHARGE_FUELPAGEUPDATE", + "retrieved_at": "2026-09-08T16:10:34Z", +} + +LTL_LIST = {"status": "success", "data": {"carriers": [ODFL_LATEST, ABF_LATEST, SEFL_LATEST]}} + +ODFL_HISTORY_PAGE_2 = { + "status": "success", + "data": { + "history": [ + { + "carrier": "odfl", + "carrier_name": "Old Dominion Freight Line", + "mode": "ltl", + "surcharge_percent": 43.82, + "effective_date": "2026-08-05", + "doe_diesel_price": 5.313, + "diesel_band": None, + "source": "https://www.odfl.com/us/en/resources/fuel-surcharge.html", + "retrieved_at": "2026-08-04T16:10:04Z", + }, + { + "carrier": "odfl", + "carrier_name": "Old Dominion Freight Line", + "mode": "ltl", + "surcharge_percent": 41.82, + "effective_date": "2026-07-29", + "doe_diesel_price": 5.134, + "diesel_band": None, + "source": "https://www.odfl.com/us/en/resources/fuel-surcharge.html", + "retrieved_at": "2026-07-28T16:10:40Z", + }, + { + "carrier": "odfl", + "carrier_name": "Old Dominion Freight Line", + "mode": "ltl", + "surcharge_percent": 38.32, + "effective_date": "2026-07-22", + "doe_diesel_price": 4.796, + "diesel_band": None, + "source": "https://www.odfl.com/us/en/resources/fuel-surcharge.html", + "retrieved_at": "2026-07-21T16:10:07Z", + }, + ], + "meta": {"page": 2, "per_page": 3, "total_count": 6, "total_pages": 2}, + }, +} + +UPS_SOURCE = "https://www.ups.com/us/en/support/shipping-support/shipping-costs-rates/fuel-surcharges" + +UPS_AIR = { + "carrier": "ups", + "carrier_name": "UPS", + "mode": "parcel", + "surcharge_percent": 29.25, + "effective_date": "2026-09-07", + "doe_diesel_price": None, + "diesel_band": None, + "source": UPS_SOURCE, + "retrieved_at": "2026-09-08T16:10:36Z", + "service_level": "air", +} + +UPS_GROUND = { + "carrier": "ups", + "carrier_name": "UPS", + "mode": "parcel", + "surcharge_percent": 27.5, + "effective_date": "2026-09-07", + "doe_diesel_price": None, + "diesel_band": None, + "source": UPS_SOURCE, + "retrieved_at": "2026-09-08T16:10:36Z", + "service_level": "ground", +} + +UPS_GROUND_PREVIOUS = { + "carrier": "ups", + "carrier_name": "UPS", + "mode": "parcel", + "surcharge_percent": 27.75, + "effective_date": "2026-08-31", + "doe_diesel_price": None, + "diesel_band": None, + "source": UPS_SOURCE, + "retrieved_at": "2026-09-08T16:10:36Z", + "service_level": "ground", +} + +DHL_EXPORT = { + "carrier": "dhl", + "carrier_name": "DHL Express (U.S.)", + "mode": "parcel", + "surcharge_percent": 33.25, + "effective_date": "2026-09-14", + "doe_diesel_price": None, + "diesel_band": None, + "source": "https://www.dhl.com/us-en/home/express/products-and-solutions/products-and-services-overview/surcharges.html", + "retrieved_at": "2026-09-08T16:10:38Z", + "service_level": "export", +} + +UPS_CARRIER = {"carrier": "ups", "carrier_name": "UPS", "mode": "parcel", "service_levels": [UPS_AIR, UPS_GROUND]} +DHL_CARRIER = { + "carrier": "dhl", + "carrier_name": "DHL Express (U.S.)", + "mode": "parcel", + "service_levels": [DHL_EXPORT], +} + +PARCEL_LIST = {"status": "success", "data": {"carriers": [UPS_CARRIER, DHL_CARRIER]}} +UPS_PARCEL_LATEST = {"status": "success", "data": UPS_CARRIER} +UPS_GROUND_LATEST = {"status": "success", "data": UPS_GROUND} +UPS_GROUND_HISTORY = { + "status": "success", + "data": { + "history": [UPS_GROUND, UPS_GROUND_PREVIOUS], + "meta": {"page": 1, "per_page": 2, "total_count": 20, "total_pages": 10}, + }, +} + +LTL_COVERED = ["odfl", "saia", "estes", "xpo", "abf", "tforce", "averitt", "southeastern-freight"] + +UNKNOWN_CARRIER_404 = { + "status": "fail", + "data": { + "error": "Unknown carrier 'nope'. Covered carriers: odfl, saia, estes, xpo, abf, tforce, " + "averitt, southeastern-freight.", + "covered_carriers": LTL_COVERED, + "hint": "Call GET /v1/fuel-surcharge to list every covered carrier with its latest surcharge.", + }, +} + +RESERVED_CARRIER_404 = { + "status": "fail", + "data": { + "error": "Carrier 'fedex-freight' is not yet covered — we have not ingested its " + "fuel-surcharge schedule. Covered carriers: odfl, saia, estes, xpo, abf, tforce, averitt, " + "southeastern-freight.", + "covered_carriers": LTL_COVERED, + }, +} + +PARCEL_NO_DATA_404 = { + "status": "fail", + "data": { + "error": "No fuel-surcharge data retrieved yet for carrier 'ups'. Call GET /v1/fuel-surcharge " + "to see which carriers currently have data.", + "covered_carriers": ["ups", "fedex", "dhl"], + }, +} + +MISSING_SERVICE_LEVEL_400 = { + "status": "fail", + "data": { + "error": "Parcel fuel-surcharge history requires a service_level parameter.", + "carrier": "ups", + "available_service_levels": [ + "air", + "ground", + "international_air_export", + "international_air_import", + "international_ground", + ], + }, +} + +UNAUTHORIZED_401 = { + "error": { + "code": "UNAUTHORIZED", + "message": "Missing or invalid API key. Include header: Authorization: Token YOUR_API_KEY", + "status": 401, + "request_id": "bc98b482-7a78-4802-b0fe-53da93baab92", + "docs": "https://docs.oilpriceapi.com#UNAUTHORIZED", + } +} + + +def _envelope(code, status, message): + return {"error": {"code": code, "message": message, "status": status}} + + +# --- transport harness -------------------------------------------------------- + + +def _response(status, payload=None, *, raw=None): + response = Mock() + response.status_code = status + response.headers = {} + if raw is not None: + response.content = raw + response.text = raw.decode() + response.json.side_effect = json.JSONDecodeError("Expecting value", raw.decode(), 0) + else: + text = json.dumps(payload) + response.content = text.encode() + response.text = text + response.json.return_value = copy.deepcopy(payload) + return response + + +class Transport: + """Both transports patched at once; tests run the same body on either client.""" + + def __init__(self, sync_mock, async_mock): + self.sync_mock = sync_mock + self.async_mock = async_mock + + def respond(self, status, payload=None, **kwargs): + response = _response(status, payload, **kwargs) + self.sync_mock.return_value = response + self.async_mock.return_value = response + + def raise_(self, exc): + self.sync_mock.side_effect = exc + self.async_mock.side_effect = exc + + @property + def calls(self): + return self.sync_mock.call_args_list + self.async_mock.call_args_list + + def last(self): + calls = self.calls + assert len(calls) == 1, f"expected exactly one request, got {len(calls)}" + return calls[0].kwargs + + +@pytest.fixture +def transport(): + sync_mock = Mock() + async_mock = AsyncMock() + with patch("httpx.Client.request", sync_mock), patch("httpx.AsyncClient.request", async_mock): + yield Transport(sync_mock, async_mock) + + +@pytest.fixture(params=["sync", "async"]) +def call(request): + """Run `fn(client)` against a real sync or async client.""" + + def run(fn): + if request.param == "sync": + client = OilPriceAPI(api_key=FIXTURE_KEY, max_retries=1) + try: + return fn(client) + finally: + client.close() + + async def go(): + client = AsyncOilPriceAPI(api_key=FIXTURE_KEY, max_retries=1) + try: + return await fn(client) + finally: + await client.close() + + return asyncio.run(go()) + + return run + + +def _path(kwargs): + return httpx.URL(str(kwargs["url"])).path + + +# --- LTL ----------------------------------------------------------------------- + + +def test_list_returns_typed_rates_with_provenance_and_nulls(transport, call): + transport.respond(200, LTL_LIST) + + rates = call(lambda c: c.fuel_surcharge.list()) + + sent = transport.last() + assert sent["method"] == "GET" + assert _path(sent) == "/v1/fuel-surcharge" + assert [r.carrier for r in rates] == ["odfl", "abf", "southeastern-freight"] + assert all(isinstance(r, FuelSurchargeRate) for r in rates) + + odfl, abf, sefl = rates + assert odfl.carrier_name == "Old Dominion Freight Line" + assert odfl.mode == "ltl" + assert odfl.surcharge_percent == 46.32 + assert odfl.effective_date == date(2026, 9, 9) + assert type(odfl.effective_date) is date + assert odfl.retrieved_at == datetime(2026, 9, 8, 16, 10, 10, tzinfo=timezone.utc) + assert odfl.retrieved_at.tzinfo is not None + assert odfl.source == "https://www.odfl.com/us/en/resources/fuel-surcharge.html" + assert odfl.doe_diesel_price == 5.599 + assert odfl.service_level is None + + # A null the API sends stays a null: no invented diesel price or band. + assert abf.doe_diesel_price is None + assert abf.diesel_band is None + + assert isinstance(sefl.diesel_band, FuelSurchargeDieselBand) + assert sefl.diesel_band.min == 5.57 + assert sefl.diesel_band.max == 5.6 + + +def test_list_with_no_carriers_is_an_empty_list(transport, call): + transport.respond(200, {"status": "success", "data": {"carriers": []}}) + assert call(lambda c: c.fuel_surcharge.list()) == [] + + +def test_latest_hits_the_carrier_route(transport, call): + transport.respond(200, {"status": "success", "data": ODFL_LATEST}) + + rate = call(lambda c: c.fuel_surcharge.latest("odfl")) + + sent = transport.last() + assert _path(sent) == "/v1/fuel-surcharge/odfl/latest" + assert not sent.get("params") + assert isinstance(rate, FuelSurchargeRate) + assert rate.surcharge_percent == 46.32 + assert rate.effective_date == date(2026, 9, 9) + + +def test_latest_keeps_the_public_hyphenated_slug(transport, call): + transport.respond(200, {"status": "success", "data": SEFL_LATEST}) + + rate = call(lambda c: c.fuel_surcharge.latest("southeastern-freight")) + + assert _path(transport.last()) == "/v1/fuel-surcharge/southeastern-freight/latest" + assert rate.carrier == "southeastern-freight" + + +def test_history_sends_pagination_and_preserves_meta(transport, call): + transport.respond(200, ODFL_HISTORY_PAGE_2) + + page = call(lambda c: c.fuel_surcharge.history("odfl", page=2, per_page=3)) + + sent = transport.last() + assert _path(sent) == "/v1/fuel-surcharge/odfl/history" + assert sent["params"] == {"page": 2, "per_page": 3} + assert isinstance(page, FuelSurchargeHistoryPage) + assert [r.effective_date for r in page.history] == [ + date(2026, 8, 5), + date(2026, 7, 29), + date(2026, 7, 22), + ] + assert page.meta.page == 2 + assert page.meta.per_page == 3 + assert page.meta.total_count == 6 + assert page.meta.total_pages == 2 + + +def test_history_without_pagination_sends_no_page_params(transport, call): + transport.respond(200, ODFL_HISTORY_PAGE_2) + + call(lambda c: c.fuel_surcharge.history("odfl")) + + assert not transport.last().get("params") + + +# --- parcel --------------------------------------------------------------------- + + +def test_parcel_list_returns_carriers_with_service_levels(transport, call): + transport.respond(200, PARCEL_LIST) + + carriers = call(lambda c: c.fuel_surcharge.parcel_list()) + + assert _path(transport.last()) == "/v1/fuel-surcharge/parcel" + assert [c.carrier for c in carriers] == ["ups", "dhl"] + ups = carriers[0] + assert isinstance(ups, ParcelFuelSurchargeCarrier) + assert ups.mode == "parcel" + assert [s.service_level for s in ups.service_levels] == ["air", "ground"] + assert ups.service_levels[1].surcharge_percent == 27.5 + assert ups.service_levels[1].doe_diesel_price is None + assert carriers[1].service_levels[0].effective_date == date(2026, 9, 14) + + +def test_parcel_latest_without_service_level_returns_the_carrier(transport, call): + transport.respond(200, UPS_PARCEL_LATEST) + + carrier = call(lambda c: c.fuel_surcharge.parcel_latest("ups")) + + sent = transport.last() + assert _path(sent) == "/v1/fuel-surcharge/parcel/ups/latest" + assert not sent.get("params") + assert isinstance(carrier, ParcelFuelSurchargeCarrier) + assert carrier.carrier_name == "UPS" + assert len(carrier.service_levels) == 2 + + +def test_parcel_latest_rate_returns_one_service_level(transport, call): + transport.respond(200, UPS_GROUND_LATEST) + + rate = call(lambda c: c.fuel_surcharge.parcel_latest_rate("ups", "ground")) + + sent = transport.last() + assert _path(sent) == "/v1/fuel-surcharge/parcel/ups/latest" + assert sent["params"] == {"service_level": "ground"} + assert isinstance(rate, FuelSurchargeRate) + assert rate.service_level == "ground" + assert rate.mode == "parcel" + assert rate.source == UPS_SOURCE + + +def test_parcel_history_requires_and_sends_service_level(transport, call): + transport.respond(200, UPS_GROUND_HISTORY) + + page = call(lambda c: c.fuel_surcharge.parcel_history("ups", "ground", per_page=2)) + + sent = transport.last() + assert _path(sent) == "/v1/fuel-surcharge/parcel/ups/history" + assert sent["params"] == {"service_level": "ground", "per_page": 2} + assert page.meta.total_count == 20 + assert page.meta.total_pages == 10 + assert [r.effective_date for r in page.history] == [date(2026, 9, 7), date(2026, 8, 31)] + + +# --- API refusals --------------------------------------------------------------- + + +def test_unknown_carrier_is_a_404_that_names_the_covered_carriers(transport, call): + transport.respond(404, UNKNOWN_CARRIER_404) + + with pytest.raises(DataNotFoundError) as info: + call(lambda c: c.fuel_surcharge.latest("nope")) + + error = info.value + assert error.status_code == 404 + assert "Unknown carrier 'nope'" in error.message + assert error.suggestions == LTL_COVERED + assert error.raw_body["data"]["covered_carriers"] == LTL_COVERED + + +def test_reserved_carrier_is_a_404_not_fabricated_data(transport, call): + transport.respond(404, RESERVED_CARRIER_404) + + with pytest.raises(DataNotFoundError) as info: + call(lambda c: c.fuel_surcharge.history("fedex-freight")) + + assert "not yet covered" in info.value.message + assert info.value.suggestions == LTL_COVERED + + +def test_no_data_yet_is_a_404(transport, call): + transport.respond(404, PARCEL_NO_DATA_404) + + with pytest.raises(DataNotFoundError) as info: + call(lambda c: c.fuel_surcharge.parcel_latest_rate("ups", "bogus")) + + assert "No fuel-surcharge data retrieved yet" in info.value.message + assert info.value.suggestions == ["ups", "fedex", "dhl"] + + +def test_server_missing_service_level_400_surfaces_available_levels(transport, call): + transport.respond(400, MISSING_SERVICE_LEVEL_400) + + with pytest.raises(BadRequestError) as info: + call(lambda c: c.fuel_surcharge.parcel_history("ups", "ground")) + + assert "requires a service_level" in info.value.message + assert info.value.suggestions == MISSING_SERVICE_LEVEL_400["data"]["available_service_levels"] + + +def test_401_raises_authentication_error(transport, call): + transport.respond(401, UNAUTHORIZED_401) + + with pytest.raises(AuthenticationError) as info: + call(lambda c: c.fuel_surcharge.list()) + + assert info.value.code == "UNAUTHORIZED" + assert info.value.request_id == "bc98b482-7a78-4802-b0fe-53da93baab92" + + +@pytest.mark.parametrize( + "status,exc", + [(402, PaymentRequiredError), (403, PermissionDeniedError)], +) +def test_entitlement_statuses_raise_typed_errors(transport, call, status, exc): + transport.respond(status, _envelope("PLAN_REQUIRED", status, "Upgrade required")) + + with pytest.raises(exc) as info: + call(lambda c: c.fuel_surcharge.parcel_list()) + + assert info.value.status_code == status + + +def test_429_raises_rate_limit_error_without_replaying(transport, call): + transport.respond(429, _envelope("RATE_LIMITED", 429, "Too many requests")) + + with pytest.raises(RateLimitError): + call(lambda c: c.fuel_surcharge.latest("odfl")) + + assert len(transport.calls) == 1 + + +def test_timeout_raises_timeout_error(transport, call): + transport.raise_(httpx.ReadTimeout("timed out")) + + with pytest.raises(TimeoutError): + call(lambda c: c.fuel_surcharge.history("odfl")) + + +# --- malformed successes ------------------------------------------------------------ + + +@pytest.mark.parametrize( + "field", + [ + "carrier", + "carrier_name", + "mode", + "surcharge_percent", + "effective_date", + "doe_diesel_price", + "diesel_band", + "source", + "retrieved_at", + ], +) +def test_latest_missing_a_field_raises_instead_of_defaulting(transport, call, field): + body = copy.deepcopy(ODFL_LATEST) + del body[field] + transport.respond(200, {"status": "success", "data": body}) + + with pytest.raises(OilPriceAPIError) as info: + call(lambda c: c.fuel_surcharge.latest("odfl")) + + assert info.value.code == "MALFORMED_RESPONSE" + assert field in str(info.value) + + +@pytest.mark.parametrize( + "field,value", + [ + ("surcharge_percent", None), + ("surcharge_percent", "46.32"), + ("surcharge_percent", True), + ("effective_date", None), + ("effective_date", "09/09/2026"), + ("effective_date", 0), + ("retrieved_at", "2026-09-08T16:10:10"), # naive: no zone, not provenance + ("retrieved_at", None), + ("source", None), + ("source", ""), + ("diesel_band", {"min": 5.57}), + ], +) +def test_latest_with_a_bad_value_raises(transport, call, field, value): + body = copy.deepcopy(ODFL_LATEST) + body[field] = value + transport.respond(200, {"status": "success", "data": body}) + + with pytest.raises(OilPriceAPIError) as info: + call(lambda c: c.fuel_surcharge.latest("odfl")) + + assert info.value.code == "MALFORMED_RESPONSE" + + +def test_ltl_route_returning_a_parcel_row_is_malformed(transport, call): + transport.respond(200, {"status": "success", "data": UPS_GROUND}) + + with pytest.raises(OilPriceAPIError) as info: + call(lambda c: c.fuel_surcharge.latest("odfl")) + + assert info.value.code == "MALFORMED_RESPONSE" + + +@pytest.mark.parametrize( + "payload", + [ + {}, + {"status": "success"}, + {"status": "success", "data": None}, + {"status": "success", "data": []}, + {"status": "success", "data": {"history": []}}, + {"status": "success", "data": {"meta": ODFL_HISTORY_PAGE_2["data"]["meta"]}}, + {"status": "success", "data": {"history": [ODFL_LATEST], "meta": {"page": 1}}}, + {"status": "success", "data": {"history": "odfl", "meta": ODFL_HISTORY_PAGE_2["data"]["meta"]}}, + ], +) +def test_history_malformed_envelopes_raise(transport, call, payload): + transport.respond(200, payload) + + with pytest.raises(OilPriceAPIError) as info: + call(lambda c: c.fuel_surcharge.history("odfl")) + + assert info.value.code == "MALFORMED_RESPONSE" + + +@pytest.mark.parametrize( + "payload", + [ + {"status": "success", "data": {}}, + {"status": "success", "data": {"carriers": None}}, + {"status": "success", "data": {"carriers": [ODFL_LATEST, "abf"]}}, + ], +) +def test_list_malformed_envelopes_raise(transport, call, payload): + transport.respond(200, payload) + + with pytest.raises(OilPriceAPIError) as info: + call(lambda c: c.fuel_surcharge.list()) + + assert info.value.code == "MALFORMED_RESPONSE" + + +def test_parcel_carrier_row_without_service_level_is_malformed(transport, call): + level = copy.deepcopy(UPS_GROUND) + del level["service_level"] + carrier = dict(UPS_CARRIER, service_levels=[level]) + transport.respond(200, {"status": "success", "data": carrier}) + + with pytest.raises(OilPriceAPIError) as info: + call(lambda c: c.fuel_surcharge.parcel_latest("ups")) + + assert info.value.code == "MALFORMED_RESPONSE" + + +def test_parcel_latest_rate_given_a_carrier_object_is_malformed(transport, call): + """The server ignored the service level: do not hand back a guessed row.""" + transport.respond(200, UPS_PARCEL_LATEST) + + with pytest.raises(OilPriceAPIError) as info: + call(lambda c: c.fuel_surcharge.parcel_latest_rate("ups", "ground")) + + assert info.value.code == "MALFORMED_RESPONSE" + + +def test_non_json_200_is_an_error_not_an_empty_result(transport, call): + transport.respond(200, raw=b"gateway") + + with pytest.raises(ValueError): + call(lambda c: c.fuel_surcharge.list()) + + +def test_malformed_error_keeps_the_raw_body(transport, call): + payload = {"status": "success", "data": {"carriers": None}} + transport.respond(200, payload) + + with pytest.raises(OilPriceAPIError) as info: + call(lambda c: c.fuel_surcharge.list()) + + assert info.value.raw_body == payload + + +# --- local validation happens before the network ---------------------------------- + + +@pytest.mark.parametrize("carrier", ["", " ", "odfl/latest", "odfl?x=1", "../prices", None, 7]) +def test_invalid_carrier_is_refused_locally(transport, call, carrier): + with pytest.raises(ValidationError) as info: + call(lambda c: c.fuel_surcharge.latest(carrier)) + + assert info.value.status_code is None + assert transport.calls == [] + + +@pytest.mark.parametrize("service_level", ["", " ", "ground/x", None]) +def test_invalid_service_level_is_refused_locally(transport, call, service_level): + with pytest.raises(ValidationError): + call(lambda c: c.fuel_surcharge.parcel_history("ups", service_level)) + + assert transport.calls == [] + + +@pytest.mark.parametrize( + "kwargs", + [{"page": 0}, {"page": -1}, {"per_page": 0}, {"per_page": 101}, {"per_page": True}, {"page": "2"}], +) +def test_out_of_range_pagination_is_refused_not_silently_clamped(transport, call, kwargs): + with pytest.raises(ValidationError): + call(lambda c: c.fuel_surcharge.history("odfl", **kwargs)) + + assert transport.calls == [] + + +def test_per_page_100_is_the_accepted_maximum(transport, call): + transport.respond(200, ODFL_HISTORY_PAGE_2) + + call(lambda c: c.fuel_surcharge.history("odfl", per_page=100)) + + assert transport.last()["params"] == {"per_page": 100} + + +def test_both_clients_expose_the_resource(): + sync_client = OilPriceAPI(api_key=FIXTURE_KEY) + async_client = AsyncOilPriceAPI(api_key=FIXTURE_KEY) + try: + for client in (sync_client, async_client): + for name in ( + "list", + "latest", + "history", + "parcel_list", + "parcel_latest", + "parcel_latest_rate", + "parcel_history", + ): + assert callable(getattr(client.fuel_surcharge, name)) + finally: + sync_client.close() From ea37a13362df97eba3da3cd1e9be21d465e49873 Mon Sep 17 00:00:00 2001 From: Karl Waldman Date: Sun, 13 Sep 2026 16:24:38 -0400 Subject: [PATCH 2/2] test(fuel-surcharge): pin local refusals to ValidationError(field, value, status_code=None) (#101) Review follow-up on #144. The carrier, service_level, page and per_page guards already raised ValidationError with status_code=None (the _url._reject convention); the tests only asserted that for carrier on latest(). Every method that takes a carrier or service level, and both history routes' pagination, now assert the exact type, status_code None, is_client_error False, field, value and zero transport calls, on sync and async. Red-capability: swapping the slug guard to a raw ValueError fails 86 of 86 selected refusal tests. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo --- tests/unit/test_fuel_surcharge_resource.py | 63 +++++++++++++++++----- 1 file changed, 49 insertions(+), 14 deletions(-) diff --git a/tests/unit/test_fuel_surcharge_resource.py b/tests/unit/test_fuel_surcharge_resource.py index 22a77d4..3225610 100644 --- a/tests/unit/test_fuel_surcharge_resource.py +++ b/tests/unit/test_fuel_surcharge_resource.py @@ -727,32 +727,67 @@ def test_malformed_error_keeps_the_raw_body(transport, call): # --- local validation happens before the network ---------------------------------- +def _assert_local_refusal(info, field, value, transport): + """A local refusal is a ValidationError with no HTTP status: nothing was sent.""" + error = info.value + assert type(error) is ValidationError + assert error.status_code is None + assert error.is_client_error is False + assert error.field == field + assert error.value == value + assert transport.calls == [] + + @pytest.mark.parametrize("carrier", ["", " ", "odfl/latest", "odfl?x=1", "../prices", None, 7]) -def test_invalid_carrier_is_refused_locally(transport, call, carrier): +@pytest.mark.parametrize( + "method", + [ + lambda c, v: c.fuel_surcharge.latest(v), + lambda c, v: c.fuel_surcharge.history(v), + lambda c, v: c.fuel_surcharge.parcel_latest(v), + lambda c, v: c.fuel_surcharge.parcel_latest_rate(v, "ground"), + lambda c, v: c.fuel_surcharge.parcel_history(v, "ground"), + ], + ids=["latest", "history", "parcel_latest", "parcel_latest_rate", "parcel_history"], +) +def test_invalid_carrier_is_refused_locally(transport, call, carrier, method): with pytest.raises(ValidationError) as info: - call(lambda c: c.fuel_surcharge.latest(carrier)) + call(lambda c: method(c, carrier)) - assert info.value.status_code is None - assert transport.calls == [] + _assert_local_refusal(info, "carrier", carrier, transport) @pytest.mark.parametrize("service_level", ["", " ", "ground/x", None]) -def test_invalid_service_level_is_refused_locally(transport, call, service_level): - with pytest.raises(ValidationError): - call(lambda c: c.fuel_surcharge.parcel_history("ups", service_level)) +@pytest.mark.parametrize( + "method", + [ + lambda c, v: c.fuel_surcharge.parcel_latest_rate("ups", v), + lambda c, v: c.fuel_surcharge.parcel_history("ups", v), + ], + ids=["parcel_latest_rate", "parcel_history"], +) +def test_invalid_service_level_is_refused_locally(transport, call, service_level, method): + with pytest.raises(ValidationError) as info: + call(lambda c: method(c, service_level)) - assert transport.calls == [] + _assert_local_refusal(info, "service_level", service_level, transport) @pytest.mark.parametrize( - "kwargs", - [{"page": 0}, {"page": -1}, {"per_page": 0}, {"per_page": 101}, {"per_page": True}, {"page": "2"}], + "field,value", + [("page", 0), ("page", -1), ("page", "2"), ("per_page", 0), ("per_page", 101), ("per_page", True)], ) -def test_out_of_range_pagination_is_refused_not_silently_clamped(transport, call, kwargs): - with pytest.raises(ValidationError): - call(lambda c: c.fuel_surcharge.history("odfl", **kwargs)) +@pytest.mark.parametrize("parcel", [False, True], ids=["ltl", "parcel"]) +def test_out_of_range_pagination_is_refused_not_silently_clamped(transport, call, field, value, parcel): + kwargs = {field: value} + if parcel: + fn = lambda c: c.fuel_surcharge.parcel_history("ups", "ground", **kwargs) # noqa: E731 + else: + fn = lambda c: c.fuel_surcharge.history("odfl", **kwargs) # noqa: E731 + with pytest.raises(ValidationError) as info: + call(fn) - assert transport.calls == [] + _assert_local_refusal(info, field, value, transport) def test_per_page_100_is_the_accepted_maximum(transport, call):