|
14 | 14 |
|
15 | 15 | if TYPE_CHECKING: |
16 | 16 | from .models import Subscription |
| 17 | + from .resources.subscriptions import SubscriptionEventsPage |
17 | 18 |
|
18 | 19 | # Default attribution source stamped on subscriptions created via this SDK. |
19 | 20 | DEFAULT_SOURCE = "sdk-python" |
@@ -251,6 +252,152 @@ def unwrap_subscription(response: Any, *, subject: str) -> "Subscription": |
251 | 252 | ) from error |
252 | 253 |
|
253 | 254 |
|
| 255 | +def _field_errors(error: Any) -> str: |
| 256 | + return ", ".join(".".join(str(part) for part in item["loc"]) for item in error.errors()) |
| 257 | + |
| 258 | + |
| 259 | +def unwrap_subscription_list(response: Any, *, subject: str) -> List["Subscription"]: |
| 260 | + """Return the typed ``data.subscriptions`` list from a success body. |
| 261 | +
|
| 262 | + ``GET /v1/subscriptions`` always answers |
| 263 | + ``{"status": "success", "data": {"subscriptions": [...]}}``. An empty list is |
| 264 | + returned only when the API sent an empty list. |
| 265 | +
|
| 266 | + Raises: |
| 267 | + OilPriceAPIError: ``code="MALFORMED_RESPONSE"`` when ``data.subscriptions`` |
| 268 | + is missing or not a list, or a record in it is not a valid |
| 269 | + subscription. A malformed success is never reported as "no |
| 270 | + subscriptions". |
| 271 | + """ |
| 272 | + from pydantic import ValidationError as PydanticValidationError |
| 273 | + |
| 274 | + from ._fuel_surcharge_common import _malformed |
| 275 | + from .models import Subscription |
| 276 | + |
| 277 | + data = response.get("data") if isinstance(response, dict) else None |
| 278 | + records = data.get("subscriptions") if isinstance(data, dict) else None |
| 279 | + if not isinstance(records, list): |
| 280 | + raise _malformed(subject, "expected data.subscriptions to be a list", response) |
| 281 | + |
| 282 | + subscriptions: List[Subscription] = [] |
| 283 | + for index, record in enumerate(records): |
| 284 | + if not isinstance(record, dict): |
| 285 | + raise _malformed( |
| 286 | + subject, f"expected data.subscriptions[{index}] to be an object", response |
| 287 | + ) |
| 288 | + try: |
| 289 | + subscriptions.append(Subscription(**record)) |
| 290 | + except PydanticValidationError as error: |
| 291 | + raise _malformed( |
| 292 | + subject, |
| 293 | + f"data.subscriptions[{index}] has {error.error_count()} invalid or missing " |
| 294 | + f"field(s): {_field_errors(error)}", |
| 295 | + response, |
| 296 | + ) from error |
| 297 | + return subscriptions |
| 298 | + |
| 299 | + |
| 300 | +def validate_since(since: Any) -> Optional[int]: |
| 301 | + """Return ``since`` if the API will read it as the cursor it is. |
| 302 | +
|
| 303 | + ``GET /v1/subscriptions/events`` reads ``params[:since].to_i``: a blank, |
| 304 | + non-numeric or negative value becomes ``0`` and replays every event the |
| 305 | + account has, and ``1.5`` becomes ``1``. ``None`` (omitted) is the first |
| 306 | + poll; anything else must be a previous page's integer ``cursor``, or ``0``. |
| 307 | +
|
| 308 | + Raises: |
| 309 | + ValidationError: ``field="since"``, ``status_code=None``. Nothing is sent. |
| 310 | + """ |
| 311 | + if since is None: |
| 312 | + return None |
| 313 | + if isinstance(since, bool) or not isinstance(since, int) or since < 0: |
| 314 | + raise _refuse( |
| 315 | + f"Invalid events cursor since={since!r}: pass page.cursor from the previous " |
| 316 | + f"events() call, 0 to start from the first event, or omit it on the first " |
| 317 | + f"poll. The API reads any other value as 0 and replays every event.", |
| 318 | + "since", |
| 319 | + since, |
| 320 | + ) |
| 321 | + return since |
| 322 | + |
| 323 | + |
| 324 | +def unwrap_events_page( |
| 325 | + response: Any, *, since: Optional[int], subject: str |
| 326 | +) -> "SubscriptionEventsPage": |
| 327 | + """Return the typed events page from a ``GET /v1/subscriptions/events`` body. |
| 328 | +
|
| 329 | + The API always sends ``data.cursor`` (an integer: the last event's ``seq``, |
| 330 | + or ``since`` when there are none), ``data.has_more`` and ``data.events``. |
| 331 | + The cursor is what the caller sends as ``since`` next, so a missing or |
| 332 | + wrong-typed cursor is refused rather than defaulted: ``cursor=None`` would |
| 333 | + make the next poll omit ``since`` and restart from the first event. |
| 334 | +
|
| 335 | + Raises: |
| 336 | + OilPriceAPIError: ``code="MALFORMED_RESPONSE"`` when ``data.events`` is |
| 337 | + not a list of events, ``data.cursor`` is not a non-negative integer, |
| 338 | + ``data.has_more`` is not a boolean, or the cursor is behind ``since`` |
| 339 | + or behind an event in the page (following it would replay events). |
| 340 | + """ |
| 341 | + from pydantic import ValidationError as PydanticValidationError |
| 342 | + |
| 343 | + from ._fuel_surcharge_common import _malformed |
| 344 | + from .models import SubscriptionEvent |
| 345 | + from .resources.subscriptions import SubscriptionEventsPage |
| 346 | + |
| 347 | + data = response.get("data") if isinstance(response, dict) else None |
| 348 | + if not isinstance(data, dict): |
| 349 | + raise _malformed(subject, "expected a 'data' object", response) |
| 350 | + |
| 351 | + records = data.get("events") |
| 352 | + if not isinstance(records, list): |
| 353 | + raise _malformed(subject, "expected data.events to be a list", response) |
| 354 | + |
| 355 | + cursor = data.get("cursor") |
| 356 | + if isinstance(cursor, bool) or not isinstance(cursor, int) or cursor < 0: |
| 357 | + raise _malformed( |
| 358 | + subject, |
| 359 | + f"expected data.cursor to be a non-negative integer, got {cursor!r}", |
| 360 | + response, |
| 361 | + ) |
| 362 | + |
| 363 | + has_more = data.get("has_more") |
| 364 | + if not isinstance(has_more, bool): |
| 365 | + raise _malformed( |
| 366 | + subject, f"expected data.has_more to be true or false, got {has_more!r}", response |
| 367 | + ) |
| 368 | + |
| 369 | + if since is not None and cursor < since: |
| 370 | + raise _malformed( |
| 371 | + subject, |
| 372 | + f"data.cursor {cursor} is behind since={since}; following it would replay events", |
| 373 | + response, |
| 374 | + ) |
| 375 | + |
| 376 | + events: List[SubscriptionEvent] = [] |
| 377 | + for index, record in enumerate(records): |
| 378 | + if not isinstance(record, dict): |
| 379 | + raise _malformed(subject, f"expected data.events[{index}] to be an object", response) |
| 380 | + try: |
| 381 | + event = SubscriptionEvent(**record) |
| 382 | + except PydanticValidationError as error: |
| 383 | + raise _malformed( |
| 384 | + subject, |
| 385 | + f"data.events[{index}] has {error.error_count()} invalid field(s): " |
| 386 | + f"{_field_errors(error)}", |
| 387 | + response, |
| 388 | + ) from error |
| 389 | + if event.seq is not None and event.seq > cursor: |
| 390 | + raise _malformed( |
| 391 | + subject, |
| 392 | + f"data.cursor {cursor} is behind data.events[{index}].seq {event.seq}; " |
| 393 | + f"following it would replay events", |
| 394 | + response, |
| 395 | + ) |
| 396 | + events.append(event) |
| 397 | + |
| 398 | + return SubscriptionEventsPage(events=events, cursor=cursor, has_more=has_more) |
| 399 | + |
| 400 | + |
254 | 401 | def unwrap_data(response: Any) -> Dict[str, Any]: |
255 | 402 | """Return the ``data`` object from a ``{status, data}`` envelope.""" |
256 | 403 | if isinstance(response, dict) and "data" in response: |
|
0 commit comments