Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ All notable changes to the OilPriceAPI Python SDK will be documented in this fil

### Added

- **Subscription lifecycle: `get`, `update`, `pause`, `resume` (#100).** Sync
and async, against `GET`/`PATCH /v1/subscriptions/{id}` and
`POST /v1/subscriptions/{id}/pause|resume`. Each returns a typed
`Subscription` with the server's timestamps and nulls as sent. `update()`
sends only the fields you pass (`name`, `codes`, `interval`,
`deliver_webhook`, `status`). Ids and update payloads are validated before
any request is built; an invalid one raises `ValidationError` with
`status_code=None`, `field` naming the argument, and nothing sent. Unknown ids raise `DataNotFoundError`; a refused update (interval
below the plan minimum, webhook delivery the plan lacks) raises
`ValidationError` with the server's `details`. `update`, `pause` and
`resume` are writes and are sent once, like `create`: after an ambiguous
timeout or 5xx the error carries `ambiguous_write=True` and `get()` tells
you whether the change landed.
- **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)`,
Expand All @@ -27,6 +40,28 @@ All notable changes to the OilPriceAPI Python SDK will be documented in this fil
an unknown carrier or a missing service level are now surfaced the same way
commodity suggestions are.

### Fixed

- **`subscriptions.create()` no longer turns a malformed success into a
half-built record.** It fell back to treating the whole `data` object as the
subscription when `data.subscription` was missing, and leaked a raw pydantic
or `TypeError` when the record was null or a list. It now raises
`OilPriceAPIError(code="MALFORMED_RESPONSE")`, the same as the new lifecycle
methods.
- **`subscriptions.delete()` validates the id before sending.** An id such as
`"abc/pause"` or `""` previously produced a request to a different route; it
now raises `ValidationError(field="subscription_id", status_code=None)`.
- **A bad subscription `interval` is now an SDK refusal as well as a
`ValueError`.** `subscriptions.create(interval=...)`, `normalize_interval` and
`build_create_body` raise the new `SubscriptionIntervalError`, a subclass of
both `ValidationError` and `ValueError` (like `FuturesContractError`), so
`except OilPriceAPIError` catches it and existing `except ValueError` code
keeps working. It carries `field="interval"`, the rejected `value`, and
`status_code=None`.
- **`Subscription.codes` is required.** A record with no `codes` used to
default to `[]`, reading as a watch on nothing; the API always sends it, so a
missing value now fails validation instead of being invented.

## [1.15.0] - 2026-09-13

### Fixed
Expand Down
8 changes: 8 additions & 0 deletions docs/reference/resources.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,11 @@
## Data Sources

::: oilpriceapi.resources.data_sources.DataSourcesResource

## Subscriptions

Agent price watches: `list`, `create`, `get`, `update`, `pause`, `resume`,
`delete`, and the `events` poll. A subscription here is a watch on commodity
codes, not a billing subscription.

::: oilpriceapi.resources.subscriptions.SubscriptionsResource
2 changes: 2 additions & 0 deletions oilpriceapi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
PermissionDeniedError,
RateLimitError,
ServerError,
SubscriptionIntervalError,
TimeoutError,
ValidationError,
)
Expand Down Expand Up @@ -68,6 +69,7 @@
"DataNotFoundError",
"ServerError",
"FuturesContractError",
"SubscriptionIntervalError",
"ValidationError",
"NetworkError",
"TimeoutError",
Expand Down
166 changes: 157 additions & 9 deletions oilpriceapi/_subscriptions_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@

from __future__ import annotations

from typing import Any, Dict, List, Optional, Union
import re
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union

from .exceptions import SubscriptionIntervalError, ValidationError

if TYPE_CHECKING:
from .models import Subscription

# Default attribution source stamped on subscriptions created via this SDK.
DEFAULT_SOURCE = "sdk-python"
Expand All @@ -31,24 +37,39 @@
_UNIT_SECONDS = {"s": 1, "m": 60, "h": 3600, "d": 86400}


def _refuse(message: str, field: Optional[str], value: Any) -> ValidationError:
"""A local input refusal. No request was sent, so there is no HTTP status."""
return ValidationError(message=message, field=field, value=value, status_code=None)


def _interval_refusal(message: str, interval: Any) -> SubscriptionIntervalError:
# Dual-base (ValidationError + ValueError): this path raised ValueError
# before #100, and callers may catch that.
return SubscriptionIntervalError(
message=message, field="interval", value=interval, status_code=None
)


def normalize_interval(interval: Union[str, int]) -> int:
"""Convert a friendly interval into ``interval_seconds``.

Accepts an int (returned as-is), a named alias ("5m", "1h", "daily"), or a
``<number><unit>`` string where unit is one of s/m/h/d (e.g. "30s", "2h").

Raises:
ValueError: If the interval cannot be parsed or is non-positive.
SubscriptionIntervalError: If the interval cannot be parsed or is
non-positive. It is both a ``ValidationError`` (``field="interval"``,
``status_code=None``) and, for compatibility, a ``ValueError``.
"""
if isinstance(interval, bool): # bool is an int subclass; reject explicitly
raise ValueError(f"Invalid interval: {interval!r}")
raise _interval_refusal(f"Invalid interval: {interval!r}", interval)
if isinstance(interval, int):
if interval <= 0:
raise ValueError(f"interval_seconds must be positive, got {interval}")
raise _interval_refusal(f"interval_seconds must be positive, got {interval}", interval)
return interval

if not isinstance(interval, str):
raise ValueError(f"Invalid interval type: {type(interval).__name__}")
raise _interval_refusal(f"Invalid interval type: {type(interval).__name__}", interval)

key = interval.strip().lower()
if key in _INTERVAL_ALIASES:
Expand All @@ -58,19 +79,20 @@ def normalize_interval(interval: Union[str, int]) -> int:
if key.isdigit():
seconds = int(key)
if seconds <= 0:
raise ValueError(f"interval_seconds must be positive, got {seconds}")
raise _interval_refusal(f"interval_seconds must be positive, got {seconds}", interval)
return seconds

# <number><unit> form e.g. "45s", "2h".
if len(key) >= 2 and key[-1] in _UNIT_SECONDS and key[:-1].isdigit():
seconds = int(key[:-1]) * _UNIT_SECONDS[key[-1]]
if seconds <= 0:
raise ValueError(f"interval_seconds must be positive, got {seconds}")
raise _interval_refusal(f"interval_seconds must be positive, got {seconds}", interval)
return seconds

raise ValueError(
raise _interval_refusal(
f"Unrecognized interval {interval!r}. Use seconds (int), a named alias "
f"('5m', '1h', 'daily'), or '<n><unit>' where unit is s/m/h/d."
f"('5m', '1h', 'daily'), or '<n><unit>' where unit is s/m/h/d.",
interval,
)


Expand Down Expand Up @@ -103,6 +125,132 @@ def build_create_body(
return body


# Watch.status is a string enum on the server: { active, paused }.
VALID_STATUSES = ("active", "paused")

# Watch ids are UUIDs. Anything outside this alphabet would change the URL the
# request goes to (a "/" reaches another route, "?" or "#" rewrites the query),
# so it is refused before a request is built.
_SUBSCRIPTION_ID = re.compile(r"[A-Za-z0-9_-]+")


def validate_subscription_id(subscription_id: Any) -> str:
"""Return ``subscription_id`` if it can be placed in a URL path segment.

Raises:
ValidationError: ``field="subscription_id"``, ``status_code=None``, if
the id is not a non-empty string of letters, digits, ``-`` or
``_``. Nothing is sent to the API.
"""
if not isinstance(subscription_id, str) or not _SUBSCRIPTION_ID.fullmatch(subscription_id):
raise _refuse(
f"Invalid subscription id {subscription_id!r}: expected the id returned "
f"by subscriptions.list() or subscriptions.create().",
"subscription_id",
subscription_id,
)
return subscription_id


def build_update_body(
name: Optional[str] = None,
codes: Optional[List[str]] = None,
interval: Optional[Union[str, int]] = None,
deliver_webhook: Optional[bool] = None,
status: Optional[str] = None,
) -> Dict[str, Any]:
"""Build the PATCH /v1/subscriptions/:id body from the fields given.

Only arguments that are not ``None`` are sent, so an update never resets a
field the caller did not mention.

Raises:
ValidationError: ``status_code=None``, with ``field`` naming the invalid
argument (``None`` when no field is given at all). Nothing is sent.
"""
body: Dict[str, Any] = {}
if name is not None:
if not isinstance(name, str):
raise _refuse(f"name must be a string, got {type(name).__name__}", "name", name)
body["name"] = name
if codes is not None:
if isinstance(codes, (str, bytes)) or not isinstance(codes, (list, tuple)):
raise _refuse(
"codes must be a list of commodity codes, e.g. ['BRENT_CRUDE_USD']", "codes", codes
)
if not codes:
raise _refuse("codes must contain at least one commodity code", "codes", codes)
if not all(isinstance(code, str) and code.strip() for code in codes):
raise _refuse("every code must be a non-empty string", "codes", codes)
body["codes"] = list(codes)
if interval is not None:
try:
body["interval_seconds"] = normalize_interval(interval)
except SubscriptionIntervalError as error:
# update() is new in #100: no caller relies on ValueError here, so
# it gets the plain ValidationError every new refusal uses.
raise _refuse(error.message, "interval", interval) from None
if deliver_webhook is not None:
if not isinstance(deliver_webhook, bool):
raise _refuse(
f"deliver_webhook must be True or False, got {deliver_webhook!r}",
"deliver_webhook",
deliver_webhook,
)
body["deliver_webhook"] = deliver_webhook
if status is not None:
if status not in VALID_STATUSES:
raise _refuse(
f"status must be one of {', '.join(VALID_STATUSES)}, got {status!r}",
"status",
status,
)
body["status"] = status
if not body:
raise _refuse(
"update() needs at least one of: name, codes, interval, deliver_webhook, status",
None,
None,
)
return body


def unwrap_subscription(response: Any, *, subject: str) -> "Subscription":
"""Return the typed ``data.subscription`` record from a success body.

Every single-subscription endpoint (show, create, update, pause, resume)
answers ``{"status": "success", "data": {"subscription": {...}}}``.

Raises:
OilPriceAPIError: ``code="MALFORMED_RESPONSE"`` when the record is
missing, is not an object, or lacks a required field. A malformed
success is reported, never turned into a partial or invented record.
"""
from pydantic import ValidationError as PydanticValidationError

from .exceptions import OilPriceAPIError
from .models import Subscription

data = response.get("data") if isinstance(response, dict) else None
record = data.get("subscription") if isinstance(data, dict) else None
if not isinstance(record, dict):
raise OilPriceAPIError(
f"Malformed {subject} response: expected data.subscription to be an object",
code="MALFORMED_RESPONSE",
raw_body=response,
)
try:
return Subscription(**record)
except PydanticValidationError as error:
raise OilPriceAPIError(
f"Malformed {subject} response: {error.error_count()} invalid or missing "
f"subscription field(s): "
+ ", ".join(".".join(str(part) for part in item["loc"]) for item in error.errors()),
code="MALFORMED_RESPONSE",
raw_body=response,
) from error


def unwrap_data(response: Any) -> Dict[str, Any]:
"""Return the ``data`` object from a ``{status, data}`` envelope."""
if isinstance(response, dict) and "data" in response:
Expand Down
64 changes: 61 additions & 3 deletions oilpriceapi/async_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@
from ._subscriptions_common import (
build_attribution_headers,
build_create_body,
build_update_body,
unwrap_data,
unwrap_subscription,
validate_subscription_id,
)
from .exceptions import ValidationError
from .models import (
Expand Down Expand Up @@ -1606,12 +1609,67 @@ async def create(
json_data=body,
headers=headers,
)
data = unwrap_data(response)
sub = data.get("subscription", data)
return Subscription(**sub)
return unwrap_subscription(response, subject="subscriptions.create")

async def get(self, subscription_id: str) -> Subscription:
"""Fetch one subscription. See ``SubscriptionsResource.get``."""
subscription_id = validate_subscription_id(subscription_id)
response = await self.client.request(
method="GET",
path=f"/v1/subscriptions/{subscription_id}",
)
return unwrap_subscription(response, subject="subscriptions.get")

async def update(
self,
subscription_id: str,
*,
name: Optional[str] = None,
codes: Optional[List[str]] = None,
interval: Optional[Union[str, int]] = None,
deliver_webhook: Optional[bool] = None,
status: Optional[str] = None,
) -> Subscription:
"""Change a subscription; only the arguments passed are sent.

Sent once (PATCH is not replayed). See ``SubscriptionsResource.update``.
"""
subscription_id = validate_subscription_id(subscription_id)
body = build_update_body(
name=name,
codes=codes,
interval=interval,
deliver_webhook=deliver_webhook,
status=status,
)
response = await self.client.request(
method="PATCH",
path=f"/v1/subscriptions/{subscription_id}",
json_data=body,
)
return unwrap_subscription(response, subject="subscriptions.update")

async def pause(self, subscription_id: str) -> Subscription:
"""Pause a subscription. See ``SubscriptionsResource.pause``."""
subscription_id = validate_subscription_id(subscription_id)
response = await self.client.request(
method="POST",
path=f"/v1/subscriptions/{subscription_id}/pause",
)
return unwrap_subscription(response, subject="subscriptions.pause")

async def resume(self, subscription_id: str) -> Subscription:
"""Resume a paused subscription. See ``SubscriptionsResource.resume``."""
subscription_id = validate_subscription_id(subscription_id)
response = await self.client.request(
method="POST",
path=f"/v1/subscriptions/{subscription_id}/resume",
)
return unwrap_subscription(response, subject="subscriptions.resume")

async def delete(self, subscription_id: str) -> bool:
"""Delete a subscription. Returns True on success."""
subscription_id = validate_subscription_id(subscription_id)
await self.client.request(
method="DELETE",
path=f"/v1/subscriptions/{subscription_id}",
Expand Down
Loading